├── .gitignore ├── CHANGELOG.rst ├── LICENSE.rst ├── MANIFEST.in ├── README.rst ├── config ├── config.ini ├── config.ssh ├── glhooks.conf └── glhooks.sh ├── glhooks ├── __init__.py ├── __main__.py ├── config.py ├── git.py ├── mailer │ ├── __init__.py │ ├── attachment.py │ ├── compat.py │ ├── mailer.py │ ├── messages.py │ └── utils.py ├── server.py ├── system.py └── utils.py ├── setup.cfg ├── setup.py └── tests └── __init__.py /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | *.py[co] 3 | *.egg-info 4 | /build 5 | /dist 6 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | .. :changelog: 2 | 3 | Changelog 4 | ========= 5 | 6 | 0.1.0 (2014-01-14) 7 | ------------------ 8 | - First public release. 9 | -------------------------------------------------------------------------------- /LICENSE.rst: -------------------------------------------------------------------------------- 1 | Copyright 2014 Michal Belica 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | include LICENSE.rst 3 | include CHANGELOG.rst 4 | recursive-include config * 5 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ======================= 2 | GitLab webhooks handler 3 | ======================= 4 | 5 | This is simple server (receiver) for `GitLab's `_ webhooks feature. 6 | 7 | Installation 8 | ------------ 9 | 10 | Make sure you have `Python `_ 2.6+/3.2+ properly installed (`Windows `_/`Linux `_). Then just type ``[sudo] pip install glhooks`` in your favorite shell. Now you have *glhooks* installed so you can run it like this ``python -m glhooks ...``. You can find skeleton for the configuration file in `git repository `_ in ``config/config.ini``. 11 | 12 | I recommend `Supervisor `_ or similar application to run the *glhooks* server. Install it via ``[sudo] apt-get install supervisor``. You can find the example of *Supervisor* configuration file for *glhooks* in `git repository `_ in ``config/glhooks.conf``. But you can just create some shell script and copy it into ``/etc/init.d/`` (example in `git repository `_ in ``config/glhooks.sh``). 13 | 14 | Next step is to generate a SSH key **without the passphrase**: ``ssh-keygen -b 4096 -t rsa``. Then add the generated SSH **public key** to your GitLab account. If you have multiple SSH keys in ``$HOME/.ssh/`` you probably has to create file ``$HOME/.ssh/config``. You can find the example file in `git repository `_ in ``config/config.ssh``. 15 | 16 | Finally add the hook ``http://server.example.com:8000/`` into your project webhooks at GitLab. 17 | 18 | 19 | Updating 20 | -------- 21 | 22 | When you change at least one of the configuration files you has to restart *glhooks* server by ``[sudo] supervisorctl restart glhooks`` or ``[sudo] service glhooks restart``. 23 | 24 | 25 | Contributing 26 | ------------ 27 | Feel free to `send a pull request `_ or to `report an issue `_. 28 | -------------------------------------------------------------------------------- /config/config.ini: -------------------------------------------------------------------------------- 1 | [server] 2 | host=gitlab.example.com 3 | log_file=/path/to/your/log/file.log ; default: /var/log/glhooks.access.log 4 | email=admin@example.com ; deploy errors will be sended there 5 | 6 | [mailer] 7 | user=noreply@example.com 8 | password=*** 9 | host=smtp.example.com 10 | security=tls ; plain/ssl/tls 11 | sender=GitLab deployer at gitlab.example.com 12 | 13 | 14 | ; list of repositories 15 | [http://gitlab.example.com/user/repository] 16 | path=/path/to/directory/with/your/dev/project/ 17 | branch=develop 18 | 19 | [http://gitlab.example.com/user/repository] 20 | path=/path/to/directory/with/your/production/project/ 21 | branch=master 22 | 23 | [http://gitlab.example.com/another-user/repo] 24 | path=/path/to/directory/with/your/another-project/ 25 | branch=master 26 | -------------------------------------------------------------------------------- /config/config.ssh: -------------------------------------------------------------------------------- 1 | Host gitlab.example.com 2 | HostName gitlab.example.com 3 | User git 4 | IdentityFile ~/.ssh/glhooks_rsa 5 | -------------------------------------------------------------------------------- /config/glhooks.conf: -------------------------------------------------------------------------------- 1 | [program:glhooks] 2 | command=python -m glhooks /path/to/your/config.ini 3 | directory=/home/glhooks/ 4 | user=glhooks 5 | autorestart=true 6 | stdout_logfile=/var/log/glhooks.supervisor.out.log 7 | stderr_logfile=/var/log/glhooks.supervisor.err.log 8 | -------------------------------------------------------------------------------- /config/glhooks.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | 4 | prog="python -m glhooks /path/to/your/config.ini" 5 | PROG_USER="glhooks" 6 | RETVAL=0 7 | 8 | 9 | start () { 10 | echo "Starting $prog" 11 | /usr/bin/sudo -u $PROG_USER $prog & 12 | RETVAL=$? 13 | } 14 | 15 | stop () { 16 | echo "Stopping $prog" 17 | kilall $prog 18 | RETVAL=$? 19 | } 20 | 21 | restart () { 22 | stop 23 | start 24 | } 25 | 26 | 27 | case "$1" in 28 | start) 29 | start 30 | ;; 31 | stop) 32 | stop 33 | ;; 34 | restart|reload) 35 | restart 36 | ;; 37 | status) 38 | RETVAL=$? 39 | ;; 40 | *) 41 | echo "Usage: service glhooks {start|stop|restart|reload}" 42 | RETVAL=2 43 | ;; 44 | esac 45 | 46 | 47 | exit $RETVAL 48 | -------------------------------------------------------------------------------- /glhooks/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import absolute_import 4 | from __future__ import division, print_function, unicode_literals 5 | 6 | 7 | __version__ = "0.1.0" 8 | -------------------------------------------------------------------------------- /glhooks/__main__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Simple server for webhooks of http://gitlab.org/. 5 | 6 | Usage: 7 | glhooks [options] ... 8 | 9 | Options: 10 | --port= Number of port on which server is running. [default: 8000] 11 | --host= Host name or IP address of HTTP server. [default: ] 12 | --help Shows this text :) 13 | 14 | """ 15 | 16 | from __future__ import absolute_import 17 | from __future__ import division, print_function, unicode_literals 18 | 19 | from docopt import docopt 20 | from . import __version__ as VERSION 21 | from .config import Configs 22 | from . import server 23 | 24 | 25 | def main(): 26 | args = docopt(__doc__, version=VERSION) 27 | configs = Configs(*args[""]) 28 | 29 | server.start(configs, args["--host"], int(args["--port"])) 30 | 31 | 32 | if __name__ == "__main__": 33 | main() 34 | -------------------------------------------------------------------------------- /glhooks/config.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import absolute_import 4 | from __future__ import division, print_function, unicode_literals 5 | 6 | import re 7 | import logging 8 | 9 | from logging.handlers import RotatingFileHandler 10 | from .mailer import SmtpMailer 11 | from .utils import cached_property 12 | 13 | try: 14 | from ConfigParser import SafeConfigParser 15 | except ImportError: 16 | from configparser import SafeConfigParser 17 | 18 | 19 | class Configs(object): 20 | DEFAULT_BRANCH = "master" 21 | LOGGER_MAX_FILE_SIZE = 16*1024*1024 # in bytes 22 | LOGGER_FORMAT = "%(asctime)s [%(levelname)s]: %(message)s" 23 | LOGGER_DEFAULT_PATH = "/var/log/glhooks.access.log" 24 | _REPO_SECTION_PATTERN = re.compile(r"^https?://") 25 | 26 | def __init__(self, *file_paths): 27 | self._file_paths = file_paths 28 | self._params = self._parse(file_paths) 29 | 30 | def _parse(self, file_paths): 31 | parser = SafeConfigParser() 32 | 33 | for path in file_paths: 34 | parser.read(path) 35 | 36 | data = { 37 | "server": { 38 | "log_file": self.LOGGER_DEFAULT_PATH, 39 | } 40 | } 41 | for section in parser.sections(): 42 | section_data = dict(parser.items(section)) 43 | if self._REPO_SECTION_PATTERN.match(section): 44 | if section.endswith("/"): # remove ending slash 45 | section = section[:-1] 46 | data[section] = section_data 47 | data[section]["branch"] = section_data.get("branch", self.DEFAULT_BRANCH) 48 | else: 49 | data[section] = section_data 50 | 51 | return data 52 | 53 | def __getitem__(self, key): 54 | return dict(self._params[key]) 55 | 56 | @cached_property 57 | def mailer(self): 58 | configs = self["mailer"] 59 | del configs["sender"] 60 | 61 | return SmtpMailer(**configs) 62 | 63 | @cached_property 64 | def logger(self): 65 | return self._build_logger(self["server"]["log_file"]) 66 | 67 | def _build_logger(self, log_file_path): 68 | handler = RotatingFileHandler(log_file_path, maxBytes=self.LOGGER_MAX_FILE_SIZE, backupCount=6) 69 | handler.setFormatter(logging.Formatter(self.LOGGER_FORMAT)) 70 | 71 | logger = logging.getLogger("glhooks") 72 | logger.addHandler(handler) 73 | logger.setLevel(logging.INFO) 74 | 75 | return logger 76 | 77 | def find_repo(self, url): 78 | repo = self._params.get(url) 79 | if repo is not None: 80 | repo = dict(repo) 81 | 82 | return repo 83 | -------------------------------------------------------------------------------- /glhooks/git.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import absolute_import 4 | from __future__ import division, print_function, unicode_literals 5 | 6 | from . import system 7 | 8 | 9 | class Repository(object): 10 | DEFAULT_REMOTE = "origin" 11 | DEFAULT_BRANCH = "master" 12 | 13 | def __init__(self, path): 14 | self._path = path 15 | 16 | def pull(self, remote=None, branch=None): 17 | if remote is None: 18 | remote = self.DEFAULT_REMOTE 19 | if branch is None: 20 | branch = self.DEFAULT_BRANCH 21 | 22 | with system.working_directory(self._path): 23 | self.git("checkout", branch) 24 | self.git("reset --hard HEAD") 25 | self.git("pull", remote, branch) 26 | 27 | def git(self, *args): 28 | if len(args) == 1: # command as a string 29 | args = args[0].split() 30 | 31 | args = ["git"] + list(args) 32 | return system.run(*args) 33 | -------------------------------------------------------------------------------- /glhooks/mailer/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import absolute_import 4 | from __future__ import division, print_function, unicode_literals 5 | 6 | from .mailer import SmtpMailer, GmailMailer 7 | from .messages import PlainMessage, HtmlMessage 8 | 9 | 10 | __version__ = "0.0.1" 11 | -------------------------------------------------------------------------------- /glhooks/mailer/attachment.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import absolute_import 4 | from __future__ import division, print_function, unicode_literals 5 | 6 | import mimetypes 7 | 8 | from email import encoders 9 | from os.path import basename 10 | from collections import defaultdict 11 | from email.mime.base import MIMEBase 12 | from email.mime.text import MIMEText 13 | from email.mime.image import MIMEImage 14 | from email.mime.audio import MIMEAudio 15 | from email.mime.application import MIMEApplication 16 | from .compat import to_unicode, to_string, to_bytes 17 | 18 | 19 | def _default_payload_builder(content, maintype, subtype, charset): 20 | payload = MIMEBase(maintype, subtype) 21 | payload.set_payload(content) 22 | encoders.encode_base64(payload) 23 | 24 | return payload 25 | 26 | 27 | class Attachment(object): 28 | _MIMETYPE_DEFAULT = "application/octet-stream" 29 | _PAYLOAD_BUILDERS = defaultdict(lambda: _default_payload_builder, { 30 | "text": lambda c, m, s, ch: MIMEText(c, s, ch), 31 | "image": lambda c, m, s, ch: MIMEImage(c, s), 32 | "audio": lambda c, m, s, ch: MIMEAudio(c, s), 33 | "application": lambda c, m, s, ch: MIMEApplication(c, s), 34 | }) 35 | 36 | def __init__(self, file, charset="utf-8", mimetype=None): 37 | self._charset = to_string(charset) 38 | self._file_path = to_unicode(file) 39 | self._mimetype = to_unicode(self._guess_mimetype(self._file_path, mimetype)) 40 | 41 | def _guess_mimetype(self, file_path, force_type): 42 | if force_type is not None: 43 | return force_type 44 | 45 | mimetype, _ = mimetypes.guess_type(file_path) 46 | if mimetype is None: 47 | mimetype = self._MIMETYPE_DEFAULT 48 | 49 | return mimetype 50 | 51 | @property 52 | def name(self): 53 | return basename(self._file_path) 54 | 55 | @property 56 | def payload(self): 57 | maintype, subtype = self._mimetype.split("/", 1) 58 | with open(self._file_path, "rb") as file: 59 | content = file.read() 60 | 61 | if not content: # some weird UnicodeError with empty files in v3.2 62 | content = to_bytes(' ') 63 | 64 | build_payload = self._PAYLOAD_BUILDERS[maintype] 65 | payload = build_payload(content, to_string(maintype), to_string(subtype), self._charset) 66 | payload.add_header(to_string("Content-Disposition"), to_string("attachment"), filename=to_string(self.name)) 67 | 68 | return payload 69 | 70 | def __repr__(self): 71 | return to_string("" % (self._mimetype, self._file_path)) 72 | -------------------------------------------------------------------------------- /glhooks/mailer/compat.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import absolute_import 4 | from __future__ import division, print_function, unicode_literals 5 | 6 | from sys import version_info 7 | 8 | 9 | PY3 = version_info[0] == 3 10 | _DEFAULT_CHARSET = "utf-8" 11 | 12 | 13 | if PY3: 14 | bytes = bytes 15 | unicode = str 16 | 17 | class_types = type 18 | 19 | def unicode_compatible(cls): 20 | return cls 21 | else: 22 | bytes = str 23 | unicode = unicode 24 | 25 | from types import ClassType 26 | class_types = (ClassType, type) 27 | 28 | def unicode_compatible(cls): 29 | cls.__unicode__ = cls.__str__ 30 | if hasattr(cls, "__bytes__"): 31 | cls.__str__ = cls.__bytes__ 32 | delattr(cls, "__bytes__") 33 | else: 34 | cls.__str__ = lambda self: self.__unicode__().encode(_DEFAULT_CHARSET) 35 | 36 | return cls 37 | 38 | string_types = (bytes, unicode,) 39 | 40 | unicode_compatible.__doc__ = """ 41 | A decorator that defines __unicode__ and __str__ methods under Python 2. 42 | Under Python 3 it does nothing. 43 | 44 | To support Python 2 and 3 with a single code base, define a __str__ method 45 | returning text and apply this decorator to the class. 46 | """ 47 | 48 | 49 | def to_bytes(value, encoding=_DEFAULT_CHARSET, strict=True): 50 | try: 51 | if isinstance(value, bytes): 52 | return value 53 | elif isinstance(value, unicode): 54 | return value.encode(encoding) 55 | else: 56 | # try encode instance to bytes 57 | return _instance_to_bytes(value, encoding) 58 | except UnicodeError: 59 | if strict: 60 | raise 61 | 62 | # recover from codec error and use 'repr' function 63 | return to_bytes(repr(value), encoding) 64 | 65 | 66 | def to_unicode(value, encoding=_DEFAULT_CHARSET, strict=True): 67 | try: 68 | if isinstance(value, unicode): 69 | return value 70 | elif isinstance(value, bytes): 71 | return value.decode(encoding) 72 | else: 73 | # try decode instance to unicode 74 | return _instance_to_unicode(value, encoding) 75 | except UnicodeError: 76 | if strict: 77 | raise 78 | 79 | # recover from codec error and use 'repr' function 80 | return to_unicode(repr(value), encoding) 81 | 82 | 83 | # converts value to native string 84 | to_string = to_unicode if PY3 else to_bytes 85 | 86 | 87 | def _instance_to_bytes(instance, encoding): 88 | if PY3: 89 | if hasattr(instance, "__bytes__"): 90 | return bytes(instance) 91 | elif hasattr(instance, "__str__"): 92 | return unicode(instance).encode(encoding) 93 | else: 94 | if hasattr(instance, "__str__"): 95 | return bytes(instance) 96 | elif hasattr(instance, "__unicode__"): 97 | return unicode(instance).encode(encoding) 98 | 99 | return to_bytes(repr(instance), encoding) 100 | 101 | 102 | def _instance_to_unicode(instance, encoding): 103 | if PY3: 104 | if hasattr(instance, "__str__"): 105 | return unicode(instance) 106 | elif hasattr(instance, "__bytes__"): 107 | return bytes(instance).decode(encoding) 108 | else: 109 | if hasattr(instance, "__unicode__"): 110 | return unicode(instance) 111 | elif hasattr(instance, "__str__"): 112 | return bytes(instance).decode(encoding) 113 | 114 | return to_unicode(repr(instance), encoding) 115 | -------------------------------------------------------------------------------- /glhooks/mailer/mailer.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import absolute_import 4 | from __future__ import division, print_function, unicode_literals 5 | 6 | import smtplib 7 | 8 | from .compat import to_string 9 | 10 | 11 | def _build_tls_mailer(host, port): 12 | mailer = smtplib.SMTP(host, port) 13 | mailer.ehlo() 14 | mailer.starttls() 15 | 16 | return mailer 17 | 18 | 19 | class SmtpMailer(object): 20 | _MAILERS = { 21 | "tls": _build_tls_mailer, 22 | "ssl": smtplib.SMTP_SSL, 23 | "plain": smtplib.SMTP, 24 | } 25 | 26 | def __init__(self, user, password, host="", port=0, security="tls"): 27 | try: 28 | self._mailer = self._MAILERS[security] 29 | except KeyError: 30 | msg = "Incorrect value of security. Use one of the %s. Given: %s" % ( 31 | "/".join(self._MAILERS.keys()), 32 | security, 33 | ) 34 | raise ValueError(msg) 35 | 36 | self._user = user 37 | self._password = password 38 | self._host = host 39 | self._port = port 40 | 41 | def __call__(self, message): 42 | mailer = self._connect() 43 | try: 44 | return mailer.sendmail(message.sender, message.recipients, to_string(message)) 45 | finally: 46 | mailer.quit() 47 | 48 | def _connect(self): 49 | mailer = self._mailer(self._host, self._port) 50 | mailer.ehlo() 51 | mailer.login(self._user, self._password) 52 | 53 | return mailer 54 | 55 | 56 | class GmailMailer(SmtpMailer): 57 | def __init__(self, user, password): 58 | super(GmailMailer, self).__init__(user, password, "smtp.gmail.com") 59 | -------------------------------------------------------------------------------- /glhooks/mailer/messages.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import absolute_import 4 | from __future__ import division, print_function, unicode_literals 5 | 6 | from time import strftime, gmtime 7 | from email.header import make_header 8 | from email.mime.text import MIMEText 9 | from email.mime.multipart import MIMEMultipart 10 | from .utils import strip_tags, format_email_address 11 | from .attachment import Attachment 12 | from .compat import unicode_compatible, to_unicode, to_string, PY3 13 | 14 | 15 | @unicode_compatible 16 | class PlainMessage(object): 17 | """Simple wrapper for data of e-mail message with plain text.""" 18 | _PREAMBLE_TEXT = "This is a multi-part message in MIME format." 19 | 20 | def __init__(self, sender, subject, content, charset="utf-8"): 21 | self._sender = format_email_address(sender) 22 | self._charset = to_string(charset) 23 | self._content = to_unicode(content) 24 | self._subject = to_unicode(subject) 25 | 26 | self._attachments = [] 27 | self._recipients = {"To": [], "Cc": [], "Bcc": []} 28 | 29 | @property 30 | def sender(self): 31 | return self._sender 32 | 33 | @property 34 | def subject(self): 35 | return self._subject 36 | 37 | @property 38 | def recipients(self): 39 | to = self._recipients["To"] 40 | cc = self._recipients["Cc"] 41 | bcc = self._recipients["Bcc"] 42 | 43 | return frozenset(to + cc + bcc) 44 | 45 | def add_recipients(self, *recipients): 46 | recipients = self._unique_recipients(recipients) 47 | self._recipients["To"].extend(recipients) 48 | 49 | def add_recipients_cc(self, *recipients): 50 | recipients = self._unique_recipients(recipients) 51 | self._recipients["Cc"].extend(recipients) 52 | 53 | def add_recipients_bcc(self, *recipients): 54 | recipients = self._unique_recipients(recipients) 55 | self._recipients["Bcc"].extend(recipients) 56 | 57 | def _unique_recipients(self, recipients): 58 | recipients = map(format_email_address, recipients) 59 | return frozenset(recipients) - self.recipients 60 | 61 | @property 62 | def content(self): 63 | return self._content 64 | 65 | @property 66 | def payload(self): 67 | payload = self._build_content_payload(self._content) 68 | 69 | if self._attachments: 70 | content_payload = payload 71 | payload = MIMEMultipart("mixed") 72 | payload.attach(content_payload) 73 | payload.preamble = self._PREAMBLE_TEXT 74 | 75 | payload = self._set_payload_headers(payload) 76 | for attachment in self._attachments: 77 | payload.attach(attachment.payload) 78 | 79 | return payload 80 | 81 | def _build_content_payload(self, content): 82 | return MIMEText(content.encode(self._charset), "plain", self._charset) 83 | 84 | def _set_payload_headers(self, payload): 85 | for copy_type, recipients in self._recipients.items(): 86 | for recipient in recipients: 87 | payload[copy_type] = self._make_header(recipient) 88 | 89 | payload["From"] = self._make_header(self._sender) 90 | payload["Subject"] = self._make_header(self._subject) 91 | payload["Date"] = strftime("%a, %d %b %Y %H:%M:%S %z", gmtime()) 92 | 93 | return payload 94 | 95 | def _make_header(self, value): 96 | return make_header([(self._to_string(value), self._charset)]) 97 | 98 | def _to_string(self, value): 99 | if PY3: 100 | return value 101 | else: 102 | return value.encode(self._charset) 103 | 104 | def attach(self, file, charset=None, mimetype=None): 105 | if charset is None: 106 | charset = self._charset 107 | 108 | attachment = Attachment(file, charset, mimetype) 109 | self._attachments.append(attachment) 110 | 111 | return attachment 112 | 113 | if PY3: 114 | def __str__(self): 115 | return self.payload.as_string() 116 | else: 117 | def __bytes__(self): 118 | return self.payload.as_string() 119 | 120 | def __repr__(self): 121 | return to_string("" % self.subject) 122 | 123 | 124 | class HtmlMessage(PlainMessage): 125 | """Simple wrapper for data of e-mail message with HTML content.""" 126 | def _build_content_payload(self, content): 127 | content = content.encode(self._charset) 128 | payload = MIMEMultipart("alternative", charset=self._charset) 129 | 130 | text_alternative = MIMEText(strip_tags(content), "plain", self._charset) 131 | payload.attach(text_alternative) 132 | 133 | html_alternative = MIMEText(content, "html", self._charset) 134 | payload.attach(html_alternative) 135 | 136 | return payload 137 | -------------------------------------------------------------------------------- /glhooks/mailer/utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import absolute_import 4 | from __future__ import division, print_function, unicode_literals 5 | 6 | import re 7 | 8 | from .compat import string_types, to_unicode 9 | 10 | 11 | _STRIP_TAGS_PATTERN = re.compile(r"<[^>]+>", re.UNICODE) 12 | 13 | 14 | def strip_tags(value): 15 | """ 16 | Strips HTML tags from a string (http://php.net/strip_tags). 17 | Quick and dirty solution to port the PHP function of the same name :) 18 | """ 19 | return _STRIP_TAGS_PATTERN.sub("", value.decode("utf-8")) 20 | 21 | 22 | def format_email_address(address): 23 | """ 24 | Takes e-mail address in common format (sth@domain.tld) or tuple of 25 | strings (name, e-mail) and returns e-mail address in format "name ". 26 | """ 27 | if isinstance(address, string_types): 28 | return to_unicode(address) 29 | 30 | try: 31 | name, email = tuple(address) 32 | except (TypeError, ValueError): 33 | raise ValueError("E-mail address may be only string or pair of strings (name, e-mail).", address) 34 | else: 35 | return "%s <%s>" % (to_unicode(name), to_unicode(email)) 36 | -------------------------------------------------------------------------------- /glhooks/server.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import absolute_import 4 | from __future__ import division, print_function, unicode_literals 5 | 6 | import json 7 | 8 | from .mailer import HtmlMessage 9 | 10 | try: 11 | from http.server import BaseHTTPRequestHandler, HTTPServer 12 | except ImportError: 13 | from BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer 14 | 15 | from .git import Repository 16 | 17 | 18 | def start(configs, host="", port=8000): 19 | try: 20 | server = HTTPServer((host, port), GitlabWebhookHandler) 21 | server.context = configs 22 | configs.logger.info("GitLab webhooks server is starting...") 23 | server.serve_forever() 24 | except KeyboardInterrupt: 25 | configs.logger.info("GitLab webhooks server is shutting down.") 26 | finally: 27 | server.server_close() 28 | 29 | 30 | class GitlabWebhookHandler(BaseHTTPRequestHandler): 31 | RESPONSE_MESSAGE = "Python GitLab webhook handler" 32 | 33 | @property 34 | def context(self): 35 | return self.server.context 36 | 37 | def do_POST(self): 38 | data_size = int(self.headers["Content-Length"]) 39 | data = self.rfile.read(data_size) 40 | json_data = json.loads(data.decode("utf-8")) 41 | 42 | try: 43 | self.handle_commits_data(json_data) 44 | except Exception as e: 45 | self.context.logger.exception("Error during parsing of data", json_data) 46 | self._send_email(json_data, e) 47 | self._send_response_message(self.RESPONSE_MESSAGE, status_code=500) 48 | else: 49 | self._send_response_message(self.RESPONSE_MESSAGE) 50 | 51 | def handle_commits_data(self, commits_json): 52 | repo_url = commits_json["repository"]["homepage"] 53 | repo_data = self.context.find_repo(repo_url) 54 | if repo_data is None: 55 | raise Exception("No configuration found for repository.", repo_url) 56 | 57 | repo = Repository(repo_data["path"]) 58 | repo.pull(branch=repo_data.get("branch")) 59 | 60 | def _send_email(self, commits_json, exception): 61 | MESSAGE = """ 62 | An error occured during GitLab webhook at server %(server.host)s.
63 | %(exception)r
64 | Following JSON was received:

65 |
%(json)s
66 | """ % { 67 | "server.host": self.context["server"]["host"], 68 | "json": json.dumps(commits_json, indent=2), 69 | "exception": exception, 70 | } 71 | 72 | emails = self._gather_emails(commits_json) 73 | message = HtmlMessage(self.context["mailer"]["sender"], "Deploy error", MESSAGE) 74 | message.add_recipients(*emails) 75 | self.context.mailer(message) 76 | 77 | def _gather_emails(self, commits_json): 78 | emails = set() 79 | for commit in commits_json["commits"]: 80 | emails.add(commit["author"]["email"]) 81 | 82 | emails.add(self.context["server"]["email"]) 83 | return list(emails) 84 | 85 | def _send_response_message(self, message, status_code=200): 86 | message = message.encode("utf-8") 87 | 88 | self.send_response(status_code) 89 | self.send_header("Content-Type", "text/plain; charset=utf-8") 90 | self.send_header("Content-Length", str(len(message))) 91 | self.end_headers() 92 | self.wfile.write(message) 93 | 94 | def log_message(self, *args, **kwargs): 95 | self.context.logger.info(*args, **kwargs) 96 | -------------------------------------------------------------------------------- /glhooks/system.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import absolute_import 4 | from __future__ import division, print_function, unicode_literals 5 | 6 | import subprocess 7 | 8 | from os import chdir 9 | from contextlib import contextmanager 10 | 11 | 12 | try: 13 | from os import getcwdu as getcwd 14 | except ImportError: 15 | from os import getcwd 16 | 17 | 18 | @contextmanager 19 | def working_directory(path): 20 | try: 21 | cwd = getcwd() 22 | chdir(path) 23 | yield 24 | finally: 25 | chdir(cwd) 26 | 27 | 28 | def run(*args): 29 | process = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE) 30 | process.wait() 31 | 32 | if process.returncode == 0: 33 | return process.stdout 34 | 35 | # process terminated with error exit code 36 | command = " ".join(repr(a) for a in args) 37 | msg = 'Process exited with code %d: %s' % (process.returncode, command) 38 | raise Exception(msg, process.stderr) 39 | -------------------------------------------------------------------------------- /glhooks/utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import absolute_import 4 | from __future__ import division, print_function, unicode_literals 5 | 6 | from functools import wraps 7 | 8 | 9 | def cached_property(getter): 10 | """ 11 | Decorator that converts a method into memoized property. 12 | The decorator works as expected only for classes with 13 | attribute '__dict__' and immutable properties. 14 | """ 15 | @wraps(getter) 16 | def decorator(self): 17 | key = "_cached_property_" + getter.__name__ 18 | 19 | if not hasattr(self, key): 20 | setattr(self, key, getter(self)) 21 | 22 | return getattr(self, key) 23 | 24 | return property(decorator) 25 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [nosetests] 2 | with-coverage=1 3 | cover-package=glhooks 4 | cover-erase=1 5 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | try: 4 | from setuptools import setup 5 | except ImportError: 6 | from distutils.core import setup 7 | 8 | import glhooks 9 | 10 | 11 | with open("README.rst") as readme: 12 | with open("CHANGELOG.rst") as changelog: 13 | long_description = readme.read() + "\n\n" + changelog.read() 14 | 15 | 16 | setup( 17 | name="glhooks", 18 | version=glhooks.__version__, 19 | description="Service for automatic updating of git repositories from GitLab.", 20 | long_description=long_description, 21 | author="Michal Belica", 22 | author_email="miso.belica@gmail.com", 23 | url="https://github.com/miso-belica/gitlab-webhooks", 24 | license="Apache License, Version 2.0", 25 | keywords=[ 26 | "Git", 27 | "GitLab", 28 | "webhook", 29 | "hook", 30 | ], 31 | install_requires=[ 32 | "docopt>=0.6.1,<0.7", 33 | ], 34 | tests_require=[ 35 | "nose", 36 | "coverage", 37 | ], 38 | test_suite="nose.collector", 39 | packages=[ 40 | "glhooks", 41 | "glhooks.mailer", 42 | ], 43 | 44 | classifiers=[ 45 | "Development Status :: 4 - Beta", 46 | "Environment :: Console", 47 | "Intended Audience :: Developers", 48 | "Intended Audience :: System Administrators", 49 | "License :: OSI Approved :: Apache Software License", 50 | 51 | "Natural Language :: English", 52 | "Operating System :: POSIX", 53 | "Operating System :: POSIX :: Linux", 54 | 55 | "Topic :: Internet :: WWW/HTTP :: HTTP Servers", 56 | "Topic :: Software Development :: Version Control", 57 | 58 | "Programming Language :: Python", 59 | "Programming Language :: Python :: 2", 60 | "Programming Language :: Python :: 2.6", 61 | "Programming Language :: Python :: 2.7", 62 | "Programming Language :: Python :: 3", 63 | "Programming Language :: Python :: 3.2", 64 | "Programming Language :: Python :: 3.3", 65 | "Programming Language :: Python :: Implementation", 66 | "Programming Language :: Python :: Implementation :: CPython", 67 | ], 68 | ) 69 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/miso-belica/gitlab-webhooks/12e161244655a37cb795ba826149a9685ae74f70/tests/__init__.py --------------------------------------------------------------------------------