# -*- coding: utf-8 -*-
# Copyright (C) 2012-2019 RhodeCode GmbH
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License, version 3
# (only), as published by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
#
# This program is dual-licensed. If you wish to learn more about the
# RhodeCode Enterprise Edition, including its added features, Support services,
# and proprietary license terms, please see https://rhodecode.com/licenses/
from __future__ import unicode_literals
import re
import time
import textwrap
import logging
import deform
import requests
import colander
from mako.template import Template
from rhodecode import events
from rhodecode.translation import _
from rhodecode.lib import helpers as h
from rhodecode.lib.celerylib import run_task, async_task, RequestContextTask
from rhodecode.lib.colander_utils import strip_whitespace
from rhodecode.integrations.types.base import (
IntegrationTypeBase, CommitParsingDataHandler, render_with_traceback,
requests_retry_call)
log = logging.getLogger(__name__)
def html_to_slack_links(message):
return re.compile(r'(.+?)').sub(
r'<\1|\2>', message)
REPO_PUSH_TEMPLATE = Template('''
<%
def branch_text(branch):
if branch:
return 'on branch: <{}|{}>'.format(branch_commits['branch']['url'], branch_commits['branch']['name'])
else:
## case for SVN no branch push...
return 'to trunk'
%> \
% for branch, branch_commits in branches_commits.items():
${len(branch_commits['commits'])} ${'commit' if len(branch_commits['commits']) == 1 else 'commits'} ${branch_text(branch)}
% for commit in branch_commits['commits']:
`<${commit['url']}|${commit['short_id']}>` - ${commit['message_html']|html_to_slack_links}
% endfor
% endfor
''')
class SlackSettingsSchema(colander.Schema):
service = colander.SchemaNode(
colander.String(),
title=_('Slack service URL'),
description=h.literal(_(
'This can be setup at the '
''
'slack app manager')),
default='',
preparer=strip_whitespace,
validator=colander.url,
widget=deform.widget.TextInputWidget(
placeholder='https://hooks.slack.com/services/...',
),
)
username = colander.SchemaNode(
colander.String(),
title=_('Username'),
description=_('Username to show notifications coming from.'),
missing='Rhodecode',
preparer=strip_whitespace,
widget=deform.widget.TextInputWidget(
placeholder='Rhodecode'
),
)
channel = colander.SchemaNode(
colander.String(),
title=_('Channel'),
description=_('Channel to send notifications to.'),
missing='',
preparer=strip_whitespace,
widget=deform.widget.TextInputWidget(
placeholder='#general'
),
)
icon_emoji = colander.SchemaNode(
colander.String(),
title=_('Emoji'),
description=_('Emoji to use eg. :studio_microphone:'),
missing='',
preparer=strip_whitespace,
widget=deform.widget.TextInputWidget(
placeholder=':studio_microphone:'
),
)
class SlackIntegrationType(IntegrationTypeBase, CommitParsingDataHandler):
key = 'slack'
display_name = _('Slack')
description = _('Send events such as repo pushes and pull requests to '
'your slack channel.')
@classmethod
def icon(cls):
return ''''''
valid_events = [
events.PullRequestCloseEvent,
events.PullRequestMergeEvent,
events.PullRequestUpdateEvent,
events.PullRequestCommentEvent,
events.PullRequestReviewEvent,
events.PullRequestCreateEvent,
events.RepoPushEvent,
events.RepoCreateEvent,
]
def send_event(self, event):
if event.__class__ not in self.valid_events:
log.debug('event not valid: %r', event)
return
allowed_events = self.settings['events']
if event.name not in allowed_events:
log.debug('event ignored: %r event %s not in allowed events %s',
event, event.name, allowed_events)
return
data = event.as_dict()
# defaults
title = '*%s* caused a *%s* event' % (
data['actor']['username'], event.name)
text = '*%s* caused a *%s* event' % (
data['actor']['username'], event.name)
fields = None
overrides = None
log.debug('handling slack event for %s', event.name)
if isinstance(event, events.PullRequestCommentEvent):
(title, text, fields, overrides) \
= self.format_pull_request_comment_event(event, data)
elif isinstance(event, events.PullRequestReviewEvent):
title, text = self.format_pull_request_review_event(event, data)
elif isinstance(event, events.PullRequestEvent):
title, text = self.format_pull_request_event(event, data)
elif isinstance(event, events.RepoPushEvent):
title, text = self.format_repo_push_event(data)
elif isinstance(event, events.RepoCreateEvent):
title, text = self.format_repo_create_event(data)
else:
log.error('unhandled event type: %r', event)
run_task(post_text_to_slack, self.settings, title, text, fields, overrides)
def settings_schema(self):
schema = SlackSettingsSchema()
schema.add(colander.SchemaNode(
colander.Set(),
widget=deform.widget.CheckboxChoiceWidget(
values=sorted(
[(e.name, e.display_name) for e in self.valid_events]
)
),
description="Events activated for this integration",
name='events'
))
return schema
def format_pull_request_comment_event(self, event, data):
comment_text = data['comment']['text']
if len(comment_text) > 200:
comment_text = '<{comment_url}|{comment_text}...>'.format(
comment_text=comment_text[:200],
comment_url=data['comment']['url'],
)
fields = None
overrides = None
status_text = None
if data['comment']['status']:
status_color = {
'approved': '#0ac878',
'rejected': '#e85e4d'}.get(data['comment']['status'])
if status_color:
overrides = {"color": status_color}
status_text = data['comment']['status']
if data['comment']['file']:
fields = [
{
"title": "file",
"value": data['comment']['file']
},
{
"title": "line",
"value": data['comment']['line']
}
]
template = Template(textwrap.dedent(r'''
*${data['actor']['username']}* left ${data['comment']['type']} on pull request <${data['pullrequest']['url']}|#${data['pullrequest']['pull_request_id']}>:
'''))
title = render_with_traceback(
template, data=data, comment=event.comment)
template = Template(textwrap.dedent(r'''
*pull request title*: ${pr_title}
% if status_text:
*submitted status*: `${status_text}`
% endif
>>> ${comment_text}
'''))
text = render_with_traceback(
template,
comment_text=comment_text,
pr_title=data['pullrequest']['title'],
status_text=status_text)
return title, text, fields, overrides
def format_pull_request_review_event(self, event, data):
template = Template(textwrap.dedent(r'''
*${data['actor']['username']}* changed status of pull request <${data['pullrequest']['url']}|#${data['pullrequest']['pull_request_id']} to `${data['pullrequest']['status']}`>:
'''))
title = render_with_traceback(template, data=data)
template = Template(textwrap.dedent(r'''
*pull request title*: ${pr_title}
'''))
text = render_with_traceback(
template,
pr_title=data['pullrequest']['title'])
return title, text
def format_pull_request_event(self, event, data):
action = {
events.PullRequestCloseEvent: 'closed',
events.PullRequestMergeEvent: 'merged',
events.PullRequestUpdateEvent: 'updated',
events.PullRequestCreateEvent: 'created',
}.get(event.__class__, str(event.__class__))
template = Template(textwrap.dedent(r'''
*${data['actor']['username']}* `${action}` pull request <${data['pullrequest']['url']}|#${data['pullrequest']['pull_request_id']}>:
'''))
title = render_with_traceback(template, data=data, action=action)
template = Template(textwrap.dedent(r'''
*pull request title*: ${pr_title}
%if data['pullrequest']['commits']:
*commits*: ${len(data['pullrequest']['commits'])}
%endif
'''))
text = render_with_traceback(
template,
pr_title=data['pullrequest']['title'],
data=data)
return title, text
def format_repo_push_event(self, data):
branches_commits = self.aggregate_branch_data(
data['push']['branches'], data['push']['commits'])
template = Template(r'''
*${data['actor']['username']}* pushed to repo <${data['repo']['url']}|${data['repo']['repo_name']}>:
''')
title = render_with_traceback(template, data=data)
text = render_with_traceback(
REPO_PUSH_TEMPLATE,
data=data,
branches_commits=branches_commits,
html_to_slack_links=html_to_slack_links,
)
return title, text
def format_repo_create_event(self, data):
template = Template(r'''
*${data['actor']['username']}* created new repository ${data['repo']['repo_name']}:
''')
title = render_with_traceback(template, data=data)
template = Template(textwrap.dedent(r'''
repo_url: ${data['repo']['url']}
repo_type: ${data['repo']['repo_type']}
'''))
text = render_with_traceback(template, data=data)
return title, text
@async_task(ignore_result=True, base=RequestContextTask)
def post_text_to_slack(settings, title, text, fields=None, overrides=None):
log.debug('sending %s (%s) to slack %s', title, text, settings['service'])
fields = fields or []
overrides = overrides or {}
message_data = {
"fallback": text,
"color": "#427cc9",
"pretext": title,
#"author_name": "Bobby Tables",
#"author_link": "http://flickr.com/bobby/",
#"author_icon": "http://flickr.com/icons/bobby.jpg",
#"title": "Slack API Documentation",
#"title_link": "https://api.slack.com/",
"text": text,
"fields": fields,
#"image_url": "http://my-website.com/path/to/image.jpg",
#"thumb_url": "http://example.com/path/to/thumb.png",
"footer": "RhodeCode",
#"footer_icon": "",
"ts": time.time(),
"mrkdwn_in": ["pretext", "text"]
}
message_data.update(overrides)
json_message = {
"icon_emoji": settings.get('icon_emoji', ':studio_microphone:'),
"channel": settings.get('channel', ''),
"username": settings.get('username', 'Rhodecode'),
"attachments": [message_data]
}
req_session = requests_retry_call()
resp = req_session.post(settings['service'], json=json_message, timeout=60)
resp.raise_for_status() # raise exception on a failed request