##// END OF EJS Templates
integrations: added safe renderers with detailed traceback information.
dan -
r2646:f61fee89 default
parent child Browse files
Show More
@@ -1,299 +1,311 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
26 from mako import exceptions
27
25 from rhodecode.translation import _
28 from rhodecode.translation import _
26
29
30
27 log = logging.getLogger(__name__)
31 log = logging.getLogger(__name__)
28
32
29
33
30 class IntegrationTypeBase(object):
34 class IntegrationTypeBase(object):
31 """ Base class for IntegrationType plugins """
35 """ Base class for IntegrationType plugins """
32 is_dummy = False
36 is_dummy = False
33 description = ''
37 description = ''
34
38
35 @classmethod
39 @classmethod
36 def icon(cls):
40 def icon(cls):
37 return '''
41 return '''
38 <?xml version="1.0" encoding="UTF-8" standalone="no"?>
42 <?xml version="1.0" encoding="UTF-8" standalone="no"?>
39 <svg
43 <svg
40 xmlns:dc="http://purl.org/dc/elements/1.1/"
44 xmlns:dc="http://purl.org/dc/elements/1.1/"
41 xmlns:cc="http://creativecommons.org/ns#"
45 xmlns:cc="http://creativecommons.org/ns#"
42 xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
46 xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
43 xmlns:svg="http://www.w3.org/2000/svg"
47 xmlns:svg="http://www.w3.org/2000/svg"
44 xmlns="http://www.w3.org/2000/svg"
48 xmlns="http://www.w3.org/2000/svg"
45 xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
49 xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
46 xmlns:inkscape="http://setwww.inkscape.org/namespaces/inkscape"
50 xmlns:inkscape="http://setwww.inkscape.org/namespaces/inkscape"
47 viewBox="0 -256 1792 1792"
51 viewBox="0 -256 1792 1792"
48 id="svg3025"
52 id="svg3025"
49 version="1.1"
53 version="1.1"
50 inkscape:version="0.48.3.1 r9886"
54 inkscape:version="0.48.3.1 r9886"
51 width="100%"
55 width="100%"
52 height="100%"
56 height="100%"
53 sodipodi:docname="cog_font_awesome.svg">
57 sodipodi:docname="cog_font_awesome.svg">
54 <metadata
58 <metadata
55 id="metadata3035">
59 id="metadata3035">
56 <rdf:RDF>
60 <rdf:RDF>
57 <cc:Work
61 <cc:Work
58 rdf:about="">
62 rdf:about="">
59 <dc:format>image/svg+xml</dc:format>
63 <dc:format>image/svg+xml</dc:format>
60 <dc:type
64 <dc:type
61 rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
65 rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
62 </cc:Work>
66 </cc:Work>
63 </rdf:RDF>
67 </rdf:RDF>
64 </metadata>
68 </metadata>
65 <defs
69 <defs
66 id="defs3033" />
70 id="defs3033" />
67 <sodipodi:namedview
71 <sodipodi:namedview
68 pagecolor="#ffffff"
72 pagecolor="#ffffff"
69 bordercolor="#666666"
73 bordercolor="#666666"
70 borderopacity="1"
74 borderopacity="1"
71 objecttolerance="10"
75 objecttolerance="10"
72 gridtolerance="10"
76 gridtolerance="10"
73 guidetolerance="10"
77 guidetolerance="10"
74 inkscape:pageopacity="0"
78 inkscape:pageopacity="0"
75 inkscape:pageshadow="2"
79 inkscape:pageshadow="2"
76 inkscape:window-width="640"
80 inkscape:window-width="640"
77 inkscape:window-height="480"
81 inkscape:window-height="480"
78 id="namedview3031"
82 id="namedview3031"
79 showgrid="false"
83 showgrid="false"
80 inkscape:zoom="0.13169643"
84 inkscape:zoom="0.13169643"
81 inkscape:cx="896"
85 inkscape:cx="896"
82 inkscape:cy="896"
86 inkscape:cy="896"
83 inkscape:window-x="0"
87 inkscape:window-x="0"
84 inkscape:window-y="25"
88 inkscape:window-y="25"
85 inkscape:window-maximized="0"
89 inkscape:window-maximized="0"
86 inkscape:current-layer="svg3025" />
90 inkscape:current-layer="svg3025" />
87 <g
91 <g
88 transform="matrix(1,0,0,-1,121.49153,1285.4237)"
92 transform="matrix(1,0,0,-1,121.49153,1285.4237)"
89 id="g3027">
93 id="g3027">
90 <path
94 <path
91 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"
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"
92 id="path3029"
96 id="path3029"
93 inkscape:connector-curvature="0"
97 inkscape:connector-curvature="0"
94 style="fill:currentColor" />
98 style="fill:currentColor" />
95 </g>
99 </g>
96 </svg>
100 </svg>
97 '''
101 '''
98
102
99 def __init__(self, settings):
103 def __init__(self, settings):
100 """
104 """
101 :param settings: dict of settings to be used for the integration
105 :param settings: dict of settings to be used for the integration
102 """
106 """
103 self.settings = settings
107 self.settings = settings
104
108
105 def settings_schema(self):
109 def settings_schema(self):
106 """
110 """
107 A colander schema of settings for the integration type
111 A colander schema of settings for the integration type
108 """
112 """
109 return colander.Schema()
113 return colander.Schema()
110
114
111
115
112 class EEIntegration(IntegrationTypeBase):
116 class EEIntegration(IntegrationTypeBase):
113 description = 'Integration available in RhodeCode EE edition.'
117 description = 'Integration available in RhodeCode EE edition.'
114 is_dummy = True
118 is_dummy = True
115
119
116 def __init__(self, name, key, settings=None):
120 def __init__(self, name, key, settings=None):
117 self.display_name = name
121 self.display_name = name
118 self.key = key
122 self.key = key
119 super(EEIntegration, self).__init__(settings)
123 super(EEIntegration, self).__init__(settings)
120
124
121
125
122 # Helpers #
126 # Helpers #
123 WEBHOOK_URL_VARS = [
127 WEBHOOK_URL_VARS = [
124 ('event_name', 'Unique name of the event type, e.g pullrequest-update'),
128 ('event_name', 'Unique name of the event type, e.g pullrequest-update'),
125 ('repo_name', 'Full name of the repository'),
129 ('repo_name', 'Full name of the repository'),
126 ('repo_type', 'VCS type of repository'),
130 ('repo_type', 'VCS type of repository'),
127 ('repo_id', 'Unique id of repository'),
131 ('repo_id', 'Unique id of repository'),
128 ('repo_url', 'Repository url'),
132 ('repo_url', 'Repository url'),
129 # extra repo fields
133 # extra repo fields
130 ('extra:<extra_key_name>', 'Extra repo variables, read from its settings.'),
134 ('extra:<extra_key_name>', 'Extra repo variables, read from its settings.'),
131
135
132 # special attrs below that we handle, using multi-call
136 # special attrs below that we handle, using multi-call
133 ('branch', 'Name of each brach submitted, if any.'),
137 ('branch', 'Name of each brach submitted, if any.'),
134 ('commit_id', 'Id of each commit submitted, if any.'),
138 ('commit_id', 'Id of each commit submitted, if any.'),
135
139
136 # pr events vars
140 # pr events vars
137 ('pull_request_id', 'Unique ID of the pull request.'),
141 ('pull_request_id', 'Unique ID of the pull request.'),
138 ('pull_request_title', 'Title of the pull request.'),
142 ('pull_request_title', 'Title of the pull request.'),
139 ('pull_request_url', 'Pull request url.'),
143 ('pull_request_url', 'Pull request url.'),
140 ('pull_request_shadow_url', 'Pull request shadow repo clone url.'),
144 ('pull_request_shadow_url', 'Pull request shadow repo clone url.'),
141 ('pull_request_commits_uid', 'Calculated UID of all commits inside the PR. '
145 ('pull_request_commits_uid', 'Calculated UID of all commits inside the PR. '
142 'Changes after PR update'),
146 'Changes after PR update'),
143
147
144 # user who triggers the call
148 # user who triggers the call
145 ('username', 'User who triggered the call.'),
149 ('username', 'User who triggered the call.'),
146 ('user_id', 'User id who triggered the call.'),
150 ('user_id', 'User id who triggered the call.'),
147 ]
151 ]
148
152
149 # common vars for url template used for CI plugins. Shared with webhook
153 # common vars for url template used for CI plugins. Shared with webhook
150 CI_URL_VARS = WEBHOOK_URL_VARS
154 CI_URL_VARS = WEBHOOK_URL_VARS
151
155
152
156
153 class CommitParsingDataHandler(object):
157 class CommitParsingDataHandler(object):
154
158
155 def aggregate_branch_data(self, branches, commits):
159 def aggregate_branch_data(self, branches, commits):
156 branch_data = collections.OrderedDict()
160 branch_data = collections.OrderedDict()
157 for obj in branches:
161 for obj in branches:
158 branch_data[obj['name']] = obj
162 branch_data[obj['name']] = obj
159
163
160 branches_commits = collections.OrderedDict()
164 branches_commits = collections.OrderedDict()
161 for commit in commits:
165 for commit in commits:
162 if commit.get('git_ref_change'):
166 if commit.get('git_ref_change'):
163 # special case for GIT that allows creating tags,
167 # special case for GIT that allows creating tags,
164 # deleting branches without associated commit
168 # deleting branches without associated commit
165 continue
169 continue
166 commit_branch = commit['branch']
170 commit_branch = commit['branch']
167
171
168 if commit_branch not in branches_commits:
172 if commit_branch not in branches_commits:
169 _branch = branch_data[commit_branch] \
173 _branch = branch_data[commit_branch] \
170 if commit_branch else commit_branch
174 if commit_branch else commit_branch
171 branch_commits = {'branch': _branch,
175 branch_commits = {'branch': _branch,
172 'commits': []}
176 'commits': []}
173 branches_commits[commit_branch] = branch_commits
177 branches_commits[commit_branch] = branch_commits
174
178
175 branch_commits = branches_commits[commit_branch]
179 branch_commits = branches_commits[commit_branch]
176 branch_commits['commits'].append(commit)
180 branch_commits['commits'].append(commit)
177 return branches_commits
181 return branches_commits
178
182
179
183
180 class WebhookDataHandler(CommitParsingDataHandler):
184 class WebhookDataHandler(CommitParsingDataHandler):
181 name = 'webhook'
185 name = 'webhook'
182
186
183 def __init__(self, template_url, headers):
187 def __init__(self, template_url, headers):
184 self.template_url = template_url
188 self.template_url = template_url
185 self.headers = headers
189 self.headers = headers
186
190
187 def get_base_parsed_template(self, data):
191 def get_base_parsed_template(self, data):
188 """
192 """
189 initially parses the passed in template with some common variables
193 initially parses the passed in template with some common variables
190 available on ALL calls
194 available on ALL calls
191 """
195 """
192 # note: make sure to update the `WEBHOOK_URL_VARS` if this changes
196 # note: make sure to update the `WEBHOOK_URL_VARS` if this changes
193 common_vars = {
197 common_vars = {
194 'repo_name': data['repo']['repo_name'],
198 'repo_name': data['repo']['repo_name'],
195 'repo_type': data['repo']['repo_type'],
199 'repo_type': data['repo']['repo_type'],
196 'repo_id': data['repo']['repo_id'],
200 'repo_id': data['repo']['repo_id'],
197 'repo_url': data['repo']['url'],
201 'repo_url': data['repo']['url'],
198 'username': data['actor']['username'],
202 'username': data['actor']['username'],
199 'user_id': data['actor']['user_id'],
203 'user_id': data['actor']['user_id'],
200 'event_name': data['name']
204 'event_name': data['name']
201 }
205 }
202
206
203 extra_vars = {}
207 extra_vars = {}
204 for extra_key, extra_val in data['repo']['extra_fields'].items():
208 for extra_key, extra_val in data['repo']['extra_fields'].items():
205 extra_vars['extra__{}'.format(extra_key)] = extra_val
209 extra_vars['extra__{}'.format(extra_key)] = extra_val
206 common_vars.update(extra_vars)
210 common_vars.update(extra_vars)
207
211
208 template_url = self.template_url.replace('${extra:', '${extra__')
212 template_url = self.template_url.replace('${extra:', '${extra__')
209 return string.Template(template_url).safe_substitute(**common_vars)
213 return string.Template(template_url).safe_substitute(**common_vars)
210
214
211 def repo_push_event_handler(self, event, data):
215 def repo_push_event_handler(self, event, data):
212 url = self.get_base_parsed_template(data)
216 url = self.get_base_parsed_template(data)
213 url_cals = []
217 url_cals = []
214
218
215 branches_commits = self.aggregate_branch_data(
219 branches_commits = self.aggregate_branch_data(
216 data['push']['branches'], data['push']['commits'])
220 data['push']['branches'], data['push']['commits'])
217 if '${branch}' in url:
221 if '${branch}' in url:
218 # call it multiple times, for each branch if used in variables
222 # call it multiple times, for each branch if used in variables
219 for branch, commit_ids in branches_commits.items():
223 for branch, commit_ids in branches_commits.items():
220 branch_url = string.Template(url).safe_substitute(branch=branch)
224 branch_url = string.Template(url).safe_substitute(branch=branch)
221 # call further down for each commit if used
225 # call further down for each commit if used
222 if '${commit_id}' in branch_url:
226 if '${commit_id}' in branch_url:
223 for commit_data in commit_ids['commits']:
227 for commit_data in commit_ids['commits']:
224 commit_id = commit_data['raw_id']
228 commit_id = commit_data['raw_id']
225 commit_url = string.Template(branch_url).safe_substitute(
229 commit_url = string.Template(branch_url).safe_substitute(
226 commit_id=commit_id)
230 commit_id=commit_id)
227 # register per-commit call
231 # register per-commit call
228 log.debug(
232 log.debug(
229 'register %s call(%s) to url %s',
233 'register %s call(%s) to url %s',
230 self.name, event, commit_url)
234 self.name, event, commit_url)
231 url_cals.append(
235 url_cals.append(
232 (commit_url, self.headers, data))
236 (commit_url, self.headers, data))
233
237
234 else:
238 else:
235 # register per-branch call
239 # register per-branch call
236 log.debug(
240 log.debug(
237 'register %s call(%s) to url %s',
241 'register %s call(%s) to url %s',
238 self.name, event, branch_url)
242 self.name, event, branch_url)
239 url_cals.append(
243 url_cals.append(
240 (branch_url, self.headers, data))
244 (branch_url, self.headers, data))
241
245
242 else:
246 else:
243 log.debug(
247 log.debug(
244 'register %s call(%s) to url %s', self.name, event, url)
248 'register %s call(%s) to url %s', self.name, event, url)
245 url_cals.append((url, self.headers, data))
249 url_cals.append((url, self.headers, data))
246
250
247 return url_cals
251 return url_cals
248
252
249 def repo_create_event_handler(self, event, data):
253 def repo_create_event_handler(self, event, data):
250 url = self.get_base_parsed_template(data)
254 url = self.get_base_parsed_template(data)
251 log.debug(
255 log.debug(
252 'register %s call(%s) to url %s', self.name, event, url)
256 'register %s call(%s) to url %s', self.name, event, url)
253 return [(url, self.headers, data)]
257 return [(url, self.headers, data)]
254
258
255 def pull_request_event_handler(self, event, data):
259 def pull_request_event_handler(self, event, data):
256 url = self.get_base_parsed_template(data)
260 url = self.get_base_parsed_template(data)
257 log.debug(
261 log.debug(
258 'register %s call(%s) to url %s', self.name, event, url)
262 'register %s call(%s) to url %s', self.name, event, url)
259 url = string.Template(url).safe_substitute(
263 url = string.Template(url).safe_substitute(
260 pull_request_id=data['pullrequest']['pull_request_id'],
264 pull_request_id=data['pullrequest']['pull_request_id'],
261 pull_request_title=data['pullrequest']['title'],
265 pull_request_title=data['pullrequest']['title'],
262 pull_request_url=data['pullrequest']['url'],
266 pull_request_url=data['pullrequest']['url'],
263 pull_request_shadow_url=data['pullrequest']['shadow_url'],
267 pull_request_shadow_url=data['pullrequest']['shadow_url'],
264 pull_request_commits_uid=data['pullrequest']['commits_uid'],
268 pull_request_commits_uid=data['pullrequest']['commits_uid'],
265 )
269 )
266 return [(url, self.headers, data)]
270 return [(url, self.headers, data)]
267
271
268 def __call__(self, event, data):
272 def __call__(self, event, data):
269 from rhodecode import events
273 from rhodecode import events
270
274
271 if isinstance(event, events.RepoPushEvent):
275 if isinstance(event, events.RepoPushEvent):
272 return self.repo_push_event_handler(event, data)
276 return self.repo_push_event_handler(event, data)
273 elif isinstance(event, events.RepoCreateEvent):
277 elif isinstance(event, events.RepoCreateEvent):
274 return self.repo_create_event_handler(event, data)
278 return self.repo_create_event_handler(event, data)
275 elif isinstance(event, events.PullRequestEvent):
279 elif isinstance(event, events.PullRequestEvent):
276 return self.pull_request_event_handler(event, data)
280 return self.pull_request_event_handler(event, data)
277 else:
281 else:
278 raise ValueError(
282 raise ValueError(
279 'event type `%s` not in supported list: %s' % (
283 'event type `%s` not in supported list: %s' % (
280 event.__class__, events))
284 event.__class__, events))
281
285
282
286
283 def get_auth(settings):
287 def get_auth(settings):
284 from requests.auth import HTTPBasicAuth
288 from requests.auth import HTTPBasicAuth
285 username = settings.get('username')
289 username = settings.get('username')
286 password = settings.get('password')
290 password = settings.get('password')
287 if username and password:
291 if username and password:
288 return HTTPBasicAuth(username, password)
292 return HTTPBasicAuth(username, password)
289 return None
293 return None
290
294
291
295
292 def get_web_token(settings):
296 def get_web_token(settings):
293 return settings['secret_token']
297 return settings['secret_token']
294
298
295
299
296 def get_url_vars(url_vars):
300 def get_url_vars(url_vars):
297 return '\n'.join(
301 return '\n'.join(
298 '{} - {}'.format('${' + key + '}', explanation)
302 '{} - {}'.format('${' + key + '}', explanation)
299 for key, explanation in url_vars)
303 for key, explanation in url_vars)
304
305
306 def render_with_traceback(template, *args, **kwargs):
307 try:
308 return template.render(*args, **kwargs)
309 except Exception:
310 log.error(exceptions.text_error_template().render())
311 raise
@@ -1,297 +1,300 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 colander
24 import colander
25
25
26 from mako.template import Template
26 from mako.template import Template
27
27
28 from rhodecode import events
28 from rhodecode import events
29 from rhodecode.translation import _
29 from rhodecode.translation import _
30 from rhodecode.lib.celerylib import run_task
30 from rhodecode.lib.celerylib import run_task
31 from rhodecode.lib.celerylib import tasks
31 from rhodecode.lib.celerylib import tasks
32 from rhodecode.integrations.types.base import IntegrationTypeBase
32 from rhodecode.integrations.types.base import (
33 IntegrationTypeBase, render_with_traceback)
33
34
34
35
35 log = logging.getLogger(__name__)
36 log = logging.getLogger(__name__)
36
37
37 repo_push_template_plaintext = Template('''
38 repo_push_template_plaintext = Template('''
38 Commits:
39 Commits:
39
40
40 % for commit in data['push']['commits']:
41 % for commit in data['push']['commits']:
41 ${commit['url']} by ${commit['author']} at ${commit['date']}
42 ${commit['url']} by ${commit['author']} at ${commit['date']}
42 ${commit['message']}
43 ${commit['message']}
43 ----
44 ----
44
45
45 % endfor
46 % endfor
46 ''')
47 ''')
47
48
48 ## TODO (marcink): think about putting this into a file, or use base.mako email template
49 ## TODO (marcink): think about putting this into a file, or use base.mako email template
49
50
50 repo_push_template_html = Template('''
51 repo_push_template_html = Template('''
51 <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
52 <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
52 <html xmlns="http://www.w3.org/1999/xhtml">
53 <html xmlns="http://www.w3.org/1999/xhtml">
53 <head>
54 <head>
54 <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
55 <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
55 <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
56 <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
56 <title>${subject}</title>
57 <title>${subject}</title>
57 <style type="text/css">
58 <style type="text/css">
58 /* Based on The MailChimp Reset INLINE: Yes. */
59 /* Based on The MailChimp Reset INLINE: Yes. */
59 #outlook a {padding:0;} /* Force Outlook to provide a "view in browser" menu link. */
60 #outlook a {padding:0;} /* Force Outlook to provide a "view in browser" menu link. */
60 body{width:100% !important; -webkit-text-size-adjust:100%; -ms-text-size-adjust:100%; margin:0; padding:0;}
61 body{width:100% !important; -webkit-text-size-adjust:100%; -ms-text-size-adjust:100%; margin:0; padding:0;}
61 /* Prevent Webkit and Windows Mobile platforms from changing default font sizes.*/
62 /* Prevent Webkit and Windows Mobile platforms from changing default font sizes.*/
62 .ExternalClass {width:100%;} /* Force Hotmail to display emails at full width */
63 .ExternalClass {width:100%;} /* Force Hotmail to display emails at full width */
63 .ExternalClass, .ExternalClass p, .ExternalClass span, .ExternalClass font, .ExternalClass td, .ExternalClass div {line-height: 100%;}
64 .ExternalClass, .ExternalClass p, .ExternalClass span, .ExternalClass font, .ExternalClass td, .ExternalClass div {line-height: 100%;}
64 /* Forces Hotmail to display normal line spacing. More on that: http://www.emailonacid.com/forum/viewthread/43/ */
65 /* Forces Hotmail to display normal line spacing. More on that: http://www.emailonacid.com/forum/viewthread/43/ */
65 #backgroundTable {margin:0; padding:0; line-height: 100% !important;}
66 #backgroundTable {margin:0; padding:0; line-height: 100% !important;}
66 /* End reset */
67 /* End reset */
67
68
68 /* defaults for images*/
69 /* defaults for images*/
69 img {outline:none; text-decoration:none; -ms-interpolation-mode: bicubic;}
70 img {outline:none; text-decoration:none; -ms-interpolation-mode: bicubic;}
70 a img {border:none;}
71 a img {border:none;}
71 .image_fix {display:block;}
72 .image_fix {display:block;}
72
73
73 body {line-height:1.2em;}
74 body {line-height:1.2em;}
74 p {margin: 0 0 20px;}
75 p {margin: 0 0 20px;}
75 h1, h2, h3, h4, h5, h6 {color:#323232!important;}
76 h1, h2, h3, h4, h5, h6 {color:#323232!important;}
76 a {color:#427cc9;text-decoration:none;outline:none;cursor:pointer;}
77 a {color:#427cc9;text-decoration:none;outline:none;cursor:pointer;}
77 a:focus {outline:none;}
78 a:focus {outline:none;}
78 a:hover {color: #305b91;}
79 a:hover {color: #305b91;}
79 h1 a, h2 a, h3 a, h4 a, h5 a, h6 a {color:#427cc9!important;text-decoration:none!important;}
80 h1 a, h2 a, h3 a, h4 a, h5 a, h6 a {color:#427cc9!important;text-decoration:none!important;}
80 h1 a:active, h2 a:active, h3 a:active, h4 a:active, h5 a:active, h6 a:active {color: #305b91!important;}
81 h1 a:active, h2 a:active, h3 a:active, h4 a:active, h5 a:active, h6 a:active {color: #305b91!important;}
81 h1 a:visited, h2 a:visited, h3 a:visited, h4 a:visited, h5 a:visited, h6 a:visited {color: #305b91!important;}
82 h1 a:visited, h2 a:visited, h3 a:visited, h4 a:visited, h5 a:visited, h6 a:visited {color: #305b91!important;}
82 table {font-size:13px;border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt;}
83 table {font-size:13px;border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt;}
83 table td {padding:.65em 1em .65em 0;border-collapse:collapse;vertical-align:top;text-align:left;}
84 table td {padding:.65em 1em .65em 0;border-collapse:collapse;vertical-align:top;text-align:left;}
84 input {display:inline;border-radius:2px;border-style:solid;border: 1px solid #dbd9da;padding:.5em;}
85 input {display:inline;border-radius:2px;border-style:solid;border: 1px solid #dbd9da;padding:.5em;}
85 input:focus {outline: 1px solid #979797}
86 input:focus {outline: 1px solid #979797}
86 @media only screen and (-webkit-min-device-pixel-ratio: 2) {
87 @media only screen and (-webkit-min-device-pixel-ratio: 2) {
87 /* Put your iPhone 4g styles in here */
88 /* Put your iPhone 4g styles in here */
88 }
89 }
89
90
90 /* Android targeting */
91 /* Android targeting */
91 @media only screen and (-webkit-device-pixel-ratio:.75){
92 @media only screen and (-webkit-device-pixel-ratio:.75){
92 /* Put CSS for low density (ldpi) Android layouts in here */
93 /* Put CSS for low density (ldpi) Android layouts in here */
93 }
94 }
94 @media only screen and (-webkit-device-pixel-ratio:1){
95 @media only screen and (-webkit-device-pixel-ratio:1){
95 /* Put CSS for medium density (mdpi) Android layouts in here */
96 /* Put CSS for medium density (mdpi) Android layouts in here */
96 }
97 }
97 @media only screen and (-webkit-device-pixel-ratio:1.5){
98 @media only screen and (-webkit-device-pixel-ratio:1.5){
98 /* Put CSS for high density (hdpi) Android layouts in here */
99 /* Put CSS for high density (hdpi) Android layouts in here */
99 }
100 }
100 /* end Android targeting */
101 /* end Android targeting */
101
102
102 </style>
103 </style>
103
104
104 <!-- Targeting Windows Mobile -->
105 <!-- Targeting Windows Mobile -->
105 <!--[if IEMobile 7]>
106 <!--[if IEMobile 7]>
106 <style type="text/css">
107 <style type="text/css">
107
108
108 </style>
109 </style>
109 <![endif]-->
110 <![endif]-->
110
111
111 <!--[if gte mso 9]>
112 <!--[if gte mso 9]>
112 <style>
113 <style>
113 /* Target Outlook 2007 and 2010 */
114 /* Target Outlook 2007 and 2010 */
114 </style>
115 </style>
115 <![endif]-->
116 <![endif]-->
116 </head>
117 </head>
117 <body>
118 <body>
118 <!-- Wrapper/Container Table: Use a wrapper table to control the width and the background color consistently of your email. Use this approach instead of setting attributes on the body tag. -->
119 <!-- Wrapper/Container Table: Use a wrapper table to control the width and the background color consistently of your email. Use this approach instead of setting attributes on the body tag. -->
119 <table cellpadding="0" cellspacing="0" border="0" id="backgroundTable" align="left" style="margin:1%;width:97%;padding:0;font-family:sans-serif;font-weight:100;border:1px solid #dbd9da">
120 <table cellpadding="0" cellspacing="0" border="0" id="backgroundTable" align="left" style="margin:1%;width:97%;padding:0;font-family:sans-serif;font-weight:100;border:1px solid #dbd9da">
120 <tr>
121 <tr>
121 <td valign="top" style="padding:0;">
122 <td valign="top" style="padding:0;">
122 <table cellpadding="0" cellspacing="0" border="0" align="left" width="100%">
123 <table cellpadding="0" cellspacing="0" border="0" align="left" width="100%">
123 <tr><td style="width:100%;padding:7px;background-color:#202020" valign="top">
124 <tr><td style="width:100%;padding:7px;background-color:#202020" valign="top">
124 <a style="color:#eeeeee;text-decoration:none;" href="${instance_url}">
125 <a style="color:#eeeeee;text-decoration:none;" href="${instance_url}">
125 ${'RhodeCode'}
126 ${'RhodeCode'}
126 </a>
127 </a>
127 </td></tr>
128 </td></tr>
128 <tr>
129 <tr>
129 <td style="padding:15px;" valign="top">
130 <td style="padding:15px;" valign="top">
130 % if data['push']['commits']:
131 % if data['push']['commits']:
131 % for commit in data['push']['commits']:
132 % for commit in data['push']['commits']:
132 <a href="${commit['url']}">${commit['short_id']}</a> by ${commit['author']} at ${commit['date']} <br/>
133 <a href="${commit['url']}">${commit['short_id']}</a> by ${commit['author']} at ${commit['date']} <br/>
133 ${commit['message_html']} <br/>
134 ${commit['message_html']} <br/>
134 <br/>
135 <br/>
135 % endfor
136 % endfor
136 % else:
137 % else:
137 No commit data
138 No commit data
138 % endif
139 % endif
139 </td>
140 </td>
140 </tr>
141 </tr>
141 </table>
142 </table>
142 </td>
143 </td>
143 </tr>
144 </tr>
144 </table>
145 </table>
145 <!-- End of wrapper table -->
146 <!-- End of wrapper table -->
146 <p><a style="margin-top:15px;margin-left:1%;font-family:sans-serif;font-weight:100;font-size:11px;color:#666666;text-decoration:none;" href="${instance_url}">
147 <p><a style="margin-top:15px;margin-left:1%;font-family:sans-serif;font-weight:100;font-size:11px;color:#666666;text-decoration:none;" href="${instance_url}">
147 ${'This is a notification from RhodeCode. %(instance_url)s' % {'instance_url': instance_url}}
148 ${'This is a notification from RhodeCode. %(instance_url)s' % {'instance_url': instance_url}}
148 </a></p>
149 </a></p>
149 </body>
150 </body>
150 </html>
151 </html>
151 ''')
152 ''')
152
153
153
154
154 class EmailSettingsSchema(colander.Schema):
155 class EmailSettingsSchema(colander.Schema):
155 @colander.instantiate(validator=colander.Length(min=1))
156 @colander.instantiate(validator=colander.Length(min=1))
156 class recipients(colander.SequenceSchema):
157 class recipients(colander.SequenceSchema):
157 title = _('Recipients')
158 title = _('Recipients')
158 description = _('Email addresses to send push events to')
159 description = _('Email addresses to send push events to')
159 widget = deform.widget.SequenceWidget(min_len=1)
160 widget = deform.widget.SequenceWidget(min_len=1)
160
161
161 recipient = colander.SchemaNode(
162 recipient = colander.SchemaNode(
162 colander.String(),
163 colander.String(),
163 title=_('Email address'),
164 title=_('Email address'),
164 description=_('Email address'),
165 description=_('Email address'),
165 default='',
166 default='',
166 validator=colander.Email(),
167 validator=colander.Email(),
167 widget=deform.widget.TextInputWidget(
168 widget=deform.widget.TextInputWidget(
168 placeholder='user@domain.com',
169 placeholder='user@domain.com',
169 ),
170 ),
170 )
171 )
171
172
172
173
173 class EmailIntegrationType(IntegrationTypeBase):
174 class EmailIntegrationType(IntegrationTypeBase):
174 key = 'email'
175 key = 'email'
175 display_name = _('Email')
176 display_name = _('Email')
176 description = _('Send repo push summaries to a list of recipients via email')
177 description = _('Send repo push summaries to a list of recipients via email')
177
178
178 @classmethod
179 @classmethod
179 def icon(cls):
180 def icon(cls):
180 return '''
181 return '''
181 <?xml version="1.0" encoding="UTF-8" standalone="no"?>
182 <?xml version="1.0" encoding="UTF-8" standalone="no"?>
182 <svg
183 <svg
183 xmlns:dc="http://purl.org/dc/elements/1.1/"
184 xmlns:dc="http://purl.org/dc/elements/1.1/"
184 xmlns:cc="http://creativecommons.org/ns#"
185 xmlns:cc="http://creativecommons.org/ns#"
185 xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
186 xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
186 xmlns:svg="http://www.w3.org/2000/svg"
187 xmlns:svg="http://www.w3.org/2000/svg"
187 xmlns="http://www.w3.org/2000/svg"
188 xmlns="http://www.w3.org/2000/svg"
188 xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
189 xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
189 xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
190 xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
190 viewBox="0 -256 1850 1850"
191 viewBox="0 -256 1850 1850"
191 id="svg2989"
192 id="svg2989"
192 version="1.1"
193 version="1.1"
193 inkscape:version="0.48.3.1 r9886"
194 inkscape:version="0.48.3.1 r9886"
194 width="100%"
195 width="100%"
195 height="100%"
196 height="100%"
196 sodipodi:docname="envelope_font_awesome.svg">
197 sodipodi:docname="envelope_font_awesome.svg">
197 <metadata
198 <metadata
198 id="metadata2999">
199 id="metadata2999">
199 <rdf:RDF>
200 <rdf:RDF>
200 <cc:Work
201 <cc:Work
201 rdf:about="">
202 rdf:about="">
202 <dc:format>image/svg+xml</dc:format>
203 <dc:format>image/svg+xml</dc:format>
203 <dc:type
204 <dc:type
204 rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
205 rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
205 </cc:Work>
206 </cc:Work>
206 </rdf:RDF>
207 </rdf:RDF>
207 </metadata>
208 </metadata>
208 <defs
209 <defs
209 id="defs2997" />
210 id="defs2997" />
210 <sodipodi:namedview
211 <sodipodi:namedview
211 pagecolor="#ffffff"
212 pagecolor="#ffffff"
212 bordercolor="#666666"
213 bordercolor="#666666"
213 borderopacity="1"
214 borderopacity="1"
214 objecttolerance="10"
215 objecttolerance="10"
215 gridtolerance="10"
216 gridtolerance="10"
216 guidetolerance="10"
217 guidetolerance="10"
217 inkscape:pageopacity="0"
218 inkscape:pageopacity="0"
218 inkscape:pageshadow="2"
219 inkscape:pageshadow="2"
219 inkscape:window-width="640"
220 inkscape:window-width="640"
220 inkscape:window-height="480"
221 inkscape:window-height="480"
221 id="namedview2995"
222 id="namedview2995"
222 showgrid="false"
223 showgrid="false"
223 inkscape:zoom="0.13169643"
224 inkscape:zoom="0.13169643"
224 inkscape:cx="896"
225 inkscape:cx="896"
225 inkscape:cy="896"
226 inkscape:cy="896"
226 inkscape:window-x="0"
227 inkscape:window-x="0"
227 inkscape:window-y="25"
228 inkscape:window-y="25"
228 inkscape:window-maximized="0"
229 inkscape:window-maximized="0"
229 inkscape:current-layer="svg2989" />
230 inkscape:current-layer="svg2989" />
230 <g
231 <g
231 transform="matrix(1,0,0,-1,37.966102,1282.678)"
232 transform="matrix(1,0,0,-1,37.966102,1282.678)"
232 id="g2991">
233 id="g2991">
233 <path
234 <path
234 d="m 1664,32 v 768 q -32,-36 -69,-66 -268,-206 -426,-338 -51,-43 -83,-67 -32,-24 -86.5,-48.5 Q 945,256 897,256 h -1 -1 Q 847,256 792.5,280.5 738,305 706,329 674,353 623,396 465,528 197,734 160,764 128,800 V 32 Q 128,19 137.5,9.5 147,0 160,0 h 1472 q 13,0 22.5,9.5 9.5,9.5 9.5,22.5 z m 0,1051 v 11 13.5 q 0,0 -0.5,13 -0.5,13 -3,12.5 -2.5,-0.5 -5.5,9 -3,9.5 -9,7.5 -6,-2 -14,2.5 H 160 q -13,0 -22.5,-9.5 Q 128,1133 128,1120 128,952 275,836 468,684 676,519 682,514 711,489.5 740,465 757,452 774,439 801.5,420.5 829,402 852,393 q 23,-9 43,-9 h 1 1 q 20,0 43,9 23,9 50.5,27.5 27.5,18.5 44.5,31.5 17,13 46,37.5 29,24.5 35,29.5 208,165 401,317 54,43 100.5,115.5 46.5,72.5 46.5,131.5 z m 128,37 V 32 q 0,-66 -47,-113 -47,-47 -113,-47 H 160 Q 94,-128 47,-81 0,-34 0,32 v 1088 q 0,66 47,113 47,47 113,47 h 1472 q 66,0 113,-47 47,-47 47,-113 z"
235 d="m 1664,32 v 768 q -32,-36 -69,-66 -268,-206 -426,-338 -51,-43 -83,-67 -32,-24 -86.5,-48.5 Q 945,256 897,256 h -1 -1 Q 847,256 792.5,280.5 738,305 706,329 674,353 623,396 465,528 197,734 160,764 128,800 V 32 Q 128,19 137.5,9.5 147,0 160,0 h 1472 q 13,0 22.5,9.5 9.5,9.5 9.5,22.5 z m 0,1051 v 11 13.5 q 0,0 -0.5,13 -0.5,13 -3,12.5 -2.5,-0.5 -5.5,9 -3,9.5 -9,7.5 -6,-2 -14,2.5 H 160 q -13,0 -22.5,-9.5 Q 128,1133 128,1120 128,952 275,836 468,684 676,519 682,514 711,489.5 740,465 757,452 774,439 801.5,420.5 829,402 852,393 q 23,-9 43,-9 h 1 1 q 20,0 43,9 23,9 50.5,27.5 27.5,18.5 44.5,31.5 17,13 46,37.5 29,24.5 35,29.5 208,165 401,317 54,43 100.5,115.5 46.5,72.5 46.5,131.5 z m 128,37 V 32 q 0,-66 -47,-113 -47,-47 -113,-47 H 160 Q 94,-128 47,-81 0,-34 0,32 v 1088 q 0,66 47,113 47,47 113,47 h 1472 q 66,0 113,-47 47,-47 47,-113 z"
235 id="path2993"
236 id="path2993"
236 inkscape:connector-curvature="0"
237 inkscape:connector-curvature="0"
237 style="fill:currentColor" />
238 style="fill:currentColor" />
238 </g>
239 </g>
239 </svg>
240 </svg>
240 '''
241 '''
241
242
242 def settings_schema(self):
243 def settings_schema(self):
243 schema = EmailSettingsSchema()
244 schema = EmailSettingsSchema()
244 return schema
245 return schema
245
246
246 def send_event(self, event):
247 def send_event(self, event):
247 data = event.as_dict()
248 data = event.as_dict()
248 log.debug('got event: %r', event)
249 log.debug('got event: %r', event)
249
250
250 if isinstance(event, events.RepoPushEvent):
251 if isinstance(event, events.RepoPushEvent):
251 repo_push_handler(data, self.settings)
252 repo_push_handler(data, self.settings)
252 else:
253 else:
253 log.debug('ignoring event: %r', event)
254 log.debug('ignoring event: %r', event)
254
255
255
256
256 def repo_push_handler(data, settings):
257 def repo_push_handler(data, settings):
257 commit_num = len(data['push']['commits'])
258 commit_num = len(data['push']['commits'])
258 server_url = data['server_url']
259 server_url = data['server_url']
259
260
260 if commit_num == 1:
261 if commit_num == 1:
261 if data['push']['branches']:
262 if data['push']['branches']:
262 _subject = '[{repo_name}] {author} pushed {commit_num} commit on branches: {branches}'
263 _subject = '[{repo_name}] {author} pushed {commit_num} commit on branches: {branches}'
263 else:
264 else:
264 _subject = '[{repo_name}] {author} pushed {commit_num} commit'
265 _subject = '[{repo_name}] {author} pushed {commit_num} commit'
265 subject = _subject.format(
266 subject = _subject.format(
266 author=data['actor']['username'],
267 author=data['actor']['username'],
267 repo_name=data['repo']['repo_name'],
268 repo_name=data['repo']['repo_name'],
268 commit_num=commit_num,
269 commit_num=commit_num,
269 branches=', '.join(
270 branches=', '.join(
270 branch['name'] for branch in data['push']['branches'])
271 branch['name'] for branch in data['push']['branches'])
271 )
272 )
272 else:
273 else:
273 if data['push']['branches']:
274 if data['push']['branches']:
274 _subject = '[{repo_name}] {author} pushed {commit_num} commits on branches: {branches}'
275 _subject = '[{repo_name}] {author} pushed {commit_num} commits on branches: {branches}'
275 else:
276 else:
276 _subject = '[{repo_name}] {author} pushed {commit_num} commits'
277 _subject = '[{repo_name}] {author} pushed {commit_num} commits'
277 subject = _subject.format(
278 subject = _subject.format(
278 author=data['actor']['username'],
279 author=data['actor']['username'],
279 repo_name=data['repo']['repo_name'],
280 repo_name=data['repo']['repo_name'],
280 commit_num=commit_num,
281 commit_num=commit_num,
281 branches=', '.join(
282 branches=', '.join(
282 branch['name'] for branch in data['push']['branches']))
283 branch['name'] for branch in data['push']['branches']))
283
284
284 email_body_plaintext = repo_push_template_plaintext.render(
285 email_body_plaintext = render_with_traceback(
286 repo_push_template_plaintext,
285 data=data,
287 data=data,
286 subject=subject,
288 subject=subject,
287 instance_url=server_url)
289 instance_url=server_url)
288
290
289 email_body_html = repo_push_template_html.render(
291 email_body_html = render_with_traceback(
292 repo_push_template_html,
290 data=data,
293 data=data,
291 subject=subject,
294 subject=subject,
292 instance_url=server_url)
295 instance_url=server_url)
293
296
294 for email_address in settings['recipients']:
297 for email_address in settings['recipients']:
295 run_task(
298 run_task(
296 tasks.send_email, email_address, subject,
299 tasks.send_email, email_address, subject,
297 email_body_plaintext, email_body_html)
300 email_body_plaintext, email_body_html)
@@ -1,246 +1,247 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)
34 IntegrationTypeBase, CommitParsingDataHandler, render_with_traceback)
35
35
36 log = logging.getLogger(__name__)
36 log = logging.getLogger(__name__)
37
37
38
38
39 class HipchatSettingsSchema(colander.Schema):
39 class HipchatSettingsSchema(colander.Schema):
40 color_choices = [
40 color_choices = [
41 ('yellow', _('Yellow')),
41 ('yellow', _('Yellow')),
42 ('red', _('Red')),
42 ('red', _('Red')),
43 ('green', _('Green')),
43 ('green', _('Green')),
44 ('purple', _('Purple')),
44 ('purple', _('Purple')),
45 ('gray', _('Gray')),
45 ('gray', _('Gray')),
46 ]
46 ]
47
47
48 server_url = colander.SchemaNode(
48 server_url = colander.SchemaNode(
49 colander.String(),
49 colander.String(),
50 title=_('Hipchat server URL'),
50 title=_('Hipchat server URL'),
51 description=_('Hipchat integration url.'),
51 description=_('Hipchat integration url.'),
52 default='',
52 default='',
53 preparer=strip_whitespace,
53 preparer=strip_whitespace,
54 validator=colander.url,
54 validator=colander.url,
55 widget=deform.widget.TextInputWidget(
55 widget=deform.widget.TextInputWidget(
56 placeholder='https://?.hipchat.com/v2/room/?/notification?auth_token=?',
56 placeholder='https://?.hipchat.com/v2/room/?/notification?auth_token=?',
57 ),
57 ),
58 )
58 )
59 notify = colander.SchemaNode(
59 notify = colander.SchemaNode(
60 colander.Bool(),
60 colander.Bool(),
61 title=_('Notify'),
61 title=_('Notify'),
62 description=_('Make a notification to the users in room.'),
62 description=_('Make a notification to the users in room.'),
63 missing=False,
63 missing=False,
64 default=False,
64 default=False,
65 )
65 )
66 color = colander.SchemaNode(
66 color = colander.SchemaNode(
67 colander.String(),
67 colander.String(),
68 title=_('Color'),
68 title=_('Color'),
69 description=_('Background color of message.'),
69 description=_('Background color of message.'),
70 missing='',
70 missing='',
71 validator=colander.OneOf([x[0] for x in color_choices]),
71 validator=colander.OneOf([x[0] for x in color_choices]),
72 widget=deform.widget.Select2Widget(
72 widget=deform.widget.Select2Widget(
73 values=color_choices,
73 values=color_choices,
74 ),
74 ),
75 )
75 )
76
76
77
77
78 repo_push_template = Template('''
78 repo_push_template = Template('''
79 <b>${data['actor']['username']}</b> pushed to repo <a href="${data['repo']['url']}">${data['repo']['repo_name']}</a>:
79 <b>${data['actor']['username']}</b> pushed to repo <a href="${data['repo']['url']}">${data['repo']['repo_name']}</a>:
80 <br>
80 <br>
81 <ul>
81 <ul>
82 %for branch, branch_commits in branches_commits.items():
82 %for branch, branch_commits in branches_commits.items():
83 <li>
83 <li>
84 <a href="${branch_commits['branch']['url']}">branch: ${branch_commits['branch']['name']}</a>
84 <a href="${branch_commits['branch']['url']}">branch: ${branch_commits['branch']['name']}</a>
85 <ul>
85 <ul>
86 %for commit in branch_commits['commits']:
86 %for commit in branch_commits['commits']:
87 <li><a href="${commit['url']}">${commit['short_id']}</a> - ${commit['message_html']}</li>
87 <li><a href="${commit['url']}">${commit['short_id']}</a> - ${commit['message_html']}</li>
88 %endfor
88 %endfor
89 </ul>
89 </ul>
90 </li>
90 </li>
91 %endfor
91 %endfor
92 ''')
92 ''')
93
93
94
94
95 class HipchatIntegrationType(IntegrationTypeBase, CommitParsingDataHandler):
95 class HipchatIntegrationType(IntegrationTypeBase, CommitParsingDataHandler):
96 key = 'hipchat'
96 key = 'hipchat'
97 display_name = _('Hipchat')
97 display_name = _('Hipchat')
98 description = _('Send events such as repo pushes and pull requests to '
98 description = _('Send events such as repo pushes and pull requests to '
99 'your hipchat channel.')
99 'your hipchat channel.')
100
100
101 @classmethod
101 @classmethod
102 def icon(cls):
102 def icon(cls):
103 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>'''
103 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>'''
104
104
105 valid_events = [
105 valid_events = [
106 events.PullRequestCloseEvent,
106 events.PullRequestCloseEvent,
107 events.PullRequestMergeEvent,
107 events.PullRequestMergeEvent,
108 events.PullRequestUpdateEvent,
108 events.PullRequestUpdateEvent,
109 events.PullRequestCommentEvent,
109 events.PullRequestCommentEvent,
110 events.PullRequestReviewEvent,
110 events.PullRequestReviewEvent,
111 events.PullRequestCreateEvent,
111 events.PullRequestCreateEvent,
112 events.RepoPushEvent,
112 events.RepoPushEvent,
113 events.RepoCreateEvent,
113 events.RepoCreateEvent,
114 ]
114 ]
115
115
116 def send_event(self, event):
116 def send_event(self, event):
117 if event.__class__ not in self.valid_events:
117 if event.__class__ not in self.valid_events:
118 log.debug('event not valid: %r' % event)
118 log.debug('event not valid: %r' % event)
119 return
119 return
120
120
121 if event.name not in self.settings['events']:
121 if event.name not in self.settings['events']:
122 log.debug('event ignored: %r' % event)
122 log.debug('event ignored: %r' % event)
123 return
123 return
124
124
125 data = event.as_dict()
125 data = event.as_dict()
126
126
127 text = '<b>%s<b> caused a <b>%s</b> event' % (
127 text = '<b>%s<b> caused a <b>%s</b> event' % (
128 data['actor']['username'], event.name)
128 data['actor']['username'], event.name)
129
129
130 log.debug('handling hipchat event for %s' % event.name)
130 log.debug('handling hipchat event for %s' % event.name)
131
131
132 if isinstance(event, events.PullRequestCommentEvent):
132 if isinstance(event, events.PullRequestCommentEvent):
133 text = self.format_pull_request_comment_event(event, data)
133 text = self.format_pull_request_comment_event(event, data)
134 elif isinstance(event, events.PullRequestReviewEvent):
134 elif isinstance(event, events.PullRequestReviewEvent):
135 text = self.format_pull_request_review_event(event, data)
135 text = self.format_pull_request_review_event(event, data)
136 elif isinstance(event, events.PullRequestEvent):
136 elif isinstance(event, events.PullRequestEvent):
137 text = self.format_pull_request_event(event, data)
137 text = self.format_pull_request_event(event, data)
138 elif isinstance(event, events.RepoPushEvent):
138 elif isinstance(event, events.RepoPushEvent):
139 text = self.format_repo_push_event(data)
139 text = self.format_repo_push_event(data)
140 elif isinstance(event, events.RepoCreateEvent):
140 elif isinstance(event, events.RepoCreateEvent):
141 text = self.format_repo_create_event(data)
141 text = self.format_repo_create_event(data)
142 else:
142 else:
143 log.error('unhandled event type: %r' % event)
143 log.error('unhandled event type: %r' % event)
144
144
145 run_task(post_text_to_hipchat, self.settings, text)
145 run_task(post_text_to_hipchat, self.settings, text)
146
146
147 def settings_schema(self):
147 def settings_schema(self):
148 schema = HipchatSettingsSchema()
148 schema = HipchatSettingsSchema()
149 schema.add(colander.SchemaNode(
149 schema.add(colander.SchemaNode(
150 colander.Set(),
150 colander.Set(),
151 widget=deform.widget.CheckboxChoiceWidget(
151 widget=deform.widget.CheckboxChoiceWidget(
152 values=sorted(
152 values=sorted(
153 [(e.name, e.display_name) for e in self.valid_events]
153 [(e.name, e.display_name) for e in self.valid_events]
154 )
154 )
155 ),
155 ),
156 description="Events activated for this integration",
156 description="Events activated for this integration",
157 name='events'
157 name='events'
158 ))
158 ))
159
159
160 return schema
160 return schema
161
161
162 def format_pull_request_comment_event(self, event, data):
162 def format_pull_request_comment_event(self, event, data):
163 comment_text = data['comment']['text']
163 comment_text = data['comment']['text']
164 if len(comment_text) > 200:
164 if len(comment_text) > 200:
165 comment_text = '{comment_text}<a href="{comment_url}">...<a/>'.format(
165 comment_text = '{comment_text}<a href="{comment_url}">...<a/>'.format(
166 comment_text=h.html_escape(comment_text[:200]),
166 comment_text=h.html_escape(comment_text[:200]),
167 comment_url=data['comment']['url'],
167 comment_url=data['comment']['url'],
168 )
168 )
169
169
170 comment_status = ''
170 comment_status = ''
171 if data['comment']['status']:
171 if data['comment']['status']:
172 comment_status = '[{}]: '.format(data['comment']['status'])
172 comment_status = '[{}]: '.format(data['comment']['status'])
173
173
174 return (textwrap.dedent(
174 return (textwrap.dedent(
175 '''
175 '''
176 {user} commented on pull request <a href="{pr_url}">{number}</a> - {pr_title}:
176 {user} commented on pull request <a href="{pr_url}">{number}</a> - {pr_title}:
177 >>> {comment_status}{comment_text}
177 >>> {comment_status}{comment_text}
178 ''').format(
178 ''').format(
179 comment_status=comment_status,
179 comment_status=comment_status,
180 user=data['actor']['username'],
180 user=data['actor']['username'],
181 number=data['pullrequest']['pull_request_id'],
181 number=data['pullrequest']['pull_request_id'],
182 pr_url=data['pullrequest']['url'],
182 pr_url=data['pullrequest']['url'],
183 pr_status=data['pullrequest']['status'],
183 pr_status=data['pullrequest']['status'],
184 pr_title=h.html_escape(data['pullrequest']['title']),
184 pr_title=h.html_escape(data['pullrequest']['title']),
185 comment_text=h.html_escape(comment_text)
185 comment_text=h.html_escape(comment_text)
186 )
186 )
187 )
187 )
188
188
189 def format_pull_request_review_event(self, event, data):
189 def format_pull_request_review_event(self, event, data):
190 return (textwrap.dedent(
190 return (textwrap.dedent(
191 '''
191 '''
192 Status changed to {pr_status} for pull request <a href="{pr_url}">#{number}</a> - {pr_title}
192 Status changed to {pr_status} for pull request <a href="{pr_url}">#{number}</a> - {pr_title}
193 ''').format(
193 ''').format(
194 user=data['actor']['username'],
194 user=data['actor']['username'],
195 number=data['pullrequest']['pull_request_id'],
195 number=data['pullrequest']['pull_request_id'],
196 pr_url=data['pullrequest']['url'],
196 pr_url=data['pullrequest']['url'],
197 pr_status=data['pullrequest']['status'],
197 pr_status=data['pullrequest']['status'],
198 pr_title=h.html_escape(data['pullrequest']['title']),
198 pr_title=h.html_escape(data['pullrequest']['title']),
199 )
199 )
200 )
200 )
201
201
202 def format_pull_request_event(self, event, data):
202 def format_pull_request_event(self, event, data):
203 action = {
203 action = {
204 events.PullRequestCloseEvent: 'closed',
204 events.PullRequestCloseEvent: 'closed',
205 events.PullRequestMergeEvent: 'merged',
205 events.PullRequestMergeEvent: 'merged',
206 events.PullRequestUpdateEvent: 'updated',
206 events.PullRequestUpdateEvent: 'updated',
207 events.PullRequestCreateEvent: 'created',
207 events.PullRequestCreateEvent: 'created',
208 }.get(event.__class__, str(event.__class__))
208 }.get(event.__class__, str(event.__class__))
209
209
210 return ('Pull request <a href="{url}">#{number}</a> - {title} '
210 return ('Pull request <a href="{url}">#{number}</a> - {title} '
211 '{action} by <b>{user}</b>').format(
211 '{action} by <b>{user}</b>').format(
212 user=data['actor']['username'],
212 user=data['actor']['username'],
213 number=data['pullrequest']['pull_request_id'],
213 number=data['pullrequest']['pull_request_id'],
214 url=data['pullrequest']['url'],
214 url=data['pullrequest']['url'],
215 title=h.html_escape(data['pullrequest']['title']),
215 title=h.html_escape(data['pullrequest']['title']),
216 action=action
216 action=action
217 )
217 )
218
218
219 def format_repo_push_event(self, data):
219 def format_repo_push_event(self, data):
220 branches_commits = self.aggregate_branch_data(
220 branches_commits = self.aggregate_branch_data(
221 data['push']['branches'], data['push']['commits'])
221 data['push']['branches'], data['push']['commits'])
222
222
223 result = repo_push_template.render(
223 result = render_with_traceback(
224 repo_push_template,
224 data=data,
225 data=data,
225 branches_commits=branches_commits,
226 branches_commits=branches_commits,
226 )
227 )
227 return result
228 return result
228
229
229 def format_repo_create_event(self, data):
230 def format_repo_create_event(self, data):
230 return '<a href="{}">{}</a> ({}) repository created by <b>{}</b>'.format(
231 return '<a href="{}">{}</a> ({}) repository created by <b>{}</b>'.format(
231 data['repo']['url'],
232 data['repo']['url'],
232 h.html_escape(data['repo']['repo_name']),
233 h.html_escape(data['repo']['repo_name']),
233 data['repo']['repo_type'],
234 data['repo']['repo_type'],
234 data['actor']['username'],
235 data['actor']['username'],
235 )
236 )
236
237
237
238
238 @async_task(ignore_result=True, base=RequestContextTask)
239 @async_task(ignore_result=True, base=RequestContextTask)
239 def post_text_to_hipchat(settings, text):
240 def post_text_to_hipchat(settings, text):
240 log.debug('sending %s to hipchat %s' % (text, settings['server_url']))
241 log.debug('sending %s to hipchat %s' % (text, settings['server_url']))
241 resp = requests.post(settings['server_url'], json={
242 resp = requests.post(settings['server_url'], json={
242 "message": text,
243 "message": text,
243 "color": settings.get('color', 'yellow'),
244 "color": settings.get('color', 'yellow'),
244 "notify": settings.get('notify', False),
245 "notify": settings.get('notify', False),
245 })
246 })
246 resp.raise_for_status() # raise exception on a failed request
247 resp.raise_for_status() # raise exception on a failed request
@@ -1,329 +1,342 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)
38 IntegrationTypeBase, CommitParsingDataHandler, render_with_traceback)
39
39
40 log = logging.getLogger(__name__)
40 log = logging.getLogger(__name__)
41
41
42
42
43 class SlackSettingsSchema(colander.Schema):
43 class SlackSettingsSchema(colander.Schema):
44 service = colander.SchemaNode(
44 service = colander.SchemaNode(
45 colander.String(),
45 colander.String(),
46 title=_('Slack service URL'),
46 title=_('Slack service URL'),
47 description=h.literal(_(
47 description=h.literal(_(
48 'This can be setup at the '
48 'This can be setup at the '
49 '<a href="https://my.slack.com/services/new/incoming-webhook/">'
49 '<a href="https://my.slack.com/services/new/incoming-webhook/">'
50 'slack app manager</a>')),
50 'slack app manager</a>')),
51 default='',
51 default='',
52 preparer=strip_whitespace,
52 preparer=strip_whitespace,
53 validator=colander.url,
53 validator=colander.url,
54 widget=deform.widget.TextInputWidget(
54 widget=deform.widget.TextInputWidget(
55 placeholder='https://hooks.slack.com/services/...',
55 placeholder='https://hooks.slack.com/services/...',
56 ),
56 ),
57 )
57 )
58 username = colander.SchemaNode(
58 username = colander.SchemaNode(
59 colander.String(),
59 colander.String(),
60 title=_('Username'),
60 title=_('Username'),
61 description=_('Username to show notifications coming from.'),
61 description=_('Username to show notifications coming from.'),
62 missing='Rhodecode',
62 missing='Rhodecode',
63 preparer=strip_whitespace,
63 preparer=strip_whitespace,
64 widget=deform.widget.TextInputWidget(
64 widget=deform.widget.TextInputWidget(
65 placeholder='Rhodecode'
65 placeholder='Rhodecode'
66 ),
66 ),
67 )
67 )
68 channel = colander.SchemaNode(
68 channel = colander.SchemaNode(
69 colander.String(),
69 colander.String(),
70 title=_('Channel'),
70 title=_('Channel'),
71 description=_('Channel to send notifications to.'),
71 description=_('Channel to send notifications to.'),
72 missing='',
72 missing='',
73 preparer=strip_whitespace,
73 preparer=strip_whitespace,
74 widget=deform.widget.TextInputWidget(
74 widget=deform.widget.TextInputWidget(
75 placeholder='#general'
75 placeholder='#general'
76 ),
76 ),
77 )
77 )
78 icon_emoji = colander.SchemaNode(
78 icon_emoji = colander.SchemaNode(
79 colander.String(),
79 colander.String(),
80 title=_('Emoji'),
80 title=_('Emoji'),
81 description=_('Emoji to use eg. :studio_microphone:'),
81 description=_('Emoji to use eg. :studio_microphone:'),
82 missing='',
82 missing='',
83 preparer=strip_whitespace,
83 preparer=strip_whitespace,
84 widget=deform.widget.TextInputWidget(
84 widget=deform.widget.TextInputWidget(
85 placeholder=':studio_microphone:'
85 placeholder=':studio_microphone:'
86 ),
86 ),
87 )
87 )
88
88
89
89
90 class SlackIntegrationType(IntegrationTypeBase, CommitParsingDataHandler):
90 class SlackIntegrationType(IntegrationTypeBase, CommitParsingDataHandler):
91 key = 'slack'
91 key = 'slack'
92 display_name = _('Slack')
92 display_name = _('Slack')
93 description = _('Send events such as repo pushes and pull requests to '
93 description = _('Send events such as repo pushes and pull requests to '
94 'your slack channel.')
94 'your slack channel.')
95
95
96 @classmethod
96 @classmethod
97 def icon(cls):
97 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>'''
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
99
100 valid_events = [
100 valid_events = [
101 events.PullRequestCloseEvent,
101 events.PullRequestCloseEvent,
102 events.PullRequestMergeEvent,
102 events.PullRequestMergeEvent,
103 events.PullRequestUpdateEvent,
103 events.PullRequestUpdateEvent,
104 events.PullRequestCommentEvent,
104 events.PullRequestCommentEvent,
105 events.PullRequestReviewEvent,
105 events.PullRequestReviewEvent,
106 events.PullRequestCreateEvent,
106 events.PullRequestCreateEvent,
107 events.RepoPushEvent,
107 events.RepoPushEvent,
108 events.RepoCreateEvent,
108 events.RepoCreateEvent,
109 ]
109 ]
110
110
111 def send_event(self, event):
111 def send_event(self, event):
112 if event.__class__ not in self.valid_events:
112 if event.__class__ not in self.valid_events:
113 log.debug('event not valid: %r' % event)
113 log.debug('event not valid: %r' % event)
114 return
114 return
115
115
116 if event.name not in self.settings['events']:
116 if event.name not in self.settings['events']:
117 log.debug('event ignored: %r' % event)
117 log.debug('event ignored: %r' % event)
118 return
118 return
119
119
120 data = event.as_dict()
120 data = event.as_dict()
121
121
122 # defaults
122 # defaults
123 title = '*%s* caused a *%s* event' % (
123 title = '*%s* caused a *%s* event' % (
124 data['actor']['username'], event.name)
124 data['actor']['username'], event.name)
125 text = '*%s* caused a *%s* event' % (
125 text = '*%s* caused a *%s* event' % (
126 data['actor']['username'], event.name)
126 data['actor']['username'], event.name)
127 fields = None
127 fields = None
128 overrides = None
128 overrides = None
129
129
130 log.debug('handling slack event for %s' % event.name)
130 log.debug('handling slack event for %s' % event.name)
131
131
132 if isinstance(event, events.PullRequestCommentEvent):
132 if isinstance(event, events.PullRequestCommentEvent):
133 (title, text, fields, overrides) \
133 (title, text, fields, overrides) \
134 = self.format_pull_request_comment_event(event, data)
134 = self.format_pull_request_comment_event(event, data)
135 elif isinstance(event, events.PullRequestReviewEvent):
135 elif isinstance(event, events.PullRequestReviewEvent):
136 title, text = self.format_pull_request_review_event(event, data)
136 title, text = self.format_pull_request_review_event(event, data)
137 elif isinstance(event, events.PullRequestEvent):
137 elif isinstance(event, events.PullRequestEvent):
138 title, text = self.format_pull_request_event(event, data)
138 title, text = self.format_pull_request_event(event, data)
139 elif isinstance(event, events.RepoPushEvent):
139 elif isinstance(event, events.RepoPushEvent):
140 title, text = self.format_repo_push_event(data)
140 title, text = self.format_repo_push_event(data)
141 elif isinstance(event, events.RepoCreateEvent):
141 elif isinstance(event, events.RepoCreateEvent):
142 title, text = self.format_repo_create_event(data)
142 title, text = self.format_repo_create_event(data)
143 else:
143 else:
144 log.error('unhandled event type: %r' % event)
144 log.error('unhandled event type: %r' % event)
145
145
146 run_task(post_text_to_slack, self.settings, title, text, fields, overrides)
146 run_task(post_text_to_slack, self.settings, title, text, fields, overrides)
147
147
148 def settings_schema(self):
148 def settings_schema(self):
149 schema = SlackSettingsSchema()
149 schema = SlackSettingsSchema()
150 schema.add(colander.SchemaNode(
150 schema.add(colander.SchemaNode(
151 colander.Set(),
151 colander.Set(),
152 widget=deform.widget.CheckboxChoiceWidget(
152 widget=deform.widget.CheckboxChoiceWidget(
153 values=sorted(
153 values=sorted(
154 [(e.name, e.display_name) for e in self.valid_events]
154 [(e.name, e.display_name) for e in self.valid_events]
155 )
155 )
156 ),
156 ),
157 description="Events activated for this integration",
157 description="Events activated for this integration",
158 name='events'
158 name='events'
159 ))
159 ))
160
160
161 return schema
161 return schema
162
162
163 def format_pull_request_comment_event(self, event, data):
163 def format_pull_request_comment_event(self, event, data):
164 comment_text = data['comment']['text']
164 comment_text = data['comment']['text']
165 if len(comment_text) > 200:
165 if len(comment_text) > 200:
166 comment_text = '<{comment_url}|{comment_text}...>'.format(
166 comment_text = '<{comment_url}|{comment_text}...>'.format(
167 comment_text=comment_text[:200],
167 comment_text=comment_text[:200],
168 comment_url=data['comment']['url'],
168 comment_url=data['comment']['url'],
169 )
169 )
170
170
171 fields = None
171 fields = None
172 overrides = None
172 overrides = None
173 status_text = None
173 status_text = None
174
174
175 if data['comment']['status']:
175 if data['comment']['status']:
176 status_color = {
176 status_color = {
177 'approved': '#0ac878',
177 'approved': '#0ac878',
178 'rejected': '#e85e4d'}.get(data['comment']['status'])
178 'rejected': '#e85e4d'}.get(data['comment']['status'])
179
179
180 if status_color:
180 if status_color:
181 overrides = {"color": status_color}
181 overrides = {"color": status_color}
182
182
183 status_text = data['comment']['status']
183 status_text = data['comment']['status']
184
184
185 if data['comment']['file']:
185 if data['comment']['file']:
186 fields = [
186 fields = [
187 {
187 {
188 "title": "file",
188 "title": "file",
189 "value": data['comment']['file']
189 "value": data['comment']['file']
190 },
190 },
191 {
191 {
192 "title": "line",
192 "title": "line",
193 "value": data['comment']['line']
193 "value": data['comment']['line']
194 }
194 }
195 ]
195 ]
196
196
197 title = Template(textwrap.dedent(r'''
197 template = Template(textwrap.dedent(r'''
198 *${data['actor']['username']}* left ${data['comment']['type']} on pull request <${data['pullrequest']['url']}|#${data['pullrequest']['pull_request_id']}>:
198 *${data['actor']['username']}* left ${data['comment']['type']} on pull request <${data['pullrequest']['url']}|#${data['pullrequest']['pull_request_id']}>:
199 ''')).render(data=data, comment=event.comment)
199 '''))
200 title = render_with_traceback(
201 template, data=data, comment=event.comment)
200
202
201 text = Template(textwrap.dedent(r'''
203 template = Template(textwrap.dedent(r'''
202 *pull request title*: ${pr_title}
204 *pull request title*: ${pr_title}
203 % if status_text:
205 % if status_text:
204 *submitted status*: `${status_text}`
206 *submitted status*: `${status_text}`
205 % endif
207 % endif
206 >>> ${comment_text}
208 >>> ${comment_text}
207 ''')).render(comment_text=comment_text,
209 '''))
208 pr_title=data['pullrequest']['title'],
210 text = render_with_traceback(
209 status_text=status_text)
211 template,
212 comment_text=comment_text,
213 pr_title=data['pullrequest']['title'],
214 status_text=status_text)
210
215
211 return title, text, fields, overrides
216 return title, text, fields, overrides
212
217
213 def format_pull_request_review_event(self, event, data):
218 def format_pull_request_review_event(self, event, data):
214 title = Template(textwrap.dedent(r'''
219 template = Template(textwrap.dedent(r'''
215 *${data['actor']['username']}* changed status of pull request <${data['pullrequest']['url']}|#${data['pullrequest']['pull_request_id']} to `${data['pullrequest']['status']}`>:
220 *${data['actor']['username']}* changed status of pull request <${data['pullrequest']['url']}|#${data['pullrequest']['pull_request_id']} to `${data['pullrequest']['status']}`>:
216 ''')).render(data=data)
221 '''))
222 title = render_with_traceback(template, data=data)
217
223
218 text = Template(textwrap.dedent(r'''
224 template = Template(textwrap.dedent(r'''
219 *pull request title*: ${pr_title}
225 *pull request title*: ${pr_title}
220 ''')).render(
226 '''))
221 pr_title=data['pullrequest']['title'],
227 text = render_with_traceback(
222 )
228 template,
229 pr_title=data['pullrequest']['title'])
223
230
224 return title, text
231 return title, text
225
232
226 def format_pull_request_event(self, event, data):
233 def format_pull_request_event(self, event, data):
227 action = {
234 action = {
228 events.PullRequestCloseEvent: 'closed',
235 events.PullRequestCloseEvent: 'closed',
229 events.PullRequestMergeEvent: 'merged',
236 events.PullRequestMergeEvent: 'merged',
230 events.PullRequestUpdateEvent: 'updated',
237 events.PullRequestUpdateEvent: 'updated',
231 events.PullRequestCreateEvent: 'created',
238 events.PullRequestCreateEvent: 'created',
232 }.get(event.__class__, str(event.__class__))
239 }.get(event.__class__, str(event.__class__))
233
240
234 title = Template(textwrap.dedent(r'''
241 template = Template(textwrap.dedent(r'''
235 *${data['actor']['username']}* `${action}` pull request <${data['pullrequest']['url']}|#${data['pullrequest']['pull_request_id']}>:
242 *${data['actor']['username']}* `${action}` pull request <${data['pullrequest']['url']}|#${data['pullrequest']['pull_request_id']}>:
236 ''')).render(data=data, action=action)
243 '''))
244 title = render_with_traceback(template, data=data, action=action)
237
245
238 text = Template(textwrap.dedent(r'''
246 template = Template(textwrap.dedent(r'''
239 *pull request title*: ${pr_title}
247 *pull request title*: ${pr_title}
240 %if data['pullrequest']['commits']:
248 %if data['pullrequest']['commits']:
241 *commits*: ${len(data['pullrequest']['commits'])}
249 *commits*: ${len(data['pullrequest']['commits'])}
242 %endif
250 %endif
243 ''')).render(
251 '''))
252 text = render_with_traceback(
253 template,
244 pr_title=data['pullrequest']['title'],
254 pr_title=data['pullrequest']['title'],
245 data=data
255 data=data)
246 )
247
256
248 return title, text
257 return title, text
249
258
250 def format_repo_push_event(self, data):
259 def format_repo_push_event(self, data):
251
260
252 branches_commits = self.aggregate_branch_data(
261 branches_commits = self.aggregate_branch_data(
253 data['push']['branches'], data['push']['commits'])
262 data['push']['branches'], data['push']['commits'])
254
263
255 title = Template(r'''
264 template = Template(r'''
256 *${data['actor']['username']}* pushed to repo <${data['repo']['url']}|${data['repo']['repo_name']}>:
265 *${data['actor']['username']}* pushed to repo <${data['repo']['url']}|${data['repo']['repo_name']}>:
257 ''').render(data=data)
266 ''')
267 title = render_with_traceback(template, data=data)
258
268
259 repo_push_template = Template(textwrap.dedent(r'''
269 repo_push_template = Template(textwrap.dedent(r'''
260 %for branch, branch_commits in branches_commits.items():
270 %for branch, branch_commits in branches_commits.items():
261 ${len(branch_commits['commits'])} ${'commit' if len(branch_commits['commits']) == 1 else 'commits'} on branch: <${branch_commits['branch']['url']}|${branch_commits['branch']['name']}>
271 ${len(branch_commits['commits'])} ${'commit' if len(branch_commits['commits']) == 1 else 'commits'} on branch: <${branch_commits['branch']['url']}|${branch_commits['branch']['name']}>
262 %for commit in branch_commits['commits']:
272 %for commit in branch_commits['commits']:
263 `<${commit['url']}|${commit['short_id']}>` - ${commit['message_html']|html_to_slack_links}
273 `<${commit['url']}|${commit['short_id']}>` - ${commit['message_html']|html_to_slack_links}
264 %endfor
274 %endfor
265 %endfor
275 %endfor
266 '''))
276 '''))
267
277
268 text = repo_push_template.render(
278 text = render_with_traceback(
279 repo_push_template,
269 data=data,
280 data=data,
270 branches_commits=branches_commits,
281 branches_commits=branches_commits,
271 html_to_slack_links=html_to_slack_links,
282 html_to_slack_links=html_to_slack_links,
272 )
283 )
273
284
274 return title, text
285 return title, text
275
286
276 def format_repo_create_event(self, data):
287 def format_repo_create_event(self, data):
277 title = Template(r'''
288 template = Template(r'''
278 *${data['actor']['username']}* created new repository ${data['repo']['repo_name']}:
289 *${data['actor']['username']}* created new repository ${data['repo']['repo_name']}:
279 ''').render(data=data)
290 ''')
291 title = render_with_traceback(template, data=data)
280
292
281 text = Template(textwrap.dedent(r'''
293 template = Template(textwrap.dedent(r'''
282 repo_url: ${data['repo']['url']}
294 repo_url: ${data['repo']['url']}
283 repo_type: ${data['repo']['repo_type']}
295 repo_type: ${data['repo']['repo_type']}
284 ''')).render(data=data)
296 '''))
297 text = render_with_traceback(template, data=data)
285
298
286 return title, text
299 return title, text
287
300
288
301
289 def html_to_slack_links(message):
302 def html_to_slack_links(message):
290 return re.compile(r'<a .*?href=["\'](.+?)".*?>(.+?)</a>').sub(
303 return re.compile(r'<a .*?href=["\'](.+?)".*?>(.+?)</a>').sub(
291 r'<\1|\2>', message)
304 r'<\1|\2>', message)
292
305
293
306
294 @async_task(ignore_result=True, base=RequestContextTask)
307 @async_task(ignore_result=True, base=RequestContextTask)
295 def post_text_to_slack(settings, title, text, fields=None, overrides=None):
308 def post_text_to_slack(settings, title, text, fields=None, overrides=None):
296 log.debug('sending %s (%s) to slack %s' % (
309 log.debug('sending %s (%s) to slack %s' % (
297 title, text, settings['service']))
310 title, text, settings['service']))
298
311
299 fields = fields or []
312 fields = fields or []
300 overrides = overrides or {}
313 overrides = overrides or {}
301
314
302 message_data = {
315 message_data = {
303 "fallback": text,
316 "fallback": text,
304 "color": "#427cc9",
317 "color": "#427cc9",
305 "pretext": title,
318 "pretext": title,
306 #"author_name": "Bobby Tables",
319 #"author_name": "Bobby Tables",
307 #"author_link": "http://flickr.com/bobby/",
320 #"author_link": "http://flickr.com/bobby/",
308 #"author_icon": "http://flickr.com/icons/bobby.jpg",
321 #"author_icon": "http://flickr.com/icons/bobby.jpg",
309 #"title": "Slack API Documentation",
322 #"title": "Slack API Documentation",
310 #"title_link": "https://api.slack.com/",
323 #"title_link": "https://api.slack.com/",
311 "text": text,
324 "text": text,
312 "fields": fields,
325 "fields": fields,
313 #"image_url": "http://my-website.com/path/to/image.jpg",
326 #"image_url": "http://my-website.com/path/to/image.jpg",
314 #"thumb_url": "http://example.com/path/to/thumb.png",
327 #"thumb_url": "http://example.com/path/to/thumb.png",
315 "footer": "RhodeCode",
328 "footer": "RhodeCode",
316 #"footer_icon": "",
329 #"footer_icon": "",
317 "ts": time.time(),
330 "ts": time.time(),
318 "mrkdwn_in": ["pretext", "text"]
331 "mrkdwn_in": ["pretext", "text"]
319 }
332 }
320 message_data.update(overrides)
333 message_data.update(overrides)
321 json_message = {
334 json_message = {
322 "icon_emoji": settings.get('icon_emoji', ':studio_microphone:'),
335 "icon_emoji": settings.get('icon_emoji', ':studio_microphone:'),
323 "channel": settings.get('channel', ''),
336 "channel": settings.get('channel', ''),
324 "username": settings.get('username', 'Rhodecode'),
337 "username": settings.get('username', 'Rhodecode'),
325 "attachments": [message_data]
338 "attachments": [message_data]
326 }
339 }
327
340
328 resp = requests.post(settings['service'], json=json_message)
341 resp = requests.post(settings['service'], json=json_message)
329 resp.raise_for_status() # raise exception on a failed request
342 resp.raise_for_status() # raise exception on a failed request
General Comments 0
You need to be logged in to leave comments. Login now