##// END OF EJS Templates
webhook: quote URL variables to prevent url errors with special chars like # in pr title.
ergo -
r3477:976a0af2 default
parent child Browse files
Show More
@@ -1,355 +1,363 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2012-2019 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 25 import requests
26 import urllib
26 27 from requests.adapters import HTTPAdapter
27 28 from requests.packages.urllib3.util.retry import Retry
28 29
29 30 from mako import exceptions
30 31
32 from rhodecode.lib.utils2 import safe_str
31 33 from rhodecode.translation import _
32 34
33 35
34 36 log = logging.getLogger(__name__)
35 37
36 38
39 class UrlTmpl(string.Template):
40
41 def safe_substitute(self, **kws):
42 # url encode the kw for usage in url
43 kws = {k: urllib.quote(safe_str(v)) for k, v in kws.items()}
44 return super(UrlTmpl, self).safe_substitute(**kws)
45
46
37 47 class IntegrationTypeBase(object):
38 48 """ Base class for IntegrationType plugins """
39 49 is_dummy = False
40 50 description = ''
41 51
42 52 @classmethod
43 53 def icon(cls):
44 54 return '''
45 55 <?xml version="1.0" encoding="UTF-8" standalone="no"?>
46 56 <svg
47 57 xmlns:dc="http://purl.org/dc/elements/1.1/"
48 58 xmlns:cc="http://creativecommons.org/ns#"
49 59 xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
50 60 xmlns:svg="http://www.w3.org/2000/svg"
51 61 xmlns="http://www.w3.org/2000/svg"
52 62 xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
53 63 xmlns:inkscape="http://setwww.inkscape.org/namespaces/inkscape"
54 64 viewBox="0 -256 1792 1792"
55 65 id="svg3025"
56 66 version="1.1"
57 67 inkscape:version="0.48.3.1 r9886"
58 68 width="100%"
59 69 height="100%"
60 70 sodipodi:docname="cog_font_awesome.svg">
61 71 <metadata
62 72 id="metadata3035">
63 73 <rdf:RDF>
64 74 <cc:Work
65 75 rdf:about="">
66 76 <dc:format>image/svg+xml</dc:format>
67 77 <dc:type
68 78 rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
69 79 </cc:Work>
70 80 </rdf:RDF>
71 81 </metadata>
72 82 <defs
73 83 id="defs3033" />
74 84 <sodipodi:namedview
75 85 pagecolor="#ffffff"
76 86 bordercolor="#666666"
77 87 borderopacity="1"
78 88 objecttolerance="10"
79 89 gridtolerance="10"
80 90 guidetolerance="10"
81 91 inkscape:pageopacity="0"
82 92 inkscape:pageshadow="2"
83 93 inkscape:window-width="640"
84 94 inkscape:window-height="480"
85 95 id="namedview3031"
86 96 showgrid="false"
87 97 inkscape:zoom="0.13169643"
88 98 inkscape:cx="896"
89 99 inkscape:cy="896"
90 100 inkscape:window-x="0"
91 101 inkscape:window-y="25"
92 102 inkscape:window-maximized="0"
93 103 inkscape:current-layer="svg3025" />
94 104 <g
95 105 transform="matrix(1,0,0,-1,121.49153,1285.4237)"
96 106 id="g3027">
97 107 <path
98 108 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"
99 109 id="path3029"
100 110 inkscape:connector-curvature="0"
101 111 style="fill:currentColor" />
102 112 </g>
103 113 </svg>
104 114 '''
105 115
106 116 def __init__(self, settings):
107 117 """
108 118 :param settings: dict of settings to be used for the integration
109 119 """
110 120 self.settings = settings
111 121
112 122 def settings_schema(self):
113 123 """
114 124 A colander schema of settings for the integration type
115 125 """
116 126 return colander.Schema()
117 127
118 128
119 129 class EEIntegration(IntegrationTypeBase):
120 130 description = 'Integration available in RhodeCode EE edition.'
121 131 is_dummy = True
122 132
123 133 def __init__(self, name, key, settings=None):
124 134 self.display_name = name
125 135 self.key = key
126 136 super(EEIntegration, self).__init__(settings)
127 137
128 138
129 139 # Helpers #
130 140 # updating this required to update the `common_vars` as well.
131 141 WEBHOOK_URL_VARS = [
132 142 ('event_name', 'Unique name of the event type, e.g pullrequest-update'),
133 143 ('repo_name', 'Full name of the repository'),
134 144 ('repo_type', 'VCS type of repository'),
135 145 ('repo_id', 'Unique id of repository'),
136 146 ('repo_url', 'Repository url'),
137 147 # extra repo fields
138 148 ('extra:<extra_key_name>', 'Extra repo variables, read from its settings.'),
139 149
140 150 # special attrs below that we handle, using multi-call
141 151 ('branch', 'Name of each branch submitted, if any.'),
142 152 ('branch_head', 'Head ID of pushed branch (full sha of last commit), if any.'),
143 153 ('commit_id', 'ID (full sha) of each commit submitted, if any.'),
144 154
145 155 # pr events vars
146 156 ('pull_request_id', 'Unique ID of the pull request.'),
147 157 ('pull_request_title', 'Title of the pull request.'),
148 158 ('pull_request_url', 'Pull request url.'),
149 159 ('pull_request_shadow_url', 'Pull request shadow repo clone url.'),
150 160 ('pull_request_commits_uid', 'Calculated UID of all commits inside the PR. '
151 161 'Changes after PR update'),
152 162
153 163 # user who triggers the call
154 164 ('username', 'User who triggered the call.'),
155 165 ('user_id', 'User id who triggered the call.'),
156 166 ]
157 167
158 168 # common vars for url template used for CI plugins. Shared with webhook
159 169 CI_URL_VARS = WEBHOOK_URL_VARS
160 170
161 171
162 172 class CommitParsingDataHandler(object):
163 173
164 174 def aggregate_branch_data(self, branches, commits):
165 175 branch_data = collections.OrderedDict()
166 176 for obj in branches:
167 177 branch_data[obj['name']] = obj
168 178
169 179 branches_commits = collections.OrderedDict()
170 180 for commit in commits:
171 181 if commit.get('git_ref_change'):
172 182 # special case for GIT that allows creating tags,
173 183 # deleting branches without associated commit
174 184 continue
175 185 commit_branch = commit['branch']
176 186
177 187 if commit_branch not in branches_commits:
178 188 _branch = branch_data[commit_branch] \
179 189 if commit_branch else commit_branch
180 190 branch_commits = {'branch': _branch,
181 191 'branch_head': '',
182 192 'commits': []}
183 193 branches_commits[commit_branch] = branch_commits
184 194
185 195 branch_commits = branches_commits[commit_branch]
186 196 branch_commits['commits'].append(commit)
187 197 branch_commits['branch_head'] = commit['raw_id']
188 198 return branches_commits
189 199
190 200
191 201 class WebhookDataHandler(CommitParsingDataHandler):
192 202 name = 'webhook'
193 203
194 204 def __init__(self, template_url, headers):
195 205 self.template_url = template_url
196 206 self.headers = headers
197 207
198 208 def get_base_parsed_template(self, data):
199 209 """
200 210 initially parses the passed in template with some common variables
201 211 available on ALL calls
202 212 """
203 213 # note: make sure to update the `WEBHOOK_URL_VARS` if this changes
204 214 common_vars = {
205 215 'repo_name': data['repo']['repo_name'],
206 216 'repo_type': data['repo']['repo_type'],
207 217 'repo_id': data['repo']['repo_id'],
208 218 'repo_url': data['repo']['url'],
209 219 'username': data['actor']['username'],
210 220 'user_id': data['actor']['user_id'],
211 221 'event_name': data['name']
212 222 }
213 223
214 224 extra_vars = {}
215 225 for extra_key, extra_val in data['repo']['extra_fields'].items():
216 226 extra_vars['extra__{}'.format(extra_key)] = extra_val
217 227 common_vars.update(extra_vars)
218 228
219 229 template_url = self.template_url.replace('${extra:', '${extra__')
220 return string.Template(template_url).safe_substitute(**common_vars)
230 for k, v in common_vars.items():
231 template_url = UrlTmpl(template_url).safe_substitute(**{k: v})
232 return template_url
221 233
222 234 def repo_push_event_handler(self, event, data):
223 235 url = self.get_base_parsed_template(data)
224 236 url_calls = []
225 237
226 238 branches_commits = self.aggregate_branch_data(
227 239 data['push']['branches'], data['push']['commits'])
228 240 if '${branch}' in url or '${branch_head}' in url or '${commit_id}' in url:
229 241 # call it multiple times, for each branch if used in variables
230 242 for branch, commit_ids in branches_commits.items():
231 branch_url = string.Template(url).safe_substitute(branch=branch)
243 branch_url = UrlTmpl(url).safe_substitute(branch=branch)
232 244
233 245 if '${branch_head}' in branch_url:
234 246 # last commit in the aggregate is the head of the branch
235 247 branch_head = commit_ids['branch_head']
236 branch_url = string.Template(branch_url).safe_substitute(
237 branch_head=branch_head)
248 branch_url = UrlTmpl(branch_url).safe_substitute(branch_head=branch_head)
238 249
239 250 # call further down for each commit if used
240 251 if '${commit_id}' in branch_url:
241 252 for commit_data in commit_ids['commits']:
242 253 commit_id = commit_data['raw_id']
243 commit_url = string.Template(branch_url).safe_substitute(
244 commit_id=commit_id)
254 commit_url = UrlTmpl(branch_url).safe_substitute(commit_id=commit_id)
245 255 # register per-commit call
246 256 log.debug(
247 257 'register %s call(%s) to url %s',
248 258 self.name, event, commit_url)
249 259 url_calls.append(
250 260 (commit_url, self.headers, data))
251 261
252 262 else:
253 263 # register per-branch call
254 log.debug(
255 'register %s call(%s) to url %s',
256 self.name, event, branch_url)
257 url_calls.append(
258 (branch_url, self.headers, data))
264 log.debug('register %s call(%s) to url %s',
265 self.name, event, branch_url)
266 url_calls.append((branch_url, self.headers, data))
259 267
260 268 else:
261 log.debug(
262 'register %s call(%s) to url %s', self.name, event, url)
269 log.debug('register %s call(%s) to url %s', self.name, event, url)
263 270 url_calls.append((url, self.headers, data))
264 271
265 272 return url_calls
266 273
267 274 def repo_create_event_handler(self, event, data):
268 275 url = self.get_base_parsed_template(data)
269 log.debug(
270 'register %s call(%s) to url %s', self.name, event, url)
276 log.debug('register %s call(%s) to url %s', self.name, event, url)
271 277 return [(url, self.headers, data)]
272 278
273 279 def pull_request_event_handler(self, event, data):
274 280 url = self.get_base_parsed_template(data)
275 log.debug(
276 'register %s call(%s) to url %s', self.name, event, url)
277 url = string.Template(url).safe_substitute(
278 pull_request_id=data['pullrequest']['pull_request_id'],
279 pull_request_title=data['pullrequest']['title'],
280 pull_request_url=data['pullrequest']['url'],
281 pull_request_shadow_url=data['pullrequest']['shadow_url'],
282 pull_request_commits_uid=data['pullrequest']['commits_uid'],
283 )
281 log.debug('register %s call(%s) to url %s', self.name, event, url)
282 pr_vars = [
283 ('pull_request_id', data['pullrequest']['pull_request_id']),
284 ('pull_request_title', data['pullrequest']['title']),
285 ('pull_request_url', data['pullrequest']['url']),
286 ('pull_request_shadow_url', data['pullrequest']['shadow_url']),
287 ('pull_request_commits_uid', data['pullrequest']['commits_uid']),
288 ]
289 for k, v in pr_vars:
290 url = UrlTmpl(url).safe_substitute(**{k: v})
291
284 292 return [(url, self.headers, data)]
285 293
286 294 def __call__(self, event, data):
287 295 from rhodecode import events
288 296
289 297 if isinstance(event, events.RepoPushEvent):
290 298 return self.repo_push_event_handler(event, data)
291 299 elif isinstance(event, events.RepoCreateEvent):
292 300 return self.repo_create_event_handler(event, data)
293 301 elif isinstance(event, events.PullRequestEvent):
294 302 return self.pull_request_event_handler(event, data)
295 303 else:
296 304 raise ValueError(
297 305 'event type `%s` not in supported list: %s' % (
298 306 event.__class__, events))
299 307
300 308
301 309 def get_auth(settings):
302 310 from requests.auth import HTTPBasicAuth
303 311 username = settings.get('username')
304 312 password = settings.get('password')
305 313 if username and password:
306 314 return HTTPBasicAuth(username, password)
307 315 return None
308 316
309 317
310 318 def get_web_token(settings):
311 319 return settings['secret_token']
312 320
313 321
314 322 def get_url_vars(url_vars):
315 323 return '\n'.join(
316 324 '{} - {}'.format('${' + key + '}', explanation)
317 325 for key, explanation in url_vars)
318 326
319 327
320 328 def render_with_traceback(template, *args, **kwargs):
321 329 try:
322 330 return template.render(*args, **kwargs)
323 331 except Exception:
324 332 log.error(exceptions.text_error_template().render())
325 333 raise
326 334
327 335
328 336 STATUS_400 = (400, 401, 403)
329 337 STATUS_500 = (500, 502, 504)
330 338
331 339
332 340 def requests_retry_call(
333 341 retries=3, backoff_factor=0.3, status_forcelist=STATUS_400+STATUS_500,
334 342 session=None):
335 343 """
336 344 session = requests_retry_session()
337 345 response = session.get('http://example.com')
338 346
339 347 :param retries:
340 348 :param backoff_factor:
341 349 :param status_forcelist:
342 350 :param session:
343 351 """
344 352 session = session or requests.Session()
345 353 retry = Retry(
346 354 total=retries,
347 355 read=retries,
348 356 connect=retries,
349 357 backoff_factor=backoff_factor,
350 358 status_forcelist=status_forcelist,
351 359 )
352 360 adapter = HTTPAdapter(max_retries=retry)
353 361 session.mount('http://', adapter)
354 362 session.mount('https://', adapter)
355 363 return session
@@ -1,265 +1,265 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2012-2019 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 26 import colander
27 27
28 28 import rhodecode
29 29 from rhodecode import events
30 30 from rhodecode.translation import _
31 31 from rhodecode.integrations.types.base import (
32 32 IntegrationTypeBase, get_auth, get_web_token, get_url_vars,
33 33 WebhookDataHandler, WEBHOOK_URL_VARS, requests_retry_call)
34 34 from rhodecode.lib.celerylib import run_task, async_task, RequestContextTask
35 35 from rhodecode.model.validation_schema import widgets
36 36
37 37 log = logging.getLogger(__name__)
38 38
39 39
40 40 # updating this required to update the `common_vars` passed in url calling func
41 41
42 42 URL_VARS = get_url_vars(WEBHOOK_URL_VARS)
43 43
44 44
45 45 class WebhookSettingsSchema(colander.Schema):
46 46 url = colander.SchemaNode(
47 47 colander.String(),
48 48 title=_('Webhook URL'),
49 49 description=
50 50 _('URL to which Webhook should submit data. If used some of the '
51 51 'variables would trigger multiple calls, like ${branch} or '
52 52 '${commit_id}. Webhook will be called as many times as unique '
53 53 'objects in data in such cases.'),
54 54 missing=colander.required,
55 55 required=True,
56 56 validator=colander.url,
57 57 widget=widgets.CodeMirrorWidget(
58 58 help_block_collapsable_name='Show url variables',
59 59 help_block_collapsable=(
60 60 'E.g http://my-serv/trigger_job/${{event_name}}'
61 61 '?PR_ID=${{pull_request_id}}'
62 62 '\nFull list of vars:\n{}'.format(URL_VARS)),
63 63 codemirror_mode='text',
64 64 codemirror_options='{"lineNumbers": false, "lineWrapping": true}'),
65 65 )
66 66 secret_token = colander.SchemaNode(
67 67 colander.String(),
68 68 title=_('Secret Token'),
69 69 description=_('Optional string used to validate received payloads. '
70 70 'It will be sent together with event data in JSON'),
71 71 default='',
72 72 missing='',
73 73 widget=deform.widget.TextInputWidget(
74 74 placeholder='e.g. secret_token'
75 75 ),
76 76 )
77 77 username = colander.SchemaNode(
78 78 colander.String(),
79 79 title=_('Username'),
80 80 description=_('Optional username to authenticate the call.'),
81 81 default='',
82 82 missing='',
83 83 widget=deform.widget.TextInputWidget(
84 84 placeholder='e.g. admin'
85 85 ),
86 86 )
87 87 password = colander.SchemaNode(
88 88 colander.String(),
89 89 title=_('Password'),
90 90 description=_('Optional password to authenticate the call.'),
91 91 default='',
92 92 missing='',
93 93 widget=deform.widget.PasswordWidget(
94 94 placeholder='e.g. secret.',
95 95 redisplay=True,
96 96 ),
97 97 )
98 98 custom_header_key = colander.SchemaNode(
99 99 colander.String(),
100 100 title=_('Custom Header Key'),
101 101 description=_('Custom Header name to be set when calling endpoint.'),
102 102 default='',
103 103 missing='',
104 104 widget=deform.widget.TextInputWidget(
105 105 placeholder='e.g: Authorization'
106 106 ),
107 107 )
108 108 custom_header_val = colander.SchemaNode(
109 109 colander.String(),
110 110 title=_('Custom Header Value'),
111 111 description=_('Custom Header value to be set when calling endpoint.'),
112 112 default='',
113 113 missing='',
114 114 widget=deform.widget.TextInputWidget(
115 115 placeholder='e.g. Basic XxXxXx'
116 116 ),
117 117 )
118 118 method_type = colander.SchemaNode(
119 119 colander.String(),
120 120 title=_('Call Method'),
121 121 description=_('Select a HTTP method to use when calling the Webhook.'),
122 122 default='post',
123 123 missing='',
124 124 widget=deform.widget.RadioChoiceWidget(
125 125 values=[('get', 'GET'), ('post', 'POST'), ('put', 'PUT')],
126 126 inline=True
127 127 ),
128 128 )
129 129
130 130
131 131 class WebhookIntegrationType(IntegrationTypeBase):
132 132 key = 'webhook'
133 133 display_name = _('Webhook')
134 134 description = _('send JSON data to a url endpoint')
135 135
136 136 @classmethod
137 137 def icon(cls):
138 138 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>'''
139 139
140 140 valid_events = [
141 141 events.PullRequestCloseEvent,
142 142 events.PullRequestMergeEvent,
143 143 events.PullRequestUpdateEvent,
144 144 events.PullRequestCommentEvent,
145 145 events.PullRequestReviewEvent,
146 146 events.PullRequestCreateEvent,
147 147 events.RepoPushEvent,
148 148 events.RepoCreateEvent,
149 149 ]
150 150
151 151 def settings_schema(self):
152 152 schema = WebhookSettingsSchema()
153 153 schema.add(colander.SchemaNode(
154 154 colander.Set(),
155 155 widget=deform.widget.CheckboxChoiceWidget(
156 156 values=sorted(
157 157 [(e.name, e.display_name) for e in self.valid_events]
158 158 )
159 159 ),
160 160 description="Events activated for this integration",
161 161 name='events'
162 162 ))
163 163 return schema
164 164
165 165 def send_event(self, event):
166 166 log.debug(
167 167 'handling event %s with Webhook integration %s', event.name, self)
168 168
169 169 if event.__class__ not in self.valid_events:
170 170 log.debug('event not valid: %r', event)
171 171 return
172 172
173 173 allowed_events = self.settings['events']
174 174 if event.name not in allowed_events:
175 175 log.debug('event ignored: %r event %s not in allowed events %s',
176 176 event, event.name, allowed_events)
177 177 return
178 178
179 179 data = event.as_dict()
180 180 template_url = self.settings['url']
181 181
182 182 headers = {}
183 183 head_key = self.settings.get('custom_header_key')
184 184 head_val = self.settings.get('custom_header_val')
185 185 if head_key and head_val:
186 186 headers = {head_key: head_val}
187 187
188 188 handler = WebhookDataHandler(template_url, headers)
189 189
190 190 url_calls = handler(event, data)
191 log.debug('webhook: calling following urls: %s', [x[0] for x in url_calls])
191 log.debug('Webhook: calling following urls: %s', [x[0] for x in url_calls])
192 192
193 193 run_task(post_to_webhook, url_calls, self.settings)
194 194
195 195
196 196 @async_task(ignore_result=True, base=RequestContextTask)
197 197 def post_to_webhook(url_calls, settings):
198 198 """
199 199 Example data::
200 200
201 201 {'actor': {'user_id': 2, 'username': u'admin'},
202 202 'actor_ip': u'192.168.157.1',
203 203 'name': 'repo-push',
204 204 'push': {'branches': [{'name': u'default',
205 205 'url': 'http://rc.local:8080/hg-repo/changelog?branch=default'}],
206 206 'commits': [{'author': u'Marcin Kuzminski <marcin@rhodecode.com>',
207 207 'branch': u'default',
208 208 'date': datetime.datetime(2017, 11, 30, 12, 59, 48),
209 209 'issues': [],
210 210 'mentions': [],
211 211 'message': u'commit Thu 30 Nov 2017 13:59:48 CET',
212 212 'message_html': u'commit Thu 30 Nov 2017 13:59:48 CET',
213 213 'message_html_title': u'commit Thu 30 Nov 2017 13:59:48 CET',
214 214 'parents': [{'raw_id': '431b772a5353dad9974b810dd3707d79e3a7f6e0'}],
215 215 'permalink_url': u'http://rc.local:8080/_7/changeset/a815cc738b9651eb5ffbcfb1ce6ccd7c701a5ddf',
216 216 'raw_id': 'a815cc738b9651eb5ffbcfb1ce6ccd7c701a5ddf',
217 217 'refs': {'bookmarks': [],
218 218 'branches': [u'default'],
219 219 'tags': [u'tip']},
220 220 'reviewers': [],
221 221 'revision': 9L,
222 222 'short_id': 'a815cc738b96',
223 223 'url': u'http://rc.local:8080/hg-repo/changeset/a815cc738b9651eb5ffbcfb1ce6ccd7c701a5ddf'}],
224 224 'issues': {}},
225 225 'repo': {'extra_fields': '',
226 226 'permalink_url': u'http://rc.local:8080/_7',
227 227 'repo_id': 7,
228 228 'repo_name': u'hg-repo',
229 229 'repo_type': u'hg',
230 230 'url': u'http://rc.local:8080/hg-repo'},
231 231 'server_url': u'http://rc.local:8080',
232 232 'utc_timestamp': datetime.datetime(2017, 11, 30, 13, 0, 1, 569276)
233 233 }
234 234 """
235 235
236 236 call_headers = {
237 237 'User-Agent': 'RhodeCode-webhook-caller/{}'.format(rhodecode.__version__)
238 238 } # updated below with custom ones, allows override
239 239
240 240 auth = get_auth(settings)
241 241 token = get_web_token(settings)
242 242
243 243 for url, headers, data in url_calls:
244 244 req_session = requests_retry_call()
245 245
246 246 method = settings.get('method_type') or 'post'
247 247 call_method = getattr(req_session, method)
248 248
249 249 headers = headers or {}
250 250 call_headers.update(headers)
251 251
252 252 log.debug('calling Webhook with method: %s, and auth:%s', call_method, auth)
253 253 if settings.get('log_data'):
254 254 log.debug('calling webhook with data: %s', data)
255 255 resp = call_method(url, json={
256 256 'token': token,
257 257 'event': data
258 258 }, headers=call_headers, auth=auth, timeout=60)
259 259 log.debug('Got Webhook response: %s', resp)
260 260
261 261 try:
262 262 resp.raise_for_status() # raise exception on a failed request
263 263 except Exception:
264 264 log.error(resp.text)
265 265 raise
@@ -1,132 +1,135 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2019 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 pytest
22 22
23 23 from rhodecode import events
24 24 from rhodecode.lib.utils2 import AttributeDict
25 25 from rhodecode.integrations.types.webhook import WebhookDataHandler
26 26
27 27
28 28 @pytest.fixture
29 29 def base_data():
30 30 return {
31 31 'name': 'event',
32 32 'repo': {
33 33 'repo_name': 'foo',
34 34 'repo_type': 'hg',
35 35 'repo_id': '12',
36 36 'url': 'http://repo.url/foo',
37 37 'extra_fields': {},
38 38 },
39 39 'actor': {
40 40 'username': 'actor_name',
41 41 'user_id': 1
42 42 }
43 43 }
44 44
45 45
46 46 def test_webhook_parse_url_invalid_event():
47 47 template_url = 'http://server.com/${repo_name}/build'
48 48 handler = WebhookDataHandler(
49 49 template_url, {'exmaple-header': 'header-values'})
50 50 event = events.RepoDeleteEvent('')
51 51 with pytest.raises(ValueError) as err:
52 52 handler(event, {})
53 53
54 54 err = str(err.value)
55 55 assert err.startswith(
56 56 'event type `%s` not in supported list' % event.__class__)
57 57
58 58
59 59 @pytest.mark.parametrize('template,expected_urls', [
60 60 ('http://server.com/${repo_name}/build',
61 61 ['http://server.com/foo/build']),
62 62 ('http://server.com/${repo_name}/${repo_type}',
63 63 ['http://server.com/foo/hg']),
64 64 ('http://${server}.com/${repo_name}/${repo_id}',
65 65 ['http://${server}.com/foo/12']),
66 66 ('http://server.com/${branch}/build',
67 67 ['http://server.com/${branch}/build']),
68 68 ])
69 69 def test_webook_parse_url_for_create_event(base_data, template, expected_urls):
70 70 headers = {'exmaple-header': 'header-values'}
71 71 handler = WebhookDataHandler(template, headers)
72 72 urls = handler(events.RepoCreateEvent(''), base_data)
73 73 assert urls == [
74 74 (url, headers, base_data) for url in expected_urls]
75 75
76 76
77 77 @pytest.mark.parametrize('template,expected_urls', [
78 78 ('http://server.com/${repo_name}/${pull_request_id}',
79 79 ['http://server.com/foo/999']),
80 80 ('http://server.com/${repo_name}/${pull_request_url}',
81 ['http://server.com/foo/http://pr-url.com']),
81 ['http://server.com/foo/http%3A//pr-url.com']),
82 ('http://server.com/${repo_name}/${pull_request_url}/?TITLE=${pull_request_title}',
83 ['http://server.com/foo/http%3A//pr-url.com/?TITLE=example-pr-title%20Ticket%20%23123']),
84 ('http://server.com/${repo_name}/?SHADOW_URL=${pull_request_shadow_url}',
85 ['http://server.com/foo/?SHADOW_URL=http%3A//pr-url.com/repository']),
82 86 ])
83 def test_webook_parse_url_for_pull_request_event(
84 base_data, template, expected_urls):
87 def test_webook_parse_url_for_pull_request_event(base_data, template, expected_urls):
85 88
86 89 base_data['pullrequest'] = {
87 90 'pull_request_id': 999,
88 91 'url': 'http://pr-url.com',
89 'title': 'example-pr-title',
92 'title': 'example-pr-title Ticket #123',
90 93 'commits_uid': 'abcdefg1234',
91 94 'shadow_url': 'http://pr-url.com/repository'
92 95 }
93 96 headers = {'exmaple-header': 'header-values'}
94 97 handler = WebhookDataHandler(template, headers)
95 98 urls = handler(events.PullRequestCreateEvent(
96 99 AttributeDict({'target_repo': 'foo'})), base_data)
97 100 assert urls == [
98 101 (url, headers, base_data) for url in expected_urls]
99 102
100 103
101 104 @pytest.mark.parametrize('template,expected_urls', [
102 105 ('http://server.com/${branch}/build',
103 106 ['http://server.com/stable/build',
104 107 'http://server.com/dev/build']),
105 108 ('http://server.com/${branch}/${commit_id}',
106 109 ['http://server.com/stable/stable-xxx',
107 110 'http://server.com/stable/stable-yyy',
108 111 'http://server.com/dev/dev-xxx',
109 112 'http://server.com/dev/dev-yyy']),
110 113 ('http://server.com/${branch_head}',
111 114 ['http://server.com/stable-yyy',
112 115 'http://server.com/dev-yyy']),
113 116 ('http://server.com/${commit_id}',
114 117 ['http://server.com/stable-xxx',
115 118 'http://server.com/stable-yyy',
116 119 'http://server.com/dev-xxx',
117 120 'http://server.com/dev-yyy']),
118 121 ])
119 122 def test_webook_parse_url_for_push_event(
120 123 baseapp, repo_push_event, base_data, template, expected_urls):
121 124 base_data['push'] = {
122 125 'branches': [{'name': 'stable'}, {'name': 'dev'}],
123 126 'commits': [{'branch': 'stable', 'raw_id': 'stable-xxx'},
124 127 {'branch': 'stable', 'raw_id': 'stable-yyy'},
125 128 {'branch': 'dev', 'raw_id': 'dev-xxx'},
126 129 {'branch': 'dev', 'raw_id': 'dev-yyy'}]
127 130 }
128 131 headers = {'exmaple-header': 'header-values'}
129 132 handler = WebhookDataHandler(template, headers)
130 133 urls = handler(repo_push_event, base_data)
131 134 assert urls == [
132 135 (url, headers, base_data) for url in expected_urls]
General Comments 0
You need to be logged in to leave comments. Login now