diff --git a/rhodecode/events/__init__.py b/rhodecode/events/__init__.py --- a/rhodecode/events/__init__.py +++ b/rhodecode/events/__init__.py @@ -18,7 +18,10 @@ import logging -from rhodecode.events.base import RhodeCodeIntegrationEvent +from rhodecode.events.base import ( + RhodeCodeIntegrationEvent, + RhodecodeEvent +) from rhodecode.events.base import ( # pragma: no cover FtsBuild diff --git a/rhodecode/events/base.py b/rhodecode/events/base.py --- a/rhodecode/events/base.py +++ b/rhodecode/events/base.py @@ -17,6 +17,7 @@ # and proprietary license terms, please see https://rhodecode.com/licenses/ import logging import datetime +import typing from zope.cachedescriptors.property import Lazy as LazyProperty from pyramid.threadlocal import get_current_request diff --git a/rhodecode/integrations/__init__.py b/rhodecode/integrations/__init__.py --- a/rhodecode/integrations/__init__.py +++ b/rhodecode/integrations/__init__.py @@ -19,7 +19,7 @@ import sys import logging from rhodecode.integrations.registry import IntegrationTypeRegistry -from rhodecode.integrations.types import webhook, slack, hipchat, email, base +from rhodecode.integrations.types import webhook, slack, email, base from rhodecode.lib.exc_tracking import store_exception log = logging.getLogger(__name__) @@ -35,8 +35,6 @@ integration_type_registry.register_integ integration_type_registry.register_integration_type( slack.SlackIntegrationType) integration_type_registry.register_integration_type( - hipchat.HipchatIntegrationType) -integration_type_registry.register_integration_type( email.EmailIntegrationType) diff --git a/rhodecode/integrations/types/base.py b/rhodecode/integrations/types/base.py --- a/rhodecode/integrations/types/base.py +++ b/rhodecode/integrations/types/base.py @@ -20,17 +20,18 @@ import colander import string import collections import logging + import requests import urllib.request import urllib.parse import urllib.error from requests.adapters import HTTPAdapter -from requests.packages.urllib3.util.retry import Retry +from requests.packages.urllib3.util import Retry from mako import exceptions -from rhodecode.lib.utils2 import safe_str -from rhodecode.translation import _ + +from rhodecode.lib.str_utils import safe_str log = logging.getLogger(__name__) @@ -238,157 +239,6 @@ class CommitParsingDataHandler(object): return branches_commits -class WebhookDataHandler(CommitParsingDataHandler): - name = 'webhook' - - def __init__(self, template_url, headers): - self.template_url = template_url - self.headers = headers - - def get_base_parsed_template(self, data): - """ - initially parses the passed in template with some common variables - available on ALL calls - """ - # note: make sure to update the `WEBHOOK_URL_VARS` if this changes - common_vars = { - 'repo_name': data['repo']['repo_name'], - 'repo_type': data['repo']['repo_type'], - 'repo_id': data['repo']['repo_id'], - 'repo_url': data['repo']['url'], - 'username': data['actor']['username'], - 'user_id': data['actor']['user_id'], - 'event_name': data['name'] - } - - extra_vars = {} - for extra_key, extra_val in data['repo']['extra_fields'].items(): - extra_vars[f'extra__{extra_key}'] = extra_val - common_vars.update(extra_vars) - - template_url = self.template_url.replace('${extra:', '${extra__') - for k, v in common_vars.items(): - template_url = UrlTmpl(template_url).safe_substitute(**{k: v}) - return template_url - - def repo_push_event_handler(self, event, data): - url = self.get_base_parsed_template(data) - url_calls = [] - - branches_commits = self.aggregate_branch_data( - data['push']['branches'], data['push']['commits']) - if '${branch}' in url or '${branch_head}' in url or '${commit_id}' in url: - # call it multiple times, for each branch if used in variables - for branch, commit_ids in branches_commits.items(): - branch_url = UrlTmpl(url).safe_substitute(branch=branch) - - if '${branch_head}' in branch_url: - # last commit in the aggregate is the head of the branch - branch_head = commit_ids['branch_head'] - branch_url = UrlTmpl(branch_url).safe_substitute(branch_head=branch_head) - - # call further down for each commit if used - if '${commit_id}' in branch_url: - for commit_data in commit_ids['commits']: - commit_id = commit_data['raw_id'] - commit_url = UrlTmpl(branch_url).safe_substitute(commit_id=commit_id) - # register per-commit call - log.debug( - 'register %s call(%s) to url %s', - self.name, event, commit_url) - url_calls.append( - (commit_url, self.headers, data)) - - else: - # register per-branch call - log.debug('register %s call(%s) to url %s', - self.name, event, branch_url) - url_calls.append((branch_url, self.headers, data)) - - else: - log.debug('register %s call(%s) to url %s', self.name, event, url) - url_calls.append((url, self.headers, data)) - - return url_calls - - def repo_commit_comment_handler(self, event, data): - url = self.get_base_parsed_template(data) - log.debug('register %s call(%s) to url %s', self.name, event, url) - comment_vars = [ - ('commit_comment_id', data['comment']['comment_id']), - ('commit_comment_text', data['comment']['comment_text']), - ('commit_comment_type', data['comment']['comment_type']), - - ('commit_comment_f_path', data['comment']['comment_f_path']), - ('commit_comment_line_no', data['comment']['comment_line_no']), - - ('commit_comment_commit_id', data['commit']['commit_id']), - ('commit_comment_commit_branch', data['commit']['commit_branch']), - ('commit_comment_commit_message', data['commit']['commit_message']), - ] - for k, v in comment_vars: - url = UrlTmpl(url).safe_substitute(**{k: v}) - - return [(url, self.headers, data)] - - def repo_commit_comment_edit_handler(self, event, data): - url = self.get_base_parsed_template(data) - log.debug('register %s call(%s) to url %s', self.name, event, url) - comment_vars = [ - ('commit_comment_id', data['comment']['comment_id']), - ('commit_comment_text', data['comment']['comment_text']), - ('commit_comment_type', data['comment']['comment_type']), - - ('commit_comment_f_path', data['comment']['comment_f_path']), - ('commit_comment_line_no', data['comment']['comment_line_no']), - - ('commit_comment_commit_id', data['commit']['commit_id']), - ('commit_comment_commit_branch', data['commit']['commit_branch']), - ('commit_comment_commit_message', data['commit']['commit_message']), - ] - for k, v in comment_vars: - url = UrlTmpl(url).safe_substitute(**{k: v}) - - return [(url, self.headers, data)] - - def repo_create_event_handler(self, event, data): - url = self.get_base_parsed_template(data) - log.debug('register %s call(%s) to url %s', self.name, event, url) - return [(url, self.headers, data)] - - def pull_request_event_handler(self, event, data): - url = self.get_base_parsed_template(data) - log.debug('register %s call(%s) to url %s', self.name, event, url) - pr_vars = [ - ('pull_request_id', data['pullrequest']['pull_request_id']), - ('pull_request_title', data['pullrequest']['title']), - ('pull_request_url', data['pullrequest']['url']), - ('pull_request_shadow_url', data['pullrequest']['shadow_url']), - ('pull_request_commits_uid', data['pullrequest']['commits_uid']), - ] - for k, v in pr_vars: - url = UrlTmpl(url).safe_substitute(**{k: v}) - - return [(url, self.headers, data)] - - def __call__(self, event, data): - from rhodecode import events - - if isinstance(event, events.RepoPushEvent): - return self.repo_push_event_handler(event, data) - elif isinstance(event, events.RepoCreateEvent): - return self.repo_create_event_handler(event, data) - elif isinstance(event, events.RepoCommitCommentEvent): - return self.repo_commit_comment_handler(event, data) - elif isinstance(event, events.RepoCommitCommentEditEvent): - return self.repo_commit_comment_edit_handler(event, data) - elif isinstance(event, events.PullRequestEvent): - return self.pull_request_event_handler(event, data) - else: - raise ValueError( - f'event type `{event.__class__}` has no handler defined') - - def get_auth(settings): from requests.auth import HTTPBasicAuth username = settings.get('username') diff --git a/rhodecode/integrations/types/handlers/__init__.py b/rhodecode/integrations/types/handlers/__init__.py new file mode 100644 --- /dev/null +++ b/rhodecode/integrations/types/handlers/__init__.py @@ -0,0 +1,17 @@ +# Copyright (C) 2012-2023 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/ diff --git a/rhodecode/integrations/types/handlers/slack.py b/rhodecode/integrations/types/handlers/slack.py new file mode 100644 --- /dev/null +++ b/rhodecode/integrations/types/handlers/slack.py @@ -0,0 +1,242 @@ +# Copyright (C) 2012-2023 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/ +import re +import textwrap +import dataclasses +import logging +import typing + +from mako.template import Template + +from rhodecode import events +from rhodecode.integrations.types.base import CommitParsingDataHandler, render_with_traceback + +log = logging.getLogger(__name__) + + +@dataclasses.dataclass +class SlackData: + title: str + text: str + fields: list[dict] | None = None + overrides: dict | None = None + + +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 SlackDataHandler(CommitParsingDataHandler): + name = 'slack' + + def __init__(self): + pass + + def __call__(self, event: events.RhodecodeEvent, data): + if not isinstance(event, events.RhodecodeEvent): + raise TypeError(f"event {event} is not subtype of events.RhodecodeEvent") + + actor = data["actor"]["username"] + default_title = f'*{actor}* caused a *{event.name}* event' + default_text = f'*{actor}* caused a *{event.name}* event' + + default_slack_data = SlackData(title=default_title, text=default_text) + + if isinstance(event, events.PullRequestCommentEvent): + return self.format_pull_request_comment_event( + event, data, default_slack_data + ) + elif isinstance(event, events.PullRequestCommentEditEvent): + return self.format_pull_request_comment_event( + event, data, default_slack_data + ) + elif isinstance(event, events.PullRequestReviewEvent): + return self.format_pull_request_review_event(event, data, default_slack_data) + elif isinstance(event, events.PullRequestEvent): + return self.format_pull_request_event(event, data, default_slack_data) + elif isinstance(event, events.RepoPushEvent): + return self.format_repo_push_event(event, data, default_slack_data) + elif isinstance(event, events.RepoCreateEvent): + return self.format_repo_create_event(event, data, default_slack_data) + else: + raise ValueError( + f'event type `{event.__class__}` has no handler defined') + + def format_pull_request_comment_event(self, event, data, slack_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) + + slack_data.title = title + slack_data.text = text + slack_data.fields = fields + slack_data.overrides = overrides + + return slack_data + + def format_pull_request_review_event(self, event, data, slack_data) -> SlackData: + 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']) + + slack_data.title = title + slack_data.text = text + + return slack_data + + def format_pull_request_event(self, event, data, slack_data) -> SlackData: + 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) + + slack_data.title = title + slack_data.text = text + + return slack_data + + def format_repo_push_event(self, event, data, slack_data) -> SlackData: + 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, + ) + + slack_data.title = title + slack_data.text = text + + return slack_data + + def format_repo_create_event(self, event, data, slack_data) -> SlackData: + 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) + + slack_data.title = title + slack_data.text = text + + return slack_data \ No newline at end of file diff --git a/rhodecode/integrations/types/handlers/webhook.py b/rhodecode/integrations/types/handlers/webhook.py new file mode 100644 --- /dev/null +++ b/rhodecode/integrations/types/handlers/webhook.py @@ -0,0 +1,175 @@ +# Copyright (C) 2012-2023 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/ + +import logging + +from rhodecode import events +from rhodecode.integrations.types.base import CommitParsingDataHandler, UrlTmpl + + +log = logging.getLogger(__name__) + + +class WebhookDataHandler(CommitParsingDataHandler): + name = 'webhook' + + def __init__(self, template_url, headers): + self.template_url = template_url + self.headers = headers + + def get_base_parsed_template(self, data): + """ + initially parses the passed in template with some common variables + available on ALL calls + """ + # note: make sure to update the `WEBHOOK_URL_VARS` if this changes + common_vars = { + 'repo_name': data['repo']['repo_name'], + 'repo_type': data['repo']['repo_type'], + 'repo_id': data['repo']['repo_id'], + 'repo_url': data['repo']['url'], + 'username': data['actor']['username'], + 'user_id': data['actor']['user_id'], + 'event_name': data['name'] + } + + extra_vars = {} + for extra_key, extra_val in data['repo']['extra_fields'].items(): + extra_vars[f'extra__{extra_key}'] = extra_val + common_vars.update(extra_vars) + + template_url = self.template_url.replace('${extra:', '${extra__') + for k, v in common_vars.items(): + template_url = UrlTmpl(template_url).safe_substitute(**{k: v}) + return template_url + + def repo_push_event_handler(self, event, data): + url = self.get_base_parsed_template(data) + url_calls = [] + + branches_commits = self.aggregate_branch_data( + data['push']['branches'], data['push']['commits']) + if '${branch}' in url or '${branch_head}' in url or '${commit_id}' in url: + # call it multiple times, for each branch if used in variables + for branch, commit_ids in branches_commits.items(): + branch_url = UrlTmpl(url).safe_substitute(branch=branch) + + if '${branch_head}' in branch_url: + # last commit in the aggregate is the head of the branch + branch_head = commit_ids['branch_head'] + branch_url = UrlTmpl(branch_url).safe_substitute(branch_head=branch_head) + + # call further down for each commit if used + if '${commit_id}' in branch_url: + for commit_data in commit_ids['commits']: + commit_id = commit_data['raw_id'] + commit_url = UrlTmpl(branch_url).safe_substitute(commit_id=commit_id) + # register per-commit call + log.debug( + 'register %s call(%s) to url %s', + self.name, event, commit_url) + url_calls.append( + (commit_url, self.headers, data)) + + else: + # register per-branch call + log.debug('register %s call(%s) to url %s', + self.name, event, branch_url) + url_calls.append((branch_url, self.headers, data)) + + else: + log.debug('register %s call(%s) to url %s', self.name, event, url) + url_calls.append((url, self.headers, data)) + + return url_calls + + def repo_commit_comment_handler(self, event, data): + url = self.get_base_parsed_template(data) + log.debug('register %s call(%s) to url %s', self.name, event, url) + comment_vars = [ + ('commit_comment_id', data['comment']['comment_id']), + ('commit_comment_text', data['comment']['comment_text']), + ('commit_comment_type', data['comment']['comment_type']), + + ('commit_comment_f_path', data['comment']['comment_f_path']), + ('commit_comment_line_no', data['comment']['comment_line_no']), + + ('commit_comment_commit_id', data['commit']['commit_id']), + ('commit_comment_commit_branch', data['commit']['commit_branch']), + ('commit_comment_commit_message', data['commit']['commit_message']), + ] + for k, v in comment_vars: + url = UrlTmpl(url).safe_substitute(**{k: v}) + + return [(url, self.headers, data)] + + def repo_commit_comment_edit_handler(self, event, data): + url = self.get_base_parsed_template(data) + log.debug('register %s call(%s) to url %s', self.name, event, url) + comment_vars = [ + ('commit_comment_id', data['comment']['comment_id']), + ('commit_comment_text', data['comment']['comment_text']), + ('commit_comment_type', data['comment']['comment_type']), + + ('commit_comment_f_path', data['comment']['comment_f_path']), + ('commit_comment_line_no', data['comment']['comment_line_no']), + + ('commit_comment_commit_id', data['commit']['commit_id']), + ('commit_comment_commit_branch', data['commit']['commit_branch']), + ('commit_comment_commit_message', data['commit']['commit_message']), + ] + for k, v in comment_vars: + url = UrlTmpl(url).safe_substitute(**{k: v}) + + return [(url, self.headers, data)] + + def repo_create_event_handler(self, event, data): + url = self.get_base_parsed_template(data) + log.debug('register %s call(%s) to url %s', self.name, event, url) + return [(url, self.headers, data)] + + def pull_request_event_handler(self, event, data): + url = self.get_base_parsed_template(data) + log.debug('register %s call(%s) to url %s', self.name, event, url) + pr_vars = [ + ('pull_request_id', data['pullrequest']['pull_request_id']), + ('pull_request_title', data['pullrequest']['title']), + ('pull_request_url', data['pullrequest']['url']), + ('pull_request_shadow_url', data['pullrequest']['shadow_url']), + ('pull_request_commits_uid', data['pullrequest']['commits_uid']), + ] + for k, v in pr_vars: + url = UrlTmpl(url).safe_substitute(**{k: v}) + + return [(url, self.headers, data)] + + def __call__(self, event, data): + + if isinstance(event, events.RepoPushEvent): + return self.repo_push_event_handler(event, data) + elif isinstance(event, events.RepoCreateEvent): + return self.repo_create_event_handler(event, data) + elif isinstance(event, events.RepoCommitCommentEvent): + return self.repo_commit_comment_handler(event, data) + elif isinstance(event, events.RepoCommitCommentEditEvent): + return self.repo_commit_comment_edit_handler(event, data) + elif isinstance(event, events.PullRequestEvent): + return self.pull_request_event_handler(event, data) + else: + raise ValueError( + f'event type `{event.__class__}` has no handler defined') diff --git a/rhodecode/integrations/types/slack.py b/rhodecode/integrations/types/slack.py --- a/rhodecode/integrations/types/slack.py +++ b/rhodecode/integrations/types/slack.py @@ -17,15 +17,13 @@ # and proprietary license terms, please see https://rhodecode.com/licenses/ -import re + import time -import textwrap import logging -import deform -import requests +import deform # noqa +import deform.widget import colander -from mako.template import Template from rhodecode import events from rhodecode.model.validation_schema.widgets import CheckboxChoiceWidgetDesc @@ -34,34 +32,14 @@ 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__) + IntegrationTypeBase, + requests_retry_call, +) - -def html_to_slack_links(message): - return re.compile(r'(.+?)').sub( - r'<\1|\2>', message) +from rhodecode.integrations.types.handlers.slack import SlackDataHandler, SlackData -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 -''') +log = logging.getLogger(__name__) class SlackSettingsSchema(colander.Schema): @@ -111,7 +89,7 @@ class SlackSettingsSchema(colander.Schem ) -class SlackIntegrationType(IntegrationTypeBase, CommitParsingDataHandler): +class SlackIntegrationType(IntegrationTypeBase): key = 'slack' display_name = _('Slack') description = _('Send events such as repo pushes and pull requests to ' @@ -132,45 +110,6 @@ class SlackIntegrationType(IntegrationTy events.RepoCreateEvent, ] - def send_event(self, event): - log.debug('handling event %s with integration %s', event.name, self) - - if event.__class__ not in self.valid_events: - log.debug('event %r not present in valid event list (%s)', event, self.valid_events) - return - - if not self.event_enabled(event): - 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 - - if isinstance(event, events.PullRequestCommentEvent): - (title, text, fields, overrides) \ - = self.format_pull_request_comment_event(event, data) - elif isinstance(event, events.PullRequestCommentEditEvent): - (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( @@ -186,137 +125,31 @@ class SlackIntegrationType(IntegrationTy 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'] + def send_event(self, event): + log.debug('handling event %s with integration %s', event.name, self) - 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) + if event.__class__ not in self.valid_events: + log.debug('event %r not present in valid event list (%s)', event, self.valid_events) + return - template = Template(textwrap.dedent(r''' - *pull request title*: ${pr_title} - ''')) - text = render_with_traceback( - template, - pr_title=data['pullrequest']['title']) - - return title, text + if not self.event_enabled(event): + return - 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) + data = event.as_dict() - 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 + handler = SlackDataHandler() + slack_data = handler(event, data) + # title, text, fields, overrides + run_task(post_text_to_slack, self.settings, slack_data) @async_task(ignore_result=True, base=RequestContextTask) -def post_text_to_slack(settings, title, text, fields=None, overrides=None): +def post_text_to_slack(settings, slack_data: SlackData): + title = slack_data.title + text = slack_data.text + fields = slack_data.fields + overrides = slack_data.overrides + log.debug('sending %s (%s) to slack %s', title, text, settings['service']) fields = fields or [] diff --git a/rhodecode/integrations/types/webhook.py b/rhodecode/integrations/types/webhook.py --- a/rhodecode/integrations/types/webhook.py +++ b/rhodecode/integrations/types/webhook.py @@ -17,7 +17,7 @@ # and proprietary license terms, please see https://rhodecode.com/licenses/ - +import deform # noqa import deform.widget import logging import colander @@ -28,8 +28,14 @@ from rhodecode.lib.colander_utils import from rhodecode.model.validation_schema.widgets import CheckboxChoiceWidgetDesc from rhodecode.translation import _ from rhodecode.integrations.types.base import ( - IntegrationTypeBase, get_auth, get_web_token, get_url_vars, - WebhookDataHandler, WEBHOOK_URL_VARS, requests_retry_call) + IntegrationTypeBase, + get_auth, + get_web_token, + get_url_vars, + WEBHOOK_URL_VARS, + requests_retry_call, +) +from rhodecode.integrations.types.handlers.webhook import WebhookDataHandler from rhodecode.lib.celerylib import run_task, async_task, RequestContextTask from rhodecode.model.validation_schema import widgets @@ -57,7 +63,7 @@ class WebhookSettingsSchema(colander.Sch widget=widgets.CodeMirrorWidget( help_block_collapsable_name='Show url variables', help_block_collapsable=( - 'E.g http://my-serv.com/trigger_job/${{event_name}}' + 'E.g https://my-serv.com/trigger_job/${{event_name}}' '?PR_ID=${{pull_request_id}}' '\nFull list of vars:\n{}'.format(URL_VARS)), codemirror_mode='text', @@ -189,11 +195,11 @@ class WebhookIntegrationType(Integration url_calls = handler(event, data) log.debug('Webhook: calling following urls: %s', [x[0] for x in url_calls]) - run_task(post_to_webhook, url_calls, self.settings) + run_task(post_to_webhook, self.settings, url_calls) @async_task(ignore_result=True, base=RequestContextTask) -def post_to_webhook(url_calls, settings): +def post_to_webhook(settings, url_calls): """ Example data:: diff --git a/rhodecode/tests/integrations/conftest.py b/rhodecode/tests/integrations/conftest.py --- a/rhodecode/tests/integrations/conftest.py +++ b/rhodecode/tests/integrations/conftest.py @@ -32,23 +32,27 @@ def repo_push_event(backend, user_regula {'message': 'this is because 5b23c3532 broke stuff'}, {'message': 'last commit'}, ] - commit_ids = backend.create_master_repo(commits).values() - repo = backend.create_repo() + r = backend.create_repo(commits) + + commit_ids = list(backend.commit_ids.values()) + repo_name = backend.repo_name + alias = backend.alias + scm_extras = AttributeDict({ 'ip': '127.0.0.1', 'username': user_regular.username, 'user_id': user_regular.user_id, 'action': '', - 'repository': repo.repo_name, - 'scm': repo.scm_instance().alias, + 'repository': repo_name, + 'scm': alias, 'config': '', 'repo_store': '', - 'server_url': 'http://example.com', + 'server_url': 'http://httpbin:9090', 'make_lock': None, 'locked_by': [None], 'commit_ids': commit_ids, }) - return events.RepoPushEvent(repo_name=repo.repo_name, + return events.RepoPushEvent(repo_name=repo_name, pushed_commit_ids=commit_ids, extras=scm_extras) diff --git a/rhodecode/tests/integrations/test_integration.py b/rhodecode/tests/integrations/test_integration.py --- a/rhodecode/tests/integrations/test_integration.py +++ b/rhodecode/tests/integrations/test_integration.py @@ -1,4 +1,3 @@ - # Copyright (C) 2010-2023 RhodeCode GmbH # # This program is free software: you can redistribute it and/or modify diff --git a/rhodecode/tests/integrations/test_slack.py b/rhodecode/tests/integrations/test_slack.py --- a/rhodecode/tests/integrations/test_slack.py +++ b/rhodecode/tests/integrations/test_slack.py @@ -1,4 +1,3 @@ - # Copyright (C) 2010-2023 RhodeCode GmbH # # This program is free software: you can redistribute it and/or modify @@ -18,11 +17,43 @@ # and proprietary license terms, please see https://rhodecode.com/licenses/ import pytest +import mock from mock import patch from rhodecode import events +from rhodecode.integrations.types.handlers.slack import SlackDataHandler from rhodecode.model.db import Session, Integration from rhodecode.integrations.types.slack import SlackIntegrationType +from rhodecode.tests import GIT_REPO + + +@pytest.fixture() +def base_slack_data(): + return { + "pullrequest": { + "url": "https://example.com/pr1", + "pull_request_id": "1", + "title": "started pr", + "status": "new", + "commits": ["1", "2"], + "shadow_url": "http://shadow-url", + }, + "actor": {"username": "foo-user"}, + "comment": { + "comment_id": 1, + "text": "test-comment", + "status": "approved", + "file": "text.py", + "line": "1", + "type": "note", + }, + "push": {"branches": "", "commits": []}, + "repo": { + "url": "https://example.com/repo1", + "repo_name": GIT_REPO, + "repo_type": "git", + }, + } @pytest.fixture() @@ -52,14 +83,66 @@ def slack_integration(request, app, slac return integration +@pytest.fixture() +def slack_integration_empty(request, app, slack_settings): + slack_settings['events'] = [] + integration = Integration() + integration.name = 'test slack integration' + integration.enabled = True + integration.integration_type = SlackIntegrationType.key + integration.settings = slack_settings + Session().add(integration) + Session().commit() + request.addfinalizer(lambda: Session().delete(integration)) + return integration + + def test_slack_push(slack_integration, repo_push_event): + with patch('rhodecode.integrations.types.slack.post_text_to_slack') as call: events.trigger(repo_push_event) - assert 'pushed to' in call.call_args[0][1] + + assert 'pushed to' in call.call_args[0][1].title + # specific commit was parsed and serialized + assert 'change that fixes #41' in call.call_args[0][1].text - slack_integration.settings['events'] = [] - Session().commit() + +def test_slack_push_no_events(slack_integration_empty, repo_push_event): + + assert Integration.get(slack_integration_empty.integration_id).settings['events'] == [] with patch('rhodecode.integrations.types.slack.post_text_to_slack') as call: events.trigger(repo_push_event) assert not call.call_args + + +def test_slack_data_handler_wrong_event(): + handler = SlackDataHandler() + data = {"actor": {"username": "foo-user"}} + with pytest.raises(ValueError): + handler(events.RhodecodeEvent(), data) + + +@pytest.mark.parametrize("event_type, args", [ + ( + events.PullRequestCommentEvent, + (mock.MagicMock(name="pull-request"), mock.MagicMock(name="comment")), + ), + ( + events.PullRequestCommentEditEvent, + (mock.MagicMock(name="pull-request"), mock.MagicMock(name="comment")), + ), + ( + events.PullRequestReviewEvent, + (mock.MagicMock(name="pull-request"), mock.MagicMock(name="status")), + ), + ( + events.RepoPushEvent, + (GIT_REPO, mock.MagicMock(name="pushed_commit_ids"), mock.MagicMock(name="extras")), + ), + (events.PullRequestEvent, (mock.MagicMock(), )), + (events.RepoCreateEvent, (mock.MagicMock(), )), +]) +def test_slack_data_handler(app, event_type: events.RhodecodeEvent, args, base_slack_data): + handler = SlackDataHandler() + handler(event_type(*args), base_slack_data) diff --git a/rhodecode/tests/integrations/test_webhook.py b/rhodecode/tests/integrations/test_webhook.py --- a/rhodecode/tests/integrations/test_webhook.py +++ b/rhodecode/tests/integrations/test_webhook.py @@ -18,10 +18,13 @@ # and proprietary license terms, please see https://rhodecode.com/licenses/ import pytest +import mock +from mock import patch from rhodecode import events from rhodecode.lib.utils2 import AttributeDict from rhodecode.integrations.types.webhook import WebhookDataHandler +from rhodecode.tests import GIT_REPO @pytest.fixture() @@ -38,7 +41,33 @@ def base_data(): 'actor': { 'username': 'actor_name', 'user_id': 1 - } + }, + "pullrequest": { + "url": "https://example.com/pr1", + "pull_request_id": "1", + "title": "started pr", + "status": "new", + "commits_uid": ["1", "2"], + "shadow_url": "http://shadow-url", + }, + "comment": { + "comment_id": 1, + "comment_text": "test-comment", + "status": "approved", + "comment_f_path": "text.py", + "comment_line_no": "1", + "comment_type": "note", + }, + "commit": { + "commit_id": "efefef", + "commit_branch": "master", + "commit_message": "changed foo" + }, + "push": { + "branches": "", + "commits": [] + }, + } @@ -131,3 +160,37 @@ def test_webook_parse_url_for_push_event urls = handler(repo_push_event, base_data) assert urls == [ (url, headers, base_data) for url in expected_urls] + + +@pytest.mark.parametrize("event_type, args", [ + ( + events.RepoPushEvent, + (GIT_REPO, mock.MagicMock(name="pushed_commit_ids"), mock.MagicMock(name="extras")), + ), + ( + events.RepoCreateEvent, + (GIT_REPO,), + ), + ( + events.RepoCommitCommentEvent, + (GIT_REPO, mock.MagicMock(name="commit"), mock.MagicMock(name="comment")), + ), + ( + events.RepoCommitCommentEditEvent, + (GIT_REPO, mock.MagicMock(name="commit"), mock.MagicMock(name="comment")), + ), + ( + events.PullRequestEvent, + (mock.MagicMock(), ), + ), +]) +def test_webhook_data_handler(app, event_type: events.RhodecodeEvent, args, base_data): + handler = WebhookDataHandler( + template_url='http://server.com/${branch}/${commit_id}', + headers={'exmaple-header': 'header-values'} + ) + handler(event_type(*args), base_data) + + + + diff --git a/rhodecode/tests/vcs/test_commits.py b/rhodecode/tests/vcs/test_commits.py --- a/rhodecode/tests/vcs/test_commits.py +++ b/rhodecode/tests/vcs/test_commits.py @@ -331,7 +331,7 @@ class TestCommits(BackendTestMixin): assert line_no == 1 assert commit_id == file_added_commit.raw_id assert commit_loader() == file_added_commit - assert 'Foobar 3' in line + assert b'Foobar 3' in line def test_get_file_annotate_does_not_exist(self): file_added_commit = self.repo.get_commit(commit_idx=2)