##// 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 # -*- coding: utf-8 -*-
1 # -*- coding: utf-8 -*-
2
2
3 # Copyright (C) 2012-2018 RhodeCode GmbH
3 # Copyright (C) 2012-2018 RhodeCode GmbH
4 #
4 #
5 # This program is free software: you can redistribute it and/or modify
5 # This program is free software: you can redistribute it and/or modify
6 # it under the terms of the GNU Affero General Public License, version 3
6 # it under the terms of the GNU Affero General Public License, version 3
7 # (only), as published by the Free Software Foundation.
7 # (only), as published by the Free Software Foundation.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU Affero General Public License
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 #
16 #
17 # This program is dual-licensed. If you wish to learn more about the
17 # This program is dual-licensed. If you wish to learn more about the
18 # RhodeCode Enterprise Edition, including its added features, Support services,
18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20
20
21 import colander
21 import colander
22 import string
22 import string
23 import collections
23 import collections
24 import logging
24 import logging
25 import requests
26 from requests.adapters import HTTPAdapter
27 from requests.packages.urllib3.util.retry import Retry
25
28
26 from mako import exceptions
29 from mako import exceptions
27
30
28 from rhodecode.translation import _
31 from rhodecode.translation import _
29
32
30
33
31 log = logging.getLogger(__name__)
34 log = logging.getLogger(__name__)
32
35
33
36
34 class IntegrationTypeBase(object):
37 class IntegrationTypeBase(object):
35 """ Base class for IntegrationType plugins """
38 """ Base class for IntegrationType plugins """
36 is_dummy = False
39 is_dummy = False
37 description = ''
40 description = ''
38
41
39 @classmethod
42 @classmethod
40 def icon(cls):
43 def icon(cls):
41 return '''
44 return '''
42 <?xml version="1.0" encoding="UTF-8" standalone="no"?>
45 <?xml version="1.0" encoding="UTF-8" standalone="no"?>
43 <svg
46 <svg
44 xmlns:dc="http://purl.org/dc/elements/1.1/"
47 xmlns:dc="http://purl.org/dc/elements/1.1/"
45 xmlns:cc="http://creativecommons.org/ns#"
48 xmlns:cc="http://creativecommons.org/ns#"
46 xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
49 xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
47 xmlns:svg="http://www.w3.org/2000/svg"
50 xmlns:svg="http://www.w3.org/2000/svg"
48 xmlns="http://www.w3.org/2000/svg"
51 xmlns="http://www.w3.org/2000/svg"
49 xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
52 xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
50 xmlns:inkscape="http://setwww.inkscape.org/namespaces/inkscape"
53 xmlns:inkscape="http://setwww.inkscape.org/namespaces/inkscape"
51 viewBox="0 -256 1792 1792"
54 viewBox="0 -256 1792 1792"
52 id="svg3025"
55 id="svg3025"
53 version="1.1"
56 version="1.1"
54 inkscape:version="0.48.3.1 r9886"
57 inkscape:version="0.48.3.1 r9886"
55 width="100%"
58 width="100%"
56 height="100%"
59 height="100%"
57 sodipodi:docname="cog_font_awesome.svg">
60 sodipodi:docname="cog_font_awesome.svg">
58 <metadata
61 <metadata
59 id="metadata3035">
62 id="metadata3035">
60 <rdf:RDF>
63 <rdf:RDF>
61 <cc:Work
64 <cc:Work
62 rdf:about="">
65 rdf:about="">
63 <dc:format>image/svg+xml</dc:format>
66 <dc:format>image/svg+xml</dc:format>
64 <dc:type
67 <dc:type
65 rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
68 rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
66 </cc:Work>
69 </cc:Work>
67 </rdf:RDF>
70 </rdf:RDF>
68 </metadata>
71 </metadata>
69 <defs
72 <defs
70 id="defs3033" />
73 id="defs3033" />
71 <sodipodi:namedview
74 <sodipodi:namedview
72 pagecolor="#ffffff"
75 pagecolor="#ffffff"
73 bordercolor="#666666"
76 bordercolor="#666666"
74 borderopacity="1"
77 borderopacity="1"
75 objecttolerance="10"
78 objecttolerance="10"
76 gridtolerance="10"
79 gridtolerance="10"
77 guidetolerance="10"
80 guidetolerance="10"
78 inkscape:pageopacity="0"
81 inkscape:pageopacity="0"
79 inkscape:pageshadow="2"
82 inkscape:pageshadow="2"
80 inkscape:window-width="640"
83 inkscape:window-width="640"
81 inkscape:window-height="480"
84 inkscape:window-height="480"
82 id="namedview3031"
85 id="namedview3031"
83 showgrid="false"
86 showgrid="false"
84 inkscape:zoom="0.13169643"
87 inkscape:zoom="0.13169643"
85 inkscape:cx="896"
88 inkscape:cx="896"
86 inkscape:cy="896"
89 inkscape:cy="896"
87 inkscape:window-x="0"
90 inkscape:window-x="0"
88 inkscape:window-y="25"
91 inkscape:window-y="25"
89 inkscape:window-maximized="0"
92 inkscape:window-maximized="0"
90 inkscape:current-layer="svg3025" />
93 inkscape:current-layer="svg3025" />
91 <g
94 <g
92 transform="matrix(1,0,0,-1,121.49153,1285.4237)"
95 transform="matrix(1,0,0,-1,121.49153,1285.4237)"
93 id="g3027">
96 id="g3027">
94 <path
97 <path
95 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"
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 id="path3029"
99 id="path3029"
97 inkscape:connector-curvature="0"
100 inkscape:connector-curvature="0"
98 style="fill:currentColor" />
101 style="fill:currentColor" />
99 </g>
102 </g>
100 </svg>
103 </svg>
101 '''
104 '''
102
105
103 def __init__(self, settings):
106 def __init__(self, settings):
104 """
107 """
105 :param settings: dict of settings to be used for the integration
108 :param settings: dict of settings to be used for the integration
106 """
109 """
107 self.settings = settings
110 self.settings = settings
108
111
109 def settings_schema(self):
112 def settings_schema(self):
110 """
113 """
111 A colander schema of settings for the integration type
114 A colander schema of settings for the integration type
112 """
115 """
113 return colander.Schema()
116 return colander.Schema()
114
117
115
118
116 class EEIntegration(IntegrationTypeBase):
119 class EEIntegration(IntegrationTypeBase):
117 description = 'Integration available in RhodeCode EE edition.'
120 description = 'Integration available in RhodeCode EE edition.'
118 is_dummy = True
121 is_dummy = True
119
122
120 def __init__(self, name, key, settings=None):
123 def __init__(self, name, key, settings=None):
121 self.display_name = name
124 self.display_name = name
122 self.key = key
125 self.key = key
123 super(EEIntegration, self).__init__(settings)
126 super(EEIntegration, self).__init__(settings)
124
127
125
128
126 # Helpers #
129 # Helpers #
127 # updating this required to update the `common_vars` as well.
130 # updating this required to update the `common_vars` as well.
128 WEBHOOK_URL_VARS = [
131 WEBHOOK_URL_VARS = [
129 ('event_name', 'Unique name of the event type, e.g pullrequest-update'),
132 ('event_name', 'Unique name of the event type, e.g pullrequest-update'),
130 ('repo_name', 'Full name of the repository'),
133 ('repo_name', 'Full name of the repository'),
131 ('repo_type', 'VCS type of repository'),
134 ('repo_type', 'VCS type of repository'),
132 ('repo_id', 'Unique id of repository'),
135 ('repo_id', 'Unique id of repository'),
133 ('repo_url', 'Repository url'),
136 ('repo_url', 'Repository url'),
134 # extra repo fields
137 # extra repo fields
135 ('extra:<extra_key_name>', 'Extra repo variables, read from its settings.'),
138 ('extra:<extra_key_name>', 'Extra repo variables, read from its settings.'),
136
139
137 # special attrs below that we handle, using multi-call
140 # special attrs below that we handle, using multi-call
138 ('branch', 'Name of each branch submitted, if any.'),
141 ('branch', 'Name of each branch submitted, if any.'),
139 ('branch_head', 'Head ID of pushed branch (full sha of last commit), if any.'),
142 ('branch_head', 'Head ID of pushed branch (full sha of last commit), if any.'),
140 ('commit_id', 'ID (full sha) of each commit submitted, if any.'),
143 ('commit_id', 'ID (full sha) of each commit submitted, if any.'),
141
144
142 # pr events vars
145 # pr events vars
143 ('pull_request_id', 'Unique ID of the pull request.'),
146 ('pull_request_id', 'Unique ID of the pull request.'),
144 ('pull_request_title', 'Title of the pull request.'),
147 ('pull_request_title', 'Title of the pull request.'),
145 ('pull_request_url', 'Pull request url.'),
148 ('pull_request_url', 'Pull request url.'),
146 ('pull_request_shadow_url', 'Pull request shadow repo clone url.'),
149 ('pull_request_shadow_url', 'Pull request shadow repo clone url.'),
147 ('pull_request_commits_uid', 'Calculated UID of all commits inside the PR. '
150 ('pull_request_commits_uid', 'Calculated UID of all commits inside the PR. '
148 'Changes after PR update'),
151 'Changes after PR update'),
149
152
150 # user who triggers the call
153 # user who triggers the call
151 ('username', 'User who triggered the call.'),
154 ('username', 'User who triggered the call.'),
152 ('user_id', 'User id who triggered the call.'),
155 ('user_id', 'User id who triggered the call.'),
153 ]
156 ]
154
157
155 # common vars for url template used for CI plugins. Shared with webhook
158 # common vars for url template used for CI plugins. Shared with webhook
156 CI_URL_VARS = WEBHOOK_URL_VARS
159 CI_URL_VARS = WEBHOOK_URL_VARS
157
160
158
161
159 class CommitParsingDataHandler(object):
162 class CommitParsingDataHandler(object):
160
163
161 def aggregate_branch_data(self, branches, commits):
164 def aggregate_branch_data(self, branches, commits):
162 branch_data = collections.OrderedDict()
165 branch_data = collections.OrderedDict()
163 for obj in branches:
166 for obj in branches:
164 branch_data[obj['name']] = obj
167 branch_data[obj['name']] = obj
165
168
166 branches_commits = collections.OrderedDict()
169 branches_commits = collections.OrderedDict()
167 for commit in commits:
170 for commit in commits:
168 if commit.get('git_ref_change'):
171 if commit.get('git_ref_change'):
169 # special case for GIT that allows creating tags,
172 # special case for GIT that allows creating tags,
170 # deleting branches without associated commit
173 # deleting branches without associated commit
171 continue
174 continue
172 commit_branch = commit['branch']
175 commit_branch = commit['branch']
173
176
174 if commit_branch not in branches_commits:
177 if commit_branch not in branches_commits:
175 _branch = branch_data[commit_branch] \
178 _branch = branch_data[commit_branch] \
176 if commit_branch else commit_branch
179 if commit_branch else commit_branch
177 branch_commits = {'branch': _branch,
180 branch_commits = {'branch': _branch,
178 'branch_head': '',
181 'branch_head': '',
179 'commits': []}
182 'commits': []}
180 branches_commits[commit_branch] = branch_commits
183 branches_commits[commit_branch] = branch_commits
181
184
182 branch_commits = branches_commits[commit_branch]
185 branch_commits = branches_commits[commit_branch]
183 branch_commits['commits'].append(commit)
186 branch_commits['commits'].append(commit)
184 branch_commits['branch_head'] = commit['raw_id']
187 branch_commits['branch_head'] = commit['raw_id']
185 return branches_commits
188 return branches_commits
186
189
187
190
188 class WebhookDataHandler(CommitParsingDataHandler):
191 class WebhookDataHandler(CommitParsingDataHandler):
189 name = 'webhook'
192 name = 'webhook'
190
193
191 def __init__(self, template_url, headers):
194 def __init__(self, template_url, headers):
192 self.template_url = template_url
195 self.template_url = template_url
193 self.headers = headers
196 self.headers = headers
194
197
195 def get_base_parsed_template(self, data):
198 def get_base_parsed_template(self, data):
196 """
199 """
197 initially parses the passed in template with some common variables
200 initially parses the passed in template with some common variables
198 available on ALL calls
201 available on ALL calls
199 """
202 """
200 # note: make sure to update the `WEBHOOK_URL_VARS` if this changes
203 # note: make sure to update the `WEBHOOK_URL_VARS` if this changes
201 common_vars = {
204 common_vars = {
202 'repo_name': data['repo']['repo_name'],
205 'repo_name': data['repo']['repo_name'],
203 'repo_type': data['repo']['repo_type'],
206 'repo_type': data['repo']['repo_type'],
204 'repo_id': data['repo']['repo_id'],
207 'repo_id': data['repo']['repo_id'],
205 'repo_url': data['repo']['url'],
208 'repo_url': data['repo']['url'],
206 'username': data['actor']['username'],
209 'username': data['actor']['username'],
207 'user_id': data['actor']['user_id'],
210 'user_id': data['actor']['user_id'],
208 'event_name': data['name']
211 'event_name': data['name']
209 }
212 }
210
213
211 extra_vars = {}
214 extra_vars = {}
212 for extra_key, extra_val in data['repo']['extra_fields'].items():
215 for extra_key, extra_val in data['repo']['extra_fields'].items():
213 extra_vars['extra__{}'.format(extra_key)] = extra_val
216 extra_vars['extra__{}'.format(extra_key)] = extra_val
214 common_vars.update(extra_vars)
217 common_vars.update(extra_vars)
215
218
216 template_url = self.template_url.replace('${extra:', '${extra__')
219 template_url = self.template_url.replace('${extra:', '${extra__')
217 return string.Template(template_url).safe_substitute(**common_vars)
220 return string.Template(template_url).safe_substitute(**common_vars)
218
221
219 def repo_push_event_handler(self, event, data):
222 def repo_push_event_handler(self, event, data):
220 url = self.get_base_parsed_template(data)
223 url = self.get_base_parsed_template(data)
221 url_calls = []
224 url_calls = []
222
225
223 branches_commits = self.aggregate_branch_data(
226 branches_commits = self.aggregate_branch_data(
224 data['push']['branches'], data['push']['commits'])
227 data['push']['branches'], data['push']['commits'])
225 if '${branch}' in url or '${branch_head}' in url or '${commit_id}' in url:
228 if '${branch}' in url or '${branch_head}' in url or '${commit_id}' in url:
226 # call it multiple times, for each branch if used in variables
229 # call it multiple times, for each branch if used in variables
227 for branch, commit_ids in branches_commits.items():
230 for branch, commit_ids in branches_commits.items():
228 branch_url = string.Template(url).safe_substitute(branch=branch)
231 branch_url = string.Template(url).safe_substitute(branch=branch)
229
232
230 if '${branch_head}' in branch_url:
233 if '${branch_head}' in branch_url:
231 # last commit in the aggregate is the head of the branch
234 # last commit in the aggregate is the head of the branch
232 branch_head = commit_ids['branch_head']
235 branch_head = commit_ids['branch_head']
233 branch_url = string.Template(branch_url).safe_substitute(
236 branch_url = string.Template(branch_url).safe_substitute(
234 branch_head=branch_head)
237 branch_head=branch_head)
235
238
236 # call further down for each commit if used
239 # call further down for each commit if used
237 if '${commit_id}' in branch_url:
240 if '${commit_id}' in branch_url:
238 for commit_data in commit_ids['commits']:
241 for commit_data in commit_ids['commits']:
239 commit_id = commit_data['raw_id']
242 commit_id = commit_data['raw_id']
240 commit_url = string.Template(branch_url).safe_substitute(
243 commit_url = string.Template(branch_url).safe_substitute(
241 commit_id=commit_id)
244 commit_id=commit_id)
242 # register per-commit call
245 # register per-commit call
243 log.debug(
246 log.debug(
244 'register %s call(%s) to url %s',
247 'register %s call(%s) to url %s',
245 self.name, event, commit_url)
248 self.name, event, commit_url)
246 url_calls.append(
249 url_calls.append(
247 (commit_url, self.headers, data))
250 (commit_url, self.headers, data))
248
251
249 else:
252 else:
250 # register per-branch call
253 # register per-branch call
251 log.debug(
254 log.debug(
252 'register %s call(%s) to url %s',
255 'register %s call(%s) to url %s',
253 self.name, event, branch_url)
256 self.name, event, branch_url)
254 url_calls.append(
257 url_calls.append(
255 (branch_url, self.headers, data))
258 (branch_url, self.headers, data))
256
259
257 else:
260 else:
258 log.debug(
261 log.debug(
259 'register %s call(%s) to url %s', self.name, event, url)
262 'register %s call(%s) to url %s', self.name, event, url)
260 url_calls.append((url, self.headers, data))
263 url_calls.append((url, self.headers, data))
261
264
262 return url_calls
265 return url_calls
263
266
264 def repo_create_event_handler(self, event, data):
267 def repo_create_event_handler(self, event, data):
265 url = self.get_base_parsed_template(data)
268 url = self.get_base_parsed_template(data)
266 log.debug(
269 log.debug(
267 'register %s call(%s) to url %s', self.name, event, url)
270 'register %s call(%s) to url %s', self.name, event, url)
268 return [(url, self.headers, data)]
271 return [(url, self.headers, data)]
269
272
270 def pull_request_event_handler(self, event, data):
273 def pull_request_event_handler(self, event, data):
271 url = self.get_base_parsed_template(data)
274 url = self.get_base_parsed_template(data)
272 log.debug(
275 log.debug(
273 'register %s call(%s) to url %s', self.name, event, url)
276 'register %s call(%s) to url %s', self.name, event, url)
274 url = string.Template(url).safe_substitute(
277 url = string.Template(url).safe_substitute(
275 pull_request_id=data['pullrequest']['pull_request_id'],
278 pull_request_id=data['pullrequest']['pull_request_id'],
276 pull_request_title=data['pullrequest']['title'],
279 pull_request_title=data['pullrequest']['title'],
277 pull_request_url=data['pullrequest']['url'],
280 pull_request_url=data['pullrequest']['url'],
278 pull_request_shadow_url=data['pullrequest']['shadow_url'],
281 pull_request_shadow_url=data['pullrequest']['shadow_url'],
279 pull_request_commits_uid=data['pullrequest']['commits_uid'],
282 pull_request_commits_uid=data['pullrequest']['commits_uid'],
280 )
283 )
281 return [(url, self.headers, data)]
284 return [(url, self.headers, data)]
282
285
283 def __call__(self, event, data):
286 def __call__(self, event, data):
284 from rhodecode import events
287 from rhodecode import events
285
288
286 if isinstance(event, events.RepoPushEvent):
289 if isinstance(event, events.RepoPushEvent):
287 return self.repo_push_event_handler(event, data)
290 return self.repo_push_event_handler(event, data)
288 elif isinstance(event, events.RepoCreateEvent):
291 elif isinstance(event, events.RepoCreateEvent):
289 return self.repo_create_event_handler(event, data)
292 return self.repo_create_event_handler(event, data)
290 elif isinstance(event, events.PullRequestEvent):
293 elif isinstance(event, events.PullRequestEvent):
291 return self.pull_request_event_handler(event, data)
294 return self.pull_request_event_handler(event, data)
292 else:
295 else:
293 raise ValueError(
296 raise ValueError(
294 'event type `%s` not in supported list: %s' % (
297 'event type `%s` not in supported list: %s' % (
295 event.__class__, events))
298 event.__class__, events))
296
299
297
300
298 def get_auth(settings):
301 def get_auth(settings):
299 from requests.auth import HTTPBasicAuth
302 from requests.auth import HTTPBasicAuth
300 username = settings.get('username')
303 username = settings.get('username')
301 password = settings.get('password')
304 password = settings.get('password')
302 if username and password:
305 if username and password:
303 return HTTPBasicAuth(username, password)
306 return HTTPBasicAuth(username, password)
304 return None
307 return None
305
308
306
309
307 def get_web_token(settings):
310 def get_web_token(settings):
308 return settings['secret_token']
311 return settings['secret_token']
309
312
310
313
311 def get_url_vars(url_vars):
314 def get_url_vars(url_vars):
312 return '\n'.join(
315 return '\n'.join(
313 '{} - {}'.format('${' + key + '}', explanation)
316 '{} - {}'.format('${' + key + '}', explanation)
314 for key, explanation in url_vars)
317 for key, explanation in url_vars)
315
318
316
319
317 def render_with_traceback(template, *args, **kwargs):
320 def render_with_traceback(template, *args, **kwargs):
318 try:
321 try:
319 return template.render(*args, **kwargs)
322 return template.render(*args, **kwargs)
320 except Exception:
323 except Exception:
321 log.error(exceptions.text_error_template().render())
324 log.error(exceptions.text_error_template().render())
322 raise
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 # -*- coding: utf-8 -*-
1 # -*- coding: utf-8 -*-
2
2
3 # Copyright (C) 2012-2018 RhodeCode GmbH
3 # Copyright (C) 2012-2018 RhodeCode GmbH
4 #
4 #
5 # This program is free software: you can redistribute it and/or modify
5 # This program is free software: you can redistribute it and/or modify
6 # it under the terms of the GNU Affero General Public License, version 3
6 # it under the terms of the GNU Affero General Public License, version 3
7 # (only), as published by the Free Software Foundation.
7 # (only), as published by the Free Software Foundation.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU Affero General Public License
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 #
16 #
17 # This program is dual-licensed. If you wish to learn more about the
17 # This program is dual-licensed. If you wish to learn more about the
18 # RhodeCode Enterprise Edition, including its added features, Support services,
18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20
20
21 from __future__ import unicode_literals
21 from __future__ import unicode_literals
22 import deform
22 import deform
23 import logging
23 import logging
24 import requests
24 import requests
25 import colander
25 import colander
26 import textwrap
26 import textwrap
27 from mako.template import Template
27 from mako.template import Template
28 from rhodecode import events
28 from rhodecode import events
29 from rhodecode.translation import _
29 from rhodecode.translation import _
30 from rhodecode.lib import helpers as h
30 from rhodecode.lib import helpers as h
31 from rhodecode.lib.celerylib import run_task, async_task, RequestContextTask
31 from rhodecode.lib.celerylib import run_task, async_task, RequestContextTask
32 from rhodecode.lib.colander_utils import strip_whitespace
32 from rhodecode.lib.colander_utils import strip_whitespace
33 from rhodecode.integrations.types.base import (
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 log = logging.getLogger(__name__)
37 log = logging.getLogger(__name__)
37
38
38
39
39 class HipchatSettingsSchema(colander.Schema):
40 class HipchatSettingsSchema(colander.Schema):
40 color_choices = [
41 color_choices = [
41 ('yellow', _('Yellow')),
42 ('yellow', _('Yellow')),
42 ('red', _('Red')),
43 ('red', _('Red')),
43 ('green', _('Green')),
44 ('green', _('Green')),
44 ('purple', _('Purple')),
45 ('purple', _('Purple')),
45 ('gray', _('Gray')),
46 ('gray', _('Gray')),
46 ]
47 ]
47
48
48 server_url = colander.SchemaNode(
49 server_url = colander.SchemaNode(
49 colander.String(),
50 colander.String(),
50 title=_('Hipchat server URL'),
51 title=_('Hipchat server URL'),
51 description=_('Hipchat integration url.'),
52 description=_('Hipchat integration url.'),
52 default='',
53 default='',
53 preparer=strip_whitespace,
54 preparer=strip_whitespace,
54 validator=colander.url,
55 validator=colander.url,
55 widget=deform.widget.TextInputWidget(
56 widget=deform.widget.TextInputWidget(
56 placeholder='https://?.hipchat.com/v2/room/?/notification?auth_token=?',
57 placeholder='https://?.hipchat.com/v2/room/?/notification?auth_token=?',
57 ),
58 ),
58 )
59 )
59 notify = colander.SchemaNode(
60 notify = colander.SchemaNode(
60 colander.Bool(),
61 colander.Bool(),
61 title=_('Notify'),
62 title=_('Notify'),
62 description=_('Make a notification to the users in room.'),
63 description=_('Make a notification to the users in room.'),
63 missing=False,
64 missing=False,
64 default=False,
65 default=False,
65 )
66 )
66 color = colander.SchemaNode(
67 color = colander.SchemaNode(
67 colander.String(),
68 colander.String(),
68 title=_('Color'),
69 title=_('Color'),
69 description=_('Background color of message.'),
70 description=_('Background color of message.'),
70 missing='',
71 missing='',
71 validator=colander.OneOf([x[0] for x in color_choices]),
72 validator=colander.OneOf([x[0] for x in color_choices]),
72 widget=deform.widget.Select2Widget(
73 widget=deform.widget.Select2Widget(
73 values=color_choices,
74 values=color_choices,
74 ),
75 ),
75 )
76 )
76
77
77
78
78 repo_push_template = Template('''
79 repo_push_template = Template('''
79 <b>${data['actor']['username']}</b> pushed to repo <a href="${data['repo']['url']}">${data['repo']['repo_name']}</a>:
80 <b>${data['actor']['username']}</b> pushed to repo <a href="${data['repo']['url']}">${data['repo']['repo_name']}</a>:
80 <br>
81 <br>
81 <ul>
82 <ul>
82 %for branch, branch_commits in branches_commits.items():
83 %for branch, branch_commits in branches_commits.items():
83 <li>
84 <li>
84 % if branch:
85 % if branch:
85 <a href="${branch_commits['branch']['url']}">branch: ${branch_commits['branch']['name']}</a>
86 <a href="${branch_commits['branch']['url']}">branch: ${branch_commits['branch']['name']}</a>
86 % else:
87 % else:
87 to trunk
88 to trunk
88 % endif
89 % endif
89 <ul>
90 <ul>
90 % for commit in branch_commits['commits']:
91 % for commit in branch_commits['commits']:
91 <li><a href="${commit['url']}">${commit['short_id']}</a> - ${commit['message_html']}</li>
92 <li><a href="${commit['url']}">${commit['short_id']}</a> - ${commit['message_html']}</li>
92 % endfor
93 % endfor
93 </ul>
94 </ul>
94 </li>
95 </li>
95 %endfor
96 %endfor
96 ''')
97 ''')
97
98
98
99
99 class HipchatIntegrationType(IntegrationTypeBase, CommitParsingDataHandler):
100 class HipchatIntegrationType(IntegrationTypeBase, CommitParsingDataHandler):
100 key = 'hipchat'
101 key = 'hipchat'
101 display_name = _('Hipchat')
102 display_name = _('Hipchat')
102 description = _('Send events such as repo pushes and pull requests to '
103 description = _('Send events such as repo pushes and pull requests to '
103 'your hipchat channel.')
104 'your hipchat channel.')
104
105
105 @classmethod
106 @classmethod
106 def icon(cls):
107 def icon(cls):
107 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 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 valid_events = [
110 valid_events = [
110 events.PullRequestCloseEvent,
111 events.PullRequestCloseEvent,
111 events.PullRequestMergeEvent,
112 events.PullRequestMergeEvent,
112 events.PullRequestUpdateEvent,
113 events.PullRequestUpdateEvent,
113 events.PullRequestCommentEvent,
114 events.PullRequestCommentEvent,
114 events.PullRequestReviewEvent,
115 events.PullRequestReviewEvent,
115 events.PullRequestCreateEvent,
116 events.PullRequestCreateEvent,
116 events.RepoPushEvent,
117 events.RepoPushEvent,
117 events.RepoCreateEvent,
118 events.RepoCreateEvent,
118 ]
119 ]
119
120
120 def send_event(self, event):
121 def send_event(self, event):
121 if event.__class__ not in self.valid_events:
122 if event.__class__ not in self.valid_events:
122 log.debug('event not valid: %r', event)
123 log.debug('event not valid: %r', event)
123 return
124 return
124
125
125 if event.name not in self.settings['events']:
126 if event.name not in self.settings['events']:
126 log.debug('event ignored: %r', event)
127 log.debug('event ignored: %r', event)
127 return
128 return
128
129
129 data = event.as_dict()
130 data = event.as_dict()
130
131
131 text = '<b>%s<b> caused a <b>%s</b> event' % (
132 text = '<b>%s<b> caused a <b>%s</b> event' % (
132 data['actor']['username'], event.name)
133 data['actor']['username'], event.name)
133
134
134 log.debug('handling hipchat event for %s', event.name)
135 log.debug('handling hipchat event for %s', event.name)
135
136
136 if isinstance(event, events.PullRequestCommentEvent):
137 if isinstance(event, events.PullRequestCommentEvent):
137 text = self.format_pull_request_comment_event(event, data)
138 text = self.format_pull_request_comment_event(event, data)
138 elif isinstance(event, events.PullRequestReviewEvent):
139 elif isinstance(event, events.PullRequestReviewEvent):
139 text = self.format_pull_request_review_event(event, data)
140 text = self.format_pull_request_review_event(event, data)
140 elif isinstance(event, events.PullRequestEvent):
141 elif isinstance(event, events.PullRequestEvent):
141 text = self.format_pull_request_event(event, data)
142 text = self.format_pull_request_event(event, data)
142 elif isinstance(event, events.RepoPushEvent):
143 elif isinstance(event, events.RepoPushEvent):
143 text = self.format_repo_push_event(data)
144 text = self.format_repo_push_event(data)
144 elif isinstance(event, events.RepoCreateEvent):
145 elif isinstance(event, events.RepoCreateEvent):
145 text = self.format_repo_create_event(data)
146 text = self.format_repo_create_event(data)
146 else:
147 else:
147 log.error('unhandled event type: %r', event)
148 log.error('unhandled event type: %r', event)
148
149
149 run_task(post_text_to_hipchat, self.settings, text)
150 run_task(post_text_to_hipchat, self.settings, text)
150
151
151 def settings_schema(self):
152 def settings_schema(self):
152 schema = HipchatSettingsSchema()
153 schema = HipchatSettingsSchema()
153 schema.add(colander.SchemaNode(
154 schema.add(colander.SchemaNode(
154 colander.Set(),
155 colander.Set(),
155 widget=deform.widget.CheckboxChoiceWidget(
156 widget=deform.widget.CheckboxChoiceWidget(
156 values=sorted(
157 values=sorted(
157 [(e.name, e.display_name) for e in self.valid_events]
158 [(e.name, e.display_name) for e in self.valid_events]
158 )
159 )
159 ),
160 ),
160 description="Events activated for this integration",
161 description="Events activated for this integration",
161 name='events'
162 name='events'
162 ))
163 ))
163
164
164 return schema
165 return schema
165
166
166 def format_pull_request_comment_event(self, event, data):
167 def format_pull_request_comment_event(self, event, data):
167 comment_text = data['comment']['text']
168 comment_text = data['comment']['text']
168 if len(comment_text) > 200:
169 if len(comment_text) > 200:
169 comment_text = '{comment_text}<a href="{comment_url}">...<a/>'.format(
170 comment_text = '{comment_text}<a href="{comment_url}">...<a/>'.format(
170 comment_text=h.html_escape(comment_text[:200]),
171 comment_text=h.html_escape(comment_text[:200]),
171 comment_url=data['comment']['url'],
172 comment_url=data['comment']['url'],
172 )
173 )
173
174
174 comment_status = ''
175 comment_status = ''
175 if data['comment']['status']:
176 if data['comment']['status']:
176 comment_status = '[{}]: '.format(data['comment']['status'])
177 comment_status = '[{}]: '.format(data['comment']['status'])
177
178
178 return (textwrap.dedent(
179 return (textwrap.dedent(
179 '''
180 '''
180 {user} commented on pull request <a href="{pr_url}">{number}</a> - {pr_title}:
181 {user} commented on pull request <a href="{pr_url}">{number}</a> - {pr_title}:
181 >>> {comment_status}{comment_text}
182 >>> {comment_status}{comment_text}
182 ''').format(
183 ''').format(
183 comment_status=comment_status,
184 comment_status=comment_status,
184 user=data['actor']['username'],
185 user=data['actor']['username'],
185 number=data['pullrequest']['pull_request_id'],
186 number=data['pullrequest']['pull_request_id'],
186 pr_url=data['pullrequest']['url'],
187 pr_url=data['pullrequest']['url'],
187 pr_status=data['pullrequest']['status'],
188 pr_status=data['pullrequest']['status'],
188 pr_title=h.html_escape(data['pullrequest']['title']),
189 pr_title=h.html_escape(data['pullrequest']['title']),
189 comment_text=h.html_escape(comment_text)
190 comment_text=h.html_escape(comment_text)
190 )
191 )
191 )
192 )
192
193
193 def format_pull_request_review_event(self, event, data):
194 def format_pull_request_review_event(self, event, data):
194 return (textwrap.dedent(
195 return (textwrap.dedent(
195 '''
196 '''
196 Status changed to {pr_status} for pull request <a href="{pr_url}">#{number}</a> - {pr_title}
197 Status changed to {pr_status} for pull request <a href="{pr_url}">#{number}</a> - {pr_title}
197 ''').format(
198 ''').format(
198 user=data['actor']['username'],
199 user=data['actor']['username'],
199 number=data['pullrequest']['pull_request_id'],
200 number=data['pullrequest']['pull_request_id'],
200 pr_url=data['pullrequest']['url'],
201 pr_url=data['pullrequest']['url'],
201 pr_status=data['pullrequest']['status'],
202 pr_status=data['pullrequest']['status'],
202 pr_title=h.html_escape(data['pullrequest']['title']),
203 pr_title=h.html_escape(data['pullrequest']['title']),
203 )
204 )
204 )
205 )
205
206
206 def format_pull_request_event(self, event, data):
207 def format_pull_request_event(self, event, data):
207 action = {
208 action = {
208 events.PullRequestCloseEvent: 'closed',
209 events.PullRequestCloseEvent: 'closed',
209 events.PullRequestMergeEvent: 'merged',
210 events.PullRequestMergeEvent: 'merged',
210 events.PullRequestUpdateEvent: 'updated',
211 events.PullRequestUpdateEvent: 'updated',
211 events.PullRequestCreateEvent: 'created',
212 events.PullRequestCreateEvent: 'created',
212 }.get(event.__class__, str(event.__class__))
213 }.get(event.__class__, str(event.__class__))
213
214
214 return ('Pull request <a href="{url}">#{number}</a> - {title} '
215 return ('Pull request <a href="{url}">#{number}</a> - {title} '
215 '{action} by <b>{user}</b>').format(
216 '{action} by <b>{user}</b>').format(
216 user=data['actor']['username'],
217 user=data['actor']['username'],
217 number=data['pullrequest']['pull_request_id'],
218 number=data['pullrequest']['pull_request_id'],
218 url=data['pullrequest']['url'],
219 url=data['pullrequest']['url'],
219 title=h.html_escape(data['pullrequest']['title']),
220 title=h.html_escape(data['pullrequest']['title']),
220 action=action
221 action=action
221 )
222 )
222
223
223 def format_repo_push_event(self, data):
224 def format_repo_push_event(self, data):
224 branches_commits = self.aggregate_branch_data(
225 branches_commits = self.aggregate_branch_data(
225 data['push']['branches'], data['push']['commits'])
226 data['push']['branches'], data['push']['commits'])
226
227
227 result = render_with_traceback(
228 result = render_with_traceback(
228 repo_push_template,
229 repo_push_template,
229 data=data,
230 data=data,
230 branches_commits=branches_commits,
231 branches_commits=branches_commits,
231 )
232 )
232 return result
233 return result
233
234
234 def format_repo_create_event(self, data):
235 def format_repo_create_event(self, data):
235 return '<a href="{}">{}</a> ({}) repository created by <b>{}</b>'.format(
236 return '<a href="{}">{}</a> ({}) repository created by <b>{}</b>'.format(
236 data['repo']['url'],
237 data['repo']['url'],
237 h.html_escape(data['repo']['repo_name']),
238 h.html_escape(data['repo']['repo_name']),
238 data['repo']['repo_type'],
239 data['repo']['repo_type'],
239 data['actor']['username'],
240 data['actor']['username'],
240 )
241 )
241
242
242
243
243 @async_task(ignore_result=True, base=RequestContextTask)
244 @async_task(ignore_result=True, base=RequestContextTask)
244 def post_text_to_hipchat(settings, text):
245 def post_text_to_hipchat(settings, text):
245 log.debug('sending %s to hipchat %s', text, settings['server_url'])
246 log.debug('sending %s to hipchat %s', text, settings['server_url'])
246 json_message = {
247 json_message = {
247 "message": text,
248 "message": text,
248 "color": settings.get('color', 'yellow'),
249 "color": settings.get('color', 'yellow'),
249 "notify": settings.get('notify', False),
250 "notify": settings.get('notify', False),
250 }
251 }
251
252 req_session = requests_retry_call()
252 resp = requests.post(settings['server_url'], json=json_message, timeout=60)
253 resp = req_session.post(settings['server_url'], json=json_message, timeout=60)
253 resp.raise_for_status() # raise exception on a failed request
254 resp.raise_for_status() # raise exception on a failed request
@@ -1,349 +1,350 b''
1 # -*- coding: utf-8 -*-
1 # -*- coding: utf-8 -*-
2
2
3 # Copyright (C) 2012-2018 RhodeCode GmbH
3 # Copyright (C) 2012-2018 RhodeCode GmbH
4 #
4 #
5 # This program is free software: you can redistribute it and/or modify
5 # This program is free software: you can redistribute it and/or modify
6 # it under the terms of the GNU Affero General Public License, version 3
6 # it under the terms of the GNU Affero General Public License, version 3
7 # (only), as published by the Free Software Foundation.
7 # (only), as published by the Free Software Foundation.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU Affero General Public License
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 #
16 #
17 # This program is dual-licensed. If you wish to learn more about the
17 # This program is dual-licensed. If you wish to learn more about the
18 # RhodeCode Enterprise Edition, including its added features, Support services,
18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20
20
21 from __future__ import unicode_literals
21 from __future__ import unicode_literals
22 import re
22 import re
23 import time
23 import time
24 import textwrap
24 import textwrap
25 import logging
25 import logging
26
26
27 import deform
27 import deform
28 import requests
28 import requests
29 import colander
29 import colander
30 from mako.template import Template
30 from mako.template import Template
31
31
32 from rhodecode import events
32 from rhodecode import events
33 from rhodecode.translation import _
33 from rhodecode.translation import _
34 from rhodecode.lib import helpers as h
34 from rhodecode.lib import helpers as h
35 from rhodecode.lib.celerylib import run_task, async_task, RequestContextTask
35 from rhodecode.lib.celerylib import run_task, async_task, RequestContextTask
36 from rhodecode.lib.colander_utils import strip_whitespace
36 from rhodecode.lib.colander_utils import strip_whitespace
37 from rhodecode.integrations.types.base import (
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 log = logging.getLogger(__name__)
41 log = logging.getLogger(__name__)
41
42
42
43
43 class SlackSettingsSchema(colander.Schema):
44 class SlackSettingsSchema(colander.Schema):
44 service = colander.SchemaNode(
45 service = colander.SchemaNode(
45 colander.String(),
46 colander.String(),
46 title=_('Slack service URL'),
47 title=_('Slack service URL'),
47 description=h.literal(_(
48 description=h.literal(_(
48 'This can be setup at the '
49 'This can be setup at the '
49 '<a href="https://my.slack.com/services/new/incoming-webhook/">'
50 '<a href="https://my.slack.com/services/new/incoming-webhook/">'
50 'slack app manager</a>')),
51 'slack app manager</a>')),
51 default='',
52 default='',
52 preparer=strip_whitespace,
53 preparer=strip_whitespace,
53 validator=colander.url,
54 validator=colander.url,
54 widget=deform.widget.TextInputWidget(
55 widget=deform.widget.TextInputWidget(
55 placeholder='https://hooks.slack.com/services/...',
56 placeholder='https://hooks.slack.com/services/...',
56 ),
57 ),
57 )
58 )
58 username = colander.SchemaNode(
59 username = colander.SchemaNode(
59 colander.String(),
60 colander.String(),
60 title=_('Username'),
61 title=_('Username'),
61 description=_('Username to show notifications coming from.'),
62 description=_('Username to show notifications coming from.'),
62 missing='Rhodecode',
63 missing='Rhodecode',
63 preparer=strip_whitespace,
64 preparer=strip_whitespace,
64 widget=deform.widget.TextInputWidget(
65 widget=deform.widget.TextInputWidget(
65 placeholder='Rhodecode'
66 placeholder='Rhodecode'
66 ),
67 ),
67 )
68 )
68 channel = colander.SchemaNode(
69 channel = colander.SchemaNode(
69 colander.String(),
70 colander.String(),
70 title=_('Channel'),
71 title=_('Channel'),
71 description=_('Channel to send notifications to.'),
72 description=_('Channel to send notifications to.'),
72 missing='',
73 missing='',
73 preparer=strip_whitespace,
74 preparer=strip_whitespace,
74 widget=deform.widget.TextInputWidget(
75 widget=deform.widget.TextInputWidget(
75 placeholder='#general'
76 placeholder='#general'
76 ),
77 ),
77 )
78 )
78 icon_emoji = colander.SchemaNode(
79 icon_emoji = colander.SchemaNode(
79 colander.String(),
80 colander.String(),
80 title=_('Emoji'),
81 title=_('Emoji'),
81 description=_('Emoji to use eg. :studio_microphone:'),
82 description=_('Emoji to use eg. :studio_microphone:'),
82 missing='',
83 missing='',
83 preparer=strip_whitespace,
84 preparer=strip_whitespace,
84 widget=deform.widget.TextInputWidget(
85 widget=deform.widget.TextInputWidget(
85 placeholder=':studio_microphone:'
86 placeholder=':studio_microphone:'
86 ),
87 ),
87 )
88 )
88
89
89
90
90 class SlackIntegrationType(IntegrationTypeBase, CommitParsingDataHandler):
91 class SlackIntegrationType(IntegrationTypeBase, CommitParsingDataHandler):
91 key = 'slack'
92 key = 'slack'
92 display_name = _('Slack')
93 display_name = _('Slack')
93 description = _('Send events such as repo pushes and pull requests to '
94 description = _('Send events such as repo pushes and pull requests to '
94 'your slack channel.')
95 'your slack channel.')
95
96
96 @classmethod
97 @classmethod
97 def icon(cls):
98 def icon(cls):
98 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 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 valid_events = [
101 valid_events = [
101 events.PullRequestCloseEvent,
102 events.PullRequestCloseEvent,
102 events.PullRequestMergeEvent,
103 events.PullRequestMergeEvent,
103 events.PullRequestUpdateEvent,
104 events.PullRequestUpdateEvent,
104 events.PullRequestCommentEvent,
105 events.PullRequestCommentEvent,
105 events.PullRequestReviewEvent,
106 events.PullRequestReviewEvent,
106 events.PullRequestCreateEvent,
107 events.PullRequestCreateEvent,
107 events.RepoPushEvent,
108 events.RepoPushEvent,
108 events.RepoCreateEvent,
109 events.RepoCreateEvent,
109 ]
110 ]
110
111
111 def send_event(self, event):
112 def send_event(self, event):
112 if event.__class__ not in self.valid_events:
113 if event.__class__ not in self.valid_events:
113 log.debug('event not valid: %r', event)
114 log.debug('event not valid: %r', event)
114 return
115 return
115
116
116 if event.name not in self.settings['events']:
117 if event.name not in self.settings['events']:
117 log.debug('event ignored: %r', event)
118 log.debug('event ignored: %r', event)
118 return
119 return
119
120
120 data = event.as_dict()
121 data = event.as_dict()
121
122
122 # defaults
123 # defaults
123 title = '*%s* caused a *%s* event' % (
124 title = '*%s* caused a *%s* event' % (
124 data['actor']['username'], event.name)
125 data['actor']['username'], event.name)
125 text = '*%s* caused a *%s* event' % (
126 text = '*%s* caused a *%s* event' % (
126 data['actor']['username'], event.name)
127 data['actor']['username'], event.name)
127 fields = None
128 fields = None
128 overrides = None
129 overrides = None
129
130
130 log.debug('handling slack event for %s', event.name)
131 log.debug('handling slack event for %s', event.name)
131
132
132 if isinstance(event, events.PullRequestCommentEvent):
133 if isinstance(event, events.PullRequestCommentEvent):
133 (title, text, fields, overrides) \
134 (title, text, fields, overrides) \
134 = self.format_pull_request_comment_event(event, data)
135 = self.format_pull_request_comment_event(event, data)
135 elif isinstance(event, events.PullRequestReviewEvent):
136 elif isinstance(event, events.PullRequestReviewEvent):
136 title, text = self.format_pull_request_review_event(event, data)
137 title, text = self.format_pull_request_review_event(event, data)
137 elif isinstance(event, events.PullRequestEvent):
138 elif isinstance(event, events.PullRequestEvent):
138 title, text = self.format_pull_request_event(event, data)
139 title, text = self.format_pull_request_event(event, data)
139 elif isinstance(event, events.RepoPushEvent):
140 elif isinstance(event, events.RepoPushEvent):
140 title, text = self.format_repo_push_event(data)
141 title, text = self.format_repo_push_event(data)
141 elif isinstance(event, events.RepoCreateEvent):
142 elif isinstance(event, events.RepoCreateEvent):
142 title, text = self.format_repo_create_event(data)
143 title, text = self.format_repo_create_event(data)
143 else:
144 else:
144 log.error('unhandled event type: %r', event)
145 log.error('unhandled event type: %r', event)
145
146
146 run_task(post_text_to_slack, self.settings, title, text, fields, overrides)
147 run_task(post_text_to_slack, self.settings, title, text, fields, overrides)
147
148
148 def settings_schema(self):
149 def settings_schema(self):
149 schema = SlackSettingsSchema()
150 schema = SlackSettingsSchema()
150 schema.add(colander.SchemaNode(
151 schema.add(colander.SchemaNode(
151 colander.Set(),
152 colander.Set(),
152 widget=deform.widget.CheckboxChoiceWidget(
153 widget=deform.widget.CheckboxChoiceWidget(
153 values=sorted(
154 values=sorted(
154 [(e.name, e.display_name) for e in self.valid_events]
155 [(e.name, e.display_name) for e in self.valid_events]
155 )
156 )
156 ),
157 ),
157 description="Events activated for this integration",
158 description="Events activated for this integration",
158 name='events'
159 name='events'
159 ))
160 ))
160
161
161 return schema
162 return schema
162
163
163 def format_pull_request_comment_event(self, event, data):
164 def format_pull_request_comment_event(self, event, data):
164 comment_text = data['comment']['text']
165 comment_text = data['comment']['text']
165 if len(comment_text) > 200:
166 if len(comment_text) > 200:
166 comment_text = '<{comment_url}|{comment_text}...>'.format(
167 comment_text = '<{comment_url}|{comment_text}...>'.format(
167 comment_text=comment_text[:200],
168 comment_text=comment_text[:200],
168 comment_url=data['comment']['url'],
169 comment_url=data['comment']['url'],
169 )
170 )
170
171
171 fields = None
172 fields = None
172 overrides = None
173 overrides = None
173 status_text = None
174 status_text = None
174
175
175 if data['comment']['status']:
176 if data['comment']['status']:
176 status_color = {
177 status_color = {
177 'approved': '#0ac878',
178 'approved': '#0ac878',
178 'rejected': '#e85e4d'}.get(data['comment']['status'])
179 'rejected': '#e85e4d'}.get(data['comment']['status'])
179
180
180 if status_color:
181 if status_color:
181 overrides = {"color": status_color}
182 overrides = {"color": status_color}
182
183
183 status_text = data['comment']['status']
184 status_text = data['comment']['status']
184
185
185 if data['comment']['file']:
186 if data['comment']['file']:
186 fields = [
187 fields = [
187 {
188 {
188 "title": "file",
189 "title": "file",
189 "value": data['comment']['file']
190 "value": data['comment']['file']
190 },
191 },
191 {
192 {
192 "title": "line",
193 "title": "line",
193 "value": data['comment']['line']
194 "value": data['comment']['line']
194 }
195 }
195 ]
196 ]
196
197
197 template = Template(textwrap.dedent(r'''
198 template = Template(textwrap.dedent(r'''
198 *${data['actor']['username']}* left ${data['comment']['type']} on pull request <${data['pullrequest']['url']}|#${data['pullrequest']['pull_request_id']}>:
199 *${data['actor']['username']}* left ${data['comment']['type']} on pull request <${data['pullrequest']['url']}|#${data['pullrequest']['pull_request_id']}>:
199 '''))
200 '''))
200 title = render_with_traceback(
201 title = render_with_traceback(
201 template, data=data, comment=event.comment)
202 template, data=data, comment=event.comment)
202
203
203 template = Template(textwrap.dedent(r'''
204 template = Template(textwrap.dedent(r'''
204 *pull request title*: ${pr_title}
205 *pull request title*: ${pr_title}
205 % if status_text:
206 % if status_text:
206 *submitted status*: `${status_text}`
207 *submitted status*: `${status_text}`
207 % endif
208 % endif
208 >>> ${comment_text}
209 >>> ${comment_text}
209 '''))
210 '''))
210 text = render_with_traceback(
211 text = render_with_traceback(
211 template,
212 template,
212 comment_text=comment_text,
213 comment_text=comment_text,
213 pr_title=data['pullrequest']['title'],
214 pr_title=data['pullrequest']['title'],
214 status_text=status_text)
215 status_text=status_text)
215
216
216 return title, text, fields, overrides
217 return title, text, fields, overrides
217
218
218 def format_pull_request_review_event(self, event, data):
219 def format_pull_request_review_event(self, event, data):
219 template = Template(textwrap.dedent(r'''
220 template = Template(textwrap.dedent(r'''
220 *${data['actor']['username']}* changed status of pull request <${data['pullrequest']['url']}|#${data['pullrequest']['pull_request_id']} to `${data['pullrequest']['status']}`>:
221 *${data['actor']['username']}* changed status of pull request <${data['pullrequest']['url']}|#${data['pullrequest']['pull_request_id']} to `${data['pullrequest']['status']}`>:
221 '''))
222 '''))
222 title = render_with_traceback(template, data=data)
223 title = render_with_traceback(template, data=data)
223
224
224 template = Template(textwrap.dedent(r'''
225 template = Template(textwrap.dedent(r'''
225 *pull request title*: ${pr_title}
226 *pull request title*: ${pr_title}
226 '''))
227 '''))
227 text = render_with_traceback(
228 text = render_with_traceback(
228 template,
229 template,
229 pr_title=data['pullrequest']['title'])
230 pr_title=data['pullrequest']['title'])
230
231
231 return title, text
232 return title, text
232
233
233 def format_pull_request_event(self, event, data):
234 def format_pull_request_event(self, event, data):
234 action = {
235 action = {
235 events.PullRequestCloseEvent: 'closed',
236 events.PullRequestCloseEvent: 'closed',
236 events.PullRequestMergeEvent: 'merged',
237 events.PullRequestMergeEvent: 'merged',
237 events.PullRequestUpdateEvent: 'updated',
238 events.PullRequestUpdateEvent: 'updated',
238 events.PullRequestCreateEvent: 'created',
239 events.PullRequestCreateEvent: 'created',
239 }.get(event.__class__, str(event.__class__))
240 }.get(event.__class__, str(event.__class__))
240
241
241 template = Template(textwrap.dedent(r'''
242 template = Template(textwrap.dedent(r'''
242 *${data['actor']['username']}* `${action}` pull request <${data['pullrequest']['url']}|#${data['pullrequest']['pull_request_id']}>:
243 *${data['actor']['username']}* `${action}` pull request <${data['pullrequest']['url']}|#${data['pullrequest']['pull_request_id']}>:
243 '''))
244 '''))
244 title = render_with_traceback(template, data=data, action=action)
245 title = render_with_traceback(template, data=data, action=action)
245
246
246 template = Template(textwrap.dedent(r'''
247 template = Template(textwrap.dedent(r'''
247 *pull request title*: ${pr_title}
248 *pull request title*: ${pr_title}
248 %if data['pullrequest']['commits']:
249 %if data['pullrequest']['commits']:
249 *commits*: ${len(data['pullrequest']['commits'])}
250 *commits*: ${len(data['pullrequest']['commits'])}
250 %endif
251 %endif
251 '''))
252 '''))
252 text = render_with_traceback(
253 text = render_with_traceback(
253 template,
254 template,
254 pr_title=data['pullrequest']['title'],
255 pr_title=data['pullrequest']['title'],
255 data=data)
256 data=data)
256
257
257 return title, text
258 return title, text
258
259
259 def format_repo_push_event(self, data):
260 def format_repo_push_event(self, data):
260
261
261 branches_commits = self.aggregate_branch_data(
262 branches_commits = self.aggregate_branch_data(
262 data['push']['branches'], data['push']['commits'])
263 data['push']['branches'], data['push']['commits'])
263
264
264 template = Template(r'''
265 template = Template(r'''
265 *${data['actor']['username']}* pushed to repo <${data['repo']['url']}|${data['repo']['repo_name']}>:
266 *${data['actor']['username']}* pushed to repo <${data['repo']['url']}|${data['repo']['repo_name']}>:
266 ''')
267 ''')
267 title = render_with_traceback(template, data=data)
268 title = render_with_traceback(template, data=data)
268
269
269 repo_push_template = Template(textwrap.dedent(r'''
270 repo_push_template = Template(textwrap.dedent(r'''
270 <%
271 <%
271 def branch_text(branch):
272 def branch_text(branch):
272 if branch:
273 if branch:
273 return 'on branch: <{}|{}>'.format(branch_commits['branch']['url'], branch_commits['branch']['name'])
274 return 'on branch: <{}|{}>'.format(branch_commits['branch']['url'], branch_commits['branch']['name'])
274 else:
275 else:
275 ## case for SVN no branch push...
276 ## case for SVN no branch push...
276 return 'to trunk'
277 return 'to trunk'
277 %> \
278 %> \
278 % for branch, branch_commits in branches_commits.items():
279 % for branch, branch_commits in branches_commits.items():
279 ${len(branch_commits['commits'])} ${'commit' if len(branch_commits['commits']) == 1 else 'commits'} ${branch_text(branch)}
280 ${len(branch_commits['commits'])} ${'commit' if len(branch_commits['commits']) == 1 else 'commits'} ${branch_text(branch)}
280 % for commit in branch_commits['commits']:
281 % for commit in branch_commits['commits']:
281 `<${commit['url']}|${commit['short_id']}>` - ${commit['message_html']|html_to_slack_links}
282 `<${commit['url']}|${commit['short_id']}>` - ${commit['message_html']|html_to_slack_links}
282 % endfor
283 % endfor
283 % endfor
284 % endfor
284 '''))
285 '''))
285
286
286 text = render_with_traceback(
287 text = render_with_traceback(
287 repo_push_template,
288 repo_push_template,
288 data=data,
289 data=data,
289 branches_commits=branches_commits,
290 branches_commits=branches_commits,
290 html_to_slack_links=html_to_slack_links,
291 html_to_slack_links=html_to_slack_links,
291 )
292 )
292
293
293 return title, text
294 return title, text
294
295
295 def format_repo_create_event(self, data):
296 def format_repo_create_event(self, data):
296 template = Template(r'''
297 template = Template(r'''
297 *${data['actor']['username']}* created new repository ${data['repo']['repo_name']}:
298 *${data['actor']['username']}* created new repository ${data['repo']['repo_name']}:
298 ''')
299 ''')
299 title = render_with_traceback(template, data=data)
300 title = render_with_traceback(template, data=data)
300
301
301 template = Template(textwrap.dedent(r'''
302 template = Template(textwrap.dedent(r'''
302 repo_url: ${data['repo']['url']}
303 repo_url: ${data['repo']['url']}
303 repo_type: ${data['repo']['repo_type']}
304 repo_type: ${data['repo']['repo_type']}
304 '''))
305 '''))
305 text = render_with_traceback(template, data=data)
306 text = render_with_traceback(template, data=data)
306
307
307 return title, text
308 return title, text
308
309
309
310
310 def html_to_slack_links(message):
311 def html_to_slack_links(message):
311 return re.compile(r'<a .*?href=["\'](.+?)".*?>(.+?)</a>').sub(
312 return re.compile(r'<a .*?href=["\'](.+?)".*?>(.+?)</a>').sub(
312 r'<\1|\2>', message)
313 r'<\1|\2>', message)
313
314
314
315
315 @async_task(ignore_result=True, base=RequestContextTask)
316 @async_task(ignore_result=True, base=RequestContextTask)
316 def post_text_to_slack(settings, title, text, fields=None, overrides=None):
317 def post_text_to_slack(settings, title, text, fields=None, overrides=None):
317 log.debug('sending %s (%s) to slack %s', title, text, settings['service'])
318 log.debug('sending %s (%s) to slack %s', title, text, settings['service'])
318
319
319 fields = fields or []
320 fields = fields or []
320 overrides = overrides or {}
321 overrides = overrides or {}
321
322
322 message_data = {
323 message_data = {
323 "fallback": text,
324 "fallback": text,
324 "color": "#427cc9",
325 "color": "#427cc9",
325 "pretext": title,
326 "pretext": title,
326 #"author_name": "Bobby Tables",
327 #"author_name": "Bobby Tables",
327 #"author_link": "http://flickr.com/bobby/",
328 #"author_link": "http://flickr.com/bobby/",
328 #"author_icon": "http://flickr.com/icons/bobby.jpg",
329 #"author_icon": "http://flickr.com/icons/bobby.jpg",
329 #"title": "Slack API Documentation",
330 #"title": "Slack API Documentation",
330 #"title_link": "https://api.slack.com/",
331 #"title_link": "https://api.slack.com/",
331 "text": text,
332 "text": text,
332 "fields": fields,
333 "fields": fields,
333 #"image_url": "http://my-website.com/path/to/image.jpg",
334 #"image_url": "http://my-website.com/path/to/image.jpg",
334 #"thumb_url": "http://example.com/path/to/thumb.png",
335 #"thumb_url": "http://example.com/path/to/thumb.png",
335 "footer": "RhodeCode",
336 "footer": "RhodeCode",
336 #"footer_icon": "",
337 #"footer_icon": "",
337 "ts": time.time(),
338 "ts": time.time(),
338 "mrkdwn_in": ["pretext", "text"]
339 "mrkdwn_in": ["pretext", "text"]
339 }
340 }
340 message_data.update(overrides)
341 message_data.update(overrides)
341 json_message = {
342 json_message = {
342 "icon_emoji": settings.get('icon_emoji', ':studio_microphone:'),
343 "icon_emoji": settings.get('icon_emoji', ':studio_microphone:'),
343 "channel": settings.get('channel', ''),
344 "channel": settings.get('channel', ''),
344 "username": settings.get('username', 'Rhodecode'),
345 "username": settings.get('username', 'Rhodecode'),
345 "attachments": [message_data]
346 "attachments": [message_data]
346 }
347 }
347
348 req_session = requests_retry_call()
348 resp = requests.post(settings['service'], json=json_message, timeout=60)
349 resp = req_session.post(settings['service'], json=json_message, timeout=60)
349 resp.raise_for_status() # raise exception on a failed request
350 resp.raise_for_status() # raise exception on a failed request
@@ -1,274 +1,264 b''
1 # -*- coding: utf-8 -*-
1 # -*- coding: utf-8 -*-
2
2
3 # Copyright (C) 2012-2018 RhodeCode GmbH
3 # Copyright (C) 2012-2018 RhodeCode GmbH
4 #
4 #
5 # This program is free software: you can redistribute it and/or modify
5 # This program is free software: you can redistribute it and/or modify
6 # it under the terms of the GNU Affero General Public License, version 3
6 # it under the terms of the GNU Affero General Public License, version 3
7 # (only), as published by the Free Software Foundation.
7 # (only), as published by the Free Software Foundation.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU Affero General Public License
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 #
16 #
17 # This program is dual-licensed. If you wish to learn more about the
17 # This program is dual-licensed. If you wish to learn more about the
18 # RhodeCode Enterprise Edition, including its added features, Support services,
18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20
20
21 from __future__ import unicode_literals
21 from __future__ import unicode_literals
22
22
23 import deform
23 import deform
24 import deform.widget
24 import deform.widget
25 import logging
25 import logging
26 import requests
27 import requests.adapters
28 import colander
26 import colander
29 from requests.packages.urllib3.util.retry import Retry
30
27
31 import rhodecode
28 import rhodecode
32 from rhodecode import events
29 from rhodecode import events
33 from rhodecode.translation import _
30 from rhodecode.translation import _
34 from rhodecode.integrations.types.base import (
31 from rhodecode.integrations.types.base import (
35 IntegrationTypeBase, get_auth, get_web_token, get_url_vars,
32 IntegrationTypeBase, get_auth, get_web_token, get_url_vars,
36 WebhookDataHandler, WEBHOOK_URL_VARS)
33 WebhookDataHandler, WEBHOOK_URL_VARS, requests_retry_call)
37 from rhodecode.lib.celerylib import run_task, async_task, RequestContextTask
34 from rhodecode.lib.celerylib import run_task, async_task, RequestContextTask
38 from rhodecode.model.validation_schema import widgets
35 from rhodecode.model.validation_schema import widgets
39
36
40 log = logging.getLogger(__name__)
37 log = logging.getLogger(__name__)
41
38
42
39
43 # updating this required to update the `common_vars` passed in url calling func
40 # updating this required to update the `common_vars` passed in url calling func
44
41
45 URL_VARS = get_url_vars(WEBHOOK_URL_VARS)
42 URL_VARS = get_url_vars(WEBHOOK_URL_VARS)
46
43
47
44
48 class WebhookSettingsSchema(colander.Schema):
45 class WebhookSettingsSchema(colander.Schema):
49 url = colander.SchemaNode(
46 url = colander.SchemaNode(
50 colander.String(),
47 colander.String(),
51 title=_('Webhook URL'),
48 title=_('Webhook URL'),
52 description=
49 description=
53 _('URL to which Webhook should submit data. If used some of the '
50 _('URL to which Webhook should submit data. If used some of the '
54 'variables would trigger multiple calls, like ${branch} or '
51 'variables would trigger multiple calls, like ${branch} or '
55 '${commit_id}. Webhook will be called as many times as unique '
52 '${commit_id}. Webhook will be called as many times as unique '
56 'objects in data in such cases.'),
53 'objects in data in such cases.'),
57 missing=colander.required,
54 missing=colander.required,
58 required=True,
55 required=True,
59 validator=colander.url,
56 validator=colander.url,
60 widget=widgets.CodeMirrorWidget(
57 widget=widgets.CodeMirrorWidget(
61 help_block_collapsable_name='Show url variables',
58 help_block_collapsable_name='Show url variables',
62 help_block_collapsable=(
59 help_block_collapsable=(
63 'E.g http://my-serv/trigger_job/${{event_name}}'
60 'E.g http://my-serv/trigger_job/${{event_name}}'
64 '?PR_ID=${{pull_request_id}}'
61 '?PR_ID=${{pull_request_id}}'
65 '\nFull list of vars:\n{}'.format(URL_VARS)),
62 '\nFull list of vars:\n{}'.format(URL_VARS)),
66 codemirror_mode='text',
63 codemirror_mode='text',
67 codemirror_options='{"lineNumbers": false, "lineWrapping": true}'),
64 codemirror_options='{"lineNumbers": false, "lineWrapping": true}'),
68 )
65 )
69 secret_token = colander.SchemaNode(
66 secret_token = colander.SchemaNode(
70 colander.String(),
67 colander.String(),
71 title=_('Secret Token'),
68 title=_('Secret Token'),
72 description=_('Optional string used to validate received payloads. '
69 description=_('Optional string used to validate received payloads. '
73 'It will be sent together with event data in JSON'),
70 'It will be sent together with event data in JSON'),
74 default='',
71 default='',
75 missing='',
72 missing='',
76 widget=deform.widget.TextInputWidget(
73 widget=deform.widget.TextInputWidget(
77 placeholder='e.g. secret_token'
74 placeholder='e.g. secret_token'
78 ),
75 ),
79 )
76 )
80 username = colander.SchemaNode(
77 username = colander.SchemaNode(
81 colander.String(),
78 colander.String(),
82 title=_('Username'),
79 title=_('Username'),
83 description=_('Optional username to authenticate the call.'),
80 description=_('Optional username to authenticate the call.'),
84 default='',
81 default='',
85 missing='',
82 missing='',
86 widget=deform.widget.TextInputWidget(
83 widget=deform.widget.TextInputWidget(
87 placeholder='e.g. admin'
84 placeholder='e.g. admin'
88 ),
85 ),
89 )
86 )
90 password = colander.SchemaNode(
87 password = colander.SchemaNode(
91 colander.String(),
88 colander.String(),
92 title=_('Password'),
89 title=_('Password'),
93 description=_('Optional password to authenticate the call.'),
90 description=_('Optional password to authenticate the call.'),
94 default='',
91 default='',
95 missing='',
92 missing='',
96 widget=deform.widget.PasswordWidget(
93 widget=deform.widget.PasswordWidget(
97 placeholder='e.g. secret.',
94 placeholder='e.g. secret.',
98 redisplay=True,
95 redisplay=True,
99 ),
96 ),
100 )
97 )
101 custom_header_key = colander.SchemaNode(
98 custom_header_key = colander.SchemaNode(
102 colander.String(),
99 colander.String(),
103 title=_('Custom Header Key'),
100 title=_('Custom Header Key'),
104 description=_('Custom Header name to be set when calling endpoint.'),
101 description=_('Custom Header name to be set when calling endpoint.'),
105 default='',
102 default='',
106 missing='',
103 missing='',
107 widget=deform.widget.TextInputWidget(
104 widget=deform.widget.TextInputWidget(
108 placeholder='e.g: Authorization'
105 placeholder='e.g: Authorization'
109 ),
106 ),
110 )
107 )
111 custom_header_val = colander.SchemaNode(
108 custom_header_val = colander.SchemaNode(
112 colander.String(),
109 colander.String(),
113 title=_('Custom Header Value'),
110 title=_('Custom Header Value'),
114 description=_('Custom Header value to be set when calling endpoint.'),
111 description=_('Custom Header value to be set when calling endpoint.'),
115 default='',
112 default='',
116 missing='',
113 missing='',
117 widget=deform.widget.TextInputWidget(
114 widget=deform.widget.TextInputWidget(
118 placeholder='e.g. Basic XxXxXx'
115 placeholder='e.g. Basic XxXxXx'
119 ),
116 ),
120 )
117 )
121 method_type = colander.SchemaNode(
118 method_type = colander.SchemaNode(
122 colander.String(),
119 colander.String(),
123 title=_('Call Method'),
120 title=_('Call Method'),
124 description=_('Select if the Webhook call should be made '
121 description=_('Select if the Webhook call should be made '
125 'with POST or GET.'),
122 'with POST or GET.'),
126 default='post',
123 default='post',
127 missing='',
124 missing='',
128 widget=deform.widget.RadioChoiceWidget(
125 widget=deform.widget.RadioChoiceWidget(
129 values=[('get', 'GET'), ('post', 'POST')],
126 values=[('get', 'GET'), ('post', 'POST')],
130 inline=True
127 inline=True
131 ),
128 ),
132 )
129 )
133
130
134
131
135 class WebhookIntegrationType(IntegrationTypeBase):
132 class WebhookIntegrationType(IntegrationTypeBase):
136 key = 'webhook'
133 key = 'webhook'
137 display_name = _('Webhook')
134 display_name = _('Webhook')
138 description = _('send JSON data to a url endpoint')
135 description = _('send JSON data to a url endpoint')
139
136
140 @classmethod
137 @classmethod
141 def icon(cls):
138 def icon(cls):
142 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 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 valid_events = [
141 valid_events = [
145 events.PullRequestCloseEvent,
142 events.PullRequestCloseEvent,
146 events.PullRequestMergeEvent,
143 events.PullRequestMergeEvent,
147 events.PullRequestUpdateEvent,
144 events.PullRequestUpdateEvent,
148 events.PullRequestCommentEvent,
145 events.PullRequestCommentEvent,
149 events.PullRequestReviewEvent,
146 events.PullRequestReviewEvent,
150 events.PullRequestCreateEvent,
147 events.PullRequestCreateEvent,
151 events.RepoPushEvent,
148 events.RepoPushEvent,
152 events.RepoCreateEvent,
149 events.RepoCreateEvent,
153 ]
150 ]
154
151
155 def settings_schema(self):
152 def settings_schema(self):
156 schema = WebhookSettingsSchema()
153 schema = WebhookSettingsSchema()
157 schema.add(colander.SchemaNode(
154 schema.add(colander.SchemaNode(
158 colander.Set(),
155 colander.Set(),
159 widget=deform.widget.CheckboxChoiceWidget(
156 widget=deform.widget.CheckboxChoiceWidget(
160 values=sorted(
157 values=sorted(
161 [(e.name, e.display_name) for e in self.valid_events]
158 [(e.name, e.display_name) for e in self.valid_events]
162 )
159 )
163 ),
160 ),
164 description="Events activated for this integration",
161 description="Events activated for this integration",
165 name='events'
162 name='events'
166 ))
163 ))
167 return schema
164 return schema
168
165
169 def send_event(self, event):
166 def send_event(self, event):
170 log.debug(
167 log.debug(
171 'handling event %s with Webhook integration %s', event.name, self)
168 'handling event %s with Webhook integration %s', event.name, self)
172
169
173 if event.__class__ not in self.valid_events:
170 if event.__class__ not in self.valid_events:
174 log.debug('event not valid: %r', event)
171 log.debug('event not valid: %r', event)
175 return
172 return
176
173
177 if event.name not in self.settings['events']:
174 if event.name not in self.settings['events']:
178 log.debug('event ignored: %r', event)
175 log.debug('event ignored: %r', event)
179 return
176 return
180
177
181 data = event.as_dict()
178 data = event.as_dict()
182 template_url = self.settings['url']
179 template_url = self.settings['url']
183
180
184 headers = {}
181 headers = {}
185 head_key = self.settings.get('custom_header_key')
182 head_key = self.settings.get('custom_header_key')
186 head_val = self.settings.get('custom_header_val')
183 head_val = self.settings.get('custom_header_val')
187 if head_key and head_val:
184 if head_key and head_val:
188 headers = {head_key: head_val}
185 headers = {head_key: head_val}
189
186
190 handler = WebhookDataHandler(template_url, headers)
187 handler = WebhookDataHandler(template_url, headers)
191
188
192 url_calls = handler(event, data)
189 url_calls = handler(event, data)
193 log.debug('webhook: calling following urls: %s',
190 log.debug('webhook: calling following urls: %s',
194 [x[0] for x in url_calls])
191 [x[0] for x in url_calls])
195
192
196 run_task(post_to_webhook, url_calls, self.settings)
193 run_task(post_to_webhook, url_calls, self.settings)
197
194
198
195
199 @async_task(ignore_result=True, base=RequestContextTask)
196 @async_task(ignore_result=True, base=RequestContextTask)
200 def post_to_webhook(url_calls, settings):
197 def post_to_webhook(url_calls, settings):
201 """
198 """
202 Example data::
199 Example data::
203
200
204 {'actor': {'user_id': 2, 'username': u'admin'},
201 {'actor': {'user_id': 2, 'username': u'admin'},
205 'actor_ip': u'192.168.157.1',
202 'actor_ip': u'192.168.157.1',
206 'name': 'repo-push',
203 'name': 'repo-push',
207 'push': {'branches': [{'name': u'default',
204 'push': {'branches': [{'name': u'default',
208 'url': 'http://rc.local:8080/hg-repo/changelog?branch=default'}],
205 'url': 'http://rc.local:8080/hg-repo/changelog?branch=default'}],
209 'commits': [{'author': u'Marcin Kuzminski <marcin@rhodecode.com>',
206 'commits': [{'author': u'Marcin Kuzminski <marcin@rhodecode.com>',
210 'branch': u'default',
207 'branch': u'default',
211 'date': datetime.datetime(2017, 11, 30, 12, 59, 48),
208 'date': datetime.datetime(2017, 11, 30, 12, 59, 48),
212 'issues': [],
209 'issues': [],
213 'mentions': [],
210 'mentions': [],
214 'message': u'commit Thu 30 Nov 2017 13:59:48 CET',
211 'message': u'commit Thu 30 Nov 2017 13:59:48 CET',
215 'message_html': u'commit Thu 30 Nov 2017 13:59:48 CET',
212 'message_html': u'commit Thu 30 Nov 2017 13:59:48 CET',
216 'message_html_title': u'commit Thu 30 Nov 2017 13:59:48 CET',
213 'message_html_title': u'commit Thu 30 Nov 2017 13:59:48 CET',
217 'parents': [{'raw_id': '431b772a5353dad9974b810dd3707d79e3a7f6e0'}],
214 'parents': [{'raw_id': '431b772a5353dad9974b810dd3707d79e3a7f6e0'}],
218 'permalink_url': u'http://rc.local:8080/_7/changeset/a815cc738b9651eb5ffbcfb1ce6ccd7c701a5ddf',
215 'permalink_url': u'http://rc.local:8080/_7/changeset/a815cc738b9651eb5ffbcfb1ce6ccd7c701a5ddf',
219 'raw_id': 'a815cc738b9651eb5ffbcfb1ce6ccd7c701a5ddf',
216 'raw_id': 'a815cc738b9651eb5ffbcfb1ce6ccd7c701a5ddf',
220 'refs': {'bookmarks': [], 'branches': [u'default'], 'tags': [u'tip']},
217 'refs': {'bookmarks': [], 'branches': [u'default'], 'tags': [u'tip']},
221 'reviewers': [],
218 'reviewers': [],
222 'revision': 9L,
219 'revision': 9L,
223 'short_id': 'a815cc738b96',
220 'short_id': 'a815cc738b96',
224 'url': u'http://rc.local:8080/hg-repo/changeset/a815cc738b9651eb5ffbcfb1ce6ccd7c701a5ddf'}],
221 'url': u'http://rc.local:8080/hg-repo/changeset/a815cc738b9651eb5ffbcfb1ce6ccd7c701a5ddf'}],
225 'issues': {}},
222 'issues': {}},
226 'repo': {'extra_fields': '',
223 'repo': {'extra_fields': '',
227 'permalink_url': u'http://rc.local:8080/_7',
224 'permalink_url': u'http://rc.local:8080/_7',
228 'repo_id': 7,
225 'repo_id': 7,
229 'repo_name': u'hg-repo',
226 'repo_name': u'hg-repo',
230 'repo_type': u'hg',
227 'repo_type': u'hg',
231 'url': u'http://rc.local:8080/hg-repo'},
228 'url': u'http://rc.local:8080/hg-repo'},
232 'server_url': u'http://rc.local:8080',
229 'server_url': u'http://rc.local:8080',
233 'utc_timestamp': datetime.datetime(2017, 11, 30, 13, 0, 1, 569276)
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 call_headers = {
233 call_headers = {
242 'User-Agent': 'RhodeCode-webhook-caller/{}'.format(
234 'User-Agent': 'RhodeCode-webhook-caller/{}'.format(
243 rhodecode.__version__)
235 rhodecode.__version__)
244 } # updated below with custom ones, allows override
236 } # updated below with custom ones, allows override
245
237
246 auth = get_auth(settings)
238 auth = get_auth(settings)
247 token = get_web_token(settings)
239 token = get_web_token(settings)
248
240
249 for url, headers, data in url_calls:
241 for url, headers, data in url_calls:
250 req_session = requests.Session()
242 req_session = requests_retry_call()
251 req_session.mount( # retry max N times
252 'http://', requests.adapters.HTTPAdapter(max_retries=retries))
253
243
254 method = settings.get('method_type') or 'post'
244 method = settings.get('method_type') or 'post'
255 call_method = getattr(req_session, method)
245 call_method = getattr(req_session, method)
256
246
257 headers = headers or {}
247 headers = headers or {}
258 call_headers.update(headers)
248 call_headers.update(headers)
259
249
260 log.debug('calling Webhook with method: %s, and auth:%s',
250 log.debug('calling Webhook with method: %s, and auth:%s',
261 call_method, auth)
251 call_method, auth)
262 if settings.get('log_data'):
252 if settings.get('log_data'):
263 log.debug('calling webhook with data: %s', data)
253 log.debug('calling webhook with data: %s', data)
264 resp = call_method(url, json={
254 resp = call_method(url, json={
265 'token': token,
255 'token': token,
266 'event': data
256 'event': data
267 }, headers=call_headers, auth=auth, timeout=60)
257 }, headers=call_headers, auth=auth, timeout=60)
268 log.debug('Got Webhook response: %s', resp)
258 log.debug('Got Webhook response: %s', resp)
269
259
270 try:
260 try:
271 resp.raise_for_status() # raise exception on a failed request
261 resp.raise_for_status() # raise exception on a failed request
272 except Exception:
262 except Exception:
273 log.error(resp.text)
263 log.error(resp.text)
274 raise
264 raise
General Comments 0
You need to be logged in to leave comments. Login now