##// END OF EJS Templates
channelstream: push events with comments on single commits....
marcink -
r1970:ef3d81a8 default
parent child Browse files
Show More
@@ -1,253 +1,254 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2016-2017 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 os
22 22 import hashlib
23 23 import itsdangerous
24 24 import logging
25 25 import requests
26 26 import datetime
27 27
28 28 from dogpile.core import ReadWriteMutex
29 29 from pyramid.threadlocal import get_current_registry
30 30
31 31 import rhodecode.lib.helpers as h
32 32 from rhodecode.lib.auth import HasRepoPermissionAny
33 33 from rhodecode.lib.ext_json import json
34 34 from rhodecode.model.db import User
35 35
36 36 log = logging.getLogger(__name__)
37 37
38 38 LOCK = ReadWriteMutex()
39 39
40 40 STATE_PUBLIC_KEYS = ['id', 'username', 'first_name', 'last_name',
41 41 'icon_link', 'display_name', 'display_link']
42 42
43 43
44 44 class ChannelstreamException(Exception):
45 45 pass
46 46
47 47
48 48 class ChannelstreamConnectionException(ChannelstreamException):
49 49 pass
50 50
51 51
52 52 class ChannelstreamPermissionException(ChannelstreamException):
53 53 pass
54 54
55 55
56 56 def channelstream_request(config, payload, endpoint, raise_exc=True):
57 57 signer = itsdangerous.TimestampSigner(config['secret'])
58 58 sig_for_server = signer.sign(endpoint)
59 59 secret_headers = {'x-channelstream-secret': sig_for_server,
60 60 'x-channelstream-endpoint': endpoint,
61 61 'Content-Type': 'application/json'}
62 62 req_url = 'http://{}{}'.format(config['server'], endpoint)
63 63 response = None
64 64 try:
65 65 response = requests.post(req_url, data=json.dumps(payload),
66 66 headers=secret_headers).json()
67 67 except requests.ConnectionError:
68 68 log.exception('ConnectionError happened')
69 69 if raise_exc:
70 70 raise ChannelstreamConnectionException()
71 71 except Exception:
72 72 log.exception('Exception related to channelstream happened')
73 73 if raise_exc:
74 74 raise ChannelstreamConnectionException()
75 75 return response
76 76
77 77
78 78 def get_user_data(user_id):
79 79 user = User.get(user_id)
80 80 return {
81 81 'id': user.user_id,
82 82 'username': user.username,
83 83 'first_name': user.first_name,
84 84 'last_name': user.last_name,
85 85 'icon_link': h.gravatar_url(user.email, 60),
86 86 'display_name': h.person(user, 'username_or_name_or_email'),
87 87 'display_link': h.link_to_user(user),
88 88 'notifications': user.user_data.get('notification_status', True)
89 89 }
90 90
91 91
92 92 def broadcast_validator(channel_name):
93 93 """ checks if user can access the broadcast channel """
94 94 if channel_name == 'broadcast':
95 95 return True
96 96
97 97
98 98 def repo_validator(channel_name):
99 99 """ checks if user can access the broadcast channel """
100 100 channel_prefix = '/repo$'
101 101 if channel_name.startswith(channel_prefix):
102 102 elements = channel_name[len(channel_prefix):].split('$')
103 103 repo_name = elements[0]
104 104 can_access = HasRepoPermissionAny(
105 105 'repository.read',
106 106 'repository.write',
107 107 'repository.admin')(repo_name)
108 108 log.debug('permission check for {} channel '
109 109 'resulted in {}'.format(repo_name, can_access))
110 110 if can_access:
111 111 return True
112 112 return False
113 113
114 114
115 115 def check_channel_permissions(channels, plugin_validators, should_raise=True):
116 116 valid_channels = []
117 117
118 118 validators = [broadcast_validator, repo_validator]
119 119 if plugin_validators:
120 120 validators.extend(plugin_validators)
121 121 for channel_name in channels:
122 122 is_valid = False
123 123 for validator in validators:
124 124 if validator(channel_name):
125 125 is_valid = True
126 126 break
127 127 if is_valid:
128 128 valid_channels.append(channel_name)
129 129 else:
130 130 if should_raise:
131 131 raise ChannelstreamPermissionException()
132 132 return valid_channels
133 133
134 134
135 135 def get_channels_info(self, channels):
136 136 payload = {'channels': channels}
137 137 # gather persistence info
138 138 return channelstream_request(self._config(), payload, '/info')
139 139
140 140
141 141 def parse_channels_info(info_result, include_channel_info=None):
142 142 """
143 143 Returns data that contains only secure information that can be
144 144 presented to clients
145 145 """
146 146 include_channel_info = include_channel_info or []
147 147
148 148 user_state_dict = {}
149 149 for userinfo in info_result['users']:
150 150 user_state_dict[userinfo['user']] = {
151 151 k: v for k, v in userinfo['state'].items()
152 152 if k in STATE_PUBLIC_KEYS
153 153 }
154 154
155 155 channels_info = {}
156 156
157 157 for c_name, c_info in info_result['channels'].items():
158 158 if c_name not in include_channel_info:
159 159 continue
160 160 connected_list = []
161 161 for userinfo in c_info['users']:
162 162 connected_list.append({
163 163 'user': userinfo['user'],
164 164 'state': user_state_dict[userinfo['user']]
165 165 })
166 166 channels_info[c_name] = {'users': connected_list,
167 167 'history': c_info['history']}
168 168
169 169 return channels_info
170 170
171 171
172 172 def log_filepath(history_location, channel_name):
173 173 hasher = hashlib.sha256()
174 174 hasher.update(channel_name.encode('utf8'))
175 175 filename = '{}.log'.format(hasher.hexdigest())
176 176 filepath = os.path.join(history_location, filename)
177 177 return filepath
178 178
179 179
180 180 def read_history(history_location, channel_name):
181 181 filepath = log_filepath(history_location, channel_name)
182 182 if not os.path.exists(filepath):
183 183 return []
184 184 history_lines_limit = -100
185 185 history = []
186 186 with open(filepath, 'rb') as f:
187 187 for line in f.readlines()[history_lines_limit:]:
188 188 try:
189 189 history.append(json.loads(line))
190 190 except Exception:
191 191 log.exception('Failed to load history')
192 192 return history
193 193
194 194
195 195 def update_history_from_logs(config, channels, payload):
196 196 history_location = config.get('history.location')
197 197 for channel in channels:
198 198 history = read_history(history_location, channel)
199 199 payload['channels_info'][channel]['history'] = history
200 200
201 201
202 202 def write_history(config, message):
203 203 """ writes a messge to a base64encoded filename """
204 204 history_location = config.get('history.location')
205 205 if not os.path.exists(history_location):
206 206 return
207 207 try:
208 208 LOCK.acquire_write_lock()
209 209 filepath = log_filepath(history_location, message['channel'])
210 210 with open(filepath, 'ab') as f:
211 211 json.dump(message, f)
212 212 f.write('\n')
213 213 finally:
214 214 LOCK.release_write_lock()
215 215
216 216
217 217 def get_connection_validators(registry):
218 218 validators = []
219 219 for k, config in registry.rhodecode_plugins.iteritems():
220 220 validator = config.get('channelstream', {}).get('connect_validator')
221 221 if validator:
222 222 validators.append(validator)
223 223 return validators
224 224
225 225
226 226 def post_message(channel, message, username, registry=None):
227 227
228 228 if not registry:
229 229 registry = get_current_registry()
230 230
231 log.debug('Channelstream: sending notification to channel %s', channel)
231 232 rhodecode_plugins = getattr(registry, 'rhodecode_plugins', {})
232 233 channelstream_config = rhodecode_plugins.get('channelstream', {})
233 234 if channelstream_config.get('enabled'):
234 235 payload = {
235 236 'type': 'message',
236 237 'timestamp': datetime.datetime.utcnow(),
237 238 'user': 'system',
238 239 'exclude_users': [username],
239 240 'channel': channel,
240 241 'message': {
241 242 'message': message,
242 243 'level': 'success',
243 244 'topic': '/notifications'
244 245 }
245 246 }
246 247
247 248 try:
248 249 return channelstream_request(
249 250 channelstream_config, [payload], '/message',
250 251 raise_exc=False)
251 252 except ChannelstreamException:
252 253 log.exception('Failed to send channelstream data')
253 254 raise
@@ -1,665 +1,656 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2011-2017 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 """
22 22 comments model for RhodeCode
23 23 """
24 24
25 25 import logging
26 26 import traceback
27 27 import collections
28 28
29 29 from datetime import datetime
30 30
31 31 from pylons.i18n.translation import _
32 32 from pyramid.threadlocal import get_current_registry, get_current_request
33 33 from sqlalchemy.sql.expression import null
34 34 from sqlalchemy.sql.functions import coalesce
35 35
36 from rhodecode.lib import helpers as h, diffs
36 from rhodecode.lib import helpers as h, diffs, channelstream
37 37 from rhodecode.lib import audit_logger
38 38 from rhodecode.lib.channelstream import channelstream_request
39 39 from rhodecode.lib.utils2 import extract_mentioned_users, safe_str
40 40 from rhodecode.model import BaseModel
41 41 from rhodecode.model.db import (
42 42 ChangesetComment, User, Notification, PullRequest, AttributeDict)
43 43 from rhodecode.model.notification import NotificationModel
44 44 from rhodecode.model.meta import Session
45 45 from rhodecode.model.settings import VcsSettingsModel
46 46 from rhodecode.model.notification import EmailNotificationModel
47 47 from rhodecode.model.validation_schema.schemas import comment_schema
48 48
49 49
50 50 log = logging.getLogger(__name__)
51 51
52 52
53 53 class CommentsModel(BaseModel):
54 54
55 55 cls = ChangesetComment
56 56
57 57 DIFF_CONTEXT_BEFORE = 3
58 58 DIFF_CONTEXT_AFTER = 3
59 59
60 60 def __get_commit_comment(self, changeset_comment):
61 61 return self._get_instance(ChangesetComment, changeset_comment)
62 62
63 63 def __get_pull_request(self, pull_request):
64 64 return self._get_instance(PullRequest, pull_request)
65 65
66 66 def _extract_mentions(self, s):
67 67 user_objects = []
68 68 for username in extract_mentioned_users(s):
69 69 user_obj = User.get_by_username(username, case_insensitive=True)
70 70 if user_obj:
71 71 user_objects.append(user_obj)
72 72 return user_objects
73 73
74 74 def _get_renderer(self, global_renderer='rst'):
75 75 try:
76 76 # try reading from visual context
77 77 from pylons import tmpl_context
78 78 global_renderer = tmpl_context.visual.default_renderer
79 79 except AttributeError:
80 80 log.debug("Renderer not set, falling back "
81 81 "to default renderer '%s'", global_renderer)
82 82 except Exception:
83 83 log.error(traceback.format_exc())
84 84 return global_renderer
85 85
86 86 def aggregate_comments(self, comments, versions, show_version, inline=False):
87 87 # group by versions, and count until, and display objects
88 88
89 89 comment_groups = collections.defaultdict(list)
90 90 [comment_groups[
91 91 _co.pull_request_version_id].append(_co) for _co in comments]
92 92
93 93 def yield_comments(pos):
94 94 for co in comment_groups[pos]:
95 95 yield co
96 96
97 97 comment_versions = collections.defaultdict(
98 98 lambda: collections.defaultdict(list))
99 99 prev_prvid = -1
100 100 # fake last entry with None, to aggregate on "latest" version which
101 101 # doesn't have an pull_request_version_id
102 102 for ver in versions + [AttributeDict({'pull_request_version_id': None})]:
103 103 prvid = ver.pull_request_version_id
104 104 if prev_prvid == -1:
105 105 prev_prvid = prvid
106 106
107 107 for co in yield_comments(prvid):
108 108 comment_versions[prvid]['at'].append(co)
109 109
110 110 # save until
111 111 current = comment_versions[prvid]['at']
112 112 prev_until = comment_versions[prev_prvid]['until']
113 113 cur_until = prev_until + current
114 114 comment_versions[prvid]['until'].extend(cur_until)
115 115
116 116 # save outdated
117 117 if inline:
118 118 outdated = [x for x in cur_until
119 119 if x.outdated_at_version(show_version)]
120 120 else:
121 121 outdated = [x for x in cur_until
122 122 if x.older_than_version(show_version)]
123 123 display = [x for x in cur_until if x not in outdated]
124 124
125 125 comment_versions[prvid]['outdated'] = outdated
126 126 comment_versions[prvid]['display'] = display
127 127
128 128 prev_prvid = prvid
129 129
130 130 return comment_versions
131 131
132 132 def get_unresolved_todos(self, pull_request, show_outdated=True):
133 133
134 134 todos = Session().query(ChangesetComment) \
135 135 .filter(ChangesetComment.pull_request == pull_request) \
136 136 .filter(ChangesetComment.resolved_by == None) \
137 137 .filter(ChangesetComment.comment_type
138 138 == ChangesetComment.COMMENT_TYPE_TODO)
139 139
140 140 if not show_outdated:
141 141 todos = todos.filter(
142 142 coalesce(ChangesetComment.display_state, '') !=
143 143 ChangesetComment.COMMENT_OUTDATED)
144 144
145 145 todos = todos.all()
146 146
147 147 return todos
148 148
149 149 def get_commit_unresolved_todos(self, commit_id, show_outdated=True):
150 150
151 151 todos = Session().query(ChangesetComment) \
152 152 .filter(ChangesetComment.revision == commit_id) \
153 153 .filter(ChangesetComment.resolved_by == None) \
154 154 .filter(ChangesetComment.comment_type
155 155 == ChangesetComment.COMMENT_TYPE_TODO)
156 156
157 157 if not show_outdated:
158 158 todos = todos.filter(
159 159 coalesce(ChangesetComment.display_state, '') !=
160 160 ChangesetComment.COMMENT_OUTDATED)
161 161
162 162 todos = todos.all()
163 163
164 164 return todos
165 165
166 166 def _log_audit_action(self, action, action_data, user, comment):
167 167 audit_logger.store(
168 168 action=action,
169 169 action_data=action_data,
170 170 user=user,
171 171 repo=comment.repo)
172 172
173 173 def create(self, text, repo, user, commit_id=None, pull_request=None,
174 174 f_path=None, line_no=None, status_change=None,
175 175 status_change_type=None, comment_type=None,
176 176 resolves_comment_id=None, closing_pr=False, send_email=True,
177 177 renderer=None):
178 178 """
179 179 Creates new comment for commit or pull request.
180 180 IF status_change is not none this comment is associated with a
181 181 status change of commit or commit associated with pull request
182 182
183 183 :param text:
184 184 :param repo:
185 185 :param user:
186 186 :param commit_id:
187 187 :param pull_request:
188 188 :param f_path:
189 189 :param line_no:
190 190 :param status_change: Label for status change
191 191 :param comment_type: Type of comment
192 192 :param status_change_type: type of status change
193 193 :param closing_pr:
194 194 :param send_email:
195 195 :param renderer: pick renderer for this comment
196 196 """
197 197 if not text:
198 198 log.warning('Missing text for comment, skipping...')
199 199 return
200 200
201 201 if not renderer:
202 202 renderer = self._get_renderer()
203 203
204 204 repo = self._get_repo(repo)
205 205 user = self._get_user(user)
206 206
207 207 schema = comment_schema.CommentSchema()
208 208 validated_kwargs = schema.deserialize(dict(
209 209 comment_body=text,
210 210 comment_type=comment_type,
211 211 comment_file=f_path,
212 212 comment_line=line_no,
213 213 renderer_type=renderer,
214 214 status_change=status_change_type,
215 215 resolves_comment_id=resolves_comment_id,
216 216 repo=repo.repo_id,
217 217 user=user.user_id,
218 218 ))
219 219
220 220 comment = ChangesetComment()
221 221 comment.renderer = validated_kwargs['renderer_type']
222 222 comment.text = validated_kwargs['comment_body']
223 223 comment.f_path = validated_kwargs['comment_file']
224 224 comment.line_no = validated_kwargs['comment_line']
225 225 comment.comment_type = validated_kwargs['comment_type']
226 226
227 227 comment.repo = repo
228 228 comment.author = user
229 229 comment.resolved_comment = self.__get_commit_comment(
230 230 validated_kwargs['resolves_comment_id'])
231 231
232 232 pull_request_id = pull_request
233 233
234 234 commit_obj = None
235 235 pull_request_obj = None
236 236
237 237 if commit_id:
238 238 notification_type = EmailNotificationModel.TYPE_COMMIT_COMMENT
239 239 # do a lookup, so we don't pass something bad here
240 240 commit_obj = repo.scm_instance().get_commit(commit_id=commit_id)
241 241 comment.revision = commit_obj.raw_id
242 242
243 243 elif pull_request_id:
244 244 notification_type = EmailNotificationModel.TYPE_PULL_REQUEST_COMMENT
245 245 pull_request_obj = self.__get_pull_request(pull_request_id)
246 246 comment.pull_request = pull_request_obj
247 247 else:
248 248 raise Exception('Please specify commit or pull_request_id')
249 249
250 250 Session().add(comment)
251 251 Session().flush()
252 252 kwargs = {
253 253 'user': user,
254 254 'renderer_type': renderer,
255 255 'repo_name': repo.repo_name,
256 256 'status_change': status_change,
257 257 'status_change_type': status_change_type,
258 258 'comment_body': text,
259 259 'comment_file': f_path,
260 260 'comment_line': line_no,
261 261 'comment_type': comment_type or 'note'
262 262 }
263 263
264 264 if commit_obj:
265 265 recipients = ChangesetComment.get_users(
266 266 revision=commit_obj.raw_id)
267 267 # add commit author if it's in RhodeCode system
268 268 cs_author = User.get_from_cs_author(commit_obj.author)
269 269 if not cs_author:
270 270 # use repo owner if we cannot extract the author correctly
271 271 cs_author = repo.user
272 272 recipients += [cs_author]
273 273
274 274 commit_comment_url = self.get_url(comment)
275 275
276 276 target_repo_url = h.link_to(
277 277 repo.repo_name,
278 278 h.route_url('repo_summary', repo_name=repo.repo_name))
279 279
280 280 # commit specifics
281 281 kwargs.update({
282 282 'commit': commit_obj,
283 283 'commit_message': commit_obj.message,
284 284 'commit_target_repo': target_repo_url,
285 285 'commit_comment_url': commit_comment_url,
286 286 })
287 287
288 288 elif pull_request_obj:
289 289 # get the current participants of this pull request
290 290 recipients = ChangesetComment.get_users(
291 291 pull_request_id=pull_request_obj.pull_request_id)
292 292 # add pull request author
293 293 recipients += [pull_request_obj.author]
294 294
295 295 # add the reviewers to notification
296 296 recipients += [x.user for x in pull_request_obj.reviewers]
297 297
298 298 pr_target_repo = pull_request_obj.target_repo
299 299 pr_source_repo = pull_request_obj.source_repo
300 300
301 301 pr_comment_url = h.url(
302 302 'pullrequest_show',
303 303 repo_name=pr_target_repo.repo_name,
304 304 pull_request_id=pull_request_obj.pull_request_id,
305 305 anchor='comment-%s' % comment.comment_id,
306 306 qualified=True,)
307 307
308 308 # set some variables for email notification
309 309 pr_target_repo_url = h.route_url(
310 310 'repo_summary', repo_name=pr_target_repo.repo_name)
311 311
312 312 pr_source_repo_url = h.route_url(
313 313 'repo_summary', repo_name=pr_source_repo.repo_name)
314 314
315 315 # pull request specifics
316 316 kwargs.update({
317 317 'pull_request': pull_request_obj,
318 318 'pr_id': pull_request_obj.pull_request_id,
319 319 'pr_target_repo': pr_target_repo,
320 320 'pr_target_repo_url': pr_target_repo_url,
321 321 'pr_source_repo': pr_source_repo,
322 322 'pr_source_repo_url': pr_source_repo_url,
323 323 'pr_comment_url': pr_comment_url,
324 324 'pr_closing': closing_pr,
325 325 })
326 326 if send_email:
327 327 # pre-generate the subject for notification itself
328 328 (subject,
329 329 _h, _e, # we don't care about those
330 330 body_plaintext) = EmailNotificationModel().render_email(
331 331 notification_type, **kwargs)
332 332
333 333 mention_recipients = set(
334 334 self._extract_mentions(text)).difference(recipients)
335 335
336 336 # create notification objects, and emails
337 337 NotificationModel().create(
338 338 created_by=user,
339 339 notification_subject=subject,
340 340 notification_body=body_plaintext,
341 341 notification_type=notification_type,
342 342 recipients=recipients,
343 343 mention_recipients=mention_recipients,
344 344 email_kwargs=kwargs,
345 345 )
346 346
347 347 Session().flush()
348 348 if comment.pull_request:
349 349 action = 'repo.pull_request.comment.create'
350 350 else:
351 351 action = 'repo.commit.comment.create'
352 352
353 353 comment_data = comment.get_api_data()
354 354 self._log_audit_action(
355 355 action, {'data': comment_data}, user, comment)
356 356
357 registry = get_current_registry()
358 rhodecode_plugins = getattr(registry, 'rhodecode_plugins', {})
359 channelstream_config = rhodecode_plugins.get('channelstream', {})
360 357 msg_url = ''
358 channel = None
361 359 if commit_obj:
362 360 msg_url = commit_comment_url
363 361 repo_name = repo.repo_name
362 channel = u'/repo${}$/commit/{}'.format(
363 repo_name,
364 commit_obj.raw_id
365 )
364 366 elif pull_request_obj:
365 367 msg_url = pr_comment_url
366 368 repo_name = pr_target_repo.repo_name
369 channel = u'/repo${}$/pr/{}'.format(
370 repo_name,
371 pull_request_id
372 )
367 373
368 if channelstream_config.get('enabled'):
369 374 message = '<strong>{}</strong> {} - ' \
370 375 '<a onclick="window.location=\'{}\';' \
371 376 'window.location.reload()">' \
372 377 '<strong>{}</strong></a>'
373 378 message = message.format(
374 379 user.username, _('made a comment'), msg_url,
375 380 _('Show it now'))
376 channel = '/repo${}$/pr/{}'.format(
377 repo_name,
378 pull_request_id
379 )
380 payload = {
381 'type': 'message',
382 'timestamp': datetime.utcnow(),
383 'user': 'system',
384 'exclude_users': [user.username],
385 'channel': channel,
386 'message': {
387 'message': message,
388 'level': 'info',
389 'topic': '/notifications'
390 }
391 }
392 channelstream_request(channelstream_config, [payload],
393 '/message', raise_exc=False)
381
382 channelstream.post_message(
383 channel, message, user.username,
384 registry=get_current_registry())
394 385
395 386 return comment
396 387
397 388 def delete(self, comment, user):
398 389 """
399 390 Deletes given comment
400 391 """
401 392 comment = self.__get_commit_comment(comment)
402 393 old_data = comment.get_api_data()
403 394 Session().delete(comment)
404 395
405 396 if comment.pull_request:
406 397 action = 'repo.pull_request.comment.delete'
407 398 else:
408 399 action = 'repo.commit.comment.delete'
409 400
410 401 self._log_audit_action(
411 402 action, {'old_data': old_data}, user, comment)
412 403
413 404 return comment
414 405
415 406 def get_all_comments(self, repo_id, revision=None, pull_request=None):
416 407 q = ChangesetComment.query()\
417 408 .filter(ChangesetComment.repo_id == repo_id)
418 409 if revision:
419 410 q = q.filter(ChangesetComment.revision == revision)
420 411 elif pull_request:
421 412 pull_request = self.__get_pull_request(pull_request)
422 413 q = q.filter(ChangesetComment.pull_request == pull_request)
423 414 else:
424 415 raise Exception('Please specify commit or pull_request')
425 416 q = q.order_by(ChangesetComment.created_on)
426 417 return q.all()
427 418
428 419 def get_url(self, comment, request=None, permalink=False):
429 420 if not request:
430 421 request = get_current_request()
431 422
432 423 comment = self.__get_commit_comment(comment)
433 424 if comment.pull_request:
434 425 pull_request = comment.pull_request
435 426 if permalink:
436 427 return request.route_url(
437 428 'pull_requests_global',
438 429 pull_request_id=pull_request.pull_request_id,
439 430 _anchor='comment-%s' % comment.comment_id)
440 431 else:
441 432 return request.route_url('pullrequest_show',
442 433 repo_name=safe_str(pull_request.target_repo.repo_name),
443 434 pull_request_id=pull_request.pull_request_id,
444 435 _anchor='comment-%s' % comment.comment_id)
445 436
446 437 else:
447 438 repo = comment.repo
448 439 commit_id = comment.revision
449 440
450 441 if permalink:
451 442 return request.route_url(
452 443 'repo_commit', repo_name=safe_str(repo.repo_id),
453 444 commit_id=commit_id,
454 445 _anchor='comment-%s' % comment.comment_id)
455 446
456 447 else:
457 448 return request.route_url(
458 449 'repo_commit', repo_name=safe_str(repo.repo_name),
459 450 commit_id=commit_id,
460 451 _anchor='comment-%s' % comment.comment_id)
461 452
462 453 def get_comments(self, repo_id, revision=None, pull_request=None):
463 454 """
464 455 Gets main comments based on revision or pull_request_id
465 456
466 457 :param repo_id:
467 458 :param revision:
468 459 :param pull_request:
469 460 """
470 461
471 462 q = ChangesetComment.query()\
472 463 .filter(ChangesetComment.repo_id == repo_id)\
473 464 .filter(ChangesetComment.line_no == None)\
474 465 .filter(ChangesetComment.f_path == None)
475 466 if revision:
476 467 q = q.filter(ChangesetComment.revision == revision)
477 468 elif pull_request:
478 469 pull_request = self.__get_pull_request(pull_request)
479 470 q = q.filter(ChangesetComment.pull_request == pull_request)
480 471 else:
481 472 raise Exception('Please specify commit or pull_request')
482 473 q = q.order_by(ChangesetComment.created_on)
483 474 return q.all()
484 475
485 476 def get_inline_comments(self, repo_id, revision=None, pull_request=None):
486 477 q = self._get_inline_comments_query(repo_id, revision, pull_request)
487 478 return self._group_comments_by_path_and_line_number(q)
488 479
489 480 def get_inline_comments_count(self, inline_comments, skip_outdated=True,
490 481 version=None):
491 482 inline_cnt = 0
492 483 for fname, per_line_comments in inline_comments.iteritems():
493 484 for lno, comments in per_line_comments.iteritems():
494 485 for comm in comments:
495 486 if not comm.outdated_at_version(version) and skip_outdated:
496 487 inline_cnt += 1
497 488
498 489 return inline_cnt
499 490
500 491 def get_outdated_comments(self, repo_id, pull_request):
501 492 # TODO: johbo: Remove `repo_id`, it is not needed to find the comments
502 493 # of a pull request.
503 494 q = self._all_inline_comments_of_pull_request(pull_request)
504 495 q = q.filter(
505 496 ChangesetComment.display_state ==
506 497 ChangesetComment.COMMENT_OUTDATED
507 498 ).order_by(ChangesetComment.comment_id.asc())
508 499
509 500 return self._group_comments_by_path_and_line_number(q)
510 501
511 502 def _get_inline_comments_query(self, repo_id, revision, pull_request):
512 503 # TODO: johbo: Split this into two methods: One for PR and one for
513 504 # commit.
514 505 if revision:
515 506 q = Session().query(ChangesetComment).filter(
516 507 ChangesetComment.repo_id == repo_id,
517 508 ChangesetComment.line_no != null(),
518 509 ChangesetComment.f_path != null(),
519 510 ChangesetComment.revision == revision)
520 511
521 512 elif pull_request:
522 513 pull_request = self.__get_pull_request(pull_request)
523 514 if not CommentsModel.use_outdated_comments(pull_request):
524 515 q = self._visible_inline_comments_of_pull_request(pull_request)
525 516 else:
526 517 q = self._all_inline_comments_of_pull_request(pull_request)
527 518
528 519 else:
529 520 raise Exception('Please specify commit or pull_request_id')
530 521 q = q.order_by(ChangesetComment.comment_id.asc())
531 522 return q
532 523
533 524 def _group_comments_by_path_and_line_number(self, q):
534 525 comments = q.all()
535 526 paths = collections.defaultdict(lambda: collections.defaultdict(list))
536 527 for co in comments:
537 528 paths[co.f_path][co.line_no].append(co)
538 529 return paths
539 530
540 531 @classmethod
541 532 def needed_extra_diff_context(cls):
542 533 return max(cls.DIFF_CONTEXT_BEFORE, cls.DIFF_CONTEXT_AFTER)
543 534
544 535 def outdate_comments(self, pull_request, old_diff_data, new_diff_data):
545 536 if not CommentsModel.use_outdated_comments(pull_request):
546 537 return
547 538
548 539 comments = self._visible_inline_comments_of_pull_request(pull_request)
549 540 comments_to_outdate = comments.all()
550 541
551 542 for comment in comments_to_outdate:
552 543 self._outdate_one_comment(comment, old_diff_data, new_diff_data)
553 544
554 545 def _outdate_one_comment(self, comment, old_diff_proc, new_diff_proc):
555 546 diff_line = _parse_comment_line_number(comment.line_no)
556 547
557 548 try:
558 549 old_context = old_diff_proc.get_context_of_line(
559 550 path=comment.f_path, diff_line=diff_line)
560 551 new_context = new_diff_proc.get_context_of_line(
561 552 path=comment.f_path, diff_line=diff_line)
562 553 except (diffs.LineNotInDiffException,
563 554 diffs.FileNotInDiffException):
564 555 comment.display_state = ChangesetComment.COMMENT_OUTDATED
565 556 return
566 557
567 558 if old_context == new_context:
568 559 return
569 560
570 561 if self._should_relocate_diff_line(diff_line):
571 562 new_diff_lines = new_diff_proc.find_context(
572 563 path=comment.f_path, context=old_context,
573 564 offset=self.DIFF_CONTEXT_BEFORE)
574 565 if not new_diff_lines:
575 566 comment.display_state = ChangesetComment.COMMENT_OUTDATED
576 567 else:
577 568 new_diff_line = self._choose_closest_diff_line(
578 569 diff_line, new_diff_lines)
579 570 comment.line_no = _diff_to_comment_line_number(new_diff_line)
580 571 else:
581 572 comment.display_state = ChangesetComment.COMMENT_OUTDATED
582 573
583 574 def _should_relocate_diff_line(self, diff_line):
584 575 """
585 576 Checks if relocation shall be tried for the given `diff_line`.
586 577
587 578 If a comment points into the first lines, then we can have a situation
588 579 that after an update another line has been added on top. In this case
589 580 we would find the context still and move the comment around. This
590 581 would be wrong.
591 582 """
592 583 should_relocate = (
593 584 (diff_line.new and diff_line.new > self.DIFF_CONTEXT_BEFORE) or
594 585 (diff_line.old and diff_line.old > self.DIFF_CONTEXT_BEFORE))
595 586 return should_relocate
596 587
597 588 def _choose_closest_diff_line(self, diff_line, new_diff_lines):
598 589 candidate = new_diff_lines[0]
599 590 best_delta = _diff_line_delta(diff_line, candidate)
600 591 for new_diff_line in new_diff_lines[1:]:
601 592 delta = _diff_line_delta(diff_line, new_diff_line)
602 593 if delta < best_delta:
603 594 candidate = new_diff_line
604 595 best_delta = delta
605 596 return candidate
606 597
607 598 def _visible_inline_comments_of_pull_request(self, pull_request):
608 599 comments = self._all_inline_comments_of_pull_request(pull_request)
609 600 comments = comments.filter(
610 601 coalesce(ChangesetComment.display_state, '') !=
611 602 ChangesetComment.COMMENT_OUTDATED)
612 603 return comments
613 604
614 605 def _all_inline_comments_of_pull_request(self, pull_request):
615 606 comments = Session().query(ChangesetComment)\
616 607 .filter(ChangesetComment.line_no != None)\
617 608 .filter(ChangesetComment.f_path != None)\
618 609 .filter(ChangesetComment.pull_request == pull_request)
619 610 return comments
620 611
621 612 def _all_general_comments_of_pull_request(self, pull_request):
622 613 comments = Session().query(ChangesetComment)\
623 614 .filter(ChangesetComment.line_no == None)\
624 615 .filter(ChangesetComment.f_path == None)\
625 616 .filter(ChangesetComment.pull_request == pull_request)
626 617 return comments
627 618
628 619 @staticmethod
629 620 def use_outdated_comments(pull_request):
630 621 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
631 622 settings = settings_model.get_general_settings()
632 623 return settings.get('rhodecode_use_outdated_comments', False)
633 624
634 625
635 626 def _parse_comment_line_number(line_no):
636 627 """
637 628 Parses line numbers of the form "(o|n)\d+" and returns them in a tuple.
638 629 """
639 630 old_line = None
640 631 new_line = None
641 632 if line_no.startswith('o'):
642 633 old_line = int(line_no[1:])
643 634 elif line_no.startswith('n'):
644 635 new_line = int(line_no[1:])
645 636 else:
646 637 raise ValueError("Comment lines have to start with either 'o' or 'n'.")
647 638 return diffs.DiffLineNumber(old_line, new_line)
648 639
649 640
650 641 def _diff_to_comment_line_number(diff_line):
651 642 if diff_line.new is not None:
652 643 return u'n{}'.format(diff_line.new)
653 644 elif diff_line.old is not None:
654 645 return u'o{}'.format(diff_line.old)
655 646 return u''
656 647
657 648
658 649 def _diff_line_delta(a, b):
659 650 if None not in (a.new, b.new):
660 651 return abs(a.new - b.new)
661 652 elif None not in (a.old, b.old):
662 653 return abs(a.old - b.old)
663 654 else:
664 655 raise ValueError(
665 656 "Cannot compute delta between {} and {}".format(a, b))
@@ -1,141 +1,150 b''
1 1 ccLog = Logger.get('RhodeCodeApp');
2 2 ccLog.setLevel(Logger.OFF);
3 3
4 4 var rhodeCodeApp = Polymer({
5 5 is: 'rhodecode-app',
6 6 attached: function () {
7 7 ccLog.debug('rhodeCodeApp created');
8 8 $.Topic('/notifications').subscribe(this.handleNotifications.bind(this));
9 9 $.Topic('/favicon/update').subscribe(this.faviconUpdate.bind(this));
10 10 $.Topic('/connection_controller/subscribe').subscribe(
11 11 this.subscribeToChannelTopic.bind(this));
12 12 // this event can be used to coordinate plugins to do their
13 13 // initialization before channelstream is kicked off
14 14 $.Topic('/__MAIN_APP__').publish({});
15 15
16 16 for (var i = 0; i < alertMessagePayloads.length; i++) {
17 17 $.Topic('/notifications').publish(alertMessagePayloads[i]);
18 18 }
19 19 this.kickoffChannelstreamPlugin();
20 20 },
21 21
22 22 /** proxy to channelstream connection */
23 23 getChannelStreamConnection: function () {
24 24 return this.$['channelstream-connection'];
25 25 },
26 26
27 27 handleNotifications: function (data) {
28 28 var elem = document.getElementById('notifications');
29 29 if(elem){
30 30 elem.handleNotification(data);
31 31 }
32 32
33 33 },
34 34
35 35 faviconUpdate: function (data) {
36 36 this.$$('rhodecode-favicon').counter = data.count;
37 37 },
38 38
39 39 /** opens connection to ws server */
40 40 kickoffChannelstreamPlugin: function (data) {
41 41 ccLog.debug('kickoffChannelstreamPlugin');
42 42 var channels = ['broadcast'];
43 43 var addChannels = this.checkViewChannels();
44 44 for (var i = 0; i < addChannels.length; i++) {
45 45 channels.push(addChannels[i]);
46 46 }
47 47 if (window.CHANNELSTREAM_SETTINGS && CHANNELSTREAM_SETTINGS.enabled){
48 48 var channelstreamConnection = this.getChannelStreamConnection();
49 49 channelstreamConnection.connectUrl = CHANNELSTREAM_URLS.connect;
50 50 channelstreamConnection.subscribeUrl = CHANNELSTREAM_URLS.subscribe;
51 51 channelstreamConnection.websocketUrl = CHANNELSTREAM_URLS.ws + '/ws';
52 52 channelstreamConnection.longPollUrl = CHANNELSTREAM_URLS.longpoll + '/listen';
53 53 // some channels might already be registered by topic
54 54 for (var i = 0; i < channels.length; i++) {
55 55 channelstreamConnection.push('channels', channels[i]);
56 56 }
57 57 // append any additional channels registered in other plugins
58 58 $.Topic('/connection_controller/subscribe').processPrepared();
59 59 channelstreamConnection.connect();
60 60 }
61 61 },
62 62
63 63 checkViewChannels: function () {
64 var channels = []
64 // subscribe to different channels data is sent.
65
66 var channels = [];
65 67 // subscribe to PR repo channel for PR's'
66 68 if (templateContext.pull_request_data.pull_request_id) {
67 69 var channelName = '/repo$' + templateContext.repo_name + '$/pr/' +
68 70 String(templateContext.pull_request_data.pull_request_id);
69 71 channels.push(channelName);
70 72 }
73
74 if (templateContext.commit_data.commit_id) {
75 var channelName = '/repo$' + templateContext.repo_name + '$/commit/' +
76 String(templateContext.commit_data.commit_id);
77 channels.push(channelName);
78 }
79
71 80 return channels;
72 81 },
73 82
74 83 /** subscribes users from channels in channelstream */
75 84 subscribeToChannelTopic: function (channels) {
76 85 var channelstreamConnection = this.getChannelStreamConnection();
77 86 var toSubscribe = channelstreamConnection.calculateSubscribe(channels);
78 87 ccLog.debug('subscribeToChannelTopic', toSubscribe);
79 88 if (toSubscribe.length > 0) {
80 89 // if we are connected then subscribe
81 90 if (channelstreamConnection.connected) {
82 91 channelstreamConnection.subscribe(toSubscribe);
83 92 }
84 93 // not connected? just push channels onto the stack
85 94 else {
86 95 for (var i = 0; i < toSubscribe.length; i++) {
87 96 channelstreamConnection.push('channels', toSubscribe[i]);
88 97 }
89 98 }
90 99 }
91 100 },
92 101
93 102 /** publish received messages into correct topic */
94 103 receivedMessage: function (event) {
95 104 for (var i = 0; i < event.detail.length; i++) {
96 105 var message = event.detail[i];
97 106 if (message.message.topic) {
98 107 ccLog.debug('publishing', message.message.topic);
99 108 $.Topic(message.message.topic).publish(message);
100 109 }
101 110 else if (message.type === 'presence'){
102 111 $.Topic('/connection_controller/presence').publish(message);
103 112 }
104 113 else {
105 114 ccLog.warn('unhandled message', message);
106 115 }
107 116 }
108 117 },
109 118
110 119 handleConnected: function (event) {
111 120 var channelstreamConnection = this.getChannelStreamConnection();
112 121 channelstreamConnection.set('channelsState',
113 122 event.detail.channels_info);
114 123 channelstreamConnection.set('userState', event.detail.state);
115 124 channelstreamConnection.set('channels', event.detail.channels);
116 125 this.propagageChannelsState();
117 126 },
118 127 handleSubscribed: function (event) {
119 128 var channelstreamConnection = this.getChannelStreamConnection();
120 129 var channelInfo = event.detail.channels_info;
121 130 var channelKeys = Object.keys(event.detail.channels_info);
122 131 for (var i = 0; i < channelKeys.length; i++) {
123 132 var key = channelKeys[i];
124 133 channelstreamConnection.set(['channelsState', key], channelInfo[key]);
125 134 }
126 135 channelstreamConnection.set('channels', event.detail.channels);
127 136 this.propagageChannelsState();
128 137 },
129 138 /** propagates channel states on topics */
130 139 propagageChannelsState: function (event) {
131 140 var channelstreamConnection = this.getChannelStreamConnection();
132 141 var channel_data = channelstreamConnection.channelsState;
133 142 var channels = channelstreamConnection.channels;
134 143 for (var i = 0; i < channels.length; i++) {
135 144 var key = channels[i];
136 145 $.Topic('/connection_controller/channel_update').publish(
137 146 {channel: key, state: channel_data[key]}
138 147 );
139 148 }
140 149 }
141 150 });
General Comments 0
You need to be logged in to leave comments. Login now