##// END OF EJS Templates
webhook: fixed extra variable replacement.
marcink -
r2545:51dda326 stable
parent child Browse files
Show More
@@ -1,394 +1,395 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 string
23 23 from collections import OrderedDict
24 24
25 25 import deform
26 26 import deform.widget
27 27 import logging
28 28 import requests
29 29 import requests.adapters
30 30 import colander
31 31 from requests.packages.urllib3.util.retry import Retry
32 32
33 33 import rhodecode
34 34 from rhodecode import events
35 35 from rhodecode.translation import _
36 36 from rhodecode.integrations.types.base import IntegrationTypeBase
37 37 from rhodecode.lib.celerylib import run_task, async_task, RequestContextTask
38 38
39 39 log = logging.getLogger(__name__)
40 40
41 41
42 42 # updating this required to update the `common_vars` passed in url calling func
43 43 WEBHOOK_URL_VARS = [
44 44 'repo_name',
45 45 'repo_type',
46 46 'repo_id',
47 47 'repo_url',
48 48 # extra repo fields
49 49 'extra:<extra_key_name>',
50 50
51 51 # special attrs below that we handle, using multi-call
52 52 'branch',
53 53 'commit_id',
54 54
55 55 # pr events vars
56 56 'pull_request_id',
57 57 'pull_request_url',
58 58
59 59 # user who triggers the call
60 60 'username',
61 61 'user_id',
62 62
63 63 ]
64 64 URL_VARS = ', '.join('${' + x + '}' for x in WEBHOOK_URL_VARS)
65 65
66 66
67 67 def get_auth(settings):
68 68 from requests.auth import HTTPBasicAuth
69 69 username = settings.get('username')
70 70 password = settings.get('password')
71 71 if username and password:
72 72 return HTTPBasicAuth(username, password)
73 73 return None
74 74
75 75
76 76 class WebhookHandler(object):
77 77 def __init__(self, template_url, secret_token, headers):
78 78 self.template_url = template_url
79 79 self.secret_token = secret_token
80 80 self.headers = headers
81 81
82 82 def get_base_parsed_template(self, data):
83 83 """
84 84 initially parses the passed in template with some common variables
85 85 available on ALL calls
86 86 """
87 87 # note: make sure to update the `WEBHOOK_URL_VARS` if this changes
88 88 common_vars = {
89 89 'repo_name': data['repo']['repo_name'],
90 90 'repo_type': data['repo']['repo_type'],
91 91 'repo_id': data['repo']['repo_id'],
92 92 'repo_url': data['repo']['url'],
93 93 'username': data['actor']['username'],
94 94 'user_id': data['actor']['user_id']
95 95 }
96
96 97 extra_vars = {}
97 98 for extra_key, extra_val in data['repo']['extra_fields'].items():
98 extra_vars['extra:{}'.format(extra_key)] = extra_val
99 extra_vars['extra__{}'.format(extra_key)] = extra_val
99 100 common_vars.update(extra_vars)
100 101
101 return string.Template(
102 self.template_url).safe_substitute(**common_vars)
102 template_url = self.template_url.replace('${extra:', '${extra__')
103 return string.Template(template_url).safe_substitute(**common_vars)
103 104
104 105 def repo_push_event_handler(self, event, data):
105 106 url = self.get_base_parsed_template(data)
106 107 url_cals = []
107 108 branch_data = OrderedDict()
108 109 for obj in data['push']['branches']:
109 110 branch_data[obj['name']] = obj
110 111
111 112 branches_commits = OrderedDict()
112 113 for commit in data['push']['commits']:
113 114 if commit.get('git_ref_change'):
114 115 # special case for GIT that allows creating tags,
115 116 # deleting branches without associated commit
116 117 continue
117 118
118 119 if commit['branch'] not in branches_commits:
119 120 branch_commits = {'branch': branch_data[commit['branch']],
120 121 'commits': []}
121 122 branches_commits[commit['branch']] = branch_commits
122 123
123 124 branch_commits = branches_commits[commit['branch']]
124 125 branch_commits['commits'].append(commit)
125 126
126 127 if '${branch}' in url:
127 128 # call it multiple times, for each branch if used in variables
128 129 for branch, commit_ids in branches_commits.items():
129 130 branch_url = string.Template(url).safe_substitute(branch=branch)
130 131 # call further down for each commit if used
131 132 if '${commit_id}' in branch_url:
132 133 for commit_data in commit_ids['commits']:
133 134 commit_id = commit_data['raw_id']
134 135 commit_url = string.Template(branch_url).safe_substitute(
135 136 commit_id=commit_id)
136 137 # register per-commit call
137 138 log.debug(
138 139 'register webhook call(%s) to url %s', event, commit_url)
139 140 url_cals.append((commit_url, self.secret_token, self.headers, data))
140 141
141 142 else:
142 143 # register per-branch call
143 144 log.debug(
144 145 'register webhook call(%s) to url %s', event, branch_url)
145 146 url_cals.append((branch_url, self.secret_token, self.headers, data))
146 147
147 148 else:
148 149 log.debug(
149 150 'register webhook call(%s) to url %s', event, url)
150 151 url_cals.append((url, self.secret_token, self.headers, data))
151 152
152 153 return url_cals
153 154
154 155 def repo_create_event_handler(self, event, data):
155 156 url = self.get_base_parsed_template(data)
156 157 log.debug(
157 158 'register webhook call(%s) to url %s', event, url)
158 159 return [(url, self.secret_token, self.headers, data)]
159 160
160 161 def pull_request_event_handler(self, event, data):
161 162 url = self.get_base_parsed_template(data)
162 163 log.debug(
163 164 'register webhook call(%s) to url %s', event, url)
164 165 url = string.Template(url).safe_substitute(
165 166 pull_request_id=data['pullrequest']['pull_request_id'],
166 167 pull_request_url=data['pullrequest']['url'])
167 168 return [(url, self.secret_token, self.headers, data)]
168 169
169 170 def __call__(self, event, data):
170 171 if isinstance(event, events.RepoPushEvent):
171 172 return self.repo_push_event_handler(event, data)
172 173 elif isinstance(event, events.RepoCreateEvent):
173 174 return self.repo_create_event_handler(event, data)
174 175 elif isinstance(event, events.PullRequestEvent):
175 176 return self.pull_request_event_handler(event, data)
176 177 else:
177 178 raise ValueError('event type not supported: %s' % events)
178 179
179 180
180 181 class WebhookSettingsSchema(colander.Schema):
181 182 url = colander.SchemaNode(
182 183 colander.String(),
183 184 title=_('Webhook URL'),
184 185 description=
185 186 _('URL to which Webhook should submit data. Following variables '
186 187 'are allowed to be used: {vars}. Some of the variables would '
187 188 'trigger multiple calls, like ${{branch}} or ${{commit_id}}. '
188 189 'Webhook will be called as many times as unique objects in '
189 190 'data in such cases.').format(vars=URL_VARS),
190 191 missing=colander.required,
191 192 required=True,
192 193 validator=colander.url,
193 194 widget=deform.widget.TextInputWidget(
194 195 placeholder='https://www.example.com/webhook'
195 196 ),
196 197 )
197 198 secret_token = colander.SchemaNode(
198 199 colander.String(),
199 200 title=_('Secret Token'),
200 201 description=_('Optional string used to validate received payloads. '
201 202 'It will be sent together with event data in JSON'),
202 203 default='',
203 204 missing='',
204 205 widget=deform.widget.TextInputWidget(
205 206 placeholder='e.g. secret_token'
206 207 ),
207 208 )
208 209 username = colander.SchemaNode(
209 210 colander.String(),
210 211 title=_('Username'),
211 212 description=_('Optional username to authenticate the call.'),
212 213 default='',
213 214 missing='',
214 215 widget=deform.widget.TextInputWidget(
215 216 placeholder='e.g. admin'
216 217 ),
217 218 )
218 219 password = colander.SchemaNode(
219 220 colander.String(),
220 221 title=_('Password'),
221 222 description=_('Optional password to authenticate the call.'),
222 223 default='',
223 224 missing='',
224 225 widget=deform.widget.PasswordWidget(
225 226 placeholder='e.g. secret.',
226 227 redisplay=True,
227 228 ),
228 229 )
229 230 custom_header_key = colander.SchemaNode(
230 231 colander.String(),
231 232 title=_('Custom Header Key'),
232 233 description=_('Custom Header name to be set when calling endpoint.'),
233 234 default='',
234 235 missing='',
235 236 widget=deform.widget.TextInputWidget(
236 237 placeholder='e.g.Authorization'
237 238 ),
238 239 )
239 240 custom_header_val = colander.SchemaNode(
240 241 colander.String(),
241 242 title=_('Custom Header Value'),
242 243 description=_('Custom Header value to be set when calling endpoint.'),
243 244 default='',
244 245 missing='',
245 246 widget=deform.widget.TextInputWidget(
246 247 placeholder='e.g. RcLogin auth=xxxx'
247 248 ),
248 249 )
249 250 method_type = colander.SchemaNode(
250 251 colander.String(),
251 252 title=_('Call Method'),
252 253 description=_('Select if the Webhook call should be made '
253 254 'with POST or GET.'),
254 255 default='post',
255 256 missing='',
256 257 widget=deform.widget.RadioChoiceWidget(
257 258 values=[('get', 'GET'), ('post', 'POST')],
258 259 inline=True
259 260 ),
260 261 )
261 262
262 263
263 264 class WebhookIntegrationType(IntegrationTypeBase):
264 265 key = 'webhook'
265 266 display_name = _('Webhook')
266 267 description = _('Post json events to a Webhook endpoint')
267 268 icon = '''<?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>'''
268 269
269 270 valid_events = [
270 271 events.PullRequestCloseEvent,
271 272 events.PullRequestMergeEvent,
272 273 events.PullRequestUpdateEvent,
273 274 events.PullRequestCommentEvent,
274 275 events.PullRequestReviewEvent,
275 276 events.PullRequestCreateEvent,
276 277 events.RepoPushEvent,
277 278 events.RepoCreateEvent,
278 279 ]
279 280
280 281 def settings_schema(self):
281 282 schema = WebhookSettingsSchema()
282 283 schema.add(colander.SchemaNode(
283 284 colander.Set(),
284 285 widget=deform.widget.CheckboxChoiceWidget(
285 286 values=sorted(
286 287 [(e.name, e.display_name) for e in self.valid_events]
287 288 )
288 289 ),
289 290 description="Events activated for this integration",
290 291 name='events'
291 292 ))
292 293 return schema
293 294
294 295 def send_event(self, event):
295 296 log.debug('handling event %s with Webhook integration %s',
296 297 event.name, self)
297 298
298 299 if event.__class__ not in self.valid_events:
299 300 log.debug('event not valid: %r' % event)
300 301 return
301 302
302 303 if event.name not in self.settings['events']:
303 304 log.debug('event ignored: %r' % event)
304 305 return
305 306
306 307 data = event.as_dict()
307 308 template_url = self.settings['url']
308 309
309 310 headers = {}
310 311 head_key = self.settings.get('custom_header_key')
311 312 head_val = self.settings.get('custom_header_val')
312 313 if head_key and head_val:
313 314 headers = {head_key: head_val}
314 315
315 316 handler = WebhookHandler(
316 317 template_url, self.settings['secret_token'], headers)
317 318
318 319 url_calls = handler(event, data)
319 320 log.debug('webhook: calling following urls: %s',
320 321 [x[0] for x in url_calls])
321 322
322 323 run_task(post_to_webhook, url_calls, self.settings)
323 324
324 325
325 326 @async_task(ignore_result=True, base=RequestContextTask)
326 327 def post_to_webhook(url_calls, settings):
327 328 """
328 329 Example data::
329 330
330 331 {'actor': {'user_id': 2, 'username': u'admin'},
331 332 'actor_ip': u'192.168.157.1',
332 333 'name': 'repo-push',
333 334 'push': {'branches': [{'name': u'default',
334 335 'url': 'http://rc.local:8080/hg-repo/changelog?branch=default'}],
335 336 'commits': [{'author': u'Marcin Kuzminski <marcin@rhodecode.com>',
336 337 'branch': u'default',
337 338 'date': datetime.datetime(2017, 11, 30, 12, 59, 48),
338 339 'issues': [],
339 340 'mentions': [],
340 341 'message': u'commit Thu 30 Nov 2017 13:59:48 CET',
341 342 'message_html': u'commit Thu 30 Nov 2017 13:59:48 CET',
342 343 'message_html_title': u'commit Thu 30 Nov 2017 13:59:48 CET',
343 344 'parents': [{'raw_id': '431b772a5353dad9974b810dd3707d79e3a7f6e0'}],
344 345 'permalink_url': u'http://rc.local:8080/_7/changeset/a815cc738b9651eb5ffbcfb1ce6ccd7c701a5ddf',
345 346 'raw_id': 'a815cc738b9651eb5ffbcfb1ce6ccd7c701a5ddf',
346 347 'refs': {'bookmarks': [], 'branches': [u'default'], 'tags': [u'tip']},
347 348 'reviewers': [],
348 349 'revision': 9L,
349 350 'short_id': 'a815cc738b96',
350 351 'url': u'http://rc.local:8080/hg-repo/changeset/a815cc738b9651eb5ffbcfb1ce6ccd7c701a5ddf'}],
351 352 'issues': {}},
352 353 'repo': {'extra_fields': '',
353 354 'permalink_url': u'http://rc.local:8080/_7',
354 355 'repo_id': 7,
355 356 'repo_name': u'hg-repo',
356 357 'repo_type': u'hg',
357 358 'url': u'http://rc.local:8080/hg-repo'},
358 359 'server_url': u'http://rc.local:8080',
359 360 'utc_timestamp': datetime.datetime(2017, 11, 30, 13, 0, 1, 569276)
360 361
361 362 """
362 363 max_retries = 3
363 364 retries = Retry(
364 365 total=max_retries,
365 366 backoff_factor=0.15,
366 367 status_forcelist=[500, 502, 503, 504])
367 368 call_headers = {
368 369 'User-Agent': 'RhodeCode-webhook-caller/{}'.format(
369 370 rhodecode.__version__)
370 371 } # updated below with custom ones, allows override
371 372
372 373 for url, token, headers, data in url_calls:
373 374 req_session = requests.Session()
374 375 req_session.mount( # retry max N times
375 376 'http://', requests.adapters.HTTPAdapter(max_retries=retries))
376 377
377 378 method = settings.get('method_type') or 'post'
378 379 call_method = getattr(req_session, method)
379 380
380 381 headers = headers or {}
381 382 call_headers.update(headers)
382 383 auth = get_auth(settings)
383 384
384 385 log.debug('calling Webhook with method: %s, and auth:%s',
385 386 call_method, auth)
386 387 if settings.get('log_data'):
387 388 log.debug('calling webhook with data: %s', data)
388 389 resp = call_method(url, json={
389 390 'token': token,
390 391 'event': data
391 392 }, headers=call_headers, auth=auth)
392 393 log.debug('Got Webhook response: %s', resp)
393 394
394 395 resp.raise_for_status() # raise exception on a failed request
General Comments 0
You need to be logged in to leave comments. Login now