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