##// END OF EJS Templates
live-comments: use show comment instead of refresh the page so...
marcink -
r551:86f5d177 default
parent child Browse files
Show More
@@ -1,515 +1,515 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2011-2016 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
33 33 from sqlalchemy.sql.expression import null
34 34 from sqlalchemy.sql.functions import coalesce
35 35
36 36 from rhodecode.lib import helpers as h, diffs
37 37 from rhodecode.lib.channelstream import channelstream_request
38 38 from rhodecode.lib.utils import action_logger
39 39 from rhodecode.lib.utils2 import extract_mentioned_users
40 40 from rhodecode.model import BaseModel
41 41 from rhodecode.model.db import (
42 42 ChangesetComment, User, Notification, PullRequest)
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
48 48 log = logging.getLogger(__name__)
49 49
50 50
51 51 class ChangesetCommentsModel(BaseModel):
52 52
53 53 cls = ChangesetComment
54 54
55 55 DIFF_CONTEXT_BEFORE = 3
56 56 DIFF_CONTEXT_AFTER = 3
57 57
58 58 def __get_commit_comment(self, changeset_comment):
59 59 return self._get_instance(ChangesetComment, changeset_comment)
60 60
61 61 def __get_pull_request(self, pull_request):
62 62 return self._get_instance(PullRequest, pull_request)
63 63
64 64 def _extract_mentions(self, s):
65 65 user_objects = []
66 66 for username in extract_mentioned_users(s):
67 67 user_obj = User.get_by_username(username, case_insensitive=True)
68 68 if user_obj:
69 69 user_objects.append(user_obj)
70 70 return user_objects
71 71
72 72 def _get_renderer(self, global_renderer='rst'):
73 73 try:
74 74 # try reading from visual context
75 75 from pylons import tmpl_context
76 76 global_renderer = tmpl_context.visual.default_renderer
77 77 except AttributeError:
78 78 log.debug("Renderer not set, falling back "
79 79 "to default renderer '%s'", global_renderer)
80 80 except Exception:
81 81 log.error(traceback.format_exc())
82 82 return global_renderer
83 83
84 84 def create(self, text, repo, user, revision=None, pull_request=None,
85 85 f_path=None, line_no=None, status_change=None,
86 86 status_change_type=None, closing_pr=False,
87 87 send_email=True, renderer=None):
88 88 """
89 89 Creates new comment for commit or pull request.
90 90 IF status_change is not none this comment is associated with a
91 91 status change of commit or commit associated with pull request
92 92
93 93 :param text:
94 94 :param repo:
95 95 :param user:
96 96 :param revision:
97 97 :param pull_request:
98 98 :param f_path:
99 99 :param line_no:
100 100 :param status_change: Label for status change
101 101 :param status_change_type: type of status change
102 102 :param closing_pr:
103 103 :param send_email:
104 104 """
105 105 if not text:
106 106 log.warning('Missing text for comment, skipping...')
107 107 return
108 108
109 109 if not renderer:
110 110 renderer = self._get_renderer()
111 111
112 112 repo = self._get_repo(repo)
113 113 user = self._get_user(user)
114 114 comment = ChangesetComment()
115 115 comment.renderer = renderer
116 116 comment.repo = repo
117 117 comment.author = user
118 118 comment.text = text
119 119 comment.f_path = f_path
120 120 comment.line_no = line_no
121 121
122 122 #TODO (marcink): fix this and remove revision as param
123 123 commit_id = revision
124 124 pull_request_id = pull_request
125 125
126 126 commit_obj = None
127 127 pull_request_obj = None
128 128
129 129 if commit_id:
130 130 notification_type = EmailNotificationModel.TYPE_COMMIT_COMMENT
131 131 # do a lookup, so we don't pass something bad here
132 132 commit_obj = repo.scm_instance().get_commit(commit_id=commit_id)
133 133 comment.revision = commit_obj.raw_id
134 134
135 135 elif pull_request_id:
136 136 notification_type = EmailNotificationModel.TYPE_PULL_REQUEST_COMMENT
137 137 pull_request_obj = self.__get_pull_request(pull_request_id)
138 138 comment.pull_request = pull_request_obj
139 139 else:
140 140 raise Exception('Please specify commit or pull_request_id')
141 141
142 142 Session().add(comment)
143 143 Session().flush()
144 144 kwargs = {
145 145 'user': user,
146 146 'renderer_type': renderer,
147 147 'repo_name': repo.repo_name,
148 148 'status_change': status_change,
149 149 'status_change_type': status_change_type,
150 150 'comment_body': text,
151 151 'comment_file': f_path,
152 152 'comment_line': line_no,
153 153 }
154 154
155 155 if commit_obj:
156 156 recipients = ChangesetComment.get_users(
157 157 revision=commit_obj.raw_id)
158 158 # add commit author if it's in RhodeCode system
159 159 cs_author = User.get_from_cs_author(commit_obj.author)
160 160 if not cs_author:
161 161 # use repo owner if we cannot extract the author correctly
162 162 cs_author = repo.user
163 163 recipients += [cs_author]
164 164
165 165 commit_comment_url = self.get_url(comment)
166 166
167 167 target_repo_url = h.link_to(
168 168 repo.repo_name,
169 169 h.url('summary_home',
170 170 repo_name=repo.repo_name, qualified=True))
171 171
172 172 # commit specifics
173 173 kwargs.update({
174 174 'commit': commit_obj,
175 175 'commit_message': commit_obj.message,
176 176 'commit_target_repo': target_repo_url,
177 177 'commit_comment_url': commit_comment_url,
178 178 })
179 179
180 180 elif pull_request_obj:
181 181 # get the current participants of this pull request
182 182 recipients = ChangesetComment.get_users(
183 183 pull_request_id=pull_request_obj.pull_request_id)
184 184 # add pull request author
185 185 recipients += [pull_request_obj.author]
186 186
187 187 # add the reviewers to notification
188 188 recipients += [x.user for x in pull_request_obj.reviewers]
189 189
190 190 pr_target_repo = pull_request_obj.target_repo
191 191 pr_source_repo = pull_request_obj.source_repo
192 192
193 193 pr_comment_url = h.url(
194 194 'pullrequest_show',
195 195 repo_name=pr_target_repo.repo_name,
196 196 pull_request_id=pull_request_obj.pull_request_id,
197 197 anchor='comment-%s' % comment.comment_id,
198 198 qualified=True,)
199 199
200 200 # set some variables for email notification
201 201 pr_target_repo_url = h.url(
202 202 'summary_home', repo_name=pr_target_repo.repo_name,
203 203 qualified=True)
204 204
205 205 pr_source_repo_url = h.url(
206 206 'summary_home', repo_name=pr_source_repo.repo_name,
207 207 qualified=True)
208 208
209 209 # pull request specifics
210 210 kwargs.update({
211 211 'pull_request': pull_request_obj,
212 212 'pr_id': pull_request_obj.pull_request_id,
213 213 'pr_target_repo': pr_target_repo,
214 214 'pr_target_repo_url': pr_target_repo_url,
215 215 'pr_source_repo': pr_source_repo,
216 216 'pr_source_repo_url': pr_source_repo_url,
217 217 'pr_comment_url': pr_comment_url,
218 218 'pr_closing': closing_pr,
219 219 })
220 220 if send_email:
221 221 # pre-generate the subject for notification itself
222 222 (subject,
223 223 _h, _e, # we don't care about those
224 224 body_plaintext) = EmailNotificationModel().render_email(
225 225 notification_type, **kwargs)
226 226
227 227 mention_recipients = set(
228 228 self._extract_mentions(text)).difference(recipients)
229 229
230 230 # create notification objects, and emails
231 231 NotificationModel().create(
232 232 created_by=user,
233 233 notification_subject=subject,
234 234 notification_body=body_plaintext,
235 235 notification_type=notification_type,
236 236 recipients=recipients,
237 237 mention_recipients=mention_recipients,
238 238 email_kwargs=kwargs,
239 239 )
240 240
241 241 action = (
242 242 'user_commented_pull_request:{}'.format(
243 243 comment.pull_request.pull_request_id)
244 244 if comment.pull_request
245 245 else 'user_commented_revision:{}'.format(comment.revision)
246 246 )
247 247 action_logger(user, action, comment.repo)
248 248
249 249 registry = get_current_registry()
250 250 rhodecode_plugins = getattr(registry, 'rhodecode_plugins', {})
251 251 channelstream_config = rhodecode_plugins.get('channelstream', {})
252 252 msg_url = ''
253 253 if commit_obj:
254 254 msg_url = commit_comment_url
255 255 repo_name = repo.repo_name
256 256 elif pull_request_obj:
257 257 msg_url = pr_comment_url
258 258 repo_name = pr_target_repo.repo_name
259 259
260 260 if channelstream_config.get('enabled'):
261 261 message = '<strong>{}</strong> {} - ' \
262 262 '<a onclick="window.location=\'{}\';' \
263 263 'window.location.reload()">' \
264 264 '<strong>{}</strong></a>'
265 265 message = message.format(
266 266 user.username, _('made a comment'), msg_url,
267 _('Refresh page'))
267 _('Show it now'))
268 268 channel = '/repo${}$/pr/{}'.format(
269 269 repo_name,
270 270 pull_request_id
271 271 )
272 272 payload = {
273 273 'type': 'message',
274 274 'timestamp': datetime.utcnow(),
275 275 'user': 'system',
276 276 'exclude_users': [user.username],
277 277 'channel': channel,
278 278 'message': {
279 279 'message': message,
280 280 'level': 'info',
281 281 'topic': '/notifications'
282 282 }
283 283 }
284 284 channelstream_request(channelstream_config, [payload],
285 285 '/message', raise_exc=False)
286 286
287 287 return comment
288 288
289 289 def delete(self, comment):
290 290 """
291 291 Deletes given comment
292 292
293 293 :param comment_id:
294 294 """
295 295 comment = self.__get_commit_comment(comment)
296 296 Session().delete(comment)
297 297
298 298 return comment
299 299
300 300 def get_all_comments(self, repo_id, revision=None, pull_request=None):
301 301 q = ChangesetComment.query()\
302 302 .filter(ChangesetComment.repo_id == repo_id)
303 303 if revision:
304 304 q = q.filter(ChangesetComment.revision == revision)
305 305 elif pull_request:
306 306 pull_request = self.__get_pull_request(pull_request)
307 307 q = q.filter(ChangesetComment.pull_request == pull_request)
308 308 else:
309 309 raise Exception('Please specify commit or pull_request')
310 310 q = q.order_by(ChangesetComment.created_on)
311 311 return q.all()
312 312
313 313 def get_url(self, comment):
314 314 comment = self.__get_commit_comment(comment)
315 315 if comment.pull_request:
316 316 return h.url(
317 317 'pullrequest_show',
318 318 repo_name=comment.pull_request.target_repo.repo_name,
319 319 pull_request_id=comment.pull_request.pull_request_id,
320 320 anchor='comment-%s' % comment.comment_id,
321 321 qualified=True,)
322 322 else:
323 323 return h.url(
324 324 'changeset_home',
325 325 repo_name=comment.repo.repo_name,
326 326 revision=comment.revision,
327 327 anchor='comment-%s' % comment.comment_id,
328 328 qualified=True,)
329 329
330 330 def get_comments(self, repo_id, revision=None, pull_request=None):
331 331 """
332 332 Gets main comments based on revision or pull_request_id
333 333
334 334 :param repo_id:
335 335 :param revision:
336 336 :param pull_request:
337 337 """
338 338
339 339 q = ChangesetComment.query()\
340 340 .filter(ChangesetComment.repo_id == repo_id)\
341 341 .filter(ChangesetComment.line_no == None)\
342 342 .filter(ChangesetComment.f_path == None)
343 343 if revision:
344 344 q = q.filter(ChangesetComment.revision == revision)
345 345 elif pull_request:
346 346 pull_request = self.__get_pull_request(pull_request)
347 347 q = q.filter(ChangesetComment.pull_request == pull_request)
348 348 else:
349 349 raise Exception('Please specify commit or pull_request')
350 350 q = q.order_by(ChangesetComment.created_on)
351 351 return q.all()
352 352
353 353 def get_inline_comments(self, repo_id, revision=None, pull_request=None):
354 354 q = self._get_inline_comments_query(repo_id, revision, pull_request)
355 355 return self._group_comments_by_path_and_line_number(q)
356 356
357 357 def get_outdated_comments(self, repo_id, pull_request):
358 358 # TODO: johbo: Remove `repo_id`, it is not needed to find the comments
359 359 # of a pull request.
360 360 q = self._all_inline_comments_of_pull_request(pull_request)
361 361 q = q.filter(
362 362 ChangesetComment.display_state ==
363 363 ChangesetComment.COMMENT_OUTDATED
364 364 ).order_by(ChangesetComment.comment_id.asc())
365 365
366 366 return self._group_comments_by_path_and_line_number(q)
367 367
368 368 def _get_inline_comments_query(self, repo_id, revision, pull_request):
369 369 # TODO: johbo: Split this into two methods: One for PR and one for
370 370 # commit.
371 371 if revision:
372 372 q = Session().query(ChangesetComment).filter(
373 373 ChangesetComment.repo_id == repo_id,
374 374 ChangesetComment.line_no != null(),
375 375 ChangesetComment.f_path != null(),
376 376 ChangesetComment.revision == revision)
377 377
378 378 elif pull_request:
379 379 pull_request = self.__get_pull_request(pull_request)
380 380 if ChangesetCommentsModel.use_outdated_comments(pull_request):
381 381 q = self._visible_inline_comments_of_pull_request(pull_request)
382 382 else:
383 383 q = self._all_inline_comments_of_pull_request(pull_request)
384 384
385 385 else:
386 386 raise Exception('Please specify commit or pull_request_id')
387 387 q = q.order_by(ChangesetComment.comment_id.asc())
388 388 return q
389 389
390 390 def _group_comments_by_path_and_line_number(self, q):
391 391 comments = q.all()
392 392 paths = collections.defaultdict(lambda: collections.defaultdict(list))
393 393 for co in comments:
394 394 paths[co.f_path][co.line_no].append(co)
395 395 return paths
396 396
397 397 @classmethod
398 398 def needed_extra_diff_context(cls):
399 399 return max(cls.DIFF_CONTEXT_BEFORE, cls.DIFF_CONTEXT_AFTER)
400 400
401 401 def outdate_comments(self, pull_request, old_diff_data, new_diff_data):
402 402 if not ChangesetCommentsModel.use_outdated_comments(pull_request):
403 403 return
404 404
405 405 comments = self._visible_inline_comments_of_pull_request(pull_request)
406 406 comments_to_outdate = comments.all()
407 407
408 408 for comment in comments_to_outdate:
409 409 self._outdate_one_comment(comment, old_diff_data, new_diff_data)
410 410
411 411 def _outdate_one_comment(self, comment, old_diff_proc, new_diff_proc):
412 412 diff_line = _parse_comment_line_number(comment.line_no)
413 413
414 414 try:
415 415 old_context = old_diff_proc.get_context_of_line(
416 416 path=comment.f_path, diff_line=diff_line)
417 417 new_context = new_diff_proc.get_context_of_line(
418 418 path=comment.f_path, diff_line=diff_line)
419 419 except (diffs.LineNotInDiffException,
420 420 diffs.FileNotInDiffException):
421 421 comment.display_state = ChangesetComment.COMMENT_OUTDATED
422 422 return
423 423
424 424 if old_context == new_context:
425 425 return
426 426
427 427 if self._should_relocate_diff_line(diff_line):
428 428 new_diff_lines = new_diff_proc.find_context(
429 429 path=comment.f_path, context=old_context,
430 430 offset=self.DIFF_CONTEXT_BEFORE)
431 431 if not new_diff_lines:
432 432 comment.display_state = ChangesetComment.COMMENT_OUTDATED
433 433 else:
434 434 new_diff_line = self._choose_closest_diff_line(
435 435 diff_line, new_diff_lines)
436 436 comment.line_no = _diff_to_comment_line_number(new_diff_line)
437 437 else:
438 438 comment.display_state = ChangesetComment.COMMENT_OUTDATED
439 439
440 440 def _should_relocate_diff_line(self, diff_line):
441 441 """
442 442 Checks if relocation shall be tried for the given `diff_line`.
443 443
444 444 If a comment points into the first lines, then we can have a situation
445 445 that after an update another line has been added on top. In this case
446 446 we would find the context still and move the comment around. This
447 447 would be wrong.
448 448 """
449 449 should_relocate = (
450 450 (diff_line.new and diff_line.new > self.DIFF_CONTEXT_BEFORE) or
451 451 (diff_line.old and diff_line.old > self.DIFF_CONTEXT_BEFORE))
452 452 return should_relocate
453 453
454 454 def _choose_closest_diff_line(self, diff_line, new_diff_lines):
455 455 candidate = new_diff_lines[0]
456 456 best_delta = _diff_line_delta(diff_line, candidate)
457 457 for new_diff_line in new_diff_lines[1:]:
458 458 delta = _diff_line_delta(diff_line, new_diff_line)
459 459 if delta < best_delta:
460 460 candidate = new_diff_line
461 461 best_delta = delta
462 462 return candidate
463 463
464 464 def _visible_inline_comments_of_pull_request(self, pull_request):
465 465 comments = self._all_inline_comments_of_pull_request(pull_request)
466 466 comments = comments.filter(
467 467 coalesce(ChangesetComment.display_state, '') !=
468 468 ChangesetComment.COMMENT_OUTDATED)
469 469 return comments
470 470
471 471 def _all_inline_comments_of_pull_request(self, pull_request):
472 472 comments = Session().query(ChangesetComment)\
473 473 .filter(ChangesetComment.line_no != None)\
474 474 .filter(ChangesetComment.f_path != None)\
475 475 .filter(ChangesetComment.pull_request == pull_request)
476 476 return comments
477 477
478 478 @staticmethod
479 479 def use_outdated_comments(pull_request):
480 480 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
481 481 settings = settings_model.get_general_settings()
482 482 return settings.get('rhodecode_use_outdated_comments', False)
483 483
484 484
485 485 def _parse_comment_line_number(line_no):
486 486 """
487 487 Parses line numbers of the form "(o|n)\d+" and returns them in a tuple.
488 488 """
489 489 old_line = None
490 490 new_line = None
491 491 if line_no.startswith('o'):
492 492 old_line = int(line_no[1:])
493 493 elif line_no.startswith('n'):
494 494 new_line = int(line_no[1:])
495 495 else:
496 496 raise ValueError("Comment lines have to start with either 'o' or 'n'.")
497 497 return diffs.DiffLineNumber(old_line, new_line)
498 498
499 499
500 500 def _diff_to_comment_line_number(diff_line):
501 501 if diff_line.new is not None:
502 502 return u'n{}'.format(diff_line.new)
503 503 elif diff_line.old is not None:
504 504 return u'o{}'.format(diff_line.old)
505 505 return u''
506 506
507 507
508 508 def _diff_line_delta(a, b):
509 509 if None not in (a.new, b.new):
510 510 return abs(a.new - b.new)
511 511 elif None not in (a.old, b.old):
512 512 return abs(a.old - b.old)
513 513 else:
514 514 raise ValueError(
515 515 "Cannot compute delta between {} and {}".format(a, b))
General Comments 0
You need to be logged in to leave comments. Login now