##// END OF EJS Templates
integrations: restructure code and added better coverage.
super-admin -
r5123:1d3bc909 default
parent child Browse files
Show More
@@ -0,0 +1,17 b''
1 # Copyright (C) 2012-2023 RhodeCode GmbH
2 #
3 # This program is free software: you can redistribute it and/or modify
4 # it under the terms of the GNU Affero General Public License, version 3
5 # (only), as published by the Free Software Foundation.
6 #
7 # This program is distributed in the hope that it will be useful,
8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10 # GNU General Public License for more details.
11 #
12 # You should have received a copy of the GNU Affero General Public License
13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
14 #
15 # This program is dual-licensed. If you wish to learn more about the
16 # RhodeCode Enterprise Edition, including its added features, Support services,
17 # and proprietary license terms, please see https://rhodecode.com/licenses/
@@ -0,0 +1,242 b''
1 # Copyright (C) 2012-2023 RhodeCode GmbH
2 #
3 # This program is free software: you can redistribute it and/or modify
4 # it under the terms of the GNU Affero General Public License, version 3
5 # (only), as published by the Free Software Foundation.
6 #
7 # This program is distributed in the hope that it will be useful,
8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10 # GNU General Public License for more details.
11 #
12 # You should have received a copy of the GNU Affero General Public License
13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
14 #
15 # This program is dual-licensed. If you wish to learn more about the
16 # RhodeCode Enterprise Edition, including its added features, Support services,
17 # and proprietary license terms, please see https://rhodecode.com/licenses/
18 import re
19 import textwrap
20 import dataclasses
21 import logging
22 import typing
23
24 from mako.template import Template
25
26 from rhodecode import events
27 from rhodecode.integrations.types.base import CommitParsingDataHandler, render_with_traceback
28
29 log = logging.getLogger(__name__)
30
31
32 @dataclasses.dataclass
33 class SlackData:
34 title: str
35 text: str
36 fields: list[dict] | None = None
37 overrides: dict | None = None
38
39
40 def html_to_slack_links(message):
41 return re.compile(r'<a .*?href=["\'](.+?)".*?>(.+?)</a>').sub(r'<\1|\2>', message)
42
43
44 REPO_PUSH_TEMPLATE = Template('''
45 <%
46 def branch_text(branch):
47 if branch:
48 return 'on branch: <{}|{}>'.format(branch_commits['branch']['url'], branch_commits['branch']['name'])
49 else:
50 ## case for SVN no branch push...
51 return 'to trunk'
52 %> \
53
54 % for branch, branch_commits in branches_commits.items():
55 ${len(branch_commits['commits'])} ${'commit' if len(branch_commits['commits']) == 1 else 'commits'} ${branch_text(branch)}
56 % for commit in branch_commits['commits']:
57 `<${commit['url']}|${commit['short_id']}>` - ${commit['message_html']|html_to_slack_links}
58 % endfor
59 % endfor
60 ''')
61
62
63 class SlackDataHandler(CommitParsingDataHandler):
64 name = 'slack'
65
66 def __init__(self):
67 pass
68
69 def __call__(self, event: events.RhodecodeEvent, data):
70 if not isinstance(event, events.RhodecodeEvent):
71 raise TypeError(f"event {event} is not subtype of events.RhodecodeEvent")
72
73 actor = data["actor"]["username"]
74 default_title = f'*{actor}* caused a *{event.name}* event'
75 default_text = f'*{actor}* caused a *{event.name}* event'
76
77 default_slack_data = SlackData(title=default_title, text=default_text)
78
79 if isinstance(event, events.PullRequestCommentEvent):
80 return self.format_pull_request_comment_event(
81 event, data, default_slack_data
82 )
83 elif isinstance(event, events.PullRequestCommentEditEvent):
84 return self.format_pull_request_comment_event(
85 event, data, default_slack_data
86 )
87 elif isinstance(event, events.PullRequestReviewEvent):
88 return self.format_pull_request_review_event(event, data, default_slack_data)
89 elif isinstance(event, events.PullRequestEvent):
90 return self.format_pull_request_event(event, data, default_slack_data)
91 elif isinstance(event, events.RepoPushEvent):
92 return self.format_repo_push_event(event, data, default_slack_data)
93 elif isinstance(event, events.RepoCreateEvent):
94 return self.format_repo_create_event(event, data, default_slack_data)
95 else:
96 raise ValueError(
97 f'event type `{event.__class__}` has no handler defined')
98
99 def format_pull_request_comment_event(self, event, data, slack_data):
100 comment_text = data['comment']['text']
101 if len(comment_text) > 200:
102 comment_text = '<{comment_url}|{comment_text}...>'.format(
103 comment_text=comment_text[:200],
104 comment_url=data['comment']['url'],
105 )
106
107 fields = None
108 overrides = None
109 status_text = None
110
111 if data['comment']['status']:
112 status_color = {
113 'approved': '#0ac878',
114 'rejected': '#e85e4d'}.get(data['comment']['status'])
115
116 if status_color:
117 overrides = {"color": status_color}
118
119 status_text = data['comment']['status']
120
121 if data['comment']['file']:
122 fields = [
123 {
124 "title": "file",
125 "value": data['comment']['file']
126 },
127 {
128 "title": "line",
129 "value": data['comment']['line']
130 }
131 ]
132
133 template = Template(textwrap.dedent(r'''
134 *${data['actor']['username']}* left ${data['comment']['type']} on pull request <${data['pullrequest']['url']}|#${data['pullrequest']['pull_request_id']}>:
135 '''))
136 title = render_with_traceback(
137 template, data=data, comment=event.comment)
138
139 template = Template(textwrap.dedent(r'''
140 *pull request title*: ${pr_title}
141 % if status_text:
142 *submitted status*: `${status_text}`
143 % endif
144 >>> ${comment_text}
145 '''))
146 text = render_with_traceback(
147 template,
148 comment_text=comment_text,
149 pr_title=data['pullrequest']['title'],
150 status_text=status_text)
151
152 slack_data.title = title
153 slack_data.text = text
154 slack_data.fields = fields
155 slack_data.overrides = overrides
156
157 return slack_data
158
159 def format_pull_request_review_event(self, event, data, slack_data) -> SlackData:
160 template = Template(textwrap.dedent(r'''
161 *${data['actor']['username']}* changed status of pull request <${data['pullrequest']['url']}|#${data['pullrequest']['pull_request_id']} to `${data['pullrequest']['status']}`>:
162 '''))
163 title = render_with_traceback(template, data=data)
164
165 template = Template(textwrap.dedent(r'''
166 *pull request title*: ${pr_title}
167 '''))
168 text = render_with_traceback(
169 template,
170 pr_title=data['pullrequest']['title'])
171
172 slack_data.title = title
173 slack_data.text = text
174
175 return slack_data
176
177 def format_pull_request_event(self, event, data, slack_data) -> SlackData:
178 action = {
179 events.PullRequestCloseEvent: 'closed',
180 events.PullRequestMergeEvent: 'merged',
181 events.PullRequestUpdateEvent: 'updated',
182 events.PullRequestCreateEvent: 'created',
183 }.get(event.__class__, str(event.__class__))
184
185 template = Template(textwrap.dedent(r'''
186 *${data['actor']['username']}* `${action}` pull request <${data['pullrequest']['url']}|#${data['pullrequest']['pull_request_id']}>:
187 '''))
188 title = render_with_traceback(template, data=data, action=action)
189
190 template = Template(textwrap.dedent(r'''
191 *pull request title*: ${pr_title}
192 %if data['pullrequest']['commits']:
193 *commits*: ${len(data['pullrequest']['commits'])}
194 %endif
195 '''))
196 text = render_with_traceback(
197 template,
198 pr_title=data['pullrequest']['title'],
199 data=data)
200
201 slack_data.title = title
202 slack_data.text = text
203
204 return slack_data
205
206 def format_repo_push_event(self, event, data, slack_data) -> SlackData:
207 branches_commits = self.aggregate_branch_data(
208 data['push']['branches'], data['push']['commits'])
209
210 template = Template(r'''
211 *${data['actor']['username']}* pushed to repo <${data['repo']['url']}|${data['repo']['repo_name']}>:
212 ''')
213 title = render_with_traceback(template, data=data)
214
215 text = render_with_traceback(
216 REPO_PUSH_TEMPLATE,
217 data=data,
218 branches_commits=branches_commits,
219 html_to_slack_links=html_to_slack_links,
220 )
221
222 slack_data.title = title
223 slack_data.text = text
224
225 return slack_data
226
227 def format_repo_create_event(self, event, data, slack_data) -> SlackData:
228 template = Template(r'''
229 *${data['actor']['username']}* created new repository ${data['repo']['repo_name']}:
230 ''')
231 title = render_with_traceback(template, data=data)
232
233 template = Template(textwrap.dedent(r'''
234 repo_url: ${data['repo']['url']}
235 repo_type: ${data['repo']['repo_type']}
236 '''))
237 text = render_with_traceback(template, data=data)
238
239 slack_data.title = title
240 slack_data.text = text
241
242 return slack_data No newline at end of file
@@ -0,0 +1,175 b''
1 # Copyright (C) 2012-2023 RhodeCode GmbH
2 #
3 # This program is free software: you can redistribute it and/or modify
4 # it under the terms of the GNU Affero General Public License, version 3
5 # (only), as published by the Free Software Foundation.
6 #
7 # This program is distributed in the hope that it will be useful,
8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10 # GNU General Public License for more details.
11 #
12 # You should have received a copy of the GNU Affero General Public License
13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
14 #
15 # This program is dual-licensed. If you wish to learn more about the
16 # RhodeCode Enterprise Edition, including its added features, Support services,
17 # and proprietary license terms, please see https://rhodecode.com/licenses/
18
19 import logging
20
21 from rhodecode import events
22 from rhodecode.integrations.types.base import CommitParsingDataHandler, UrlTmpl
23
24
25 log = logging.getLogger(__name__)
26
27
28 class WebhookDataHandler(CommitParsingDataHandler):
29 name = 'webhook'
30
31 def __init__(self, template_url, headers):
32 self.template_url = template_url
33 self.headers = headers
34
35 def get_base_parsed_template(self, data):
36 """
37 initially parses the passed in template with some common variables
38 available on ALL calls
39 """
40 # note: make sure to update the `WEBHOOK_URL_VARS` if this changes
41 common_vars = {
42 'repo_name': data['repo']['repo_name'],
43 'repo_type': data['repo']['repo_type'],
44 'repo_id': data['repo']['repo_id'],
45 'repo_url': data['repo']['url'],
46 'username': data['actor']['username'],
47 'user_id': data['actor']['user_id'],
48 'event_name': data['name']
49 }
50
51 extra_vars = {}
52 for extra_key, extra_val in data['repo']['extra_fields'].items():
53 extra_vars[f'extra__{extra_key}'] = extra_val
54 common_vars.update(extra_vars)
55
56 template_url = self.template_url.replace('${extra:', '${extra__')
57 for k, v in common_vars.items():
58 template_url = UrlTmpl(template_url).safe_substitute(**{k: v})
59 return template_url
60
61 def repo_push_event_handler(self, event, data):
62 url = self.get_base_parsed_template(data)
63 url_calls = []
64
65 branches_commits = self.aggregate_branch_data(
66 data['push']['branches'], data['push']['commits'])
67 if '${branch}' in url or '${branch_head}' in url or '${commit_id}' in url:
68 # call it multiple times, for each branch if used in variables
69 for branch, commit_ids in branches_commits.items():
70 branch_url = UrlTmpl(url).safe_substitute(branch=branch)
71
72 if '${branch_head}' in branch_url:
73 # last commit in the aggregate is the head of the branch
74 branch_head = commit_ids['branch_head']
75 branch_url = UrlTmpl(branch_url).safe_substitute(branch_head=branch_head)
76
77 # call further down for each commit if used
78 if '${commit_id}' in branch_url:
79 for commit_data in commit_ids['commits']:
80 commit_id = commit_data['raw_id']
81 commit_url = UrlTmpl(branch_url).safe_substitute(commit_id=commit_id)
82 # register per-commit call
83 log.debug(
84 'register %s call(%s) to url %s',
85 self.name, event, commit_url)
86 url_calls.append(
87 (commit_url, self.headers, data))
88
89 else:
90 # register per-branch call
91 log.debug('register %s call(%s) to url %s',
92 self.name, event, branch_url)
93 url_calls.append((branch_url, self.headers, data))
94
95 else:
96 log.debug('register %s call(%s) to url %s', self.name, event, url)
97 url_calls.append((url, self.headers, data))
98
99 return url_calls
100
101 def repo_commit_comment_handler(self, event, data):
102 url = self.get_base_parsed_template(data)
103 log.debug('register %s call(%s) to url %s', self.name, event, url)
104 comment_vars = [
105 ('commit_comment_id', data['comment']['comment_id']),
106 ('commit_comment_text', data['comment']['comment_text']),
107 ('commit_comment_type', data['comment']['comment_type']),
108
109 ('commit_comment_f_path', data['comment']['comment_f_path']),
110 ('commit_comment_line_no', data['comment']['comment_line_no']),
111
112 ('commit_comment_commit_id', data['commit']['commit_id']),
113 ('commit_comment_commit_branch', data['commit']['commit_branch']),
114 ('commit_comment_commit_message', data['commit']['commit_message']),
115 ]
116 for k, v in comment_vars:
117 url = UrlTmpl(url).safe_substitute(**{k: v})
118
119 return [(url, self.headers, data)]
120
121 def repo_commit_comment_edit_handler(self, event, data):
122 url = self.get_base_parsed_template(data)
123 log.debug('register %s call(%s) to url %s', self.name, event, url)
124 comment_vars = [
125 ('commit_comment_id', data['comment']['comment_id']),
126 ('commit_comment_text', data['comment']['comment_text']),
127 ('commit_comment_type', data['comment']['comment_type']),
128
129 ('commit_comment_f_path', data['comment']['comment_f_path']),
130 ('commit_comment_line_no', data['comment']['comment_line_no']),
131
132 ('commit_comment_commit_id', data['commit']['commit_id']),
133 ('commit_comment_commit_branch', data['commit']['commit_branch']),
134 ('commit_comment_commit_message', data['commit']['commit_message']),
135 ]
136 for k, v in comment_vars:
137 url = UrlTmpl(url).safe_substitute(**{k: v})
138
139 return [(url, self.headers, data)]
140
141 def repo_create_event_handler(self, event, data):
142 url = self.get_base_parsed_template(data)
143 log.debug('register %s call(%s) to url %s', self.name, event, url)
144 return [(url, self.headers, data)]
145
146 def pull_request_event_handler(self, event, data):
147 url = self.get_base_parsed_template(data)
148 log.debug('register %s call(%s) to url %s', self.name, event, url)
149 pr_vars = [
150 ('pull_request_id', data['pullrequest']['pull_request_id']),
151 ('pull_request_title', data['pullrequest']['title']),
152 ('pull_request_url', data['pullrequest']['url']),
153 ('pull_request_shadow_url', data['pullrequest']['shadow_url']),
154 ('pull_request_commits_uid', data['pullrequest']['commits_uid']),
155 ]
156 for k, v in pr_vars:
157 url = UrlTmpl(url).safe_substitute(**{k: v})
158
159 return [(url, self.headers, data)]
160
161 def __call__(self, event, data):
162
163 if isinstance(event, events.RepoPushEvent):
164 return self.repo_push_event_handler(event, data)
165 elif isinstance(event, events.RepoCreateEvent):
166 return self.repo_create_event_handler(event, data)
167 elif isinstance(event, events.RepoCommitCommentEvent):
168 return self.repo_commit_comment_handler(event, data)
169 elif isinstance(event, events.RepoCommitCommentEditEvent):
170 return self.repo_commit_comment_edit_handler(event, data)
171 elif isinstance(event, events.PullRequestEvent):
172 return self.pull_request_event_handler(event, data)
173 else:
174 raise ValueError(
175 f'event type `{event.__class__}` has no handler defined')
@@ -18,7 +18,10 b''
18
18
19 import logging
19 import logging
20
20
21 from rhodecode.events.base import RhodeCodeIntegrationEvent
21 from rhodecode.events.base import (
22 RhodeCodeIntegrationEvent,
23 RhodecodeEvent
24 )
22
25
23 from rhodecode.events.base import ( # pragma: no cover
26 from rhodecode.events.base import ( # pragma: no cover
24 FtsBuild
27 FtsBuild
@@ -17,6 +17,7 b''
17 # and proprietary license terms, please see https://rhodecode.com/licenses/
17 # and proprietary license terms, please see https://rhodecode.com/licenses/
18 import logging
18 import logging
19 import datetime
19 import datetime
20 import typing
20
21
21 from zope.cachedescriptors.property import Lazy as LazyProperty
22 from zope.cachedescriptors.property import Lazy as LazyProperty
22 from pyramid.threadlocal import get_current_request
23 from pyramid.threadlocal import get_current_request
@@ -19,7 +19,7 b' import sys'
19 import logging
19 import logging
20
20
21 from rhodecode.integrations.registry import IntegrationTypeRegistry
21 from rhodecode.integrations.registry import IntegrationTypeRegistry
22 from rhodecode.integrations.types import webhook, slack, hipchat, email, base
22 from rhodecode.integrations.types import webhook, slack, email, base
23 from rhodecode.lib.exc_tracking import store_exception
23 from rhodecode.lib.exc_tracking import store_exception
24
24
25 log = logging.getLogger(__name__)
25 log = logging.getLogger(__name__)
@@ -35,8 +35,6 b' integration_type_registry.register_integ'
35 integration_type_registry.register_integration_type(
35 integration_type_registry.register_integration_type(
36 slack.SlackIntegrationType)
36 slack.SlackIntegrationType)
37 integration_type_registry.register_integration_type(
37 integration_type_registry.register_integration_type(
38 hipchat.HipchatIntegrationType)
39 integration_type_registry.register_integration_type(
40 email.EmailIntegrationType)
38 email.EmailIntegrationType)
41
39
42
40
@@ -20,17 +20,18 b' import colander'
20 import string
20 import string
21 import collections
21 import collections
22 import logging
22 import logging
23
23 import requests
24 import requests
24 import urllib.request
25 import urllib.request
25 import urllib.parse
26 import urllib.parse
26 import urllib.error
27 import urllib.error
27 from requests.adapters import HTTPAdapter
28 from requests.adapters import HTTPAdapter
28 from requests.packages.urllib3.util.retry import Retry
29 from requests.packages.urllib3.util import Retry
29
30
30 from mako import exceptions
31 from mako import exceptions
31
32
32 from rhodecode.lib.utils2 import safe_str
33
33 from rhodecode.translation import _
34 from rhodecode.lib.str_utils import safe_str
34
35
35
36
36 log = logging.getLogger(__name__)
37 log = logging.getLogger(__name__)
@@ -238,157 +239,6 b' class CommitParsingDataHandler(object):'
238 return branches_commits
239 return branches_commits
239
240
240
241
241 class WebhookDataHandler(CommitParsingDataHandler):
242 name = 'webhook'
243
244 def __init__(self, template_url, headers):
245 self.template_url = template_url
246 self.headers = headers
247
248 def get_base_parsed_template(self, data):
249 """
250 initially parses the passed in template with some common variables
251 available on ALL calls
252 """
253 # note: make sure to update the `WEBHOOK_URL_VARS` if this changes
254 common_vars = {
255 'repo_name': data['repo']['repo_name'],
256 'repo_type': data['repo']['repo_type'],
257 'repo_id': data['repo']['repo_id'],
258 'repo_url': data['repo']['url'],
259 'username': data['actor']['username'],
260 'user_id': data['actor']['user_id'],
261 'event_name': data['name']
262 }
263
264 extra_vars = {}
265 for extra_key, extra_val in data['repo']['extra_fields'].items():
266 extra_vars[f'extra__{extra_key}'] = extra_val
267 common_vars.update(extra_vars)
268
269 template_url = self.template_url.replace('${extra:', '${extra__')
270 for k, v in common_vars.items():
271 template_url = UrlTmpl(template_url).safe_substitute(**{k: v})
272 return template_url
273
274 def repo_push_event_handler(self, event, data):
275 url = self.get_base_parsed_template(data)
276 url_calls = []
277
278 branches_commits = self.aggregate_branch_data(
279 data['push']['branches'], data['push']['commits'])
280 if '${branch}' in url or '${branch_head}' in url or '${commit_id}' in url:
281 # call it multiple times, for each branch if used in variables
282 for branch, commit_ids in branches_commits.items():
283 branch_url = UrlTmpl(url).safe_substitute(branch=branch)
284
285 if '${branch_head}' in branch_url:
286 # last commit in the aggregate is the head of the branch
287 branch_head = commit_ids['branch_head']
288 branch_url = UrlTmpl(branch_url).safe_substitute(branch_head=branch_head)
289
290 # call further down for each commit if used
291 if '${commit_id}' in branch_url:
292 for commit_data in commit_ids['commits']:
293 commit_id = commit_data['raw_id']
294 commit_url = UrlTmpl(branch_url).safe_substitute(commit_id=commit_id)
295 # register per-commit call
296 log.debug(
297 'register %s call(%s) to url %s',
298 self.name, event, commit_url)
299 url_calls.append(
300 (commit_url, self.headers, data))
301
302 else:
303 # register per-branch call
304 log.debug('register %s call(%s) to url %s',
305 self.name, event, branch_url)
306 url_calls.append((branch_url, self.headers, data))
307
308 else:
309 log.debug('register %s call(%s) to url %s', self.name, event, url)
310 url_calls.append((url, self.headers, data))
311
312 return url_calls
313
314 def repo_commit_comment_handler(self, event, data):
315 url = self.get_base_parsed_template(data)
316 log.debug('register %s call(%s) to url %s', self.name, event, url)
317 comment_vars = [
318 ('commit_comment_id', data['comment']['comment_id']),
319 ('commit_comment_text', data['comment']['comment_text']),
320 ('commit_comment_type', data['comment']['comment_type']),
321
322 ('commit_comment_f_path', data['comment']['comment_f_path']),
323 ('commit_comment_line_no', data['comment']['comment_line_no']),
324
325 ('commit_comment_commit_id', data['commit']['commit_id']),
326 ('commit_comment_commit_branch', data['commit']['commit_branch']),
327 ('commit_comment_commit_message', data['commit']['commit_message']),
328 ]
329 for k, v in comment_vars:
330 url = UrlTmpl(url).safe_substitute(**{k: v})
331
332 return [(url, self.headers, data)]
333
334 def repo_commit_comment_edit_handler(self, event, data):
335 url = self.get_base_parsed_template(data)
336 log.debug('register %s call(%s) to url %s', self.name, event, url)
337 comment_vars = [
338 ('commit_comment_id', data['comment']['comment_id']),
339 ('commit_comment_text', data['comment']['comment_text']),
340 ('commit_comment_type', data['comment']['comment_type']),
341
342 ('commit_comment_f_path', data['comment']['comment_f_path']),
343 ('commit_comment_line_no', data['comment']['comment_line_no']),
344
345 ('commit_comment_commit_id', data['commit']['commit_id']),
346 ('commit_comment_commit_branch', data['commit']['commit_branch']),
347 ('commit_comment_commit_message', data['commit']['commit_message']),
348 ]
349 for k, v in comment_vars:
350 url = UrlTmpl(url).safe_substitute(**{k: v})
351
352 return [(url, self.headers, data)]
353
354 def repo_create_event_handler(self, event, data):
355 url = self.get_base_parsed_template(data)
356 log.debug('register %s call(%s) to url %s', self.name, event, url)
357 return [(url, self.headers, data)]
358
359 def pull_request_event_handler(self, event, data):
360 url = self.get_base_parsed_template(data)
361 log.debug('register %s call(%s) to url %s', self.name, event, url)
362 pr_vars = [
363 ('pull_request_id', data['pullrequest']['pull_request_id']),
364 ('pull_request_title', data['pullrequest']['title']),
365 ('pull_request_url', data['pullrequest']['url']),
366 ('pull_request_shadow_url', data['pullrequest']['shadow_url']),
367 ('pull_request_commits_uid', data['pullrequest']['commits_uid']),
368 ]
369 for k, v in pr_vars:
370 url = UrlTmpl(url).safe_substitute(**{k: v})
371
372 return [(url, self.headers, data)]
373
374 def __call__(self, event, data):
375 from rhodecode import events
376
377 if isinstance(event, events.RepoPushEvent):
378 return self.repo_push_event_handler(event, data)
379 elif isinstance(event, events.RepoCreateEvent):
380 return self.repo_create_event_handler(event, data)
381 elif isinstance(event, events.RepoCommitCommentEvent):
382 return self.repo_commit_comment_handler(event, data)
383 elif isinstance(event, events.RepoCommitCommentEditEvent):
384 return self.repo_commit_comment_edit_handler(event, data)
385 elif isinstance(event, events.PullRequestEvent):
386 return self.pull_request_event_handler(event, data)
387 else:
388 raise ValueError(
389 f'event type `{event.__class__}` has no handler defined')
390
391
392 def get_auth(settings):
242 def get_auth(settings):
393 from requests.auth import HTTPBasicAuth
243 from requests.auth import HTTPBasicAuth
394 username = settings.get('username')
244 username = settings.get('username')
@@ -17,15 +17,13 b''
17 # and proprietary license terms, please see https://rhodecode.com/licenses/
17 # and proprietary license terms, please see https://rhodecode.com/licenses/
18
18
19
19
20 import re
20
21 import time
21 import time
22 import textwrap
23 import logging
22 import logging
24
23
25 import deform
24 import deform # noqa
26 import requests
25 import deform.widget
27 import colander
26 import colander
28 from mako.template import Template
29
27
30 from rhodecode import events
28 from rhodecode import events
31 from rhodecode.model.validation_schema.widgets import CheckboxChoiceWidgetDesc
29 from rhodecode.model.validation_schema.widgets import CheckboxChoiceWidgetDesc
@@ -34,34 +32,14 b' from rhodecode.lib import helpers as h'
34 from rhodecode.lib.celerylib import run_task, async_task, RequestContextTask
32 from rhodecode.lib.celerylib import run_task, async_task, RequestContextTask
35 from rhodecode.lib.colander_utils import strip_whitespace
33 from rhodecode.lib.colander_utils import strip_whitespace
36 from rhodecode.integrations.types.base import (
34 from rhodecode.integrations.types.base import (
37 IntegrationTypeBase, CommitParsingDataHandler, render_with_traceback,
35 IntegrationTypeBase,
38 requests_retry_call)
36 requests_retry_call,
39
37 )
40 log = logging.getLogger(__name__)
41
38
42
39 from rhodecode.integrations.types.handlers.slack import SlackDataHandler, SlackData
43 def html_to_slack_links(message):
44 return re.compile(r'<a .*?href=["\'](.+?)".*?>(.+?)</a>').sub(
45 r'<\1|\2>', message)
46
40
47
41
48 REPO_PUSH_TEMPLATE = Template('''
42 log = logging.getLogger(__name__)
49 <%
50 def branch_text(branch):
51 if branch:
52 return 'on branch: <{}|{}>'.format(branch_commits['branch']['url'], branch_commits['branch']['name'])
53 else:
54 ## case for SVN no branch push...
55 return 'to trunk'
56 %> \
57
58 % for branch, branch_commits in branches_commits.items():
59 ${len(branch_commits['commits'])} ${'commit' if len(branch_commits['commits']) == 1 else 'commits'} ${branch_text(branch)}
60 % for commit in branch_commits['commits']:
61 `<${commit['url']}|${commit['short_id']}>` - ${commit['message_html']|html_to_slack_links}
62 % endfor
63 % endfor
64 ''')
65
43
66
44
67 class SlackSettingsSchema(colander.Schema):
45 class SlackSettingsSchema(colander.Schema):
@@ -111,7 +89,7 b' class SlackSettingsSchema(colander.Schem'
111 )
89 )
112
90
113
91
114 class SlackIntegrationType(IntegrationTypeBase, CommitParsingDataHandler):
92 class SlackIntegrationType(IntegrationTypeBase):
115 key = 'slack'
93 key = 'slack'
116 display_name = _('Slack')
94 display_name = _('Slack')
117 description = _('Send events such as repo pushes and pull requests to '
95 description = _('Send events such as repo pushes and pull requests to '
@@ -132,45 +110,6 b' class SlackIntegrationType(IntegrationTy'
132 events.RepoCreateEvent,
110 events.RepoCreateEvent,
133 ]
111 ]
134
112
135 def send_event(self, event):
136 log.debug('handling event %s with integration %s', event.name, self)
137
138 if event.__class__ not in self.valid_events:
139 log.debug('event %r not present in valid event list (%s)', event, self.valid_events)
140 return
141
142 if not self.event_enabled(event):
143 return
144
145 data = event.as_dict()
146
147 # defaults
148 title = '*%s* caused a *%s* event' % (
149 data['actor']['username'], event.name)
150 text = '*%s* caused a *%s* event' % (
151 data['actor']['username'], event.name)
152 fields = None
153 overrides = None
154
155 if isinstance(event, events.PullRequestCommentEvent):
156 (title, text, fields, overrides) \
157 = self.format_pull_request_comment_event(event, data)
158 elif isinstance(event, events.PullRequestCommentEditEvent):
159 (title, text, fields, overrides) \
160 = self.format_pull_request_comment_event(event, data)
161 elif isinstance(event, events.PullRequestReviewEvent):
162 title, text = self.format_pull_request_review_event(event, data)
163 elif isinstance(event, events.PullRequestEvent):
164 title, text = self.format_pull_request_event(event, data)
165 elif isinstance(event, events.RepoPushEvent):
166 title, text = self.format_repo_push_event(data)
167 elif isinstance(event, events.RepoCreateEvent):
168 title, text = self.format_repo_create_event(data)
169 else:
170 log.error('unhandled event type: %r', event)
171
172 run_task(post_text_to_slack, self.settings, title, text, fields, overrides)
173
174 def settings_schema(self):
113 def settings_schema(self):
175 schema = SlackSettingsSchema()
114 schema = SlackSettingsSchema()
176 schema.add(colander.SchemaNode(
115 schema.add(colander.SchemaNode(
@@ -186,137 +125,31 b' class SlackIntegrationType(IntegrationTy'
186
125
187 return schema
126 return schema
188
127
189 def format_pull_request_comment_event(self, event, data):
128 def send_event(self, event):
190 comment_text = data['comment']['text']
129 log.debug('handling event %s with integration %s', event.name, self)
191 if len(comment_text) > 200:
192 comment_text = '<{comment_url}|{comment_text}...>'.format(
193 comment_text=comment_text[:200],
194 comment_url=data['comment']['url'],
195 )
196
197 fields = None
198 overrides = None
199 status_text = None
200
201 if data['comment']['status']:
202 status_color = {
203 'approved': '#0ac878',
204 'rejected': '#e85e4d'}.get(data['comment']['status'])
205
206 if status_color:
207 overrides = {"color": status_color}
208
209 status_text = data['comment']['status']
210
130
211 if data['comment']['file']:
131 if event.__class__ not in self.valid_events:
212 fields = [
132 log.debug('event %r not present in valid event list (%s)', event, self.valid_events)
213 {
133 return
214 "title": "file",
215 "value": data['comment']['file']
216 },
217 {
218 "title": "line",
219 "value": data['comment']['line']
220 }
221 ]
222
223 template = Template(textwrap.dedent(r'''
224 *${data['actor']['username']}* left ${data['comment']['type']} on pull request <${data['pullrequest']['url']}|#${data['pullrequest']['pull_request_id']}>:
225 '''))
226 title = render_with_traceback(
227 template, data=data, comment=event.comment)
228
229 template = Template(textwrap.dedent(r'''
230 *pull request title*: ${pr_title}
231 % if status_text:
232 *submitted status*: `${status_text}`
233 % endif
234 >>> ${comment_text}
235 '''))
236 text = render_with_traceback(
237 template,
238 comment_text=comment_text,
239 pr_title=data['pullrequest']['title'],
240 status_text=status_text)
241
242 return title, text, fields, overrides
243
244 def format_pull_request_review_event(self, event, data):
245 template = Template(textwrap.dedent(r'''
246 *${data['actor']['username']}* changed status of pull request <${data['pullrequest']['url']}|#${data['pullrequest']['pull_request_id']} to `${data['pullrequest']['status']}`>:
247 '''))
248 title = render_with_traceback(template, data=data)
249
134
250 template = Template(textwrap.dedent(r'''
135 if not self.event_enabled(event):
251 *pull request title*: ${pr_title}
136 return
252 '''))
253 text = render_with_traceback(
254 template,
255 pr_title=data['pullrequest']['title'])
256
257 return title, text
258
137
259 def format_pull_request_event(self, event, data):
138 data = event.as_dict()
260 action = {
261 events.PullRequestCloseEvent: 'closed',
262 events.PullRequestMergeEvent: 'merged',
263 events.PullRequestUpdateEvent: 'updated',
264 events.PullRequestCreateEvent: 'created',
265 }.get(event.__class__, str(event.__class__))
266
267 template = Template(textwrap.dedent(r'''
268 *${data['actor']['username']}* `${action}` pull request <${data['pullrequest']['url']}|#${data['pullrequest']['pull_request_id']}>:
269 '''))
270 title = render_with_traceback(template, data=data, action=action)
271
272 template = Template(textwrap.dedent(r'''
273 *pull request title*: ${pr_title}
274 %if data['pullrequest']['commits']:
275 *commits*: ${len(data['pullrequest']['commits'])}
276 %endif
277 '''))
278 text = render_with_traceback(
279 template,
280 pr_title=data['pullrequest']['title'],
281 data=data)
282
139
283 return title, text
140 handler = SlackDataHandler()
284
141 slack_data = handler(event, data)
285 def format_repo_push_event(self, data):
142 # title, text, fields, overrides
286 branches_commits = self.aggregate_branch_data(
143 run_task(post_text_to_slack, self.settings, slack_data)
287 data['push']['branches'], data['push']['commits'])
288
289 template = Template(r'''
290 *${data['actor']['username']}* pushed to repo <${data['repo']['url']}|${data['repo']['repo_name']}>:
291 ''')
292 title = render_with_traceback(template, data=data)
293
294 text = render_with_traceback(
295 REPO_PUSH_TEMPLATE,
296 data=data,
297 branches_commits=branches_commits,
298 html_to_slack_links=html_to_slack_links,
299 )
300
301 return title, text
302
303 def format_repo_create_event(self, data):
304 template = Template(r'''
305 *${data['actor']['username']}* created new repository ${data['repo']['repo_name']}:
306 ''')
307 title = render_with_traceback(template, data=data)
308
309 template = Template(textwrap.dedent(r'''
310 repo_url: ${data['repo']['url']}
311 repo_type: ${data['repo']['repo_type']}
312 '''))
313 text = render_with_traceback(template, data=data)
314
315 return title, text
316
144
317
145
318 @async_task(ignore_result=True, base=RequestContextTask)
146 @async_task(ignore_result=True, base=RequestContextTask)
319 def post_text_to_slack(settings, title, text, fields=None, overrides=None):
147 def post_text_to_slack(settings, slack_data: SlackData):
148 title = slack_data.title
149 text = slack_data.text
150 fields = slack_data.fields
151 overrides = slack_data.overrides
152
320 log.debug('sending %s (%s) to slack %s', title, text, settings['service'])
153 log.debug('sending %s (%s) to slack %s', title, text, settings['service'])
321
154
322 fields = fields or []
155 fields = fields or []
@@ -17,7 +17,7 b''
17 # and proprietary license terms, please see https://rhodecode.com/licenses/
17 # and proprietary license terms, please see https://rhodecode.com/licenses/
18
18
19
19
20
20 import deform # noqa
21 import deform.widget
21 import deform.widget
22 import logging
22 import logging
23 import colander
23 import colander
@@ -28,8 +28,14 b' from rhodecode.lib.colander_utils import'
28 from rhodecode.model.validation_schema.widgets import CheckboxChoiceWidgetDesc
28 from rhodecode.model.validation_schema.widgets import CheckboxChoiceWidgetDesc
29 from rhodecode.translation import _
29 from rhodecode.translation import _
30 from rhodecode.integrations.types.base import (
30 from rhodecode.integrations.types.base import (
31 IntegrationTypeBase, get_auth, get_web_token, get_url_vars,
31 IntegrationTypeBase,
32 WebhookDataHandler, WEBHOOK_URL_VARS, requests_retry_call)
32 get_auth,
33 get_web_token,
34 get_url_vars,
35 WEBHOOK_URL_VARS,
36 requests_retry_call,
37 )
38 from rhodecode.integrations.types.handlers.webhook import WebhookDataHandler
33 from rhodecode.lib.celerylib import run_task, async_task, RequestContextTask
39 from rhodecode.lib.celerylib import run_task, async_task, RequestContextTask
34 from rhodecode.model.validation_schema import widgets
40 from rhodecode.model.validation_schema import widgets
35
41
@@ -57,7 +63,7 b' class WebhookSettingsSchema(colander.Sch'
57 widget=widgets.CodeMirrorWidget(
63 widget=widgets.CodeMirrorWidget(
58 help_block_collapsable_name='Show url variables',
64 help_block_collapsable_name='Show url variables',
59 help_block_collapsable=(
65 help_block_collapsable=(
60 'E.g http://my-serv.com/trigger_job/${{event_name}}'
66 'E.g https://my-serv.com/trigger_job/${{event_name}}'
61 '?PR_ID=${{pull_request_id}}'
67 '?PR_ID=${{pull_request_id}}'
62 '\nFull list of vars:\n{}'.format(URL_VARS)),
68 '\nFull list of vars:\n{}'.format(URL_VARS)),
63 codemirror_mode='text',
69 codemirror_mode='text',
@@ -189,11 +195,11 b' class WebhookIntegrationType(Integration'
189 url_calls = handler(event, data)
195 url_calls = handler(event, data)
190 log.debug('Webhook: calling following urls: %s', [x[0] for x in url_calls])
196 log.debug('Webhook: calling following urls: %s', [x[0] for x in url_calls])
191
197
192 run_task(post_to_webhook, url_calls, self.settings)
198 run_task(post_to_webhook, self.settings, url_calls)
193
199
194
200
195 @async_task(ignore_result=True, base=RequestContextTask)
201 @async_task(ignore_result=True, base=RequestContextTask)
196 def post_to_webhook(url_calls, settings):
202 def post_to_webhook(settings, url_calls):
197 """
203 """
198 Example data::
204 Example data::
199
205
@@ -32,23 +32,27 b' def repo_push_event(backend, user_regula'
32 {'message': 'this is because 5b23c3532 broke stuff'},
32 {'message': 'this is because 5b23c3532 broke stuff'},
33 {'message': 'last commit'},
33 {'message': 'last commit'},
34 ]
34 ]
35 commit_ids = backend.create_master_repo(commits).values()
35 r = backend.create_repo(commits)
36 repo = backend.create_repo()
36
37 commit_ids = list(backend.commit_ids.values())
38 repo_name = backend.repo_name
39 alias = backend.alias
40
37 scm_extras = AttributeDict({
41 scm_extras = AttributeDict({
38 'ip': '127.0.0.1',
42 'ip': '127.0.0.1',
39 'username': user_regular.username,
43 'username': user_regular.username,
40 'user_id': user_regular.user_id,
44 'user_id': user_regular.user_id,
41 'action': '',
45 'action': '',
42 'repository': repo.repo_name,
46 'repository': repo_name,
43 'scm': repo.scm_instance().alias,
47 'scm': alias,
44 'config': '',
48 'config': '',
45 'repo_store': '',
49 'repo_store': '',
46 'server_url': 'http://example.com',
50 'server_url': 'http://httpbin:9090',
47 'make_lock': None,
51 'make_lock': None,
48 'locked_by': [None],
52 'locked_by': [None],
49 'commit_ids': commit_ids,
53 'commit_ids': commit_ids,
50 })
54 })
51
55
52 return events.RepoPushEvent(repo_name=repo.repo_name,
56 return events.RepoPushEvent(repo_name=repo_name,
53 pushed_commit_ids=commit_ids,
57 pushed_commit_ids=commit_ids,
54 extras=scm_extras)
58 extras=scm_extras)
@@ -1,4 +1,3 b''
1
2 # Copyright (C) 2010-2023 RhodeCode GmbH
1 # Copyright (C) 2010-2023 RhodeCode GmbH
3 #
2 #
4 # This program is free software: you can redistribute it and/or modify
3 # This program is free software: you can redistribute it and/or modify
@@ -1,4 +1,3 b''
1
2 # Copyright (C) 2010-2023 RhodeCode GmbH
1 # Copyright (C) 2010-2023 RhodeCode GmbH
3 #
2 #
4 # This program is free software: you can redistribute it and/or modify
3 # This program is free software: you can redistribute it and/or modify
@@ -18,11 +17,43 b''
18 # and proprietary license terms, please see https://rhodecode.com/licenses/
17 # and proprietary license terms, please see https://rhodecode.com/licenses/
19
18
20 import pytest
19 import pytest
20 import mock
21 from mock import patch
21 from mock import patch
22
22
23 from rhodecode import events
23 from rhodecode import events
24 from rhodecode.integrations.types.handlers.slack import SlackDataHandler
24 from rhodecode.model.db import Session, Integration
25 from rhodecode.model.db import Session, Integration
25 from rhodecode.integrations.types.slack import SlackIntegrationType
26 from rhodecode.integrations.types.slack import SlackIntegrationType
27 from rhodecode.tests import GIT_REPO
28
29
30 @pytest.fixture()
31 def base_slack_data():
32 return {
33 "pullrequest": {
34 "url": "https://example.com/pr1",
35 "pull_request_id": "1",
36 "title": "started pr",
37 "status": "new",
38 "commits": ["1", "2"],
39 "shadow_url": "http://shadow-url",
40 },
41 "actor": {"username": "foo-user"},
42 "comment": {
43 "comment_id": 1,
44 "text": "test-comment",
45 "status": "approved",
46 "file": "text.py",
47 "line": "1",
48 "type": "note",
49 },
50 "push": {"branches": "", "commits": []},
51 "repo": {
52 "url": "https://example.com/repo1",
53 "repo_name": GIT_REPO,
54 "repo_type": "git",
55 },
56 }
26
57
27
58
28 @pytest.fixture()
59 @pytest.fixture()
@@ -52,14 +83,66 b' def slack_integration(request, app, slac'
52 return integration
83 return integration
53
84
54
85
86 @pytest.fixture()
87 def slack_integration_empty(request, app, slack_settings):
88 slack_settings['events'] = []
89 integration = Integration()
90 integration.name = 'test slack integration'
91 integration.enabled = True
92 integration.integration_type = SlackIntegrationType.key
93 integration.settings = slack_settings
94 Session().add(integration)
95 Session().commit()
96 request.addfinalizer(lambda: Session().delete(integration))
97 return integration
98
99
55 def test_slack_push(slack_integration, repo_push_event):
100 def test_slack_push(slack_integration, repo_push_event):
101
56 with patch('rhodecode.integrations.types.slack.post_text_to_slack') as call:
102 with patch('rhodecode.integrations.types.slack.post_text_to_slack') as call:
57 events.trigger(repo_push_event)
103 events.trigger(repo_push_event)
58 assert 'pushed to' in call.call_args[0][1]
104
105 assert 'pushed to' in call.call_args[0][1].title
106 # specific commit was parsed and serialized
107 assert 'change that fixes #41' in call.call_args[0][1].text
59
108
60 slack_integration.settings['events'] = []
109
61 Session().commit()
110 def test_slack_push_no_events(slack_integration_empty, repo_push_event):
111
112 assert Integration.get(slack_integration_empty.integration_id).settings['events'] == []
62
113
63 with patch('rhodecode.integrations.types.slack.post_text_to_slack') as call:
114 with patch('rhodecode.integrations.types.slack.post_text_to_slack') as call:
64 events.trigger(repo_push_event)
115 events.trigger(repo_push_event)
65 assert not call.call_args
116 assert not call.call_args
117
118
119 def test_slack_data_handler_wrong_event():
120 handler = SlackDataHandler()
121 data = {"actor": {"username": "foo-user"}}
122 with pytest.raises(ValueError):
123 handler(events.RhodecodeEvent(), data)
124
125
126 @pytest.mark.parametrize("event_type, args", [
127 (
128 events.PullRequestCommentEvent,
129 (mock.MagicMock(name="pull-request"), mock.MagicMock(name="comment")),
130 ),
131 (
132 events.PullRequestCommentEditEvent,
133 (mock.MagicMock(name="pull-request"), mock.MagicMock(name="comment")),
134 ),
135 (
136 events.PullRequestReviewEvent,
137 (mock.MagicMock(name="pull-request"), mock.MagicMock(name="status")),
138 ),
139 (
140 events.RepoPushEvent,
141 (GIT_REPO, mock.MagicMock(name="pushed_commit_ids"), mock.MagicMock(name="extras")),
142 ),
143 (events.PullRequestEvent, (mock.MagicMock(), )),
144 (events.RepoCreateEvent, (mock.MagicMock(), )),
145 ])
146 def test_slack_data_handler(app, event_type: events.RhodecodeEvent, args, base_slack_data):
147 handler = SlackDataHandler()
148 handler(event_type(*args), base_slack_data)
@@ -18,10 +18,13 b''
18 # and proprietary license terms, please see https://rhodecode.com/licenses/
18 # and proprietary license terms, please see https://rhodecode.com/licenses/
19
19
20 import pytest
20 import pytest
21 import mock
22 from mock import patch
21
23
22 from rhodecode import events
24 from rhodecode import events
23 from rhodecode.lib.utils2 import AttributeDict
25 from rhodecode.lib.utils2 import AttributeDict
24 from rhodecode.integrations.types.webhook import WebhookDataHandler
26 from rhodecode.integrations.types.webhook import WebhookDataHandler
27 from rhodecode.tests import GIT_REPO
25
28
26
29
27 @pytest.fixture()
30 @pytest.fixture()
@@ -38,7 +41,33 b' def base_data():'
38 'actor': {
41 'actor': {
39 'username': 'actor_name',
42 'username': 'actor_name',
40 'user_id': 1
43 'user_id': 1
41 }
44 },
45 "pullrequest": {
46 "url": "https://example.com/pr1",
47 "pull_request_id": "1",
48 "title": "started pr",
49 "status": "new",
50 "commits_uid": ["1", "2"],
51 "shadow_url": "http://shadow-url",
52 },
53 "comment": {
54 "comment_id": 1,
55 "comment_text": "test-comment",
56 "status": "approved",
57 "comment_f_path": "text.py",
58 "comment_line_no": "1",
59 "comment_type": "note",
60 },
61 "commit": {
62 "commit_id": "efefef",
63 "commit_branch": "master",
64 "commit_message": "changed foo"
65 },
66 "push": {
67 "branches": "",
68 "commits": []
69 },
70
42 }
71 }
43
72
44
73
@@ -131,3 +160,37 b' def test_webook_parse_url_for_push_event'
131 urls = handler(repo_push_event, base_data)
160 urls = handler(repo_push_event, base_data)
132 assert urls == [
161 assert urls == [
133 (url, headers, base_data) for url in expected_urls]
162 (url, headers, base_data) for url in expected_urls]
163
164
165 @pytest.mark.parametrize("event_type, args", [
166 (
167 events.RepoPushEvent,
168 (GIT_REPO, mock.MagicMock(name="pushed_commit_ids"), mock.MagicMock(name="extras")),
169 ),
170 (
171 events.RepoCreateEvent,
172 (GIT_REPO,),
173 ),
174 (
175 events.RepoCommitCommentEvent,
176 (GIT_REPO, mock.MagicMock(name="commit"), mock.MagicMock(name="comment")),
177 ),
178 (
179 events.RepoCommitCommentEditEvent,
180 (GIT_REPO, mock.MagicMock(name="commit"), mock.MagicMock(name="comment")),
181 ),
182 (
183 events.PullRequestEvent,
184 (mock.MagicMock(), ),
185 ),
186 ])
187 def test_webhook_data_handler(app, event_type: events.RhodecodeEvent, args, base_data):
188 handler = WebhookDataHandler(
189 template_url='http://server.com/${branch}/${commit_id}',
190 headers={'exmaple-header': 'header-values'}
191 )
192 handler(event_type(*args), base_data)
193
194
195
196
@@ -331,7 +331,7 b' class TestCommits(BackendTestMixin):'
331 assert line_no == 1
331 assert line_no == 1
332 assert commit_id == file_added_commit.raw_id
332 assert commit_id == file_added_commit.raw_id
333 assert commit_loader() == file_added_commit
333 assert commit_loader() == file_added_commit
334 assert 'Foobar 3' in line
334 assert b'Foobar 3' in line
335
335
336 def test_get_file_annotate_does_not_exist(self):
336 def test_get_file_annotate_does_not_exist(self):
337 file_added_commit = self.repo.get_commit(commit_idx=2)
337 file_added_commit = self.repo.get_commit(commit_idx=2)
General Comments 0
You need to be logged in to leave comments. Login now