├── .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
--------------------------------------------------------------------------------