##// END OF EJS Templates
integrations: implement retry to HTTP[S] calls for integrations.
marcink -
r3110:b4084273 default
parent child Browse files
Show More
@@ -1,322 +1,355 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2012-2018 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 import colander
22 22 import string
23 23 import collections
24 24 import logging
25 import requests
26 from requests.adapters import HTTPAdapter
27 from requests.packages.urllib3.util.retry import Retry
25 28
26 29 from mako import exceptions
27 30
28 31 from rhodecode.translation import _
29 32
30 33
31 34 log = logging.getLogger(__name__)
32 35
33 36
34 37 class IntegrationTypeBase(object):
35 38 """ Base class for IntegrationType plugins """
36 39 is_dummy = False
37 40 description = ''
38 41
39 42 @classmethod
40 43 def icon(cls):
41 44 return '''
42 45 <?xml version="1.0" encoding="UTF-8" standalone="no"?>
43 46 <svg
44 47 xmlns:dc="http://purl.org/dc/elements/1.1/"
45 48 xmlns:cc="http://creativecommons.org/ns#"
46 49 xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
47 50 xmlns:svg="http://www.w3.org/2000/svg"
48 51 xmlns="http://www.w3.org/2000/svg"
49 52 xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
50 53 xmlns:inkscape="http://setwww.inkscape.org/namespaces/inkscape"
51 54 viewBox="0 -256 1792 1792"
52 55 id="svg3025"
53 56 version="1.1"
54 57 inkscape:version="0.48.3.1 r9886"
55 58 width="100%"
56 59 height="100%"
57 60 sodipodi:docname="cog_font_awesome.svg">
58 61 <metadata
59 62 id="metadata3035">
60 63 <rdf:RDF>
61 64 <cc:Work
62 65 rdf:about="">
63 66 <dc:format>image/svg+xml</dc:format>
64 67 <dc:type
65 68 rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
66 69 </cc:Work>
67 70 </rdf:RDF>
68 71 </metadata>
69 72 <defs
70 73 id="defs3033" />
71 74 <sodipodi:namedview
72 75 pagecolor="#ffffff"
73 76 bordercolor="#666666"
74 77 borderopacity="1"
75 78 objecttolerance="10"
76 79 gridtolerance="10"
77 80 guidetolerance="10"
78 81 inkscape:pageopacity="0"
79 82 inkscape:pageshadow="2"
80 83 inkscape:window-width="640"
81 84 inkscape:window-height="480"
82 85 id="namedview3031"
83 86 showgrid="false"
84 87 inkscape:zoom="0.13169643"
85 88 inkscape:cx="896"
86 89 inkscape:cy="896"
87 90 inkscape:window-x="0"
88 91 inkscape:window-y="25"
89 92 inkscape:window-maximized="0"
90 93 inkscape:current-layer="svg3025" />
91 94 <g
92 95 transform="matrix(1,0,0,-1,121.49153,1285.4237)"
93 96 id="g3027">
94 97 <path
95 98 d="m 1024,640 q 0,106 -75,181 -75,75 -181,75 -106,0 -181,-75 -75,-75 -75,-181 0,-106 75,-181 75,-75 181,-75 106,0 181,75 75,75 75,181 z m 512,109 V 527 q 0,-12 -8,-23 -8,-11 -20,-13 l -185,-28 q -19,-54 -39,-91 35,-50 107,-138 10,-12 10,-25 0,-13 -9,-23 -27,-37 -99,-108 -72,-71 -94,-71 -12,0 -26,9 l -138,108 q -44,-23 -91,-38 -16,-136 -29,-186 -7,-28 -36,-28 H 657 q -14,0 -24.5,8.5 Q 622,-111 621,-98 L 593,86 q -49,16 -90,37 L 362,16 Q 352,7 337,7 323,7 312,18 186,132 147,186 q -7,10 -7,23 0,12 8,23 15,21 51,66.5 36,45.5 54,70.5 -27,50 -41,99 L 29,495 Q 16,497 8,507.5 0,518 0,531 v 222 q 0,12 8,23 8,11 19,13 l 186,28 q 14,46 39,92 -40,57 -107,138 -10,12 -10,24 0,10 9,23 26,36 98.5,107.5 72.5,71.5 94.5,71.5 13,0 26,-10 l 138,-107 q 44,23 91,38 16,136 29,186 7,28 36,28 h 222 q 14,0 24.5,-8.5 Q 914,1391 915,1378 l 28,-184 q 49,-16 90,-37 l 142,107 q 9,9 24,9 13,0 25,-10 129,-119 165,-170 7,-8 7,-22 0,-12 -8,-23 -15,-21 -51,-66.5 -36,-45.5 -54,-70.5 26,-50 41,-98 l 183,-28 q 13,-2 21,-12.5 8,-10.5 8,-23.5 z"
96 99 id="path3029"
97 100 inkscape:connector-curvature="0"
98 101 style="fill:currentColor" />
99 102 </g>
100 103 </svg>
101 104 '''
102 105
103 106 def __init__(self, settings):
104 107 """
105 108 :param settings: dict of settings to be used for the integration
106 109 """
107 110 self.settings = settings
108 111
109 112 def settings_schema(self):
110 113 """
111 114 A colander schema of settings for the integration type
112 115 """
113 116 return colander.Schema()
114 117
115 118
116 119 class EEIntegration(IntegrationTypeBase):
117 120 description = 'Integration available in RhodeCode EE edition.'
118 121 is_dummy = True
119 122
120 123 def __init__(self, name, key, settings=None):
121 124 self.display_name = name
122 125 self.key = key
123 126 super(EEIntegration, self).__init__(settings)
124 127
125 128
126 129 # Helpers #
127 130 # updating this required to update the `common_vars` as well.
128 131 WEBHOOK_URL_VARS = [
129 132 ('event_name', 'Unique name of the event type, e.g pullrequest-update'),
130 133 ('repo_name', 'Full name of the repository'),
131 134 ('repo_type', 'VCS type of repository'),
132 135 ('repo_id', 'Unique id of repository'),
133 136 ('repo_url', 'Repository url'),
134 137 # extra repo fields
135 138 ('extra:<extra_key_name>', 'Extra repo variables, read from its settings.'),
136 139
137 140 # special attrs below that we handle, using multi-call
138 141 ('branch', 'Name of each branch submitted, if any.'),
139 142 ('branch_head', 'Head ID of pushed branch (full sha of last commit), if any.'),
140 143 ('commit_id', 'ID (full sha) of each commit submitted, if any.'),
141 144
142 145 # pr events vars
143 146 ('pull_request_id', 'Unique ID of the pull request.'),
144 147 ('pull_request_title', 'Title of the pull request.'),
145 148 ('pull_request_url', 'Pull request url.'),
146 149 ('pull_request_shadow_url', 'Pull request shadow repo clone url.'),
147 150 ('pull_request_commits_uid', 'Calculated UID of all commits inside the PR. '
148 151 'Changes after PR update'),
149 152
150 153 # user who triggers the call
151 154 ('username', 'User who triggered the call.'),
152 155 ('user_id', 'User id who triggered the call.'),
153 156 ]
154 157
155 158 # common vars for url template used for CI plugins. Shared with webhook
156 159 CI_URL_VARS = WEBHOOK_URL_VARS
157 160
158 161
159 162 class CommitParsingDataHandler(object):
160 163
161 164 def aggregate_branch_data(self, branches, commits):
162 165 branch_data = collections.OrderedDict()
163 166 for obj in branches:
164 167 branch_data[obj['name']] = obj
165 168
166 169 branches_commits = collections.OrderedDict()
167 170 for commit in commits:
168 171 if commit.get('git_ref_change'):
169 172 # special case for GIT that allows creating tags,
170 173 # deleting branches without associated commit
171 174 continue
172 175 commit_branch = commit['branch']
173 176
174 177 if commit_branch not in branches_commits:
175 178 _branch = branch_data[commit_branch] \
176 179 if commit_branch else commit_branch
177 180 branch_commits = {'branch': _branch,
178 181 'branch_head': '',
179 182 'commits': []}
180 183 branches_commits[commit_branch] = branch_commits
181 184
182 185 branch_commits = branches_commits[commit_branch]
183 186 branch_commits['commits'].append(commit)
184 187 branch_commits['branch_head'] = commit['raw_id']
185 188 return branches_commits
186 189
187 190
188 191 class WebhookDataHandler(CommitParsingDataHandler):
189 192 name = 'webhook'
190 193
191 194 def __init__(self, template_url, headers):
192 195 self.template_url = template_url
193 196 self.headers = headers
194 197
195 198 def get_base_parsed_template(self, data):
196 199 """
197 200 initially parses the passed in template with some common variables
198 201 available on ALL calls
199 202 """
200 203 # note: make sure to update the `WEBHOOK_URL_VARS` if this changes
201 204 common_vars = {
202 205 'repo_name': data['repo']['repo_name'],
203 206 'repo_type': data['repo']['repo_type'],
204 207 'repo_id': data['repo']['repo_id'],
205 208 'repo_url': data['repo']['url'],
206 209 'username': data['actor']['username'],
207 210 'user_id': data['actor']['user_id'],
208 211 'event_name': data['name']
209 212 }
210 213
211 214 extra_vars = {}
212 215 for extra_key, extra_val in data['repo']['extra_fields'].items():
213 216 extra_vars['extra__{}'.format(extra_key)] = extra_val
214 217 common_vars.update(extra_vars)
215 218
216 219 template_url = self.template_url.replace('${extra:', '${extra__')
217 220 return string.Template(template_url).safe_substitute(**common_vars)
218 221
219 222 def repo_push_event_handler(self, event, data):
220 223 url = self.get_base_parsed_template(data)
221 224 url_calls = []
222 225
223 226 branches_commits = self.aggregate_branch_data(
224 227 data['push']['branches'], data['push']['commits'])
225 228 if '${branch}' in url or '${branch_head}' in url or '${commit_id}' in url:
226 229 # call it multiple times, for each branch if used in variables
227 230 for branch, commit_ids in branches_commits.items():
228 231 branch_url = string.Template(url).safe_substitute(branch=branch)
229 232
230 233 if '${branch_head}' in branch_url:
231 234 # last commit in the aggregate is the head of the branch
232 235 branch_head = commit_ids['branch_head']
233 236 branch_url = string.Template(branch_url).safe_substitute(
234 237 branch_head=branch_head)
235 238
236 239 # call further down for each commit if used
237 240 if '${commit_id}' in branch_url:
238 241 for commit_data in commit_ids['commits']:
239 242 commit_id = commit_data['raw_id']
240 243 commit_url = string.Template(branch_url).safe_substitute(
241 244 commit_id=commit_id)
242 245 # register per-commit call
243 246 log.debug(
244 247 'register %s call(%s) to url %s',
245 248 self.name, event, commit_url)
246 249 url_calls.append(
247 250 (commit_url, self.headers, data))
248 251
249 252 else:
250 253 # register per-branch call
251 254 log.debug(
252 255 'register %s call(%s) to url %s',
253 256 self.name, event, branch_url)
254 257 url_calls.append(
255 258 (branch_url, self.headers, data))
256 259
257 260 else:
258 261 log.debug(
259 262 'register %s call(%s) to url %s', self.name, event, url)
260 263 url_calls.append((url, self.headers, data))
261 264
262 265 return url_calls
263 266
264 267 def repo_create_event_handler(self, event, data):
265 268 url = self.get_base_parsed_template(data)
266 269 log.debug(
267 270 'register %s call(%s) to url %s', self.name, event, url)
268 271 return [(url, self.headers, data)]
269 272
270 273 def pull_request_event_handler(self, event, data):
271 274 url = self.get_base_parsed_template(data)
272 275 log.debug(
273 276 'register %s call(%s) to url %s', self.name, event, url)
274 277 url = string.Template(url).safe_substitute(
275 278 pull_request_id=data['pullrequest']['pull_request_id'],
276 279 pull_request_title=data['pullrequest']['title'],
277 280 pull_request_url=data['pullrequest']['url'],
278 281 pull_request_shadow_url=data['pullrequest']['shadow_url'],
279 282 pull_request_commits_uid=data['pullrequest']['commits_uid'],
280 283 )
281 284 return [(url, self.headers, data)]
282 285
283 286 def __call__(self, event, data):
284 287 from rhodecode import events
285 288
286 289 if isinstance(event, events.RepoPushEvent):
287 290 return self.repo_push_event_handler(event, data)
288 291 elif isinstance(event, events.RepoCreateEvent):
289 292 return self.repo_create_event_handler(event, data)
290 293 elif isinstance(event, events.PullRequestEvent):
291 294 return self.pull_request_event_handler(event, data)
292 295 else:
293 296 raise ValueError(
294 297 'event type `%s` not in supported list: %s' % (
295 298 event.__class__, events))
296 299
297 300
298 301 def get_auth(settings):
299 302 from requests.auth import HTTPBasicAuth
300 303 username = settings.get('username')
301 304 password = settings.get('password')
302 305 if username and password:
303 306 return HTTPBasicAuth(username, password)
304 307 return None
305 308
306 309
307 310 def get_web_token(settings):
308 311 return settings['secret_token']
309 312
310 313
311 314 def get_url_vars(url_vars):
312 315 return '\n'.join(
313 316 '{} - {}'.format('${' + key + '}', explanation)
314 317 for key, explanation in url_vars)
315 318
316 319
317 320 def render_with_traceback(template, *args, **kwargs):
318 321 try:
319 322 return template.render(*args, **kwargs)
320 323 except Exception:
321 324 log.error(exceptions.text_error_template().render())
322 325 raise
326
327
328 STATUS_400 = (400, 401, 403)
329 STATUS_500 = (500, 502, 504)
330
331
332 def requests_retry_call(
333 retries=3, backoff_factor=0.3, status_forcelist=STATUS_400+STATUS_500,
334 session=None):
335 """
336 session = requests_retry_session()
337 response = session.get('http://example.com')
338
339 :param retries:
340 :param backoff_factor:
341 :param status_forcelist:
342 :param session:
343 """
344 session = session or requests.Session()
345 retry = Retry(
346 total=retries,
347 read=retries,
348 connect=retries,
349 backoff_factor=backoff_factor,
350 status_forcelist=status_forcelist,
351 )
352 adapter = HTTPAdapter(max_retries=retry)
353 session.mount('http://', adapter)
354 session.mount('https://', adapter)
355 return session
@@ -1,253 +1,254 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2012-2018 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 from __future__ import unicode_literals
22 22 import deform
23 23 import logging
24 24 import requests
25 25 import colander
26 26 import textwrap
27 27 from mako.template import Template
28 28 from rhodecode import events
29 29 from rhodecode.translation import _
30 30 from rhodecode.lib import helpers as h
31 31 from rhodecode.lib.celerylib import run_task, async_task, RequestContextTask
32 32 from rhodecode.lib.colander_utils import strip_whitespace
33 33 from rhodecode.integrations.types.base import (
34 IntegrationTypeBase, CommitParsingDataHandler, render_with_traceback)
34 IntegrationTypeBase, CommitParsingDataHandler, render_with_traceback,
35 requests_retry_call)
35 36
36 37 log = logging.getLogger(__name__)
37 38
38 39
39 40 class HipchatSettingsSchema(colander.Schema):
40 41 color_choices = [
41 42 ('yellow', _('Yellow')),
42 43 ('red', _('Red')),
43 44 ('green', _('Green')),
44 45 ('purple', _('Purple')),
45 46 ('gray', _('Gray')),
46 47 ]
47 48
48 49 server_url = colander.SchemaNode(
49 50 colander.String(),
50 51 title=_('Hipchat server URL'),
51 52 description=_('Hipchat integration url.'),
52 53 default='',
53 54 preparer=strip_whitespace,
54 55 validator=colander.url,
55 56 widget=deform.widget.TextInputWidget(
56 57 placeholder='https://?.hipchat.com/v2/room/?/notification?auth_token=?',
57 58 ),
58 59 )
59 60 notify = colander.SchemaNode(
60 61 colander.Bool(),
61 62 title=_('Notify'),
62 63 description=_('Make a notification to the users in room.'),
63 64 missing=False,
64 65 default=False,
65 66 )
66 67 color = colander.SchemaNode(
67 68 colander.String(),
68 69 title=_('Color'),
69 70 description=_('Background color of message.'),
70 71 missing='',
71 72 validator=colander.OneOf([x[0] for x in color_choices]),
72 73 widget=deform.widget.Select2Widget(
73 74 values=color_choices,
74 75 ),
75 76 )
76 77
77 78
78 79 repo_push_template = Template('''
79 80 <b>${data['actor']['username']}</b> pushed to repo <a href="${data['repo']['url']}">${data['repo']['repo_name']}</a>:
80 81 <br>
81 82 <ul>
82 83 %for branch, branch_commits in branches_commits.items():
83 84 <li>
84 85 % if branch:
85 86 <a href="${branch_commits['branch']['url']}">branch: ${branch_commits['branch']['name']}</a>
86 87 % else:
87 88 to trunk
88 89 % endif
89 90 <ul>
90 91 % for commit in branch_commits['commits']:
91 92 <li><a href="${commit['url']}">${commit['short_id']}</a> - ${commit['message_html']}</li>
92 93 % endfor
93 94 </ul>
94 95 </li>
95 96 %endfor
96 97 ''')
97 98
98 99
99 100 class HipchatIntegrationType(IntegrationTypeBase, CommitParsingDataHandler):
100 101 key = 'hipchat'
101 102 display_name = _('Hipchat')
102 103 description = _('Send events such as repo pushes and pull requests to '
103 104 'your hipchat channel.')
104 105
105 106 @classmethod
106 107 def icon(cls):
107 108 return '''<?xml version="1.0" encoding="utf-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 1000 1000" enable-background="new 0 0 1000 1000" xml:space="preserve"><g><g transform="translate(0.000000,511.000000) scale(0.100000,-0.100000)"><path fill="#205281" d="M4197.1,4662.4c-1661.5-260.4-3018-1171.6-3682.6-2473.3C219.9,1613.6,100,1120.3,100,462.6c0-1014,376.8-1918.4,1127-2699.4C2326.7-3377.6,3878.5-3898.3,5701-3730.5l486.5,44.5l208.9-123.3c637.2-373.4,1551.8-640.6,2240.4-650.9c304.9-6.9,335.7,0,417.9,75.4c185,174.7,147.3,411.1-89.1,548.1c-315.2,181.6-620,544.7-733.1,870.1l-51.4,157.6l472.7,472.7c349.4,349.4,520.7,551.5,657.7,774.2c784.5,1281.2,784.5,2788.5,0,4052.6c-236.4,376.8-794.8,966-1178.4,1236.7c-572.1,407.7-1264.1,709.1-1993.7,870.1c-267.2,58.2-479.6,75.4-1038,82.2C4714.4,4686.4,4310.2,4679.6,4197.1,4662.4z M5947.6,3740.9c1856.7-380.3,3127.6-1709.4,3127.6-3275c0-1000.3-534.4-1949.2-1466.2-2600.1c-188.4-133.6-287.8-226.1-301.5-284.4c-41.1-157.6,263.8-938.6,397.4-1020.8c20.5-10.3,34.3-44.5,34.3-75.4c0-167.8-811.9,195.3-1363.4,609.8l-181.6,137l-332.3-58.2c-445.3-78.8-1281.2-78.8-1702.6,0C2796-2569.2,1734.1-1832.6,1220.2-801.5C983.8-318.5,905,51.5,929,613.3c27.4,640.6,243.2,1192.1,685.1,1740.3c620,770.8,1661.5,1305.2,2822.8,1452.5C4806.9,3854,5553.7,3819.7,5947.6,3740.9z"/><path fill="#205281" d="M2381.5-345.9c-75.4-106.2-68.5-167.8,34.3-322c332.3-500.2,1010.6-928.4,1760.8-1120.2c417.9-106.2,1226.4-106.2,1644.3,0c712.5,181.6,1270.9,517.3,1685.4,1014C7681-561.7,7715.3-424.7,7616-325.4c-89.1,89.1-167.9,65.1-431.7-133.6c-835.8-630.3-2028-856.4-3086.5-585.8C3683.3-938.6,3142-685,2830.3-448.7C2576.8-253.4,2463.7-229.4,2381.5-345.9z"/></g></g><!-- Svg Vector Icons : http://www.onlinewebfonts.com/icon --></svg>'''
108 109
109 110 valid_events = [
110 111 events.PullRequestCloseEvent,
111 112 events.PullRequestMergeEvent,
112 113 events.PullRequestUpdateEvent,
113 114 events.PullRequestCommentEvent,
114 115 events.PullRequestReviewEvent,
115 116 events.PullRequestCreateEvent,
116 117 events.RepoPushEvent,
117 118 events.RepoCreateEvent,
118 119 ]
119 120
120 121 def send_event(self, event):
121 122 if event.__class__ not in self.valid_events:
122 123 log.debug('event not valid: %r', event)
123 124 return
124 125
125 126 if event.name not in self.settings['events']:
126 127 log.debug('event ignored: %r', event)
127 128 return
128 129
129 130 data = event.as_dict()
130 131
131 132 text = '<b>%s<b> caused a <b>%s</b> event' % (
132 133 data['actor']['username'], event.name)
133 134
134 135 log.debug('handling hipchat event for %s', event.name)
135 136
136 137 if isinstance(event, events.PullRequestCommentEvent):
137 138 text = self.format_pull_request_comment_event(event, data)
138 139 elif isinstance(event, events.PullRequestReviewEvent):
139 140 text = self.format_pull_request_review_event(event, data)
140 141 elif isinstance(event, events.PullRequestEvent):
141 142 text = self.format_pull_request_event(event, data)
142 143 elif isinstance(event, events.RepoPushEvent):
143 144 text = self.format_repo_push_event(data)
144 145 elif isinstance(event, events.RepoCreateEvent):
145 146 text = self.format_repo_create_event(data)
146 147 else:
147 148 log.error('unhandled event type: %r', event)
148 149
149 150 run_task(post_text_to_hipchat, self.settings, text)
150 151
151 152 def settings_schema(self):
152 153 schema = HipchatSettingsSchema()
153 154 schema.add(colander.SchemaNode(
154 155 colander.Set(),
155 156 widget=deform.widget.CheckboxChoiceWidget(
156 157 values=sorted(
157 158 [(e.name, e.display_name) for e in self.valid_events]
158 159 )
159 160 ),
160 161 description="Events activated for this integration",
161 162 name='events'
162 163 ))
163 164
164 165 return schema
165 166
166 167 def format_pull_request_comment_event(self, event, data):
167 168 comment_text = data['comment']['text']
168 169 if len(comment_text) > 200:
169 170 comment_text = '{comment_text}<a href="{comment_url}">...<a/>'.format(
170 171 comment_text=h.html_escape(comment_text[:200]),
171 172 comment_url=data['comment']['url'],
172 173 )
173 174
174 175 comment_status = ''
175 176 if data['comment']['status']:
176 177 comment_status = '[{}]: '.format(data['comment']['status'])
177 178
178 179 return (textwrap.dedent(
179 180 '''
180 181 {user} commented on pull request <a href="{pr_url}">{number}</a> - {pr_title}:
181 182 >>> {comment_status}{comment_text}
182 183 ''').format(
183 184 comment_status=comment_status,
184 185 user=data['actor']['username'],
185 186 number=data['pullrequest']['pull_request_id'],
186 187 pr_url=data['pullrequest']['url'],
187 188 pr_status=data['pullrequest']['status'],
188 189 pr_title=h.html_escape(data['pullrequest']['title']),
189 190 comment_text=h.html_escape(comment_text)
190 191 )
191 192 )
192 193
193 194 def format_pull_request_review_event(self, event, data):
194 195 return (textwrap.dedent(
195 196 '''
196 197 Status changed to {pr_status} for pull request <a href="{pr_url}">#{number}</a> - {pr_title}
197 198 ''').format(
198 199 user=data['actor']['username'],
199 200 number=data['pullrequest']['pull_request_id'],
200 201 pr_url=data['pullrequest']['url'],
201 202 pr_status=data['pullrequest']['status'],
202 203 pr_title=h.html_escape(data['pullrequest']['title']),
203 204 )
204 205 )
205 206
206 207 def format_pull_request_event(self, event, data):
207 208 action = {
208 209 events.PullRequestCloseEvent: 'closed',
209 210 events.PullRequestMergeEvent: 'merged',
210 211 events.PullRequestUpdateEvent: 'updated',
211 212 events.PullRequestCreateEvent: 'created',
212 213 }.get(event.__class__, str(event.__class__))
213 214
214 215 return ('Pull request <a href="{url}">#{number}</a> - {title} '
215 216 '{action} by <b>{user}</b>').format(
216 217 user=data['actor']['username'],
217 218 number=data['pullrequest']['pull_request_id'],
218 219 url=data['pullrequest']['url'],
219 220 title=h.html_escape(data['pullrequest']['title']),
220 221 action=action
221 222 )
222 223
223 224 def format_repo_push_event(self, data):
224 225 branches_commits = self.aggregate_branch_data(
225 226 data['push']['branches'], data['push']['commits'])
226 227
227 228 result = render_with_traceback(
228 229 repo_push_template,
229 230 data=data,
230 231 branches_commits=branches_commits,
231 232 )
232 233 return result
233 234
234 235 def format_repo_create_event(self, data):
235 236 return '<a href="{}">{}</a> ({}) repository created by <b>{}</b>'.format(
236 237 data['repo']['url'],
237 238 h.html_escape(data['repo']['repo_name']),
238 239 data['repo']['repo_type'],
239 240 data['actor']['username'],
240 241 )
241 242
242 243
243 244 @async_task(ignore_result=True, base=RequestContextTask)
244 245 def post_text_to_hipchat(settings, text):
245 246 log.debug('sending %s to hipchat %s', text, settings['server_url'])
246 247 json_message = {
247 248 "message": text,
248 249 "color": settings.get('color', 'yellow'),
249 250 "notify": settings.get('notify', False),
250 251 }
251
252 resp = requests.post(settings['server_url'], json=json_message, timeout=60)
252 req_session = requests_retry_call()
253 resp = req_session.post(settings['server_url'], json=json_message, timeout=60)
253 254 resp.raise_for_status() # raise exception on a failed request
@@ -1,349 +1,350 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2012-2018 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 from __future__ import unicode_literals
22 22 import re
23 23 import time
24 24 import textwrap
25 25 import logging
26 26
27 27 import deform
28 28 import requests
29 29 import colander
30 30 from mako.template import Template
31 31
32 32 from rhodecode import events
33 33 from rhodecode.translation import _
34 34 from rhodecode.lib import helpers as h
35 35 from rhodecode.lib.celerylib import run_task, async_task, RequestContextTask
36 36 from rhodecode.lib.colander_utils import strip_whitespace
37 37 from rhodecode.integrations.types.base import (
38 IntegrationTypeBase, CommitParsingDataHandler, render_with_traceback)
38 IntegrationTypeBase, CommitParsingDataHandler, render_with_traceback,
39 requests_retry_call)
39 40
40 41 log = logging.getLogger(__name__)
41 42
42 43
43 44 class SlackSettingsSchema(colander.Schema):
44 45 service = colander.SchemaNode(
45 46 colander.String(),
46 47 title=_('Slack service URL'),
47 48 description=h.literal(_(
48 49 'This can be setup at the '
49 50 '<a href="https://my.slack.com/services/new/incoming-webhook/">'
50 51 'slack app manager</a>')),
51 52 default='',
52 53 preparer=strip_whitespace,
53 54 validator=colander.url,
54 55 widget=deform.widget.TextInputWidget(
55 56 placeholder='https://hooks.slack.com/services/...',
56 57 ),
57 58 )
58 59 username = colander.SchemaNode(
59 60 colander.String(),
60 61 title=_('Username'),
61 62 description=_('Username to show notifications coming from.'),
62 63 missing='Rhodecode',
63 64 preparer=strip_whitespace,
64 65 widget=deform.widget.TextInputWidget(
65 66 placeholder='Rhodecode'
66 67 ),
67 68 )
68 69 channel = colander.SchemaNode(
69 70 colander.String(),
70 71 title=_('Channel'),
71 72 description=_('Channel to send notifications to.'),
72 73 missing='',
73 74 preparer=strip_whitespace,
74 75 widget=deform.widget.TextInputWidget(
75 76 placeholder='#general'
76 77 ),
77 78 )
78 79 icon_emoji = colander.SchemaNode(
79 80 colander.String(),
80 81 title=_('Emoji'),
81 82 description=_('Emoji to use eg. :studio_microphone:'),
82 83 missing='',
83 84 preparer=strip_whitespace,
84 85 widget=deform.widget.TextInputWidget(
85 86 placeholder=':studio_microphone:'
86 87 ),
87 88 )
88 89
89 90
90 91 class SlackIntegrationType(IntegrationTypeBase, CommitParsingDataHandler):
91 92 key = 'slack'
92 93 display_name = _('Slack')
93 94 description = _('Send events such as repo pushes and pull requests to '
94 95 'your slack channel.')
95 96
96 97 @classmethod
97 98 def icon(cls):
98 99 return '''<?xml version="1.0" encoding="UTF-8" standalone="no"?><svg viewBox="0 0 256 256" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid"><g><path d="M165.963541,15.8384262 C162.07318,3.86308197 149.212328,-2.69009836 137.239082,1.20236066 C125.263738,5.09272131 118.710557,17.9535738 122.603016,29.9268197 L181.550164,211.292328 C185.597902,222.478689 197.682361,228.765377 209.282098,225.426885 C221.381246,221.943607 228.756984,209.093246 224.896,197.21023 C224.749115,196.756984 165.963541,15.8384262 165.963541,15.8384262" fill="#DFA22F"></path><path d="M74.6260984,45.515541 C70.7336393,33.5422951 57.8727869,26.9891148 45.899541,30.8794754 C33.9241967,34.7698361 27.3710164,47.6306885 31.2634754,59.6060328 L90.210623,240.971541 C94.2583607,252.157902 106.34282,258.44459 117.942557,255.104 C130.041705,251.62282 137.417443,238.772459 133.556459,226.887344 C133.409574,226.436197 74.6260984,45.515541 74.6260984,45.515541" fill="#3CB187"></path><path d="M240.161574,166.045377 C252.136918,162.155016 258.688,149.294164 254.797639,137.31882 C250.907279,125.345574 238.046426,118.792393 226.07318,122.682754 L44.7076721,181.632 C33.5213115,185.677639 27.234623,197.762098 30.5731148,209.361836 C34.0563934,221.460984 46.9067541,228.836721 58.7897705,224.975738 C59.2430164,224.828852 240.161574,166.045377 240.161574,166.045377" fill="#CE1E5B"></path><path d="M82.507541,217.270557 C94.312918,213.434754 109.528131,208.491016 125.855475,203.186361 C122.019672,191.380984 117.075934,176.163672 111.76918,159.83423 L68.4191475,173.924721 L82.507541,217.270557" fill="#392538"></path><path d="M173.847082,187.591344 C190.235279,182.267803 205.467279,177.31777 217.195016,173.507148 C213.359213,161.70177 208.413377,146.480262 203.106623,130.146623 L159.75659,144.237115 L173.847082,187.591344" fill="#BB242A"></path><path d="M210.484459,74.7058361 C222.457705,70.8154754 229.010885,57.954623 225.120525,45.9792787 C221.230164,34.0060328 208.369311,27.4528525 196.393967,31.3432131 L15.028459,90.292459 C3.84209836,94.3380984 -2.44459016,106.422557 0.896,118.022295 C4.37718033,130.121443 17.227541,137.49718 29.1126557,133.636197 C29.5638033,133.489311 210.484459,74.7058361 210.484459,74.7058361" fill="#72C5CD"></path><path d="M52.8220328,125.933115 C64.6274098,122.097311 79.8468197,117.151475 96.1762623,111.84682 C90.8527213,95.4565246 85.9026885,80.2245246 82.0920656,68.4946885 L38.731541,82.5872787 L52.8220328,125.933115" fill="#248C73"></path><path d="M144.159475,96.256 C160.551869,90.9303607 175.785967,85.9803279 187.515803,82.1676066 C182.190164,65.7752131 177.240131,50.5390164 173.42741,38.807082 L130.068984,52.8996721 L144.159475,96.256" fill="#62803A"></path></g></svg>'''
99 100
100 101 valid_events = [
101 102 events.PullRequestCloseEvent,
102 103 events.PullRequestMergeEvent,
103 104 events.PullRequestUpdateEvent,
104 105 events.PullRequestCommentEvent,
105 106 events.PullRequestReviewEvent,
106 107 events.PullRequestCreateEvent,
107 108 events.RepoPushEvent,
108 109 events.RepoCreateEvent,
109 110 ]
110 111
111 112 def send_event(self, event):
112 113 if event.__class__ not in self.valid_events:
113 114 log.debug('event not valid: %r', event)
114 115 return
115 116
116 117 if event.name not in self.settings['events']:
117 118 log.debug('event ignored: %r', event)
118 119 return
119 120
120 121 data = event.as_dict()
121 122
122 123 # defaults
123 124 title = '*%s* caused a *%s* event' % (
124 125 data['actor']['username'], event.name)
125 126 text = '*%s* caused a *%s* event' % (
126 127 data['actor']['username'], event.name)
127 128 fields = None
128 129 overrides = None
129 130
130 131 log.debug('handling slack event for %s', event.name)
131 132
132 133 if isinstance(event, events.PullRequestCommentEvent):
133 134 (title, text, fields, overrides) \
134 135 = self.format_pull_request_comment_event(event, data)
135 136 elif isinstance(event, events.PullRequestReviewEvent):
136 137 title, text = self.format_pull_request_review_event(event, data)
137 138 elif isinstance(event, events.PullRequestEvent):
138 139 title, text = self.format_pull_request_event(event, data)
139 140 elif isinstance(event, events.RepoPushEvent):
140 141 title, text = self.format_repo_push_event(data)
141 142 elif isinstance(event, events.RepoCreateEvent):
142 143 title, text = self.format_repo_create_event(data)
143 144 else:
144 145 log.error('unhandled event type: %r', event)
145 146
146 147 run_task(post_text_to_slack, self.settings, title, text, fields, overrides)
147 148
148 149 def settings_schema(self):
149 150 schema = SlackSettingsSchema()
150 151 schema.add(colander.SchemaNode(
151 152 colander.Set(),
152 153 widget=deform.widget.CheckboxChoiceWidget(
153 154 values=sorted(
154 155 [(e.name, e.display_name) for e in self.valid_events]
155 156 )
156 157 ),
157 158 description="Events activated for this integration",
158 159 name='events'
159 160 ))
160 161
161 162 return schema
162 163
163 164 def format_pull_request_comment_event(self, event, data):
164 165 comment_text = data['comment']['text']
165 166 if len(comment_text) > 200:
166 167 comment_text = '<{comment_url}|{comment_text}...>'.format(
167 168 comment_text=comment_text[:200],
168 169 comment_url=data['comment']['url'],
169 170 )
170 171
171 172 fields = None
172 173 overrides = None
173 174 status_text = None
174 175
175 176 if data['comment']['status']:
176 177 status_color = {
177 178 'approved': '#0ac878',
178 179 'rejected': '#e85e4d'}.get(data['comment']['status'])
179 180
180 181 if status_color:
181 182 overrides = {"color": status_color}
182 183
183 184 status_text = data['comment']['status']
184 185
185 186 if data['comment']['file']:
186 187 fields = [
187 188 {
188 189 "title": "file",
189 190 "value": data['comment']['file']
190 191 },
191 192 {
192 193 "title": "line",
193 194 "value": data['comment']['line']
194 195 }
195 196 ]
196 197
197 198 template = Template(textwrap.dedent(r'''
198 199 *${data['actor']['username']}* left ${data['comment']['type']} on pull request <${data['pullrequest']['url']}|#${data['pullrequest']['pull_request_id']}>:
199 200 '''))
200 201 title = render_with_traceback(
201 202 template, data=data, comment=event.comment)
202 203
203 204 template = Template(textwrap.dedent(r'''
204 205 *pull request title*: ${pr_title}
205 206 % if status_text:
206 207 *submitted status*: `${status_text}`
207 208 % endif
208 209 >>> ${comment_text}
209 210 '''))
210 211 text = render_with_traceback(
211 212 template,
212 213 comment_text=comment_text,
213 214 pr_title=data['pullrequest']['title'],
214 215 status_text=status_text)
215 216
216 217 return title, text, fields, overrides
217 218
218 219 def format_pull_request_review_event(self, event, data):
219 220 template = Template(textwrap.dedent(r'''
220 221 *${data['actor']['username']}* changed status of pull request <${data['pullrequest']['url']}|#${data['pullrequest']['pull_request_id']} to `${data['pullrequest']['status']}`>:
221 222 '''))
222 223 title = render_with_traceback(template, data=data)
223 224
224 225 template = Template(textwrap.dedent(r'''
225 226 *pull request title*: ${pr_title}
226 227 '''))
227 228 text = render_with_traceback(
228 229 template,
229 230 pr_title=data['pullrequest']['title'])
230 231
231 232 return title, text
232 233
233 234 def format_pull_request_event(self, event, data):
234 235 action = {
235 236 events.PullRequestCloseEvent: 'closed',
236 237 events.PullRequestMergeEvent: 'merged',
237 238 events.PullRequestUpdateEvent: 'updated',
238 239 events.PullRequestCreateEvent: 'created',
239 240 }.get(event.__class__, str(event.__class__))
240 241
241 242 template = Template(textwrap.dedent(r'''
242 243 *${data['actor']['username']}* `${action}` pull request <${data['pullrequest']['url']}|#${data['pullrequest']['pull_request_id']}>:
243 244 '''))
244 245 title = render_with_traceback(template, data=data, action=action)
245 246
246 247 template = Template(textwrap.dedent(r'''
247 248 *pull request title*: ${pr_title}
248 249 %if data['pullrequest']['commits']:
249 250 *commits*: ${len(data['pullrequest']['commits'])}
250 251 %endif
251 252 '''))
252 253 text = render_with_traceback(
253 254 template,
254 255 pr_title=data['pullrequest']['title'],
255 256 data=data)
256 257
257 258 return title, text
258 259
259 260 def format_repo_push_event(self, data):
260 261
261 262 branches_commits = self.aggregate_branch_data(
262 263 data['push']['branches'], data['push']['commits'])
263 264
264 265 template = Template(r'''
265 266 *${data['actor']['username']}* pushed to repo <${data['repo']['url']}|${data['repo']['repo_name']}>:
266 267 ''')
267 268 title = render_with_traceback(template, data=data)
268 269
269 270 repo_push_template = Template(textwrap.dedent(r'''
270 271 <%
271 272 def branch_text(branch):
272 273 if branch:
273 274 return 'on branch: <{}|{}>'.format(branch_commits['branch']['url'], branch_commits['branch']['name'])
274 275 else:
275 276 ## case for SVN no branch push...
276 277 return 'to trunk'
277 278 %> \
278 279 % for branch, branch_commits in branches_commits.items():
279 280 ${len(branch_commits['commits'])} ${'commit' if len(branch_commits['commits']) == 1 else 'commits'} ${branch_text(branch)}
280 281 % for commit in branch_commits['commits']:
281 282 `<${commit['url']}|${commit['short_id']}>` - ${commit['message_html']|html_to_slack_links}
282 283 % endfor
283 284 % endfor
284 285 '''))
285 286
286 287 text = render_with_traceback(
287 288 repo_push_template,
288 289 data=data,
289 290 branches_commits=branches_commits,
290 291 html_to_slack_links=html_to_slack_links,
291 292 )
292 293
293 294 return title, text
294 295
295 296 def format_repo_create_event(self, data):
296 297 template = Template(r'''
297 298 *${data['actor']['username']}* created new repository ${data['repo']['repo_name']}:
298 299 ''')
299 300 title = render_with_traceback(template, data=data)
300 301
301 302 template = Template(textwrap.dedent(r'''
302 303 repo_url: ${data['repo']['url']}
303 304 repo_type: ${data['repo']['repo_type']}
304 305 '''))
305 306 text = render_with_traceback(template, data=data)
306 307
307 308 return title, text
308 309
309 310
310 311 def html_to_slack_links(message):
311 312 return re.compile(r'<a .*?href=["\'](.+?)".*?>(.+?)</a>').sub(
312 313 r'<\1|\2>', message)
313 314
314 315
315 316 @async_task(ignore_result=True, base=RequestContextTask)
316 317 def post_text_to_slack(settings, title, text, fields=None, overrides=None):
317 318 log.debug('sending %s (%s) to slack %s', title, text, settings['service'])
318 319
319 320 fields = fields or []
320 321 overrides = overrides or {}
321 322
322 323 message_data = {
323 324 "fallback": text,
324 325 "color": "#427cc9",
325 326 "pretext": title,
326 327 #"author_name": "Bobby Tables",
327 328 #"author_link": "http://flickr.com/bobby/",
328 329 #"author_icon": "http://flickr.com/icons/bobby.jpg",
329 330 #"title": "Slack API Documentation",
330 331 #"title_link": "https://api.slack.com/",
331 332 "text": text,
332 333 "fields": fields,
333 334 #"image_url": "http://my-website.com/path/to/image.jpg",
334 335 #"thumb_url": "http://example.com/path/to/thumb.png",
335 336 "footer": "RhodeCode",
336 337 #"footer_icon": "",
337 338 "ts": time.time(),
338 339 "mrkdwn_in": ["pretext", "text"]
339 340 }
340 341 message_data.update(overrides)
341 342 json_message = {
342 343 "icon_emoji": settings.get('icon_emoji', ':studio_microphone:'),
343 344 "channel": settings.get('channel', ''),
344 345 "username": settings.get('username', 'Rhodecode'),
345 346 "attachments": [message_data]
346 347 }
347
348 resp = requests.post(settings['service'], json=json_message, timeout=60)
348 req_session = requests_retry_call()
349 resp = req_session.post(settings['service'], json=json_message, timeout=60)
349 350 resp.raise_for_status() # raise exception on a failed request
@@ -1,274 +1,264 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2012-2018 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 from __future__ import unicode_literals
22 22
23 23 import deform
24 24 import deform.widget
25 25 import logging
26 import requests
27 import requests.adapters
28 26 import colander
29 from requests.packages.urllib3.util.retry import Retry
30 27
31 28 import rhodecode
32 29 from rhodecode import events
33 30 from rhodecode.translation import _
34 31 from rhodecode.integrations.types.base import (
35 32 IntegrationTypeBase, get_auth, get_web_token, get_url_vars,
36 WebhookDataHandler, WEBHOOK_URL_VARS)
33 WebhookDataHandler, WEBHOOK_URL_VARS, requests_retry_call)
37 34 from rhodecode.lib.celerylib import run_task, async_task, RequestContextTask
38 35 from rhodecode.model.validation_schema import widgets
39 36
40 37 log = logging.getLogger(__name__)
41 38
42 39
43 40 # updating this required to update the `common_vars` passed in url calling func
44 41
45 42 URL_VARS = get_url_vars(WEBHOOK_URL_VARS)
46 43
47 44
48 45 class WebhookSettingsSchema(colander.Schema):
49 46 url = colander.SchemaNode(
50 47 colander.String(),
51 48 title=_('Webhook URL'),
52 49 description=
53 50 _('URL to which Webhook should submit data. If used some of the '
54 51 'variables would trigger multiple calls, like ${branch} or '
55 52 '${commit_id}. Webhook will be called as many times as unique '
56 53 'objects in data in such cases.'),
57 54 missing=colander.required,
58 55 required=True,
59 56 validator=colander.url,
60 57 widget=widgets.CodeMirrorWidget(
61 58 help_block_collapsable_name='Show url variables',
62 59 help_block_collapsable=(
63 60 'E.g http://my-serv/trigger_job/${{event_name}}'
64 61 '?PR_ID=${{pull_request_id}}'
65 62 '\nFull list of vars:\n{}'.format(URL_VARS)),
66 63 codemirror_mode='text',
67 64 codemirror_options='{"lineNumbers": false, "lineWrapping": true}'),
68 65 )
69 66 secret_token = colander.SchemaNode(
70 67 colander.String(),
71 68 title=_('Secret Token'),
72 69 description=_('Optional string used to validate received payloads. '
73 70 'It will be sent together with event data in JSON'),
74 71 default='',
75 72 missing='',
76 73 widget=deform.widget.TextInputWidget(
77 74 placeholder='e.g. secret_token'
78 75 ),
79 76 )
80 77 username = colander.SchemaNode(
81 78 colander.String(),
82 79 title=_('Username'),
83 80 description=_('Optional username to authenticate the call.'),
84 81 default='',
85 82 missing='',
86 83 widget=deform.widget.TextInputWidget(
87 84 placeholder='e.g. admin'
88 85 ),
89 86 )
90 87 password = colander.SchemaNode(
91 88 colander.String(),
92 89 title=_('Password'),
93 90 description=_('Optional password to authenticate the call.'),
94 91 default='',
95 92 missing='',
96 93 widget=deform.widget.PasswordWidget(
97 94 placeholder='e.g. secret.',
98 95 redisplay=True,
99 96 ),
100 97 )
101 98 custom_header_key = colander.SchemaNode(
102 99 colander.String(),
103 100 title=_('Custom Header Key'),
104 101 description=_('Custom Header name to be set when calling endpoint.'),
105 102 default='',
106 103 missing='',
107 104 widget=deform.widget.TextInputWidget(
108 105 placeholder='e.g: Authorization'
109 106 ),
110 107 )
111 108 custom_header_val = colander.SchemaNode(
112 109 colander.String(),
113 110 title=_('Custom Header Value'),
114 111 description=_('Custom Header value to be set when calling endpoint.'),
115 112 default='',
116 113 missing='',
117 114 widget=deform.widget.TextInputWidget(
118 115 placeholder='e.g. Basic XxXxXx'
119 116 ),
120 117 )
121 118 method_type = colander.SchemaNode(
122 119 colander.String(),
123 120 title=_('Call Method'),
124 121 description=_('Select if the Webhook call should be made '
125 122 'with POST or GET.'),
126 123 default='post',
127 124 missing='',
128 125 widget=deform.widget.RadioChoiceWidget(
129 126 values=[('get', 'GET'), ('post', 'POST')],
130 127 inline=True
131 128 ),
132 129 )
133 130
134 131
135 132 class WebhookIntegrationType(IntegrationTypeBase):
136 133 key = 'webhook'
137 134 display_name = _('Webhook')
138 135 description = _('send JSON data to a url endpoint')
139 136
140 137 @classmethod
141 138 def icon(cls):
142 139 return '''<?xml version="1.0" encoding="UTF-8" standalone="no"?><svg viewBox="0 0 256 239" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid"><g><path d="M119.540432,100.502743 C108.930124,118.338815 98.7646301,135.611455 88.3876025,152.753617 C85.7226696,157.154315 84.4040417,160.738531 86.5332204,166.333309 C92.4107024,181.787152 84.1193605,196.825836 68.5350381,200.908244 C53.8383677,204.759349 39.5192953,195.099955 36.6032893,179.365384 C34.0194114,165.437749 44.8274148,151.78491 60.1824106,149.608284 C61.4694072,149.424428 62.7821041,149.402681 64.944891,149.240571 C72.469175,136.623655 80.1773157,123.700312 88.3025935,110.073173 C73.611854,95.4654658 64.8677898,78.3885437 66.803227,57.2292132 C68.1712787,42.2715849 74.0527146,29.3462646 84.8033863,18.7517722 C105.393354,-1.53572199 136.805164,-4.82141828 161.048542,10.7510424 C184.333097,25.7086706 194.996783,54.8450075 185.906752,79.7822957 C179.052655,77.9239597 172.151111,76.049808 164.563565,73.9917997 C167.418285,60.1274266 165.306899,47.6765751 155.95591,37.0109123 C149.777932,29.9690049 141.850349,26.2780332 132.835442,24.9178894 C114.764113,22.1877169 97.0209573,33.7983633 91.7563309,51.5355878 C85.7800012,71.6669027 94.8245623,88.1111998 119.540432,100.502743 L119.540432,100.502743 Z" fill="#C73A63"></path><path d="M149.841194,79.4106285 C157.316054,92.5969067 164.905578,105.982857 172.427885,119.246236 C210.44865,107.483365 239.114472,128.530009 249.398582,151.063322 C261.81978,178.282014 253.328765,210.520191 228.933162,227.312431 C203.893073,244.551464 172.226236,241.605803 150.040866,219.46195 C155.694953,214.729124 161.376716,209.974552 167.44794,204.895759 C189.360489,219.088306 208.525074,218.420096 222.753207,201.614016 C234.885769,187.277151 234.622834,165.900356 222.138374,151.863988 C207.730339,135.66681 188.431321,135.172572 165.103273,150.721309 C155.426087,133.553447 145.58086,116.521995 136.210101,99.2295848 C133.05093,93.4015266 129.561608,90.0209366 122.440622,88.7873178 C110.547271,86.7253555 102.868785,76.5124151 102.408155,65.0698097 C101.955433,53.7537294 108.621719,43.5249733 119.04224,39.5394355 C129.363912,35.5914599 141.476705,38.7783085 148.419765,47.554004 C154.093621,54.7244134 155.896602,62.7943365 152.911402,71.6372484 C152.081082,74.1025091 151.00562,76.4886916 149.841194,79.4106285 L149.841194,79.4106285 Z" fill="#4B4B4B"></path><path d="M167.706921,187.209935 L121.936499,187.209935 C117.54964,205.253587 108.074103,219.821756 91.7464461,229.085759 C79.0544063,236.285822 65.3738898,238.72736 50.8136292,236.376762 C24.0061432,232.053165 2.08568567,207.920497 0.156179306,180.745298 C-2.02835403,149.962159 19.1309765,122.599149 47.3341915,116.452801 C49.2814904,123.524363 51.2485589,130.663141 53.1958579,137.716911 C27.3195169,150.919004 18.3639187,167.553089 25.6054984,188.352614 C31.9811726,206.657224 50.0900643,216.690262 69.7528413,212.809503 C89.8327554,208.847688 99.9567329,192.160226 98.7211371,165.37844 C117.75722,165.37844 136.809118,165.180745 155.847178,165.475311 C163.280522,165.591951 169.019617,164.820939 174.620326,158.267339 C183.840836,147.48306 200.811003,148.455721 210.741239,158.640984 C220.88894,169.049642 220.402609,185.79839 209.663799,195.768166 C199.302587,205.38802 182.933414,204.874012 173.240413,194.508846 C171.247644,192.37176 169.677943,189.835329 167.706921,187.209935 L167.706921,187.209935 Z" fill="#4A4A4A"></path></g></svg>'''
143 140
144 141 valid_events = [
145 142 events.PullRequestCloseEvent,
146 143 events.PullRequestMergeEvent,
147 144 events.PullRequestUpdateEvent,
148 145 events.PullRequestCommentEvent,
149 146 events.PullRequestReviewEvent,
150 147 events.PullRequestCreateEvent,
151 148 events.RepoPushEvent,
152 149 events.RepoCreateEvent,
153 150 ]
154 151
155 152 def settings_schema(self):
156 153 schema = WebhookSettingsSchema()
157 154 schema.add(colander.SchemaNode(
158 155 colander.Set(),
159 156 widget=deform.widget.CheckboxChoiceWidget(
160 157 values=sorted(
161 158 [(e.name, e.display_name) for e in self.valid_events]
162 159 )
163 160 ),
164 161 description="Events activated for this integration",
165 162 name='events'
166 163 ))
167 164 return schema
168 165
169 166 def send_event(self, event):
170 167 log.debug(
171 168 'handling event %s with Webhook integration %s', event.name, self)
172 169
173 170 if event.__class__ not in self.valid_events:
174 171 log.debug('event not valid: %r', event)
175 172 return
176 173
177 174 if event.name not in self.settings['events']:
178 175 log.debug('event ignored: %r', event)
179 176 return
180 177
181 178 data = event.as_dict()
182 179 template_url = self.settings['url']
183 180
184 181 headers = {}
185 182 head_key = self.settings.get('custom_header_key')
186 183 head_val = self.settings.get('custom_header_val')
187 184 if head_key and head_val:
188 185 headers = {head_key: head_val}
189 186
190 187 handler = WebhookDataHandler(template_url, headers)
191 188
192 189 url_calls = handler(event, data)
193 190 log.debug('webhook: calling following urls: %s',
194 191 [x[0] for x in url_calls])
195 192
196 193 run_task(post_to_webhook, url_calls, self.settings)
197 194
198 195
199 196 @async_task(ignore_result=True, base=RequestContextTask)
200 197 def post_to_webhook(url_calls, settings):
201 198 """
202 199 Example data::
203 200
204 201 {'actor': {'user_id': 2, 'username': u'admin'},
205 202 'actor_ip': u'192.168.157.1',
206 203 'name': 'repo-push',
207 204 'push': {'branches': [{'name': u'default',
208 205 'url': 'http://rc.local:8080/hg-repo/changelog?branch=default'}],
209 206 'commits': [{'author': u'Marcin Kuzminski <marcin@rhodecode.com>',
210 207 'branch': u'default',
211 208 'date': datetime.datetime(2017, 11, 30, 12, 59, 48),
212 209 'issues': [],
213 210 'mentions': [],
214 211 'message': u'commit Thu 30 Nov 2017 13:59:48 CET',
215 212 'message_html': u'commit Thu 30 Nov 2017 13:59:48 CET',
216 213 'message_html_title': u'commit Thu 30 Nov 2017 13:59:48 CET',
217 214 'parents': [{'raw_id': '431b772a5353dad9974b810dd3707d79e3a7f6e0'}],
218 215 'permalink_url': u'http://rc.local:8080/_7/changeset/a815cc738b9651eb5ffbcfb1ce6ccd7c701a5ddf',
219 216 'raw_id': 'a815cc738b9651eb5ffbcfb1ce6ccd7c701a5ddf',
220 217 'refs': {'bookmarks': [], 'branches': [u'default'], 'tags': [u'tip']},
221 218 'reviewers': [],
222 219 'revision': 9L,
223 220 'short_id': 'a815cc738b96',
224 221 'url': u'http://rc.local:8080/hg-repo/changeset/a815cc738b9651eb5ffbcfb1ce6ccd7c701a5ddf'}],
225 222 'issues': {}},
226 223 'repo': {'extra_fields': '',
227 224 'permalink_url': u'http://rc.local:8080/_7',
228 225 'repo_id': 7,
229 226 'repo_name': u'hg-repo',
230 227 'repo_type': u'hg',
231 228 'url': u'http://rc.local:8080/hg-repo'},
232 229 'server_url': u'http://rc.local:8080',
233 230 'utc_timestamp': datetime.datetime(2017, 11, 30, 13, 0, 1, 569276)
234 231
235 232 """
236 max_retries = 3
237 retries = Retry(
238 total=max_retries,
239 backoff_factor=0.15,
240 status_forcelist=[500, 502, 503, 504])
241 233 call_headers = {
242 234 'User-Agent': 'RhodeCode-webhook-caller/{}'.format(
243 235 rhodecode.__version__)
244 236 } # updated below with custom ones, allows override
245 237
246 238 auth = get_auth(settings)
247 239 token = get_web_token(settings)
248 240
249 241 for url, headers, data in url_calls:
250 req_session = requests.Session()
251 req_session.mount( # retry max N times
252 'http://', requests.adapters.HTTPAdapter(max_retries=retries))
242 req_session = requests_retry_call()
253 243
254 244 method = settings.get('method_type') or 'post'
255 245 call_method = getattr(req_session, method)
256 246
257 247 headers = headers or {}
258 248 call_headers.update(headers)
259 249
260 250 log.debug('calling Webhook with method: %s, and auth:%s',
261 251 call_method, auth)
262 252 if settings.get('log_data'):
263 253 log.debug('calling webhook with data: %s', data)
264 254 resp = call_method(url, json={
265 255 'token': token,
266 256 'event': data
267 257 }, headers=call_headers, auth=auth, timeout=60)
268 258 log.debug('Got Webhook response: %s', resp)
269 259
270 260 try:
271 261 resp.raise_for_status() # raise exception on a failed request
272 262 except Exception:
273 263 log.error(resp.text)
274 264 raise
General Comments 0
You need to be logged in to leave comments. Login now