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