##// END OF EJS Templates
comments-api: make edit API resilent to bad version data.
marcink -
r4443:d2ba3852 default
parent child Browse files
Show More
@@ -1,833 +1,837 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2011-2020 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 import datetime
25 25
26 26 import logging
27 27 import traceback
28 28 import collections
29 29
30 30 from pyramid.threadlocal import get_current_registry, get_current_request
31 31 from sqlalchemy.sql.expression import null
32 32 from sqlalchemy.sql.functions import coalesce
33 33
34 34 from rhodecode.lib import helpers as h, diffs, channelstream, hooks_utils
35 35 from rhodecode.lib import audit_logger
36 36 from rhodecode.lib.exceptions import CommentVersionMismatch
37 from rhodecode.lib.utils2 import extract_mentioned_users, safe_str
37 from rhodecode.lib.utils2 import extract_mentioned_users, safe_str, safe_int
38 38 from rhodecode.model import BaseModel
39 39 from rhodecode.model.db import (
40 40 ChangesetComment,
41 41 User,
42 42 Notification,
43 43 PullRequest,
44 44 AttributeDict,
45 45 ChangesetCommentHistory,
46 46 )
47 47 from rhodecode.model.notification import NotificationModel
48 48 from rhodecode.model.meta import Session
49 49 from rhodecode.model.settings import VcsSettingsModel
50 50 from rhodecode.model.notification import EmailNotificationModel
51 51 from rhodecode.model.validation_schema.schemas import comment_schema
52 52
53 53
54 54 log = logging.getLogger(__name__)
55 55
56 56
57 57 class CommentsModel(BaseModel):
58 58
59 59 cls = ChangesetComment
60 60
61 61 DIFF_CONTEXT_BEFORE = 3
62 62 DIFF_CONTEXT_AFTER = 3
63 63
64 64 def __get_commit_comment(self, changeset_comment):
65 65 return self._get_instance(ChangesetComment, changeset_comment)
66 66
67 67 def __get_pull_request(self, pull_request):
68 68 return self._get_instance(PullRequest, pull_request)
69 69
70 70 def _extract_mentions(self, s):
71 71 user_objects = []
72 72 for username in extract_mentioned_users(s):
73 73 user_obj = User.get_by_username(username, case_insensitive=True)
74 74 if user_obj:
75 75 user_objects.append(user_obj)
76 76 return user_objects
77 77
78 78 def _get_renderer(self, global_renderer='rst', request=None):
79 79 request = request or get_current_request()
80 80
81 81 try:
82 82 global_renderer = request.call_context.visual.default_renderer
83 83 except AttributeError:
84 84 log.debug("Renderer not set, falling back "
85 85 "to default renderer '%s'", global_renderer)
86 86 except Exception:
87 87 log.error(traceback.format_exc())
88 88 return global_renderer
89 89
90 90 def aggregate_comments(self, comments, versions, show_version, inline=False):
91 91 # group by versions, and count until, and display objects
92 92
93 93 comment_groups = collections.defaultdict(list)
94 94 [comment_groups[
95 95 _co.pull_request_version_id].append(_co) for _co in comments]
96 96
97 97 def yield_comments(pos):
98 98 for co in comment_groups[pos]:
99 99 yield co
100 100
101 101 comment_versions = collections.defaultdict(
102 102 lambda: collections.defaultdict(list))
103 103 prev_prvid = -1
104 104 # fake last entry with None, to aggregate on "latest" version which
105 105 # doesn't have an pull_request_version_id
106 106 for ver in versions + [AttributeDict({'pull_request_version_id': None})]:
107 107 prvid = ver.pull_request_version_id
108 108 if prev_prvid == -1:
109 109 prev_prvid = prvid
110 110
111 111 for co in yield_comments(prvid):
112 112 comment_versions[prvid]['at'].append(co)
113 113
114 114 # save until
115 115 current = comment_versions[prvid]['at']
116 116 prev_until = comment_versions[prev_prvid]['until']
117 117 cur_until = prev_until + current
118 118 comment_versions[prvid]['until'].extend(cur_until)
119 119
120 120 # save outdated
121 121 if inline:
122 122 outdated = [x for x in cur_until
123 123 if x.outdated_at_version(show_version)]
124 124 else:
125 125 outdated = [x for x in cur_until
126 126 if x.older_than_version(show_version)]
127 127 display = [x for x in cur_until if x not in outdated]
128 128
129 129 comment_versions[prvid]['outdated'] = outdated
130 130 comment_versions[prvid]['display'] = display
131 131
132 132 prev_prvid = prvid
133 133
134 134 return comment_versions
135 135
136 136 def get_repository_comments(self, repo, comment_type=None, user=None, commit_id=None):
137 137 qry = Session().query(ChangesetComment) \
138 138 .filter(ChangesetComment.repo == repo)
139 139
140 140 if comment_type and comment_type in ChangesetComment.COMMENT_TYPES:
141 141 qry = qry.filter(ChangesetComment.comment_type == comment_type)
142 142
143 143 if user:
144 144 user = self._get_user(user)
145 145 if user:
146 146 qry = qry.filter(ChangesetComment.user_id == user.user_id)
147 147
148 148 if commit_id:
149 149 qry = qry.filter(ChangesetComment.revision == commit_id)
150 150
151 151 qry = qry.order_by(ChangesetComment.created_on)
152 152 return qry.all()
153 153
154 154 def get_repository_unresolved_todos(self, repo):
155 155 todos = Session().query(ChangesetComment) \
156 156 .filter(ChangesetComment.repo == repo) \
157 157 .filter(ChangesetComment.resolved_by == None) \
158 158 .filter(ChangesetComment.comment_type
159 159 == ChangesetComment.COMMENT_TYPE_TODO)
160 160 todos = todos.all()
161 161
162 162 return todos
163 163
164 164 def get_pull_request_unresolved_todos(self, pull_request, show_outdated=True):
165 165
166 166 todos = Session().query(ChangesetComment) \
167 167 .filter(ChangesetComment.pull_request == pull_request) \
168 168 .filter(ChangesetComment.resolved_by == None) \
169 169 .filter(ChangesetComment.comment_type
170 170 == ChangesetComment.COMMENT_TYPE_TODO)
171 171
172 172 if not show_outdated:
173 173 todos = todos.filter(
174 174 coalesce(ChangesetComment.display_state, '') !=
175 175 ChangesetComment.COMMENT_OUTDATED)
176 176
177 177 todos = todos.all()
178 178
179 179 return todos
180 180
181 181 def get_pull_request_resolved_todos(self, pull_request, show_outdated=True):
182 182
183 183 todos = Session().query(ChangesetComment) \
184 184 .filter(ChangesetComment.pull_request == pull_request) \
185 185 .filter(ChangesetComment.resolved_by != None) \
186 186 .filter(ChangesetComment.comment_type
187 187 == ChangesetComment.COMMENT_TYPE_TODO)
188 188
189 189 if not show_outdated:
190 190 todos = todos.filter(
191 191 coalesce(ChangesetComment.display_state, '') !=
192 192 ChangesetComment.COMMENT_OUTDATED)
193 193
194 194 todos = todos.all()
195 195
196 196 return todos
197 197
198 198 def get_commit_unresolved_todos(self, commit_id, show_outdated=True):
199 199
200 200 todos = Session().query(ChangesetComment) \
201 201 .filter(ChangesetComment.revision == commit_id) \
202 202 .filter(ChangesetComment.resolved_by == None) \
203 203 .filter(ChangesetComment.comment_type
204 204 == ChangesetComment.COMMENT_TYPE_TODO)
205 205
206 206 if not show_outdated:
207 207 todos = todos.filter(
208 208 coalesce(ChangesetComment.display_state, '') !=
209 209 ChangesetComment.COMMENT_OUTDATED)
210 210
211 211 todos = todos.all()
212 212
213 213 return todos
214 214
215 215 def get_commit_resolved_todos(self, commit_id, show_outdated=True):
216 216
217 217 todos = Session().query(ChangesetComment) \
218 218 .filter(ChangesetComment.revision == commit_id) \
219 219 .filter(ChangesetComment.resolved_by != None) \
220 220 .filter(ChangesetComment.comment_type
221 221 == ChangesetComment.COMMENT_TYPE_TODO)
222 222
223 223 if not show_outdated:
224 224 todos = todos.filter(
225 225 coalesce(ChangesetComment.display_state, '') !=
226 226 ChangesetComment.COMMENT_OUTDATED)
227 227
228 228 todos = todos.all()
229 229
230 230 return todos
231 231
232 232 def _log_audit_action(self, action, action_data, auth_user, comment):
233 233 audit_logger.store(
234 234 action=action,
235 235 action_data=action_data,
236 236 user=auth_user,
237 237 repo=comment.repo)
238 238
239 239 def create(self, text, repo, user, commit_id=None, pull_request=None,
240 240 f_path=None, line_no=None, status_change=None,
241 241 status_change_type=None, comment_type=None,
242 242 resolves_comment_id=None, closing_pr=False, send_email=True,
243 243 renderer=None, auth_user=None, extra_recipients=None):
244 244 """
245 245 Creates new comment for commit or pull request.
246 246 IF status_change is not none this comment is associated with a
247 247 status change of commit or commit associated with pull request
248 248
249 249 :param text:
250 250 :param repo:
251 251 :param user:
252 252 :param commit_id:
253 253 :param pull_request:
254 254 :param f_path:
255 255 :param line_no:
256 256 :param status_change: Label for status change
257 257 :param comment_type: Type of comment
258 258 :param resolves_comment_id: id of comment which this one will resolve
259 259 :param status_change_type: type of status change
260 260 :param closing_pr:
261 261 :param send_email:
262 262 :param renderer: pick renderer for this comment
263 263 :param auth_user: current authenticated user calling this method
264 264 :param extra_recipients: list of extra users to be added to recipients
265 265 """
266 266
267 267 if not text:
268 268 log.warning('Missing text for comment, skipping...')
269 269 return
270 270 request = get_current_request()
271 271 _ = request.translate
272 272
273 273 if not renderer:
274 274 renderer = self._get_renderer(request=request)
275 275
276 276 repo = self._get_repo(repo)
277 277 user = self._get_user(user)
278 278 auth_user = auth_user or user
279 279
280 280 schema = comment_schema.CommentSchema()
281 281 validated_kwargs = schema.deserialize(dict(
282 282 comment_body=text,
283 283 comment_type=comment_type,
284 284 comment_file=f_path,
285 285 comment_line=line_no,
286 286 renderer_type=renderer,
287 287 status_change=status_change_type,
288 288 resolves_comment_id=resolves_comment_id,
289 289 repo=repo.repo_id,
290 290 user=user.user_id,
291 291 ))
292 292
293 293 comment = ChangesetComment()
294 294 comment.renderer = validated_kwargs['renderer_type']
295 295 comment.text = validated_kwargs['comment_body']
296 296 comment.f_path = validated_kwargs['comment_file']
297 297 comment.line_no = validated_kwargs['comment_line']
298 298 comment.comment_type = validated_kwargs['comment_type']
299 299
300 300 comment.repo = repo
301 301 comment.author = user
302 302 resolved_comment = self.__get_commit_comment(
303 303 validated_kwargs['resolves_comment_id'])
304 304 # check if the comment actually belongs to this PR
305 305 if resolved_comment and resolved_comment.pull_request and \
306 306 resolved_comment.pull_request != pull_request:
307 307 log.warning('Comment tried to resolved unrelated todo comment: %s',
308 308 resolved_comment)
309 309 # comment not bound to this pull request, forbid
310 310 resolved_comment = None
311 311
312 312 elif resolved_comment and resolved_comment.repo and \
313 313 resolved_comment.repo != repo:
314 314 log.warning('Comment tried to resolved unrelated todo comment: %s',
315 315 resolved_comment)
316 316 # comment not bound to this repo, forbid
317 317 resolved_comment = None
318 318
319 319 comment.resolved_comment = resolved_comment
320 320
321 321 pull_request_id = pull_request
322 322
323 323 commit_obj = None
324 324 pull_request_obj = None
325 325
326 326 if commit_id:
327 327 notification_type = EmailNotificationModel.TYPE_COMMIT_COMMENT
328 328 # do a lookup, so we don't pass something bad here
329 329 commit_obj = repo.scm_instance().get_commit(commit_id=commit_id)
330 330 comment.revision = commit_obj.raw_id
331 331
332 332 elif pull_request_id:
333 333 notification_type = EmailNotificationModel.TYPE_PULL_REQUEST_COMMENT
334 334 pull_request_obj = self.__get_pull_request(pull_request_id)
335 335 comment.pull_request = pull_request_obj
336 336 else:
337 337 raise Exception('Please specify commit or pull_request_id')
338 338
339 339 Session().add(comment)
340 340 Session().flush()
341 341 kwargs = {
342 342 'user': user,
343 343 'renderer_type': renderer,
344 344 'repo_name': repo.repo_name,
345 345 'status_change': status_change,
346 346 'status_change_type': status_change_type,
347 347 'comment_body': text,
348 348 'comment_file': f_path,
349 349 'comment_line': line_no,
350 350 'comment_type': comment_type or 'note',
351 351 'comment_id': comment.comment_id
352 352 }
353 353
354 354 if commit_obj:
355 355 recipients = ChangesetComment.get_users(
356 356 revision=commit_obj.raw_id)
357 357 # add commit author if it's in RhodeCode system
358 358 cs_author = User.get_from_cs_author(commit_obj.author)
359 359 if not cs_author:
360 360 # use repo owner if we cannot extract the author correctly
361 361 cs_author = repo.user
362 362 recipients += [cs_author]
363 363
364 364 commit_comment_url = self.get_url(comment, request=request)
365 365 commit_comment_reply_url = self.get_url(
366 366 comment, request=request,
367 367 anchor='comment-{}/?/ReplyToComment'.format(comment.comment_id))
368 368
369 369 target_repo_url = h.link_to(
370 370 repo.repo_name,
371 371 h.route_url('repo_summary', repo_name=repo.repo_name))
372 372
373 373 # commit specifics
374 374 kwargs.update({
375 375 'commit': commit_obj,
376 376 'commit_message': commit_obj.message,
377 377 'commit_target_repo_url': target_repo_url,
378 378 'commit_comment_url': commit_comment_url,
379 379 'commit_comment_reply_url': commit_comment_reply_url
380 380 })
381 381
382 382 elif pull_request_obj:
383 383 # get the current participants of this pull request
384 384 recipients = ChangesetComment.get_users(
385 385 pull_request_id=pull_request_obj.pull_request_id)
386 386 # add pull request author
387 387 recipients += [pull_request_obj.author]
388 388
389 389 # add the reviewers to notification
390 390 recipients += [x.user for x in pull_request_obj.reviewers]
391 391
392 392 pr_target_repo = pull_request_obj.target_repo
393 393 pr_source_repo = pull_request_obj.source_repo
394 394
395 395 pr_comment_url = self.get_url(comment, request=request)
396 396 pr_comment_reply_url = self.get_url(
397 397 comment, request=request,
398 398 anchor='comment-{}/?/ReplyToComment'.format(comment.comment_id))
399 399
400 400 pr_url = h.route_url(
401 401 'pullrequest_show',
402 402 repo_name=pr_target_repo.repo_name,
403 403 pull_request_id=pull_request_obj.pull_request_id, )
404 404
405 405 # set some variables for email notification
406 406 pr_target_repo_url = h.route_url(
407 407 'repo_summary', repo_name=pr_target_repo.repo_name)
408 408
409 409 pr_source_repo_url = h.route_url(
410 410 'repo_summary', repo_name=pr_source_repo.repo_name)
411 411
412 412 # pull request specifics
413 413 kwargs.update({
414 414 'pull_request': pull_request_obj,
415 415 'pr_id': pull_request_obj.pull_request_id,
416 416 'pull_request_url': pr_url,
417 417 'pull_request_target_repo': pr_target_repo,
418 418 'pull_request_target_repo_url': pr_target_repo_url,
419 419 'pull_request_source_repo': pr_source_repo,
420 420 'pull_request_source_repo_url': pr_source_repo_url,
421 421 'pr_comment_url': pr_comment_url,
422 422 'pr_comment_reply_url': pr_comment_reply_url,
423 423 'pr_closing': closing_pr,
424 424 })
425 425
426 426 recipients += [self._get_user(u) for u in (extra_recipients or [])]
427 427
428 428 if send_email:
429 429 # pre-generate the subject for notification itself
430 430 (subject,
431 431 _h, _e, # we don't care about those
432 432 body_plaintext) = EmailNotificationModel().render_email(
433 433 notification_type, **kwargs)
434 434
435 435 mention_recipients = set(
436 436 self._extract_mentions(text)).difference(recipients)
437 437
438 438 # create notification objects, and emails
439 439 NotificationModel().create(
440 440 created_by=user,
441 441 notification_subject=subject,
442 442 notification_body=body_plaintext,
443 443 notification_type=notification_type,
444 444 recipients=recipients,
445 445 mention_recipients=mention_recipients,
446 446 email_kwargs=kwargs,
447 447 )
448 448
449 449 Session().flush()
450 450 if comment.pull_request:
451 451 action = 'repo.pull_request.comment.create'
452 452 else:
453 453 action = 'repo.commit.comment.create'
454 454
455 455 comment_data = comment.get_api_data()
456 456 self._log_audit_action(
457 457 action, {'data': comment_data}, auth_user, comment)
458 458
459 459 msg_url = ''
460 460 channel = None
461 461 if commit_obj:
462 462 msg_url = commit_comment_url
463 463 repo_name = repo.repo_name
464 464 channel = u'/repo${}$/commit/{}'.format(
465 465 repo_name,
466 466 commit_obj.raw_id
467 467 )
468 468 elif pull_request_obj:
469 469 msg_url = pr_comment_url
470 470 repo_name = pr_target_repo.repo_name
471 471 channel = u'/repo${}$/pr/{}'.format(
472 472 repo_name,
473 473 pull_request_id
474 474 )
475 475
476 476 message = '<strong>{}</strong> {} - ' \
477 477 '<a onclick="window.location=\'{}\';' \
478 478 'window.location.reload()">' \
479 479 '<strong>{}</strong></a>'
480 480 message = message.format(
481 481 user.username, _('made a comment'), msg_url,
482 482 _('Show it now'))
483 483
484 484 channelstream.post_message(
485 485 channel, message, user.username,
486 486 registry=get_current_registry())
487 487
488 488 return comment
489 489
490 490 def edit(self, comment_id, text, auth_user, version):
491 491 """
492 492 Change existing comment for commit or pull request.
493 493
494 494 :param comment_id:
495 495 :param text:
496 496 :param auth_user: current authenticated user calling this method
497 497 :param version: last comment version
498 498 """
499 499 if not text:
500 500 log.warning('Missing text for comment, skipping...')
501 501 return
502 502
503 503 comment = ChangesetComment.get(comment_id)
504 504 old_comment_text = comment.text
505 505 comment.text = text
506 506 comment.modified_at = datetime.datetime.now()
507 version = safe_int(version)
507 508
509 # NOTE(marcink): this returns initial comment + edits, so v2 from ui
510 # would return 3 here
508 511 comment_version = ChangesetCommentHistory.get_version(comment_id)
509 if (comment_version - version) != 1:
512
513 if isinstance(version, (int, long)) and (comment_version - version) != 1:
510 514 log.warning(
511 515 'Version mismatch comment_version {} submitted {}, skipping'.format(
512 comment_version,
516 comment_version-1, # -1 since note above
513 517 version
514 518 )
515 519 )
516 520 raise CommentVersionMismatch()
517 521
518 522 comment_history = ChangesetCommentHistory()
519 523 comment_history.comment_id = comment_id
520 524 comment_history.version = comment_version
521 525 comment_history.created_by_user_id = auth_user.user_id
522 526 comment_history.text = old_comment_text
523 527 # TODO add email notification
524 528 Session().add(comment_history)
525 529 Session().add(comment)
526 530 Session().flush()
527 531
528 532 if comment.pull_request:
529 533 action = 'repo.pull_request.comment.edit'
530 534 else:
531 535 action = 'repo.commit.comment.edit'
532 536
533 537 comment_data = comment.get_api_data()
534 538 comment_data['old_comment_text'] = old_comment_text
535 539 self._log_audit_action(
536 540 action, {'data': comment_data}, auth_user, comment)
537 541
538 542 return comment_history
539 543
540 544 def delete(self, comment, auth_user):
541 545 """
542 546 Deletes given comment
543 547 """
544 548 comment = self.__get_commit_comment(comment)
545 549 old_data = comment.get_api_data()
546 550 Session().delete(comment)
547 551
548 552 if comment.pull_request:
549 553 action = 'repo.pull_request.comment.delete'
550 554 else:
551 555 action = 'repo.commit.comment.delete'
552 556
553 557 self._log_audit_action(
554 558 action, {'old_data': old_data}, auth_user, comment)
555 559
556 560 return comment
557 561
558 562 def get_all_comments(self, repo_id, revision=None, pull_request=None):
559 563 q = ChangesetComment.query()\
560 564 .filter(ChangesetComment.repo_id == repo_id)
561 565 if revision:
562 566 q = q.filter(ChangesetComment.revision == revision)
563 567 elif pull_request:
564 568 pull_request = self.__get_pull_request(pull_request)
565 569 q = q.filter(ChangesetComment.pull_request == pull_request)
566 570 else:
567 571 raise Exception('Please specify commit or pull_request')
568 572 q = q.order_by(ChangesetComment.created_on)
569 573 return q.all()
570 574
571 575 def get_url(self, comment, request=None, permalink=False, anchor=None):
572 576 if not request:
573 577 request = get_current_request()
574 578
575 579 comment = self.__get_commit_comment(comment)
576 580 if anchor is None:
577 581 anchor = 'comment-{}'.format(comment.comment_id)
578 582
579 583 if comment.pull_request:
580 584 pull_request = comment.pull_request
581 585 if permalink:
582 586 return request.route_url(
583 587 'pull_requests_global',
584 588 pull_request_id=pull_request.pull_request_id,
585 589 _anchor=anchor)
586 590 else:
587 591 return request.route_url(
588 592 'pullrequest_show',
589 593 repo_name=safe_str(pull_request.target_repo.repo_name),
590 594 pull_request_id=pull_request.pull_request_id,
591 595 _anchor=anchor)
592 596
593 597 else:
594 598 repo = comment.repo
595 599 commit_id = comment.revision
596 600
597 601 if permalink:
598 602 return request.route_url(
599 603 'repo_commit', repo_name=safe_str(repo.repo_id),
600 604 commit_id=commit_id,
601 605 _anchor=anchor)
602 606
603 607 else:
604 608 return request.route_url(
605 609 'repo_commit', repo_name=safe_str(repo.repo_name),
606 610 commit_id=commit_id,
607 611 _anchor=anchor)
608 612
609 613 def get_comments(self, repo_id, revision=None, pull_request=None):
610 614 """
611 615 Gets main comments based on revision or pull_request_id
612 616
613 617 :param repo_id:
614 618 :param revision:
615 619 :param pull_request:
616 620 """
617 621
618 622 q = ChangesetComment.query()\
619 623 .filter(ChangesetComment.repo_id == repo_id)\
620 624 .filter(ChangesetComment.line_no == None)\
621 625 .filter(ChangesetComment.f_path == None)
622 626 if revision:
623 627 q = q.filter(ChangesetComment.revision == revision)
624 628 elif pull_request:
625 629 pull_request = self.__get_pull_request(pull_request)
626 630 q = q.filter(ChangesetComment.pull_request == pull_request)
627 631 else:
628 632 raise Exception('Please specify commit or pull_request')
629 633 q = q.order_by(ChangesetComment.created_on)
630 634 return q.all()
631 635
632 636 def get_inline_comments(self, repo_id, revision=None, pull_request=None):
633 637 q = self._get_inline_comments_query(repo_id, revision, pull_request)
634 638 return self._group_comments_by_path_and_line_number(q)
635 639
636 640 def get_inline_comments_count(self, inline_comments, skip_outdated=True,
637 641 version=None):
638 642 inline_cnt = 0
639 643 for fname, per_line_comments in inline_comments.iteritems():
640 644 for lno, comments in per_line_comments.iteritems():
641 645 for comm in comments:
642 646 if not comm.outdated_at_version(version) and skip_outdated:
643 647 inline_cnt += 1
644 648
645 649 return inline_cnt
646 650
647 651 def get_outdated_comments(self, repo_id, pull_request):
648 652 # TODO: johbo: Remove `repo_id`, it is not needed to find the comments
649 653 # of a pull request.
650 654 q = self._all_inline_comments_of_pull_request(pull_request)
651 655 q = q.filter(
652 656 ChangesetComment.display_state ==
653 657 ChangesetComment.COMMENT_OUTDATED
654 658 ).order_by(ChangesetComment.comment_id.asc())
655 659
656 660 return self._group_comments_by_path_and_line_number(q)
657 661
658 662 def _get_inline_comments_query(self, repo_id, revision, pull_request):
659 663 # TODO: johbo: Split this into two methods: One for PR and one for
660 664 # commit.
661 665 if revision:
662 666 q = Session().query(ChangesetComment).filter(
663 667 ChangesetComment.repo_id == repo_id,
664 668 ChangesetComment.line_no != null(),
665 669 ChangesetComment.f_path != null(),
666 670 ChangesetComment.revision == revision)
667 671
668 672 elif pull_request:
669 673 pull_request = self.__get_pull_request(pull_request)
670 674 if not CommentsModel.use_outdated_comments(pull_request):
671 675 q = self._visible_inline_comments_of_pull_request(pull_request)
672 676 else:
673 677 q = self._all_inline_comments_of_pull_request(pull_request)
674 678
675 679 else:
676 680 raise Exception('Please specify commit or pull_request_id')
677 681 q = q.order_by(ChangesetComment.comment_id.asc())
678 682 return q
679 683
680 684 def _group_comments_by_path_and_line_number(self, q):
681 685 comments = q.all()
682 686 paths = collections.defaultdict(lambda: collections.defaultdict(list))
683 687 for co in comments:
684 688 paths[co.f_path][co.line_no].append(co)
685 689 return paths
686 690
687 691 @classmethod
688 692 def needed_extra_diff_context(cls):
689 693 return max(cls.DIFF_CONTEXT_BEFORE, cls.DIFF_CONTEXT_AFTER)
690 694
691 695 def outdate_comments(self, pull_request, old_diff_data, new_diff_data):
692 696 if not CommentsModel.use_outdated_comments(pull_request):
693 697 return
694 698
695 699 comments = self._visible_inline_comments_of_pull_request(pull_request)
696 700 comments_to_outdate = comments.all()
697 701
698 702 for comment in comments_to_outdate:
699 703 self._outdate_one_comment(comment, old_diff_data, new_diff_data)
700 704
701 705 def _outdate_one_comment(self, comment, old_diff_proc, new_diff_proc):
702 706 diff_line = _parse_comment_line_number(comment.line_no)
703 707
704 708 try:
705 709 old_context = old_diff_proc.get_context_of_line(
706 710 path=comment.f_path, diff_line=diff_line)
707 711 new_context = new_diff_proc.get_context_of_line(
708 712 path=comment.f_path, diff_line=diff_line)
709 713 except (diffs.LineNotInDiffException,
710 714 diffs.FileNotInDiffException):
711 715 comment.display_state = ChangesetComment.COMMENT_OUTDATED
712 716 return
713 717
714 718 if old_context == new_context:
715 719 return
716 720
717 721 if self._should_relocate_diff_line(diff_line):
718 722 new_diff_lines = new_diff_proc.find_context(
719 723 path=comment.f_path, context=old_context,
720 724 offset=self.DIFF_CONTEXT_BEFORE)
721 725 if not new_diff_lines:
722 726 comment.display_state = ChangesetComment.COMMENT_OUTDATED
723 727 else:
724 728 new_diff_line = self._choose_closest_diff_line(
725 729 diff_line, new_diff_lines)
726 730 comment.line_no = _diff_to_comment_line_number(new_diff_line)
727 731 else:
728 732 comment.display_state = ChangesetComment.COMMENT_OUTDATED
729 733
730 734 def _should_relocate_diff_line(self, diff_line):
731 735 """
732 736 Checks if relocation shall be tried for the given `diff_line`.
733 737
734 738 If a comment points into the first lines, then we can have a situation
735 739 that after an update another line has been added on top. In this case
736 740 we would find the context still and move the comment around. This
737 741 would be wrong.
738 742 """
739 743 should_relocate = (
740 744 (diff_line.new and diff_line.new > self.DIFF_CONTEXT_BEFORE) or
741 745 (diff_line.old and diff_line.old > self.DIFF_CONTEXT_BEFORE))
742 746 return should_relocate
743 747
744 748 def _choose_closest_diff_line(self, diff_line, new_diff_lines):
745 749 candidate = new_diff_lines[0]
746 750 best_delta = _diff_line_delta(diff_line, candidate)
747 751 for new_diff_line in new_diff_lines[1:]:
748 752 delta = _diff_line_delta(diff_line, new_diff_line)
749 753 if delta < best_delta:
750 754 candidate = new_diff_line
751 755 best_delta = delta
752 756 return candidate
753 757
754 758 def _visible_inline_comments_of_pull_request(self, pull_request):
755 759 comments = self._all_inline_comments_of_pull_request(pull_request)
756 760 comments = comments.filter(
757 761 coalesce(ChangesetComment.display_state, '') !=
758 762 ChangesetComment.COMMENT_OUTDATED)
759 763 return comments
760 764
761 765 def _all_inline_comments_of_pull_request(self, pull_request):
762 766 comments = Session().query(ChangesetComment)\
763 767 .filter(ChangesetComment.line_no != None)\
764 768 .filter(ChangesetComment.f_path != None)\
765 769 .filter(ChangesetComment.pull_request == pull_request)
766 770 return comments
767 771
768 772 def _all_general_comments_of_pull_request(self, pull_request):
769 773 comments = Session().query(ChangesetComment)\
770 774 .filter(ChangesetComment.line_no == None)\
771 775 .filter(ChangesetComment.f_path == None)\
772 776 .filter(ChangesetComment.pull_request == pull_request)
773 777
774 778 return comments
775 779
776 780 @staticmethod
777 781 def use_outdated_comments(pull_request):
778 782 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
779 783 settings = settings_model.get_general_settings()
780 784 return settings.get('rhodecode_use_outdated_comments', False)
781 785
782 786 def trigger_commit_comment_hook(self, repo, user, action, data=None):
783 787 repo = self._get_repo(repo)
784 788 target_scm = repo.scm_instance()
785 789 if action == 'create':
786 790 trigger_hook = hooks_utils.trigger_comment_commit_hooks
787 791 elif action == 'edit':
788 792 # TODO(dan): when this is supported we trigger edit hook too
789 793 return
790 794 else:
791 795 return
792 796
793 797 log.debug('Handling repo %s trigger_commit_comment_hook with action %s: %s',
794 798 repo, action, trigger_hook)
795 799 trigger_hook(
796 800 username=user.username,
797 801 repo_name=repo.repo_name,
798 802 repo_type=target_scm.alias,
799 803 repo=repo,
800 804 data=data)
801 805
802 806
803 807 def _parse_comment_line_number(line_no):
804 808 """
805 809 Parses line numbers of the form "(o|n)\d+" and returns them in a tuple.
806 810 """
807 811 old_line = None
808 812 new_line = None
809 813 if line_no.startswith('o'):
810 814 old_line = int(line_no[1:])
811 815 elif line_no.startswith('n'):
812 816 new_line = int(line_no[1:])
813 817 else:
814 818 raise ValueError("Comment lines have to start with either 'o' or 'n'.")
815 819 return diffs.DiffLineNumber(old_line, new_line)
816 820
817 821
818 822 def _diff_to_comment_line_number(diff_line):
819 823 if diff_line.new is not None:
820 824 return u'n{}'.format(diff_line.new)
821 825 elif diff_line.old is not None:
822 826 return u'o{}'.format(diff_line.old)
823 827 return u''
824 828
825 829
826 830 def _diff_line_delta(a, b):
827 831 if None not in (a.new, b.new):
828 832 return abs(a.new - b.new)
829 833 elif None not in (a.old, b.old):
830 834 return abs(a.old - b.old)
831 835 else:
832 836 raise ValueError(
833 837 "Cannot compute delta between {} and {}".format(a, b))
General Comments 0
You need to be logged in to leave comments. Login now