diff --git a/build/lib/mattermost_gitlab/__init__.py b/build/lib/mattermost_gitlab/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/build/lib/mattermost_gitlab/constants.py b/build/lib/mattermost_gitlab/constants.py new file mode 100644 index 0000000..10fca16 --- /dev/null +++ b/build/lib/mattermost_gitlab/constants.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# Python Future imports +from __future__ import unicode_literals, absolute_import, print_function + +# Python System imports + +PUSH_EVENT = 'push' +ISSUE_EVENT = 'issue' +TAG_EVENT = 'tag_push' +COMMENT_EVENT = 'note' +MERGE_EVENT = 'merge_request' +CI_EVENT = 'ci' diff --git a/build/lib/mattermost_gitlab/event_formatter.py b/build/lib/mattermost_gitlab/event_formatter.py new file mode 100644 index 0000000..a8a1079 --- /dev/null +++ b/build/lib/mattermost_gitlab/event_formatter.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# Python Future imports +from __future__ import unicode_literals, absolute_import, print_function + +# Python System imports +import re + +from . import constants + + +def fix_gitlab_links(base_url, text): + """ + Fixes gitlab upload links that are relative and makes them absolute + """ + + matches = re.findall(r'(\[[^]]*\]\s*\((/[^)]+)\))', text) + + for (replace_string, link) in matches: + new_string = replace_string.replace(link, base_url + link) + text = text.replace(replace_string, new_string) + + return text + + +def add_markdown_quotes(text): + """ + Add Markdown quotes around a piece of text + """ + + if not text: + return '' + + split_desc = text.split('\n') + + for index, line in enumerate(split_desc): + split_desc[index] = '> ' + line + + return '\n'.join(split_desc) + + +class BaseEvent(object): + + def __init__(self, data): + self.data = data + self.object_kind = data['object_kind'] + + @property + def push_event(self): + raise NotImplementedError + + def should_report_event(self, report_events): + return report_events[self.object_kind] + + def format(self): + raise NotImplementedError + + def gitlab_user_url(self, username): + base_url = '/'.join(self.data['repository']['homepage'].split('/')[:-2]) + return '{}/u/{}'.format(base_url, username) + + +class PushEvent(BaseEvent): + + def format(self): + + if self.data['before'] == '0' * 40: + description = 'the first commit' + else: + description = '{} commit'.format(self.data['total_commits_count']) + if self.data['total_commits_count'] > 1: + description += "s" + + return '%s pushed %s into the `%s` branch for project [%s](%s).' % ( + self.data['user_name'], + description, + self.data['ref'], + self.data['repository']['name'], + self.data['repository']['homepage'] + ) + + +class IssueEvent(BaseEvent): + + @property + def action(self): + return self.data['object_attributes']['action'] + + def should_report_event(self, report_events): + return super(IssueEvent, self).should_report_event(report_events) and self.action != "update" + + def format(self): + description = add_markdown_quotes(self.data['object_attributes']['description']) + + if self.action == 'open': + verbose_action = 'created' + elif self.action == 'reopen': + verbose_action = 'reopened' + elif self.action == 'update': + verbose_action = 'updated' + elif self.action == 'close': + verbose_action = 'closed' + else: + raise NotImplementedError("Unsupported action %s for issue event" % self.action) + + text = '#### [%s](%s)\n*[Issue #%s](%s) %s by %s in [%s](%s) on [%s](%s)*\n %s' % ( + self.data['object_attributes']['title'], + self.data['object_attributes']['url'], + self.data['object_attributes']['iid'], + self.data['object_attributes']['url'], + verbose_action, + self.data['user']['username'], + self.data['repository']['name'], + self.data['repository']['homepage'], + self.data['object_attributes']['created_at'], + self.data['object_attributes']['url'], + description + ) + + base_url = self.data['repository']['homepage'] + + return fix_gitlab_links(base_url, text) + + +class TagEvent(BaseEvent): + def format(self): + return '%s pushed tag `%s` to the project [%s](%s).' % ( + self.data['user_name'], + self.data['ref'], + self.data['repository']['name'], + self.data['repository']['homepage'] + ) + + +class NoteEvent(BaseEvent): + def format(self): + symbol = '' + type_grammar = 'a' + note_type = self.data['object_attributes']['noteable_type'].lower() + note_id = '' + parent_title = '' + + if note_type == 'mergerequest': + symbol = '!' + note_id = self.data['merge_request']['iid'] + parent_title = self.data['merge_request']['title'] + note_type = 'merge request' + elif note_type == 'snippet': + symbol = '$' + note_id = self.data['snippet']['iid'] + parent_title = self.data['snippet']['title'] + elif note_type == 'issue': + symbol = '#' + note_id = self.data['issue']['iid'] + parent_title = self.data['issue']['title'] + type_grammar = 'an' + + subtitle = '' + if note_type == 'commit': + subtitle = '%s' % self.data['commit']['id'] + else: + subtitle = '%s%s - %s' % (symbol, note_id, parent_title) + + description = add_markdown_quotes(self.data['object_attributes']['note']) + + text = '#### **New Comment** on [%s](%s)\n*[%s](%s) commented on %s %s in [%s](%s) on [%s](%s)*\n %s' % ( + subtitle, + self.data['object_attributes']['url'], + self.data['user']['username'], + self.gitlab_user_url(self.data['user']['username']), + type_grammar, + note_type, + self.data['repository']['name'], + self.data['repository']['homepage'], + self.data['object_attributes']['created_at'], + self.data['object_attributes']['url'], + description + ) + + base_url = self.data['repository']['homepage'] + + return fix_gitlab_links(base_url, text) + + +class MergeEvent(BaseEvent): + + @property + def action(self): + return self.data['object_attributes']['action'] + + def format(self): + + if self.action == 'open': + text_action = 'created a' + elif self.action == 'reopen': + text_action = 'reopened a' + elif self.action == 'update': + text_action = 'updated a' + elif self.action == 'merge': + text_action = 'accepted a' + elif self.action == 'close': + text_action = 'closed a' + else: + raise NotImplementedError('Unsupported action %s for merge event' % self.action) + + text = '#### [!%s - %s](%s)\n*[%s](%s) %s merge request in [%s](%s) on [%s](%s)*' % ( + self.data['object_attributes']['iid'], + self.data['object_attributes']['title'], + self.data['object_attributes']['url'], + self.data['user']['username'], + self.gitlab_user_url(self.data['user']['username']), + text_action, + self.data['object_attributes']['target']['name'], + self.data['object_attributes']['target']['web_url'], + self.data['object_attributes']['created_at'], + self.data['object_attributes']['url'] + ) + + if self.action == 'open': + description = add_markdown_quotes(self.data['object_attributes']['description']) + text = '%s\n %s' % ( + text, + description + ) + + base_url = self.data['object_attributes']['target']['web_url'] + + return fix_gitlab_links(base_url, text) + + +class CIEvent(BaseEvent): + + def __init__(self, data): + self.data = data + self.object_kind = "ci" + + def format(self): + icon = ':white_check_mark:' if self.data['build_status'] == "success" else ':x:' + return '%s %s build for the project [%s](%s) on commit %s.' % ( + icon, + self.data['build_status'].title(), + self.data['project_name'], + self.data['gitlab_url'], + self.data['sha'], + ) + + +EVENT_CLASS_MAP = { + constants.PUSH_EVENT: PushEvent, + constants.ISSUE_EVENT: IssueEvent, + constants.TAG_EVENT: TagEvent, + constants.COMMENT_EVENT: NoteEvent, + constants.MERGE_EVENT: MergeEvent, +} + + +def as_event(data): + if data['object_kind'] in EVENT_CLASS_MAP: + return EVENT_CLASS_MAP[data['object_kind']](data) + else: + raise NotImplementedError('Unsupported event of type %s' % data['object_kind']) diff --git a/build/lib/mattermost_gitlab/mock_http.py b/build/lib/mattermost_gitlab/mock_http.py new file mode 100644 index 0000000..a158085 --- /dev/null +++ b/build/lib/mattermost_gitlab/mock_http.py @@ -0,0 +1,181 @@ +# -*- coding: utf-8 -*- + +# Python Future imports +from __future__ import unicode_literals, absolute_import, print_function + +# Python System imports +import threading +from six.moves.SimpleHTTPServer import SimpleHTTPRequestHandler +from six.moves.BaseHTTPServer import HTTPServer +from six.moves.http_client import HTTPConnection +import socket + +# Django imports +# from django import ... + +# Third-party imports + +# Smart impulse common modules +# from smartimpulse import ... + +# Relative imports + +""" +code from http://www.ianlewis.org/en/testing-using-mocked-server + +import threading +import mock +import gc + +setup: + def setUp(self): + self.cond = threading.Condition() + self.server = mock.http.TestServer(port=9854, self.cond) + self.cond.acquire() + self.server.start() + + # Wait until the server is ready + while not self.server.ready: + # Collect left over servers so they release their + # sockets + import gc + gc.collect() + self.cond.wait() + + self.cond.release() + +tearDown: + def tearDown(self): + self.server.stop_server() + self.server = None +""" + + +def get_available_port(): + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.bind(('localhost', 0)) + __, port = sock.getsockname() + sock.close() + return port + + +class StoppableHttpServer(HTTPServer): + """http server that reacts to self.stop flag""" + + received_requests = [] + allow_reuse_address = True + + def serve_forever(self, poll_interval=0.5): + """Handle one request at a time until stopped.""" + self.stop = False + while not self.stop: + self.handle_request() + + +class TestRequestHandler(SimpleHTTPRequestHandler): + + def log_request(self, *args, **kwargs): + pass + + def do_POST(self): + """send 200 OK response, with 'OK' as content""" + # extract any POSTDATA + self.data = "" + if "Content-Length" in self.headers: + self.data = self.rfile.read(int(self.headers["Content-Length"])) + + self.server.received_requests.append({ + 'post': self.data, + }) + + self.send_response(200) + self.end_headers() + self.wfile.write('OK\n'.encode()) + + def do_QUIT(self): + """send 200 OK response, and set server.stop to True""" + self.send_response(200) + self.end_headers() + self.server.stop = True + + +class TestServer(threading.Thread): + """HTTP Server that runs in a thread and handles a predetermined number of requests""" + TIMEOUT = 10 + + def __init__(self, port, cond=None): + threading.Thread.__init__(self) + self.port = port + self.ready = False + self.cond = cond + + def run(self): + self.cond.acquire() + timeout = 0 + self.httpd = None + while self.httpd is None: + try: + self.httpd = StoppableHttpServer(('', self.port), TestRequestHandler) + except Exception as exc: + import socket + import errno + import time + if isinstance(exc, socket.error) and errno.errorcode[exc.args[0]] == 'EADDRINUSE' and timeout < self.TIMEOUT: + timeout += 1 + time.sleep(1) + else: + print(exc) + self.cond.notifyAll() + self.cond.release() + self.ready = True + raise exc + + self.ready = True + if self.cond: + self.cond.notifyAll() + self.cond.release() + self.httpd.serve_forever() + + def stop_server(self): + """send QUIT request to http server running on localhost:""" + conn = HTTPConnection("127.0.0.1:{}".format(self.port)) + conn.request("QUIT", "/") + conn.getresponse() + + +class MockHttpServerMixin(object): + + port = 9854 + + def setUp(self): + super(MockHttpServerMixin, self).setUp() + + try: + self.server.httpd.received_requests = [] + except AssertionError: + pass + + @classmethod + def setUpClass(cls): + super(MockHttpServerMixin, cls).setUpClass() + cls.cond = threading.Condition() + cls.server = TestServer(port=cls.port, cond=cls.cond) + cls.cond.acquire() + cls.server.start() + + # Wait until the server is ready + while not cls.server.ready: + # Collect left over servers so they release their + # sockets + import gc + gc.collect() + cls.cond.wait() + + cls.cond.release() + + @classmethod + def tearDownClass(cls): + super(MockHttpServerMixin, cls).tearDownClass() + cls.server.stop_server() + cls.server.httpd.server_close() + cls.server = None diff --git a/build/lib/mattermost_gitlab/server.py b/build/lib/mattermost_gitlab/server.py new file mode 100644 index 0000000..64eb162 --- /dev/null +++ b/build/lib/mattermost_gitlab/server.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# Python Future imports +from __future__ import unicode_literals, absolute_import, print_function + +# Python System imports +import requests +import json +import argparse + + +# Third-party imports +from flask import Flask, request + +from . import event_formatter, constants + + +app = Flask(__name__) + + +@app.route('/') +def root(): + """ + Home handler + """ + + return "OK" + + +@app.route('/new_event', methods=['POST']) +def new_event(): + """ + GitLab event handler, handles POST events from a GitLab project + """ + + if request.json is None: + print('Invalid Content-Type') + return 'Content-Type must be application/json and the request body must contain valid JSON', 400 + elif 'webhook_url' not in request.args: + print('Webhook URL not set') + return 'Webhook URL must be set' + + try: + event = event_formatter.as_event(request.json) + webhook_url = request.args['webhook_url'] + + if event.should_report_event(app.config['REPORT_EVENTS']): + text = event.format() + post_text(text, webhook_url) + except Exception: + import traceback + traceback.print_exc() + + return 'OK' + + +@app.route('/new_ci_event', methods=['POST']) +def new_ci_event(): + """ + GitLab event handler, handles POST events from a GitLab CI project + """ + + if request.json is None: + print('Invalid Content-Type') + return 'Content-Type must be application/json and the request body must contain valid JSON', 400 + elif 'webhook_url' not in request.args: + print('Webhok URL not set') + return 'Webhook URL must be set' + + try: + event = event_formatter.CIEvent(request.json) + webhook_url = request.args['webhook_url'] + + if event.should_report_event(app.config['REPORT_EVENTS']): + text = event.format() + post_text(text, webhook_url) + except Exception: + import traceback + traceback.print_exc() + + return 'OK' + + +def post_text(text, mattermost_webhook_url): + """ + Mattermost POST method, posts text to the Mattermost incoming webhook URL + """ + + data = {} + data['text'] = text.strip() + if app.config['USERNAME']: + data['username'] = app.config['USERNAME'] + if app.config['ICON_URL']: + data['icon_url'] = app.config['ICON_URL'] + if app.config['CHANNEL']: + data['channel'] = app.config['CHANNEL'] + + headers = {'Content-Type': 'application/json'} + if app.config['VERIFY_SSL'].lower() == 'false': + resp = requests.post(mattermost_webhook_url, headers=headers, data=json.dumps(data), verify=False) + else: + resp = requests.post(mattermost_webhook_url, headers=headers, data=json.dumps(data), verify=True) + + if resp.status_code is not requests.codes.ok: + print('Encountered error posting to Mattermost URL %s, status=%d, response_body=%s' % (app.config['MATTERMOST_WEBHOOK_URL'], resp.status_code, resp.json())) + + +def parse_args(args=None): + parser = argparse.ArgumentParser() + server_options = parser.add_argument_group("Server") + server_options.add_argument('-p', '--port', type=int, default=5000) + server_options.add_argument('--host', default='0.0.0.0') + server_options.add_argument('--verify-ssl', dest='VERIFY_SSL', default=True) + + parser.add_argument('-u', '--username', dest='USERNAME', default='gitlab') + parser.add_argument('--channel', dest='CHANNEL', default='') # Leave this blank to post to the default channel of your webhook + parser.add_argument('--icon', dest='ICON_URL', default='https://gitlab.com/uploads/project/avatar/13083/gitlab-logo-square.png') + + event_options = parser.add_argument_group("Events") + + event_options.add_argument( + '--push', + action='store_true', + dest=constants.PUSH_EVENT, + help='On pushes to the repository excluding tags' + ) + event_options.add_argument( + '--tag', + action='store_true', + dest=constants.TAG_EVENT, + help='On creation of tags' + ) + event_options.add_argument( + '--no-issue', + action='store_false', + dest=constants.ISSUE_EVENT, + help='On creation of a new issue' + ) + event_options.add_argument( + '--no-comment', + action='store_false', + dest=constants.COMMENT_EVENT, + help='When a new comment is made on commits, merge requests, issues, and code snippets' + ) + event_options.add_argument( + '--no-merge-request', + action='store_false', + dest=constants.MERGE_EVENT, + help='When a merge request is created' + ) + event_options.add_argument( + '--no-ci', + action='store_false', + dest=constants.CI_EVENT, + help='On Continuous Integration events' + ) + + options = vars(parser.parse_args(args=args)) + + host, port = options.pop("host"), options.pop("port") + + options["REPORT_EVENTS"] = { + constants.PUSH_EVENT: options.pop(constants.PUSH_EVENT), + constants.TAG_EVENT: options.pop(constants.TAG_EVENT), + constants.ISSUE_EVENT: options.pop(constants.ISSUE_EVENT), + constants.COMMENT_EVENT: options.pop(constants.COMMENT_EVENT), + constants.MERGE_EVENT: options.pop(constants.MERGE_EVENT), + constants.CI_EVENT: options.pop(constants.CI_EVENT), + } + + return host, port, options + + +def main(): + host, port, options = parse_args() + app.config.update(options) + + app.run(host=host, port=port) + + +if __name__ == "__main__": + + main() diff --git a/dist/mattermost_integration_gitlab-0.1.0-py2.7.egg b/dist/mattermost_integration_gitlab-0.1.0-py2.7.egg new file mode 100644 index 0000000..ce0fa4f Binary files /dev/null and b/dist/mattermost_integration_gitlab-0.1.0-py2.7.egg differ diff --git a/mattermost_gitlab/server.py b/mattermost_gitlab/server.py index e3d9870..64eb162 100644 --- a/mattermost_gitlab/server.py +++ b/mattermost_gitlab/server.py @@ -37,13 +37,17 @@ def new_event(): if request.json is None: print('Invalid Content-Type') return 'Content-Type must be application/json and the request body must contain valid JSON', 400 + elif 'webhook_url' not in request.args: + print('Webhook URL not set') + return 'Webhook URL must be set' try: event = event_formatter.as_event(request.json) + webhook_url = request.args['webhook_url'] if event.should_report_event(app.config['REPORT_EVENTS']): text = event.format() - post_text(text) + post_text(text, webhook_url) except Exception: import traceback traceback.print_exc() @@ -60,13 +64,17 @@ def new_ci_event(): if request.json is None: print('Invalid Content-Type') return 'Content-Type must be application/json and the request body must contain valid JSON', 400 + elif 'webhook_url' not in request.args: + print('Webhok URL not set') + return 'Webhook URL must be set' try: event = event_formatter.CIEvent(request.json) + webhook_url = request.args['webhook_url'] if event.should_report_event(app.config['REPORT_EVENTS']): text = event.format() - post_text(text) + post_text(text, webhook_url) except Exception: import traceback traceback.print_exc() @@ -74,7 +82,7 @@ def new_ci_event(): return 'OK' -def post_text(text): +def post_text(text, mattermost_webhook_url): """ Mattermost POST method, posts text to the Mattermost incoming webhook URL """ @@ -89,7 +97,10 @@ def post_text(text): data['channel'] = app.config['CHANNEL'] headers = {'Content-Type': 'application/json'} - resp = requests.post(app.config['MATTERMOST_WEBHOOK_URL'], headers=headers, data=json.dumps(data)) + if app.config['VERIFY_SSL'].lower() == 'false': + resp = requests.post(mattermost_webhook_url, headers=headers, data=json.dumps(data), verify=False) + else: + resp = requests.post(mattermost_webhook_url, headers=headers, data=json.dumps(data), verify=True) if resp.status_code is not requests.codes.ok: print('Encountered error posting to Mattermost URL %s, status=%d, response_body=%s' % (app.config['MATTERMOST_WEBHOOK_URL'], resp.status_code, resp.json())) @@ -97,11 +108,10 @@ def post_text(text): def parse_args(args=None): parser = argparse.ArgumentParser() - parser.add_argument('MATTERMOST_WEBHOOK_URL', help='The Mattermost webhook URL you created') - server_options = parser.add_argument_group("Server") server_options.add_argument('-p', '--port', type=int, default=5000) server_options.add_argument('--host', default='0.0.0.0') + server_options.add_argument('--verify-ssl', dest='VERIFY_SSL', default=True) parser.add_argument('-u', '--username', dest='USERNAME', default='gitlab') parser.add_argument('--channel', dest='CHANNEL', default='') # Leave this blank to post to the default channel of your webhook