##// END OF EJS Templates
integrations: show branches/commits separately when posting push...
dan -
r776:f73a9bfb default
parent child Browse files
Show More
@@ -1,243 +1,255 b''
1 # -*- coding: utf-8 -*-
1 # -*- coding: utf-8 -*-
2
2
3 # Copyright (C) 2012-2016 RhodeCode GmbH
3 # Copyright (C) 2012-2016 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 re
23 import re
24 import logging
24 import logging
25 import requests
25 import requests
26 import colander
26 import colander
27 import textwrap
27 import textwrap
28 from celery.task import task
28 from celery.task import task
29 from mako.template import Template
29 from mako.template import Template
30
30
31 from rhodecode import events
31 from rhodecode import events
32 from rhodecode.translation import _
32 from rhodecode.translation import _
33 from rhodecode.lib import helpers as h
33 from rhodecode.lib import helpers as h
34 from rhodecode.lib.celerylib import run_task
34 from rhodecode.lib.celerylib import run_task
35 from rhodecode.lib.colander_utils import strip_whitespace
35 from rhodecode.lib.colander_utils import strip_whitespace
36 from rhodecode.integrations.types.base import IntegrationTypeBase
36 from rhodecode.integrations.types.base import IntegrationTypeBase
37
37
38 log = logging.getLogger(__name__)
38 log = logging.getLogger(__name__)
39
39
40
40
41 class HipchatSettingsSchema(colander.Schema):
41 class HipchatSettingsSchema(colander.Schema):
42 color_choices = [
42 color_choices = [
43 ('yellow', _('Yellow')),
43 ('yellow', _('Yellow')),
44 ('red', _('Red')),
44 ('red', _('Red')),
45 ('green', _('Green')),
45 ('green', _('Green')),
46 ('purple', _('Purple')),
46 ('purple', _('Purple')),
47 ('gray', _('Gray')),
47 ('gray', _('Gray')),
48 ]
48 ]
49
49
50 server_url = colander.SchemaNode(
50 server_url = colander.SchemaNode(
51 colander.String(),
51 colander.String(),
52 title=_('Hipchat server URL'),
52 title=_('Hipchat server URL'),
53 description=_('Hipchat integration url.'),
53 description=_('Hipchat integration url.'),
54 default='',
54 default='',
55 preparer=strip_whitespace,
55 preparer=strip_whitespace,
56 validator=colander.url,
56 validator=colander.url,
57 widget=deform.widget.TextInputWidget(
57 widget=deform.widget.TextInputWidget(
58 placeholder='https://?.hipchat.com/v2/room/?/notification?auth_token=?',
58 placeholder='https://?.hipchat.com/v2/room/?/notification?auth_token=?',
59 ),
59 ),
60 )
60 )
61 notify = colander.SchemaNode(
61 notify = colander.SchemaNode(
62 colander.Bool(),
62 colander.Bool(),
63 title=_('Notify'),
63 title=_('Notify'),
64 description=_('Make a notification to the users in room.'),
64 description=_('Make a notification to the users in room.'),
65 missing=False,
65 missing=False,
66 default=False,
66 default=False,
67 )
67 )
68 color = colander.SchemaNode(
68 color = colander.SchemaNode(
69 colander.String(),
69 colander.String(),
70 title=_('Color'),
70 title=_('Color'),
71 description=_('Background color of message.'),
71 description=_('Background color of message.'),
72 missing='',
72 missing='',
73 validator=colander.OneOf([x[0] for x in color_choices]),
73 validator=colander.OneOf([x[0] for x in color_choices]),
74 widget=deform.widget.Select2Widget(
74 widget=deform.widget.Select2Widget(
75 values=color_choices,
75 values=color_choices,
76 ),
76 ),
77 )
77 )
78
78
79
79
80 repo_push_template = Template('''
80 repo_push_template = Template('''
81 <b>${data['actor']['username']}</b> pushed to
81 <b>${data['actor']['username']}</b> pushed to repo <a href="${data['repo']['url']}">${data['repo']['repo_name']}</a>:
82 %if data['push']['branches']:
83 ${len(data['push']['branches']) > 1 and 'branches' or 'branch'}
84 ${', '.join('<a href="%s">%s</a>' % (branch['url'], branch['name']) for branch in data['push']['branches'])}
85 %else:
86 unknown branch
87 %endif
88 in <a href="${data['repo']['url']}">${data['repo']['repo_name']}</a>
89 <br>
82 <br>
90 <ul>
83 <ul>
91 %for commit in data['push']['commits']:
84 %for branch, branch_commits in branches_commits.items():
92 <li>
85 <li>
93 <a href="${commit['url']}">${commit['short_id']}</a> - ${commit['message_html']}
86 <a href="${branch_commits['branch']['url']}">branch: ${branch_commits['branch']['name']}</a>
87 <ul>
88 %for commit in branch_commits['commits']:
89 <li><a href="${commit['url']}">${commit['short_id']}</a> - ${commit['message_html']}</li>
90 %endfor
91 </ul>
94 </li>
92 </li>
95 %endfor
93 %endfor
96 </ul>
97 ''')
94 ''')
98
95
99
96
100 class HipchatIntegrationType(IntegrationTypeBase):
97 class HipchatIntegrationType(IntegrationTypeBase):
101 key = 'hipchat'
98 key = 'hipchat'
102 display_name = _('Hipchat')
99 display_name = _('Hipchat')
103 description = _('Send events such as repo pushes and pull requests to '
100 description = _('Send events such as repo pushes and pull requests to '
104 'your hipchat channel.')
101 'your hipchat channel.')
105 icon = '''<?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>'''
102 icon = '''<?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>'''
106 valid_events = [
103 valid_events = [
107 events.PullRequestCloseEvent,
104 events.PullRequestCloseEvent,
108 events.PullRequestMergeEvent,
105 events.PullRequestMergeEvent,
109 events.PullRequestUpdateEvent,
106 events.PullRequestUpdateEvent,
110 events.PullRequestCommentEvent,
107 events.PullRequestCommentEvent,
111 events.PullRequestReviewEvent,
108 events.PullRequestReviewEvent,
112 events.PullRequestCreateEvent,
109 events.PullRequestCreateEvent,
113 events.RepoPushEvent,
110 events.RepoPushEvent,
114 events.RepoCreateEvent,
111 events.RepoCreateEvent,
115 ]
112 ]
116
113
117 def send_event(self, event):
114 def send_event(self, event):
118 if event.__class__ not in self.valid_events:
115 if event.__class__ not in self.valid_events:
119 log.debug('event not valid: %r' % event)
116 log.debug('event not valid: %r' % event)
120 return
117 return
121
118
122 if event.name not in self.settings['events']:
119 if event.name not in self.settings['events']:
123 log.debug('event ignored: %r' % event)
120 log.debug('event ignored: %r' % event)
124 return
121 return
125
122
126 data = event.as_dict()
123 data = event.as_dict()
127
124
128 text = '<b>%s<b> caused a <b>%s</b> event' % (
125 text = '<b>%s<b> caused a <b>%s</b> event' % (
129 data['actor']['username'], event.name)
126 data['actor']['username'], event.name)
130
127
131 log.debug('handling hipchat event for %s' % event.name)
128 log.debug('handling hipchat event for %s' % event.name)
132
129
133 if isinstance(event, events.PullRequestCommentEvent):
130 if isinstance(event, events.PullRequestCommentEvent):
134 text = self.format_pull_request_comment_event(event, data)
131 text = self.format_pull_request_comment_event(event, data)
135 elif isinstance(event, events.PullRequestReviewEvent):
132 elif isinstance(event, events.PullRequestReviewEvent):
136 text = self.format_pull_request_review_event(event, data)
133 text = self.format_pull_request_review_event(event, data)
137 elif isinstance(event, events.PullRequestEvent):
134 elif isinstance(event, events.PullRequestEvent):
138 text = self.format_pull_request_event(event, data)
135 text = self.format_pull_request_event(event, data)
139 elif isinstance(event, events.RepoPushEvent):
136 elif isinstance(event, events.RepoPushEvent):
140 text = self.format_repo_push_event(data)
137 text = self.format_repo_push_event(data)
141 elif isinstance(event, events.RepoCreateEvent):
138 elif isinstance(event, events.RepoCreateEvent):
142 text = self.format_repo_create_event(data)
139 text = self.format_repo_create_event(data)
143 else:
140 else:
144 log.error('unhandled event type: %r' % event)
141 log.error('unhandled event type: %r' % event)
145
142
146 run_task(post_text_to_hipchat, self.settings, text)
143 run_task(post_text_to_hipchat, self.settings, text)
147
144
148 def settings_schema(self):
145 def settings_schema(self):
149 schema = HipchatSettingsSchema()
146 schema = HipchatSettingsSchema()
150 schema.add(colander.SchemaNode(
147 schema.add(colander.SchemaNode(
151 colander.Set(),
148 colander.Set(),
152 widget=deform.widget.CheckboxChoiceWidget(
149 widget=deform.widget.CheckboxChoiceWidget(
153 values=sorted(
150 values=sorted(
154 [(e.name, e.display_name) for e in self.valid_events]
151 [(e.name, e.display_name) for e in self.valid_events]
155 )
152 )
156 ),
153 ),
157 description="Events activated for this integration",
154 description="Events activated for this integration",
158 name='events'
155 name='events'
159 ))
156 ))
160
157
161 return schema
158 return schema
162
159
163 def format_pull_request_comment_event(self, event, data):
160 def format_pull_request_comment_event(self, event, data):
164 comment_text = data['comment']['text']
161 comment_text = data['comment']['text']
165 if len(comment_text) > 200:
162 if len(comment_text) > 200:
166 comment_text = '{comment_text}<a href="{comment_url}">...<a/>'.format(
163 comment_text = '{comment_text}<a href="{comment_url}">...<a/>'.format(
167 comment_text=comment_text[:200],
164 comment_text=comment_text[:200],
168 comment_url=data['comment']['url'],
165 comment_url=data['comment']['url'],
169 )
166 )
170
167
171 comment_status = ''
168 comment_status = ''
172 if data['comment']['status']:
169 if data['comment']['status']:
173 comment_status = '[{}]: '.format(data['comment']['status'])
170 comment_status = '[{}]: '.format(data['comment']['status'])
174
171
175 return (textwrap.dedent(
172 return (textwrap.dedent(
176 '''
173 '''
177 {user} commented on pull request <a href="{pr_url}">{number}</a> - {pr_title}:
174 {user} commented on pull request <a href="{pr_url}">{number}</a> - {pr_title}:
178 >>> {comment_status}{comment_text}
175 >>> {comment_status}{comment_text}
179 ''').format(
176 ''').format(
180 comment_status=comment_status,
177 comment_status=comment_status,
181 user=data['actor']['username'],
178 user=data['actor']['username'],
182 number=data['pullrequest']['pull_request_id'],
179 number=data['pullrequest']['pull_request_id'],
183 pr_url=data['pullrequest']['url'],
180 pr_url=data['pullrequest']['url'],
184 pr_status=data['pullrequest']['status'],
181 pr_status=data['pullrequest']['status'],
185 pr_title=data['pullrequest']['title'],
182 pr_title=data['pullrequest']['title'],
186 comment_text=comment_text
183 comment_text=comment_text
187 )
184 )
188 )
185 )
189
186
190 def format_pull_request_review_event(self, event, data):
187 def format_pull_request_review_event(self, event, data):
191 return (textwrap.dedent(
188 return (textwrap.dedent(
192 '''
189 '''
193 Status changed to {pr_status} for pull request <a href="{pr_url}">#{number}</a> - {pr_title}
190 Status changed to {pr_status} for pull request <a href="{pr_url}">#{number}</a> - {pr_title}
194 ''').format(
191 ''').format(
195 user=data['actor']['username'],
192 user=data['actor']['username'],
196 number=data['pullrequest']['pull_request_id'],
193 number=data['pullrequest']['pull_request_id'],
197 pr_url=data['pullrequest']['url'],
194 pr_url=data['pullrequest']['url'],
198 pr_status=data['pullrequest']['status'],
195 pr_status=data['pullrequest']['status'],
199 pr_title=data['pullrequest']['title'],
196 pr_title=data['pullrequest']['title'],
200 )
197 )
201 )
198 )
202
199
203 def format_pull_request_event(self, event, data):
200 def format_pull_request_event(self, event, data):
204 action = {
201 action = {
205 events.PullRequestCloseEvent: 'closed',
202 events.PullRequestCloseEvent: 'closed',
206 events.PullRequestMergeEvent: 'merged',
203 events.PullRequestMergeEvent: 'merged',
207 events.PullRequestUpdateEvent: 'updated',
204 events.PullRequestUpdateEvent: 'updated',
208 events.PullRequestCreateEvent: 'created',
205 events.PullRequestCreateEvent: 'created',
209 }.get(event.__class__, str(event.__class__))
206 }.get(event.__class__, str(event.__class__))
210
207
211 return ('Pull request <a href="{url}">#{number}</a> - {title} '
208 return ('Pull request <a href="{url}">#{number}</a> - {title} '
212 '{action} by {user}').format(
209 '{action} by {user}').format(
213 user=data['actor']['username'],
210 user=data['actor']['username'],
214 number=data['pullrequest']['pull_request_id'],
211 number=data['pullrequest']['pull_request_id'],
215 url=data['pullrequest']['url'],
212 url=data['pullrequest']['url'],
216 title=data['pullrequest']['title'],
213 title=data['pullrequest']['title'],
217 action=action
214 action=action
218 )
215 )
219
216
220 def format_repo_push_event(self, data):
217 def format_repo_push_event(self, data):
218 branch_data = {branch['name']: branch
219 for branch in data['push']['branches']}
220
221 branches_commits = {}
222 for commit in data['push']['commits']:
223 log.critical(commit)
224 if commit['branch'] not in branches_commits:
225 branch_commits = {'branch': branch_data[commit['branch']],
226 'commits': []}
227 branches_commits[commit['branch']] = branch_commits
228
229 branch_commits = branches_commits[commit['branch']]
230 branch_commits['commits'].append(commit)
231
221 result = repo_push_template.render(
232 result = repo_push_template.render(
222 data=data,
233 data=data,
234 branches_commits=branches_commits,
223 )
235 )
224 return result
236 return result
225
237
226 def format_repo_create_event(self, data):
238 def format_repo_create_event(self, data):
227 return '<a href="{}">{}</a> ({}) repository created by <b>{}</b>'.format(
239 return '<a href="{}">{}</a> ({}) repository created by <b>{}</b>'.format(
228 data['repo']['url'],
240 data['repo']['url'],
229 data['repo']['repo_name'],
241 data['repo']['repo_name'],
230 data['repo']['repo_type'],
242 data['repo']['repo_type'],
231 data['actor']['username'],
243 data['actor']['username'],
232 )
244 )
233
245
234
246
235 @task(ignore_result=True)
247 @task(ignore_result=True)
236 def post_text_to_hipchat(settings, text):
248 def post_text_to_hipchat(settings, text):
237 log.debug('sending %s to hipchat %s' % (text, settings['server_url']))
249 log.debug('sending %s to hipchat %s' % (text, settings['server_url']))
238 resp = requests.post(settings['server_url'], json={
250 resp = requests.post(settings['server_url'], json={
239 "message": text,
251 "message": text,
240 "color": settings.get('color', 'yellow'),
252 "color": settings.get('color', 'yellow'),
241 "notify": settings.get('notify', False),
253 "notify": settings.get('notify', False),
242 })
254 })
243 resp.raise_for_status() # raise exception on a failed request
255 resp.raise_for_status() # raise exception on a failed request
@@ -1,256 +1,264 b''
1 # -*- coding: utf-8 -*-
1 # -*- coding: utf-8 -*-
2
2
3 # Copyright (C) 2012-2016 RhodeCode GmbH
3 # Copyright (C) 2012-2016 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 re
23 import re
24 import logging
24 import logging
25 import requests
25 import requests
26 import colander
26 import colander
27 import textwrap
27 import textwrap
28 from celery.task import task
28 from celery.task import task
29 from mako.template import Template
29 from mako.template import Template
30
30
31 from rhodecode import events
31 from rhodecode import events
32 from rhodecode.translation import _
32 from rhodecode.translation import _
33 from rhodecode.lib import helpers as h
33 from rhodecode.lib import helpers as h
34 from rhodecode.lib.celerylib import run_task
34 from rhodecode.lib.celerylib import run_task
35 from rhodecode.lib.colander_utils import strip_whitespace
35 from rhodecode.lib.colander_utils import strip_whitespace
36 from rhodecode.integrations.types.base import IntegrationTypeBase
36 from rhodecode.integrations.types.base import IntegrationTypeBase
37
37
38 log = logging.getLogger(__name__)
38 log = logging.getLogger(__name__)
39
39
40
40
41 class SlackSettingsSchema(colander.Schema):
41 class SlackSettingsSchema(colander.Schema):
42 service = colander.SchemaNode(
42 service = colander.SchemaNode(
43 colander.String(),
43 colander.String(),
44 title=_('Slack service URL'),
44 title=_('Slack service URL'),
45 description=h.literal(_(
45 description=h.literal(_(
46 'This can be setup at the '
46 'This can be setup at the '
47 '<a href="https://my.slack.com/services/new/incoming-webhook/">'
47 '<a href="https://my.slack.com/services/new/incoming-webhook/">'
48 'slack app manager</a>')),
48 'slack app manager</a>')),
49 default='',
49 default='',
50 preparer=strip_whitespace,
50 preparer=strip_whitespace,
51 validator=colander.url,
51 validator=colander.url,
52 widget=deform.widget.TextInputWidget(
52 widget=deform.widget.TextInputWidget(
53 placeholder='https://hooks.slack.com/services/...',
53 placeholder='https://hooks.slack.com/services/...',
54 ),
54 ),
55 )
55 )
56 username = colander.SchemaNode(
56 username = colander.SchemaNode(
57 colander.String(),
57 colander.String(),
58 title=_('Username'),
58 title=_('Username'),
59 description=_('Username to show notifications coming from.'),
59 description=_('Username to show notifications coming from.'),
60 missing='Rhodecode',
60 missing='Rhodecode',
61 preparer=strip_whitespace,
61 preparer=strip_whitespace,
62 widget=deform.widget.TextInputWidget(
62 widget=deform.widget.TextInputWidget(
63 placeholder='Rhodecode'
63 placeholder='Rhodecode'
64 ),
64 ),
65 )
65 )
66 channel = colander.SchemaNode(
66 channel = colander.SchemaNode(
67 colander.String(),
67 colander.String(),
68 title=_('Channel'),
68 title=_('Channel'),
69 description=_('Channel to send notifications to.'),
69 description=_('Channel to send notifications to.'),
70 missing='',
70 missing='',
71 preparer=strip_whitespace,
71 preparer=strip_whitespace,
72 widget=deform.widget.TextInputWidget(
72 widget=deform.widget.TextInputWidget(
73 placeholder='#general'
73 placeholder='#general'
74 ),
74 ),
75 )
75 )
76 icon_emoji = colander.SchemaNode(
76 icon_emoji = colander.SchemaNode(
77 colander.String(),
77 colander.String(),
78 title=_('Emoji'),
78 title=_('Emoji'),
79 description=_('Emoji to use eg. :studio_microphone:'),
79 description=_('Emoji to use eg. :studio_microphone:'),
80 missing='',
80 missing='',
81 preparer=strip_whitespace,
81 preparer=strip_whitespace,
82 widget=deform.widget.TextInputWidget(
82 widget=deform.widget.TextInputWidget(
83 placeholder=':studio_microphone:'
83 placeholder=':studio_microphone:'
84 ),
84 ),
85 )
85 )
86
86
87
87
88 repo_push_template = Template(r'''
88 repo_push_template = Template(r'''
89 *${data['actor']['username']}* pushed to \
89 *${data['actor']['username']}* pushed to repo <${data['repo']['url']}|${data['repo']['repo_name']}>:
90 %if data['push']['branches']:
90 %for branch, branch_commits in branches_commits.items():
91 ${len(data['push']['branches']) > 1 and 'branches' or 'branch'} \
91 branch: <${branch_commits['branch']['url']}|${branch_commits['branch']['name']}>
92 ${', '.join('<%s|%s>' % (branch['url'], branch['name']) for branch in data['push']['branches'])} \
92 %for commit in branch_commits['commits']:
93 %else:
93 > <${commit['url']}|${commit['short_id']}> - ${commit['message_html']|html_to_slack_links}
94 unknown branch \
94 %endfor
95 %endif
96 in <${data['repo']['url']}|${data['repo']['repo_name']}>
97 >>>
98 %for commit in data['push']['commits']:
99 <${commit['url']}|${commit['short_id']}> - ${commit['message_html']|html_to_slack_links}
100 %endfor
95 %endfor
101 ''')
96 ''')
102
97
103
98
104
105
106 class SlackIntegrationType(IntegrationTypeBase):
99 class SlackIntegrationType(IntegrationTypeBase):
107 key = 'slack'
100 key = 'slack'
108 display_name = _('Slack')
101 display_name = _('Slack')
109 description = _('Send events such as repo pushes and pull requests to '
102 description = _('Send events such as repo pushes and pull requests to '
110 'your slack channel.')
103 'your slack channel.')
111 icon = '''<?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>'''
104 icon = '''<?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>'''
112 valid_events = [
105 valid_events = [
113 events.PullRequestCloseEvent,
106 events.PullRequestCloseEvent,
114 events.PullRequestMergeEvent,
107 events.PullRequestMergeEvent,
115 events.PullRequestUpdateEvent,
108 events.PullRequestUpdateEvent,
116 events.PullRequestCommentEvent,
109 events.PullRequestCommentEvent,
117 events.PullRequestReviewEvent,
110 events.PullRequestReviewEvent,
118 events.PullRequestCreateEvent,
111 events.PullRequestCreateEvent,
119 events.RepoPushEvent,
112 events.RepoPushEvent,
120 events.RepoCreateEvent,
113 events.RepoCreateEvent,
121 ]
114 ]
122
115
123 def send_event(self, event):
116 def send_event(self, event):
124 if event.__class__ not in self.valid_events:
117 if event.__class__ not in self.valid_events:
125 log.debug('event not valid: %r' % event)
118 log.debug('event not valid: %r' % event)
126 return
119 return
127
120
128 if event.name not in self.settings['events']:
121 if event.name not in self.settings['events']:
129 log.debug('event ignored: %r' % event)
122 log.debug('event ignored: %r' % event)
130 return
123 return
131
124
132 data = event.as_dict()
125 data = event.as_dict()
133
126
134 text = '*%s* caused a *%s* event' % (
127 text = '*%s* caused a *%s* event' % (
135 data['actor']['username'], event.name)
128 data['actor']['username'], event.name)
136
129
137 log.debug('handling slack event for %s' % event.name)
130 log.debug('handling slack event for %s' % event.name)
138
131
139 if isinstance(event, events.PullRequestCommentEvent):
132 if isinstance(event, events.PullRequestCommentEvent):
140 text = self.format_pull_request_comment_event(event, data)
133 text = self.format_pull_request_comment_event(event, data)
141 elif isinstance(event, events.PullRequestReviewEvent):
134 elif isinstance(event, events.PullRequestReviewEvent):
142 text = self.format_pull_request_review_event(event, data)
135 text = self.format_pull_request_review_event(event, data)
143 elif isinstance(event, events.PullRequestEvent):
136 elif isinstance(event, events.PullRequestEvent):
144 text = self.format_pull_request_event(event, data)
137 text = self.format_pull_request_event(event, data)
145 elif isinstance(event, events.RepoPushEvent):
138 elif isinstance(event, events.RepoPushEvent):
146 text = self.format_repo_push_event(data)
139 text = self.format_repo_push_event(data)
147 elif isinstance(event, events.RepoCreateEvent):
140 elif isinstance(event, events.RepoCreateEvent):
148 text = self.format_repo_create_event(data)
141 text = self.format_repo_create_event(data)
149 else:
142 else:
150 log.error('unhandled event type: %r' % event)
143 log.error('unhandled event type: %r' % event)
151
144
152 run_task(post_text_to_slack, self.settings, text)
145 run_task(post_text_to_slack, self.settings, text)
153
146
154 def settings_schema(self):
147 def settings_schema(self):
155 schema = SlackSettingsSchema()
148 schema = SlackSettingsSchema()
156 schema.add(colander.SchemaNode(
149 schema.add(colander.SchemaNode(
157 colander.Set(),
150 colander.Set(),
158 widget=deform.widget.CheckboxChoiceWidget(
151 widget=deform.widget.CheckboxChoiceWidget(
159 values=sorted(
152 values=sorted(
160 [(e.name, e.display_name) for e in self.valid_events]
153 [(e.name, e.display_name) for e in self.valid_events]
161 )
154 )
162 ),
155 ),
163 description="Events activated for this integration",
156 description="Events activated for this integration",
164 name='events'
157 name='events'
165 ))
158 ))
166
159
167 return schema
160 return schema
168
161
169 def format_pull_request_comment_event(self, event, data):
162 def format_pull_request_comment_event(self, event, data):
170 comment_text = data['comment']['text']
163 comment_text = data['comment']['text']
171 if len(comment_text) > 200:
164 if len(comment_text) > 200:
172 comment_text = '<{comment_url}|{comment_text}...>'.format(
165 comment_text = '<{comment_url}|{comment_text}...>'.format(
173 comment_text=comment_text[:200],
166 comment_text=comment_text[:200],
174 comment_url=data['comment']['url'],
167 comment_url=data['comment']['url'],
175 )
168 )
176
169
177 comment_status = ''
170 comment_status = ''
178 if data['comment']['status']:
171 if data['comment']['status']:
179 comment_status = '[{}]: '.format(data['comment']['status'])
172 comment_status = '[{}]: '.format(data['comment']['status'])
180
173
181 return (textwrap.dedent(
174 return (textwrap.dedent(
182 '''
175 '''
183 {user} commented on pull request <{pr_url}|#{number}> - {pr_title}:
176 {user} commented on pull request <{pr_url}|#{number}> - {pr_title}:
184 >>> {comment_status}{comment_text}
177 >>> {comment_status}{comment_text}
185 ''').format(
178 ''').format(
186 comment_status=comment_status,
179 comment_status=comment_status,
187 user=data['actor']['username'],
180 user=data['actor']['username'],
188 number=data['pullrequest']['pull_request_id'],
181 number=data['pullrequest']['pull_request_id'],
189 pr_url=data['pullrequest']['url'],
182 pr_url=data['pullrequest']['url'],
190 pr_status=data['pullrequest']['status'],
183 pr_status=data['pullrequest']['status'],
191 pr_title=data['pullrequest']['title'],
184 pr_title=data['pullrequest']['title'],
192 comment_text=comment_text
185 comment_text=comment_text
193 )
186 )
194 )
187 )
195
188
196 def format_pull_request_review_event(self, event, data):
189 def format_pull_request_review_event(self, event, data):
197 return (textwrap.dedent(
190 return (textwrap.dedent(
198 '''
191 '''
199 Status changed to {pr_status} for pull request <{pr_url}|#{number}> - {pr_title}
192 Status changed to {pr_status} for pull request <{pr_url}|#{number}> - {pr_title}
200 ''').format(
193 ''').format(
201 user=data['actor']['username'],
194 user=data['actor']['username'],
202 number=data['pullrequest']['pull_request_id'],
195 number=data['pullrequest']['pull_request_id'],
203 pr_url=data['pullrequest']['url'],
196 pr_url=data['pullrequest']['url'],
204 pr_status=data['pullrequest']['status'],
197 pr_status=data['pullrequest']['status'],
205 pr_title=data['pullrequest']['title'],
198 pr_title=data['pullrequest']['title'],
206 )
199 )
207 )
200 )
208
201
209 def format_pull_request_event(self, event, data):
202 def format_pull_request_event(self, event, data):
210 action = {
203 action = {
211 events.PullRequestCloseEvent: 'closed',
204 events.PullRequestCloseEvent: 'closed',
212 events.PullRequestMergeEvent: 'merged',
205 events.PullRequestMergeEvent: 'merged',
213 events.PullRequestUpdateEvent: 'updated',
206 events.PullRequestUpdateEvent: 'updated',
214 events.PullRequestCreateEvent: 'created',
207 events.PullRequestCreateEvent: 'created',
215 }.get(event.__class__, str(event.__class__))
208 }.get(event.__class__, str(event.__class__))
216
209
217 return ('Pull request <{url}|#{number}> - {title} '
210 return ('Pull request <{url}|#{number}> - {title} '
218 '{action} by {user}').format(
211 '{action} by {user}').format(
219 user=data['actor']['username'],
212 user=data['actor']['username'],
220 number=data['pullrequest']['pull_request_id'],
213 number=data['pullrequest']['pull_request_id'],
221 url=data['pullrequest']['url'],
214 url=data['pullrequest']['url'],
222 title=data['pullrequest']['title'],
215 title=data['pullrequest']['title'],
223 action=action
216 action=action
224 )
217 )
225
218
226 def format_repo_push_event(self, data):
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 = {}
224 for commit in data['push']['commits']:
225 log.critical(commit)
226 if commit['branch'] not in branches_commits:
227 branch_commits = {'branch': branch_data[commit['branch']],
228 'commits': []}
229 branches_commits[commit['branch']] = branch_commits
230
231 branch_commits = branches_commits[commit['branch']]
232 branch_commits['commits'].append(commit)
233
227 result = repo_push_template.render(
234 result = repo_push_template.render(
228 data=data,
235 data=data,
236 branches_commits=branches_commits,
229 html_to_slack_links=html_to_slack_links,
237 html_to_slack_links=html_to_slack_links,
230 )
238 )
231 return result
239 return result
232
240
233 def format_repo_create_event(self, data):
241 def format_repo_create_event(self, data):
234 return '<{}|{}> ({}) repository created by *{}*'.format(
242 return '<{}|{}> ({}) repository created by *{}*'.format(
235 data['repo']['url'],
243 data['repo']['url'],
236 data['repo']['repo_name'],
244 data['repo']['repo_name'],
237 data['repo']['repo_type'],
245 data['repo']['repo_type'],
238 data['actor']['username'],
246 data['actor']['username'],
239 )
247 )
240
248
241
249
242 def html_to_slack_links(message):
250 def html_to_slack_links(message):
243 return re.compile(r'<a .*?href=["\'](.+?)".*?>(.+?)</a>').sub(
251 return re.compile(r'<a .*?href=["\'](.+?)".*?>(.+?)</a>').sub(
244 r'<\1|\2>', message)
252 r'<\1|\2>', message)
245
253
246
254
247 @task(ignore_result=True)
255 @task(ignore_result=True)
248 def post_text_to_slack(settings, text):
256 def post_text_to_slack(settings, text):
249 log.debug('sending %s to slack %s' % (text, settings['service']))
257 log.debug('sending %s to slack %s' % (text, settings['service']))
250 resp = requests.post(settings['service'], json={
258 resp = requests.post(settings['service'], json={
251 "channel": settings.get('channel', ''),
259 "channel": settings.get('channel', ''),
252 "username": settings.get('username', 'Rhodecode'),
260 "username": settings.get('username', 'Rhodecode'),
253 "text": text,
261 "text": text,
254 "icon_emoji": settings.get('icon_emoji', ':studio_microphone:')
262 "icon_emoji": settings.get('icon_emoji', ':studio_microphone:')
255 })
263 })
256 resp.raise_for_status() # raise exception on a failed request
264 resp.raise_for_status() # raise exception on a failed request
General Comments 0
You need to be logged in to leave comments. Login now