##// END OF EJS Templates
comments: add ability to resolve todos from the side-bar.
milka -
r4633:bc11fd7f stable
parent child Browse files
Show More
@@ -1,852 +1,857 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 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 false, true,
41 41 ChangesetComment,
42 42 User,
43 43 Notification,
44 44 PullRequest,
45 45 AttributeDict,
46 46 ChangesetCommentHistory,
47 47 )
48 48 from rhodecode.model.notification import NotificationModel
49 49 from rhodecode.model.meta import Session
50 50 from rhodecode.model.settings import VcsSettingsModel
51 51 from rhodecode.model.notification import EmailNotificationModel
52 52 from rhodecode.model.validation_schema.schemas import comment_schema
53 53
54 54
55 55 log = logging.getLogger(__name__)
56 56
57 57
58 58 class CommentsModel(BaseModel):
59 59
60 60 cls = ChangesetComment
61 61
62 62 DIFF_CONTEXT_BEFORE = 3
63 63 DIFF_CONTEXT_AFTER = 3
64 64
65 65 def __get_commit_comment(self, changeset_comment):
66 66 return self._get_instance(ChangesetComment, changeset_comment)
67 67
68 68 def __get_pull_request(self, pull_request):
69 69 return self._get_instance(PullRequest, pull_request)
70 70
71 71 def _extract_mentions(self, s):
72 72 user_objects = []
73 73 for username in extract_mentioned_users(s):
74 74 user_obj = User.get_by_username(username, case_insensitive=True)
75 75 if user_obj:
76 76 user_objects.append(user_obj)
77 77 return user_objects
78 78
79 79 def _get_renderer(self, global_renderer='rst', request=None):
80 80 request = request or get_current_request()
81 81
82 82 try:
83 83 global_renderer = request.call_context.visual.default_renderer
84 84 except AttributeError:
85 85 log.debug("Renderer not set, falling back "
86 86 "to default renderer '%s'", global_renderer)
87 87 except Exception:
88 88 log.error(traceback.format_exc())
89 89 return global_renderer
90 90
91 91 def aggregate_comments(self, comments, versions, show_version, inline=False):
92 92 # group by versions, and count until, and display objects
93 93
94 94 comment_groups = collections.defaultdict(list)
95 95 [comment_groups[_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, include_drafts=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 include_drafts:
173 173 todos = todos.filter(ChangesetComment.draft == false())
174 174
175 175 if not show_outdated:
176 176 todos = todos.filter(
177 177 coalesce(ChangesetComment.display_state, '') !=
178 178 ChangesetComment.COMMENT_OUTDATED)
179 179
180 180 todos = todos.all()
181 181
182 182 return todos
183 183
184 184 def get_pull_request_resolved_todos(self, pull_request, show_outdated=True, include_drafts=True):
185 185
186 186 todos = Session().query(ChangesetComment) \
187 187 .filter(ChangesetComment.pull_request == pull_request) \
188 188 .filter(ChangesetComment.resolved_by != None) \
189 189 .filter(ChangesetComment.comment_type
190 190 == ChangesetComment.COMMENT_TYPE_TODO)
191 191
192 192 if not include_drafts:
193 193 todos = todos.filter(ChangesetComment.draft == false())
194 194
195 195 if not show_outdated:
196 196 todos = todos.filter(
197 197 coalesce(ChangesetComment.display_state, '') !=
198 198 ChangesetComment.COMMENT_OUTDATED)
199 199
200 200 todos = todos.all()
201 201
202 202 return todos
203 203
204 204 def get_pull_request_drafts(self, user_id, pull_request):
205 205 drafts = Session().query(ChangesetComment) \
206 206 .filter(ChangesetComment.pull_request == pull_request) \
207 207 .filter(ChangesetComment.user_id == user_id) \
208 208 .filter(ChangesetComment.draft == true())
209 209 return drafts.all()
210 210
211 211 def get_commit_unresolved_todos(self, commit_id, show_outdated=True, include_drafts=True):
212 212
213 213 todos = Session().query(ChangesetComment) \
214 214 .filter(ChangesetComment.revision == commit_id) \
215 215 .filter(ChangesetComment.resolved_by == None) \
216 216 .filter(ChangesetComment.comment_type
217 217 == ChangesetComment.COMMENT_TYPE_TODO)
218 218
219 219 if not include_drafts:
220 220 todos = todos.filter(ChangesetComment.draft == false())
221 221
222 222 if not show_outdated:
223 223 todos = todos.filter(
224 224 coalesce(ChangesetComment.display_state, '') !=
225 225 ChangesetComment.COMMENT_OUTDATED)
226 226
227 227 todos = todos.all()
228 228
229 229 return todos
230 230
231 231 def get_commit_resolved_todos(self, commit_id, show_outdated=True, include_drafts=True):
232 232
233 233 todos = Session().query(ChangesetComment) \
234 234 .filter(ChangesetComment.revision == commit_id) \
235 235 .filter(ChangesetComment.resolved_by != None) \
236 236 .filter(ChangesetComment.comment_type
237 237 == ChangesetComment.COMMENT_TYPE_TODO)
238 238
239 239 if not include_drafts:
240 240 todos = todos.filter(ChangesetComment.draft == false())
241 241
242 242 if not show_outdated:
243 243 todos = todos.filter(
244 244 coalesce(ChangesetComment.display_state, '') !=
245 245 ChangesetComment.COMMENT_OUTDATED)
246 246
247 247 todos = todos.all()
248 248
249 249 return todos
250 250
251 251 def get_commit_inline_comments(self, commit_id, include_drafts=True):
252 252 inline_comments = Session().query(ChangesetComment) \
253 253 .filter(ChangesetComment.line_no != None) \
254 254 .filter(ChangesetComment.f_path != None) \
255 255 .filter(ChangesetComment.revision == commit_id)
256 256
257 257 if not include_drafts:
258 258 inline_comments = inline_comments.filter(ChangesetComment.draft == false())
259 259
260 260 inline_comments = inline_comments.all()
261 261 return inline_comments
262 262
263 263 def _log_audit_action(self, action, action_data, auth_user, comment):
264 264 audit_logger.store(
265 265 action=action,
266 266 action_data=action_data,
267 267 user=auth_user,
268 268 repo=comment.repo)
269 269
270 270 def create(self, text, repo, user, commit_id=None, pull_request=None,
271 271 f_path=None, line_no=None, status_change=None,
272 272 status_change_type=None, comment_type=None, is_draft=False,
273 273 resolves_comment_id=None, closing_pr=False, send_email=True,
274 274 renderer=None, auth_user=None, extra_recipients=None):
275 275 """
276 276 Creates new comment for commit or pull request.
277 277 IF status_change is not none this comment is associated with a
278 278 status change of commit or commit associated with pull request
279 279
280 280 :param text:
281 281 :param repo:
282 282 :param user:
283 283 :param commit_id:
284 284 :param pull_request:
285 285 :param f_path:
286 286 :param line_no:
287 287 :param status_change: Label for status change
288 288 :param comment_type: Type of comment
289 289 :param is_draft: is comment a draft only
290 290 :param resolves_comment_id: id of comment which this one will resolve
291 291 :param status_change_type: type of status change
292 292 :param closing_pr:
293 293 :param send_email:
294 294 :param renderer: pick renderer for this comment
295 295 :param auth_user: current authenticated user calling this method
296 296 :param extra_recipients: list of extra users to be added to recipients
297 297 """
298 298
299 299 if not text:
300 300 log.warning('Missing text for comment, skipping...')
301 301 return
302 302 request = get_current_request()
303 303 _ = request.translate
304 304
305 305 if not renderer:
306 306 renderer = self._get_renderer(request=request)
307 307
308 308 repo = self._get_repo(repo)
309 309 user = self._get_user(user)
310 310 auth_user = auth_user or user
311 311
312 312 schema = comment_schema.CommentSchema()
313 313 validated_kwargs = schema.deserialize(dict(
314 314 comment_body=text,
315 315 comment_type=comment_type,
316 316 is_draft=is_draft,
317 317 comment_file=f_path,
318 318 comment_line=line_no,
319 319 renderer_type=renderer,
320 320 status_change=status_change_type,
321 321 resolves_comment_id=resolves_comment_id,
322 322 repo=repo.repo_id,
323 323 user=user.user_id,
324 324 ))
325 325 is_draft = validated_kwargs['is_draft']
326 326
327 327 comment = ChangesetComment()
328 328 comment.renderer = validated_kwargs['renderer_type']
329 329 comment.text = validated_kwargs['comment_body']
330 330 comment.f_path = validated_kwargs['comment_file']
331 331 comment.line_no = validated_kwargs['comment_line']
332 332 comment.comment_type = validated_kwargs['comment_type']
333 333 comment.draft = is_draft
334 334
335 335 comment.repo = repo
336 336 comment.author = user
337 337 resolved_comment = self.__get_commit_comment(
338 338 validated_kwargs['resolves_comment_id'])
339
339 340 # check if the comment actually belongs to this PR
340 341 if resolved_comment and resolved_comment.pull_request and \
341 342 resolved_comment.pull_request != pull_request:
342 343 log.warning('Comment tried to resolved unrelated todo comment: %s',
343 344 resolved_comment)
344 345 # comment not bound to this pull request, forbid
345 346 resolved_comment = None
346 347
347 348 elif resolved_comment and resolved_comment.repo and \
348 349 resolved_comment.repo != repo:
349 350 log.warning('Comment tried to resolved unrelated todo comment: %s',
350 351 resolved_comment)
351 352 # comment not bound to this repo, forbid
352 353 resolved_comment = None
353 354
355 if resolved_comment and resolved_comment.resolved_by:
356 # if this comment is already resolved, don't mark it again!
357 resolved_comment = None
358
354 359 comment.resolved_comment = resolved_comment
355 360
356 361 pull_request_id = pull_request
357 362
358 363 commit_obj = None
359 364 pull_request_obj = None
360 365
361 366 if commit_id:
362 367 notification_type = EmailNotificationModel.TYPE_COMMIT_COMMENT
363 368 # do a lookup, so we don't pass something bad here
364 369 commit_obj = repo.scm_instance().get_commit(commit_id=commit_id)
365 370 comment.revision = commit_obj.raw_id
366 371
367 372 elif pull_request_id:
368 373 notification_type = EmailNotificationModel.TYPE_PULL_REQUEST_COMMENT
369 374 pull_request_obj = self.__get_pull_request(pull_request_id)
370 375 comment.pull_request = pull_request_obj
371 376 else:
372 377 raise Exception('Please specify commit or pull_request_id')
373 378
374 379 Session().add(comment)
375 380 Session().flush()
376 381 kwargs = {
377 382 'user': user,
378 383 'renderer_type': renderer,
379 384 'repo_name': repo.repo_name,
380 385 'status_change': status_change,
381 386 'status_change_type': status_change_type,
382 387 'comment_body': text,
383 388 'comment_file': f_path,
384 389 'comment_line': line_no,
385 390 'comment_type': comment_type or 'note',
386 391 'comment_id': comment.comment_id
387 392 }
388 393
389 394 if commit_obj:
390 395 recipients = ChangesetComment.get_users(
391 396 revision=commit_obj.raw_id)
392 397 # add commit author if it's in RhodeCode system
393 398 cs_author = User.get_from_cs_author(commit_obj.author)
394 399 if not cs_author:
395 400 # use repo owner if we cannot extract the author correctly
396 401 cs_author = repo.user
397 402 recipients += [cs_author]
398 403
399 404 commit_comment_url = self.get_url(comment, request=request)
400 405 commit_comment_reply_url = self.get_url(
401 406 comment, request=request,
402 407 anchor='comment-{}/?/ReplyToComment'.format(comment.comment_id))
403 408
404 409 target_repo_url = h.link_to(
405 410 repo.repo_name,
406 411 h.route_url('repo_summary', repo_name=repo.repo_name))
407 412
408 413 commit_url = h.route_url('repo_commit', repo_name=repo.repo_name,
409 414 commit_id=commit_id)
410 415
411 416 # commit specifics
412 417 kwargs.update({
413 418 'commit': commit_obj,
414 419 'commit_message': commit_obj.message,
415 420 'commit_target_repo_url': target_repo_url,
416 421 'commit_comment_url': commit_comment_url,
417 422 'commit_comment_reply_url': commit_comment_reply_url,
418 423 'commit_url': commit_url,
419 424 'thread_ids': [commit_url, commit_comment_url],
420 425 })
421 426
422 427 elif pull_request_obj:
423 428 # get the current participants of this pull request
424 429 recipients = ChangesetComment.get_users(
425 430 pull_request_id=pull_request_obj.pull_request_id)
426 431 # add pull request author
427 432 recipients += [pull_request_obj.author]
428 433
429 434 # add the reviewers to notification
430 435 recipients += [x.user for x in pull_request_obj.get_pull_request_reviewers()]
431 436
432 437 pr_target_repo = pull_request_obj.target_repo
433 438 pr_source_repo = pull_request_obj.source_repo
434 439
435 440 pr_comment_url = self.get_url(comment, request=request)
436 441 pr_comment_reply_url = self.get_url(
437 442 comment, request=request,
438 443 anchor='comment-{}/?/ReplyToComment'.format(comment.comment_id))
439 444
440 445 pr_url = h.route_url(
441 446 'pullrequest_show',
442 447 repo_name=pr_target_repo.repo_name,
443 448 pull_request_id=pull_request_obj.pull_request_id, )
444 449
445 450 # set some variables for email notification
446 451 pr_target_repo_url = h.route_url(
447 452 'repo_summary', repo_name=pr_target_repo.repo_name)
448 453
449 454 pr_source_repo_url = h.route_url(
450 455 'repo_summary', repo_name=pr_source_repo.repo_name)
451 456
452 457 # pull request specifics
453 458 kwargs.update({
454 459 'pull_request': pull_request_obj,
455 460 'pr_id': pull_request_obj.pull_request_id,
456 461 'pull_request_url': pr_url,
457 462 'pull_request_target_repo': pr_target_repo,
458 463 'pull_request_target_repo_url': pr_target_repo_url,
459 464 'pull_request_source_repo': pr_source_repo,
460 465 'pull_request_source_repo_url': pr_source_repo_url,
461 466 'pr_comment_url': pr_comment_url,
462 467 'pr_comment_reply_url': pr_comment_reply_url,
463 468 'pr_closing': closing_pr,
464 469 'thread_ids': [pr_url, pr_comment_url],
465 470 })
466 471
467 472 if send_email:
468 473 recipients += [self._get_user(u) for u in (extra_recipients or [])]
469 474
470 475 mention_recipients = set(
471 476 self._extract_mentions(text)).difference(recipients)
472 477
473 478 # create notification objects, and emails
474 479 NotificationModel().create(
475 480 created_by=user,
476 481 notification_subject='', # Filled in based on the notification_type
477 482 notification_body='', # Filled in based on the notification_type
478 483 notification_type=notification_type,
479 484 recipients=recipients,
480 485 mention_recipients=mention_recipients,
481 486 email_kwargs=kwargs,
482 487 )
483 488
484 489 Session().flush()
485 490 if comment.pull_request:
486 491 action = 'repo.pull_request.comment.create'
487 492 else:
488 493 action = 'repo.commit.comment.create'
489 494
490 495 if not is_draft:
491 496 comment_data = comment.get_api_data()
492 497
493 498 self._log_audit_action(
494 499 action, {'data': comment_data}, auth_user, comment)
495 500
496 501 return comment
497 502
498 503 def edit(self, comment_id, text, auth_user, version):
499 504 """
500 505 Change existing comment for commit or pull request.
501 506
502 507 :param comment_id:
503 508 :param text:
504 509 :param auth_user: current authenticated user calling this method
505 510 :param version: last comment version
506 511 """
507 512 if not text:
508 513 log.warning('Missing text for comment, skipping...')
509 514 return
510 515
511 516 comment = ChangesetComment.get(comment_id)
512 517 old_comment_text = comment.text
513 518 comment.text = text
514 519 comment.modified_at = datetime.datetime.now()
515 520 version = safe_int(version)
516 521
517 522 # NOTE(marcink): this returns initial comment + edits, so v2 from ui
518 523 # would return 3 here
519 524 comment_version = ChangesetCommentHistory.get_version(comment_id)
520 525
521 526 if isinstance(version, (int, long)) and (comment_version - version) != 1:
522 527 log.warning(
523 528 'Version mismatch comment_version {} submitted {}, skipping'.format(
524 529 comment_version-1, # -1 since note above
525 530 version
526 531 )
527 532 )
528 533 raise CommentVersionMismatch()
529 534
530 535 comment_history = ChangesetCommentHistory()
531 536 comment_history.comment_id = comment_id
532 537 comment_history.version = comment_version
533 538 comment_history.created_by_user_id = auth_user.user_id
534 539 comment_history.text = old_comment_text
535 540 # TODO add email notification
536 541 Session().add(comment_history)
537 542 Session().add(comment)
538 543 Session().flush()
539 544
540 545 if comment.pull_request:
541 546 action = 'repo.pull_request.comment.edit'
542 547 else:
543 548 action = 'repo.commit.comment.edit'
544 549
545 550 comment_data = comment.get_api_data()
546 551 comment_data['old_comment_text'] = old_comment_text
547 552 self._log_audit_action(
548 553 action, {'data': comment_data}, auth_user, comment)
549 554
550 555 return comment_history
551 556
552 557 def delete(self, comment, auth_user):
553 558 """
554 559 Deletes given comment
555 560 """
556 561 comment = self.__get_commit_comment(comment)
557 562 old_data = comment.get_api_data()
558 563 Session().delete(comment)
559 564
560 565 if comment.pull_request:
561 566 action = 'repo.pull_request.comment.delete'
562 567 else:
563 568 action = 'repo.commit.comment.delete'
564 569
565 570 self._log_audit_action(
566 571 action, {'old_data': old_data}, auth_user, comment)
567 572
568 573 return comment
569 574
570 575 def get_all_comments(self, repo_id, revision=None, pull_request=None,
571 576 include_drafts=True, count_only=False):
572 577 q = ChangesetComment.query()\
573 578 .filter(ChangesetComment.repo_id == repo_id)
574 579 if revision:
575 580 q = q.filter(ChangesetComment.revision == revision)
576 581 elif pull_request:
577 582 pull_request = self.__get_pull_request(pull_request)
578 583 q = q.filter(ChangesetComment.pull_request_id == pull_request.pull_request_id)
579 584 else:
580 585 raise Exception('Please specify commit or pull_request')
581 586 if not include_drafts:
582 587 q = q.filter(ChangesetComment.draft == false())
583 588 q = q.order_by(ChangesetComment.created_on)
584 589 if count_only:
585 590 return q.count()
586 591
587 592 return q.all()
588 593
589 594 def get_url(self, comment, request=None, permalink=False, anchor=None):
590 595 if not request:
591 596 request = get_current_request()
592 597
593 598 comment = self.__get_commit_comment(comment)
594 599 if anchor is None:
595 600 anchor = 'comment-{}'.format(comment.comment_id)
596 601
597 602 if comment.pull_request:
598 603 pull_request = comment.pull_request
599 604 if permalink:
600 605 return request.route_url(
601 606 'pull_requests_global',
602 607 pull_request_id=pull_request.pull_request_id,
603 608 _anchor=anchor)
604 609 else:
605 610 return request.route_url(
606 611 'pullrequest_show',
607 612 repo_name=safe_str(pull_request.target_repo.repo_name),
608 613 pull_request_id=pull_request.pull_request_id,
609 614 _anchor=anchor)
610 615
611 616 else:
612 617 repo = comment.repo
613 618 commit_id = comment.revision
614 619
615 620 if permalink:
616 621 return request.route_url(
617 622 'repo_commit', repo_name=safe_str(repo.repo_id),
618 623 commit_id=commit_id,
619 624 _anchor=anchor)
620 625
621 626 else:
622 627 return request.route_url(
623 628 'repo_commit', repo_name=safe_str(repo.repo_name),
624 629 commit_id=commit_id,
625 630 _anchor=anchor)
626 631
627 632 def get_comments(self, repo_id, revision=None, pull_request=None):
628 633 """
629 634 Gets main comments based on revision or pull_request_id
630 635
631 636 :param repo_id:
632 637 :param revision:
633 638 :param pull_request:
634 639 """
635 640
636 641 q = ChangesetComment.query()\
637 642 .filter(ChangesetComment.repo_id == repo_id)\
638 643 .filter(ChangesetComment.line_no == None)\
639 644 .filter(ChangesetComment.f_path == None)
640 645 if revision:
641 646 q = q.filter(ChangesetComment.revision == revision)
642 647 elif pull_request:
643 648 pull_request = self.__get_pull_request(pull_request)
644 649 q = q.filter(ChangesetComment.pull_request == pull_request)
645 650 else:
646 651 raise Exception('Please specify commit or pull_request')
647 652 q = q.order_by(ChangesetComment.created_on)
648 653 return q.all()
649 654
650 655 def get_inline_comments(self, repo_id, revision=None, pull_request=None):
651 656 q = self._get_inline_comments_query(repo_id, revision, pull_request)
652 657 return self._group_comments_by_path_and_line_number(q)
653 658
654 659 def get_inline_comments_as_list(self, inline_comments, skip_outdated=True,
655 660 version=None):
656 661 inline_comms = []
657 662 for fname, per_line_comments in inline_comments.iteritems():
658 663 for lno, comments in per_line_comments.iteritems():
659 664 for comm in comments:
660 665 if not comm.outdated_at_version(version) and skip_outdated:
661 666 inline_comms.append(comm)
662 667
663 668 return inline_comms
664 669
665 670 def get_outdated_comments(self, repo_id, pull_request):
666 671 # TODO: johbo: Remove `repo_id`, it is not needed to find the comments
667 672 # of a pull request.
668 673 q = self._all_inline_comments_of_pull_request(pull_request)
669 674 q = q.filter(
670 675 ChangesetComment.display_state ==
671 676 ChangesetComment.COMMENT_OUTDATED
672 677 ).order_by(ChangesetComment.comment_id.asc())
673 678
674 679 return self._group_comments_by_path_and_line_number(q)
675 680
676 681 def _get_inline_comments_query(self, repo_id, revision, pull_request):
677 682 # TODO: johbo: Split this into two methods: One for PR and one for
678 683 # commit.
679 684 if revision:
680 685 q = Session().query(ChangesetComment).filter(
681 686 ChangesetComment.repo_id == repo_id,
682 687 ChangesetComment.line_no != null(),
683 688 ChangesetComment.f_path != null(),
684 689 ChangesetComment.revision == revision)
685 690
686 691 elif pull_request:
687 692 pull_request = self.__get_pull_request(pull_request)
688 693 if not CommentsModel.use_outdated_comments(pull_request):
689 694 q = self._visible_inline_comments_of_pull_request(pull_request)
690 695 else:
691 696 q = self._all_inline_comments_of_pull_request(pull_request)
692 697
693 698 else:
694 699 raise Exception('Please specify commit or pull_request_id')
695 700 q = q.order_by(ChangesetComment.comment_id.asc())
696 701 return q
697 702
698 703 def _group_comments_by_path_and_line_number(self, q):
699 704 comments = q.all()
700 705 paths = collections.defaultdict(lambda: collections.defaultdict(list))
701 706 for co in comments:
702 707 paths[co.f_path][co.line_no].append(co)
703 708 return paths
704 709
705 710 @classmethod
706 711 def needed_extra_diff_context(cls):
707 712 return max(cls.DIFF_CONTEXT_BEFORE, cls.DIFF_CONTEXT_AFTER)
708 713
709 714 def outdate_comments(self, pull_request, old_diff_data, new_diff_data):
710 715 if not CommentsModel.use_outdated_comments(pull_request):
711 716 return
712 717
713 718 comments = self._visible_inline_comments_of_pull_request(pull_request)
714 719 comments_to_outdate = comments.all()
715 720
716 721 for comment in comments_to_outdate:
717 722 self._outdate_one_comment(comment, old_diff_data, new_diff_data)
718 723
719 724 def _outdate_one_comment(self, comment, old_diff_proc, new_diff_proc):
720 725 diff_line = _parse_comment_line_number(comment.line_no)
721 726
722 727 try:
723 728 old_context = old_diff_proc.get_context_of_line(
724 729 path=comment.f_path, diff_line=diff_line)
725 730 new_context = new_diff_proc.get_context_of_line(
726 731 path=comment.f_path, diff_line=diff_line)
727 732 except (diffs.LineNotInDiffException,
728 733 diffs.FileNotInDiffException):
729 734 if not comment.draft:
730 735 comment.display_state = ChangesetComment.COMMENT_OUTDATED
731 736 return
732 737
733 738 if old_context == new_context:
734 739 return
735 740
736 741 if self._should_relocate_diff_line(diff_line):
737 742 new_diff_lines = new_diff_proc.find_context(
738 743 path=comment.f_path, context=old_context,
739 744 offset=self.DIFF_CONTEXT_BEFORE)
740 745 if not new_diff_lines and not comment.draft:
741 746 comment.display_state = ChangesetComment.COMMENT_OUTDATED
742 747 else:
743 748 new_diff_line = self._choose_closest_diff_line(
744 749 diff_line, new_diff_lines)
745 750 comment.line_no = _diff_to_comment_line_number(new_diff_line)
746 751 else:
747 752 if not comment.draft:
748 753 comment.display_state = ChangesetComment.COMMENT_OUTDATED
749 754
750 755 def _should_relocate_diff_line(self, diff_line):
751 756 """
752 757 Checks if relocation shall be tried for the given `diff_line`.
753 758
754 759 If a comment points into the first lines, then we can have a situation
755 760 that after an update another line has been added on top. In this case
756 761 we would find the context still and move the comment around. This
757 762 would be wrong.
758 763 """
759 764 should_relocate = (
760 765 (diff_line.new and diff_line.new > self.DIFF_CONTEXT_BEFORE) or
761 766 (diff_line.old and diff_line.old > self.DIFF_CONTEXT_BEFORE))
762 767 return should_relocate
763 768
764 769 def _choose_closest_diff_line(self, diff_line, new_diff_lines):
765 770 candidate = new_diff_lines[0]
766 771 best_delta = _diff_line_delta(diff_line, candidate)
767 772 for new_diff_line in new_diff_lines[1:]:
768 773 delta = _diff_line_delta(diff_line, new_diff_line)
769 774 if delta < best_delta:
770 775 candidate = new_diff_line
771 776 best_delta = delta
772 777 return candidate
773 778
774 779 def _visible_inline_comments_of_pull_request(self, pull_request):
775 780 comments = self._all_inline_comments_of_pull_request(pull_request)
776 781 comments = comments.filter(
777 782 coalesce(ChangesetComment.display_state, '') !=
778 783 ChangesetComment.COMMENT_OUTDATED)
779 784 return comments
780 785
781 786 def _all_inline_comments_of_pull_request(self, pull_request):
782 787 comments = Session().query(ChangesetComment)\
783 788 .filter(ChangesetComment.line_no != None)\
784 789 .filter(ChangesetComment.f_path != None)\
785 790 .filter(ChangesetComment.pull_request == pull_request)
786 791 return comments
787 792
788 793 def _all_general_comments_of_pull_request(self, pull_request):
789 794 comments = Session().query(ChangesetComment)\
790 795 .filter(ChangesetComment.line_no == None)\
791 796 .filter(ChangesetComment.f_path == None)\
792 797 .filter(ChangesetComment.pull_request == pull_request)
793 798
794 799 return comments
795 800
796 801 @staticmethod
797 802 def use_outdated_comments(pull_request):
798 803 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
799 804 settings = settings_model.get_general_settings()
800 805 return settings.get('rhodecode_use_outdated_comments', False)
801 806
802 807 def trigger_commit_comment_hook(self, repo, user, action, data=None):
803 808 repo = self._get_repo(repo)
804 809 target_scm = repo.scm_instance()
805 810 if action == 'create':
806 811 trigger_hook = hooks_utils.trigger_comment_commit_hooks
807 812 elif action == 'edit':
808 813 trigger_hook = hooks_utils.trigger_comment_commit_edit_hooks
809 814 else:
810 815 return
811 816
812 817 log.debug('Handling repo %s trigger_commit_comment_hook with action %s: %s',
813 818 repo, action, trigger_hook)
814 819 trigger_hook(
815 820 username=user.username,
816 821 repo_name=repo.repo_name,
817 822 repo_type=target_scm.alias,
818 823 repo=repo,
819 824 data=data)
820 825
821 826
822 827 def _parse_comment_line_number(line_no):
823 828 """
824 829 Parses line numbers of the form "(o|n)\d+" and returns them in a tuple.
825 830 """
826 831 old_line = None
827 832 new_line = None
828 833 if line_no.startswith('o'):
829 834 old_line = int(line_no[1:])
830 835 elif line_no.startswith('n'):
831 836 new_line = int(line_no[1:])
832 837 else:
833 838 raise ValueError("Comment lines have to start with either 'o' or 'n'.")
834 839 return diffs.DiffLineNumber(old_line, new_line)
835 840
836 841
837 842 def _diff_to_comment_line_number(diff_line):
838 843 if diff_line.new is not None:
839 844 return u'n{}'.format(diff_line.new)
840 845 elif diff_line.old is not None:
841 846 return u'o{}'.format(diff_line.old)
842 847 return u''
843 848
844 849
845 850 def _diff_line_delta(a, b):
846 851 if None not in (a.new, b.new):
847 852 return abs(a.new - b.new)
848 853 elif None not in (a.old, b.old):
849 854 return abs(a.old - b.old)
850 855 else:
851 856 raise ValueError(
852 857 "Cannot compute delta between {} and {}".format(a, b))
@@ -1,1501 +1,1639 b''
1 1 // # Copyright (C) 2010-2020 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 var firefoxAnchorFix = function() {
20 20 // hack to make anchor links behave properly on firefox, in our inline
21 21 // comments generation when comments are injected firefox is misbehaving
22 22 // when jumping to anchor links
23 23 if (location.href.indexOf('#') > -1) {
24 24 location.href += '';
25 25 }
26 26 };
27 27
28
28 29 var linkifyComments = function(comments) {
29 30 var firstCommentId = null;
30 31 if (comments) {
31 32 firstCommentId = $(comments[0]).data('comment-id');
32 33 }
33 34
34 35 if (firstCommentId){
35 36 $('#inline-comments-counter').attr('href', '#comment-' + firstCommentId);
36 37 }
37 38 };
38 39
40
39 41 var bindToggleButtons = function() {
40 42 $('.comment-toggle').on('click', function() {
41 43 $(this).parent().nextUntil('tr.line').toggle('inline-comments');
42 44 });
43 45 };
44 46
45 47
46
47 48 var _submitAjaxPOST = function(url, postData, successHandler, failHandler) {
48 49 failHandler = failHandler || function() {};
49 50 postData = toQueryString(postData);
50 51 var request = $.ajax({
51 52 url: url,
52 53 type: 'POST',
53 54 data: postData,
54 55 headers: {'X-PARTIAL-XHR': true}
55 56 })
56 57 .done(function (data) {
57 58 successHandler(data);
58 59 })
59 60 .fail(function (data, textStatus, errorThrown) {
60 61 failHandler(data, textStatus, errorThrown)
61 62 });
62 63 return request;
63 64 };
64 65
65 66
66
67
68 67 /* Comment form for main and inline comments */
69 68 (function(mod) {
70 69
71 70 if (typeof exports == "object" && typeof module == "object") {
72 71 // CommonJS
73 72 module.exports = mod();
74 73 }
75 74 else {
76 75 // Plain browser env
77 76 (this || window).CommentForm = mod();
78 77 }
79 78
80 79 })(function() {
81 80 "use strict";
82 81
83 82 function CommentForm(formElement, commitId, pullRequestId, lineNo, initAutocompleteActions, resolvesCommentId, edit, comment_id) {
84 83
85 84 if (!(this instanceof CommentForm)) {
86 85 return new CommentForm(formElement, commitId, pullRequestId, lineNo, initAutocompleteActions, resolvesCommentId, edit, comment_id);
87 86 }
88 87
89 88 // bind the element instance to our Form
90 89 $(formElement).get(0).CommentForm = this;
91 90
92 91 this.withLineNo = function(selector) {
93 92 var lineNo = this.lineNo;
94 93 if (lineNo === undefined) {
95 94 return selector
96 95 } else {
97 96 return selector + '_' + lineNo;
98 97 }
99 98 };
100 99
101 100 this.commitId = commitId;
102 101 this.pullRequestId = pullRequestId;
103 102 this.lineNo = lineNo;
104 103 this.initAutocompleteActions = initAutocompleteActions;
105 104
106 105 this.previewButton = this.withLineNo('#preview-btn');
107 106 this.previewContainer = this.withLineNo('#preview-container');
108 107
109 108 this.previewBoxSelector = this.withLineNo('#preview-box');
110 109
111 110 this.editButton = this.withLineNo('#edit-btn');
112 111 this.editContainer = this.withLineNo('#edit-container');
113 112 this.cancelButton = this.withLineNo('#cancel-btn');
114 113 this.commentType = this.withLineNo('#comment_type');
115 114
116 115 this.resolvesId = null;
117 116 this.resolvesActionId = null;
118 117
119 118 this.closesPr = '#close_pull_request';
120 119
121 120 this.cmBox = this.withLineNo('#text');
122 121 this.cm = initCommentBoxCodeMirror(this, this.cmBox, this.initAutocompleteActions);
123 122
124 123 this.statusChange = this.withLineNo('#change_status');
125 124
126 125 this.submitForm = formElement;
127 126
128 127 this.submitButton = $(this.submitForm).find('.submit-comment-action');
129 128 this.submitButtonText = this.submitButton.val();
130 129
131 130 this.submitDraftButton = $(this.submitForm).find('.submit-draft-action');
132 131 this.submitDraftButtonText = this.submitDraftButton.val();
133 132
134 133 this.previewUrl = pyroutes.url('repo_commit_comment_preview',
135 134 {'repo_name': templateContext.repo_name,
136 135 'commit_id': templateContext.commit_data.commit_id});
137 136
138 137 if (edit){
139 138 this.submitDraftButton.hide();
140 139 this.submitButtonText = _gettext('Update Comment');
141 140 $(this.commentType).prop('disabled', true);
142 141 $(this.commentType).addClass('disabled');
143 142 var editInfo =
144 143 '';
145 144 $(editInfo).insertBefore($(this.editButton).parent());
146 145 }
147 146
148 147 if (resolvesCommentId){
149 148 this.resolvesId = '#resolve_comment_{0}'.format(resolvesCommentId);
150 149 this.resolvesActionId = '#resolve_comment_action_{0}'.format(resolvesCommentId);
151 150 $(this.commentType).prop('disabled', true);
152 151 $(this.commentType).addClass('disabled');
153 152
154 153 // disable select
155 154 setTimeout(function() {
156 155 $(self.statusChange).select2('readonly', true);
157 156 }, 10);
158 157
159 158 var resolvedInfo = (
160 159 '<li class="resolve-action">' +
161 160 '<input type="hidden" id="resolve_comment_{0}" name="resolve_comment_{0}" value="{0}">' +
162 161 '<button id="resolve_comment_action_{0}" class="resolve-text btn btn-sm" onclick="return Rhodecode.comments.submitResolution({0})">{1} #{0}</button>' +
163 162 '</li>'
164 163 ).format(resolvesCommentId, _gettext('resolve comment'));
165 164 $(resolvedInfo).insertAfter($(this.commentType).parent());
166 165 }
167 166
168 167 // based on commitId, or pullRequestId decide where do we submit
169 168 // out data
170 169 if (this.commitId){
171 170 var pyurl = 'repo_commit_comment_create';
172 171 if(edit){
173 172 pyurl = 'repo_commit_comment_edit';
174 173 }
175 174 this.submitUrl = pyroutes.url(pyurl,
176 175 {'repo_name': templateContext.repo_name,
177 176 'commit_id': this.commitId,
178 177 'comment_id': comment_id});
179 178 this.selfUrl = pyroutes.url('repo_commit',
180 179 {'repo_name': templateContext.repo_name,
181 180 'commit_id': this.commitId});
182 181
183 182 } else if (this.pullRequestId) {
184 183 var pyurl = 'pullrequest_comment_create';
185 184 if(edit){
186 185 pyurl = 'pullrequest_comment_edit';
187 186 }
188 187 this.submitUrl = pyroutes.url(pyurl,
189 188 {'repo_name': templateContext.repo_name,
190 189 'pull_request_id': this.pullRequestId,
191 190 'comment_id': comment_id});
192 191 this.selfUrl = pyroutes.url('pullrequest_show',
193 192 {'repo_name': templateContext.repo_name,
194 193 'pull_request_id': this.pullRequestId});
195 194
196 195 } else {
197 196 throw new Error(
198 197 'CommentForm requires pullRequestId, or commitId to be specified.')
199 198 }
200 199
201 200 // FUNCTIONS and helpers
202 201 var self = this;
203 202
204 203 this.isInline = function(){
205 204 return this.lineNo && this.lineNo != 'general';
206 205 };
207 206
208 207 this.getCmInstance = function(){
209 208 return this.cm
210 209 };
211 210
212 211 this.setPlaceholder = function(placeholder) {
213 212 var cm = this.getCmInstance();
214 213 if (cm){
215 214 cm.setOption('placeholder', placeholder);
216 215 }
217 216 };
218 217
219 218 this.getCommentStatus = function() {
220 219 return $(this.submitForm).find(this.statusChange).val();
221 220 };
222 221
223 222 this.getCommentType = function() {
224 223 return $(this.submitForm).find(this.commentType).val();
225 224 };
226 225
227 226 this.getDraftState = function () {
228 227 var submitterElem = $(this.submitForm).find('input[type="submit"].submitter');
229 228 var data = $(submitterElem).data('isDraft');
230 229 return data
231 230 }
232 231
233 232 this.getResolvesId = function() {
234 233 return $(this.submitForm).find(this.resolvesId).val() || null;
235 234 };
236 235
237 236 this.getClosePr = function() {
238 237 return $(this.submitForm).find(this.closesPr).val() || null;
239 238 };
240 239
241 240 this.markCommentResolved = function(resolvedCommentId){
242 $('#comment-label-{0}'.format(resolvedCommentId)).find('.resolved').show();
243 $('#comment-label-{0}'.format(resolvedCommentId)).find('.resolve').hide();
241 Rhodecode.comments.markCommentResolved(resolvedCommentId)
244 242 };
245 243
246 244 this.isAllowedToSubmit = function() {
247 245 var commentDisabled = $(this.submitButton).prop('disabled');
248 246 var draftDisabled = $(this.submitDraftButton).prop('disabled');
249 247 return !commentDisabled && !draftDisabled;
250 248 };
251 249
252 250 this.initStatusChangeSelector = function(){
253 251 var formatChangeStatus = function(state, escapeMarkup) {
254 252 var originalOption = state.element;
255 253 var tmpl = '<i class="icon-circle review-status-{0}"></i><span>{1}</span>'.format($(originalOption).data('status'), escapeMarkup(state.text));
256 254 return tmpl
257 255 };
258 256 var formatResult = function(result, container, query, escapeMarkup) {
259 257 return formatChangeStatus(result, escapeMarkup);
260 258 };
261 259
262 260 var formatSelection = function(data, container, escapeMarkup) {
263 261 return formatChangeStatus(data, escapeMarkup);
264 262 };
265 263
266 264 $(this.submitForm).find(this.statusChange).select2({
267 265 placeholder: _gettext('Status Review'),
268 266 formatResult: formatResult,
269 267 formatSelection: formatSelection,
270 268 containerCssClass: "drop-menu status_box_menu",
271 269 dropdownCssClass: "drop-menu-dropdown",
272 270 dropdownAutoWidth: true,
273 271 minimumResultsForSearch: -1
274 272 });
275 273
276 274 $(this.submitForm).find(this.statusChange).on('change', function() {
277 275 var status = self.getCommentStatus();
278 276
279 277 if (status && !self.isInline()) {
280 278 $(self.submitButton).prop('disabled', false);
281 279 $(self.submitDraftButton).prop('disabled', false);
282 280 }
283 281
284 282 var placeholderText = _gettext('Comment text will be set automatically based on currently selected status ({0}) ...').format(status);
285 283 self.setPlaceholder(placeholderText)
286 284 })
287 285 };
288 286
289 287 // reset the comment form into it's original state
290 288 this.resetCommentFormState = function(content) {
291 289 content = content || '';
292 290
293 291 $(this.editContainer).show();
294 292 $(this.editButton).parent().addClass('active');
295 293
296 294 $(this.previewContainer).hide();
297 295 $(this.previewButton).parent().removeClass('active');
298 296
299 297 this.setActionButtonsDisabled(true);
300 298 self.cm.setValue(content);
301 299 self.cm.setOption("readOnly", false);
302 300
303 301 if (this.resolvesId) {
304 302 // destroy the resolve action
305 303 $(this.resolvesId).parent().remove();
306 304 }
307 305 // reset closingPR flag
308 306 $('.close-pr-input').remove();
309 307
310 308 $(this.statusChange).select2('readonly', false);
311 309 };
312 310
313 311 this.globalSubmitSuccessCallback = function(comment){
314 312 // default behaviour is to call GLOBAL hook, if it's registered.
315 313 if (window.commentFormGlobalSubmitSuccessCallback !== undefined){
316 314 commentFormGlobalSubmitSuccessCallback(comment);
317 315 }
318 316 };
319 317
320 318 this.submitAjaxPOST = function(url, postData, successHandler, failHandler) {
321 319 return _submitAjaxPOST(url, postData, successHandler, failHandler);
322 320 };
323 321
324 322 // overwrite a submitHandler, we need to do it for inline comments
325 323 this.setHandleFormSubmit = function(callback) {
326 324 this.handleFormSubmit = callback;
327 325 };
328 326
329 327 // overwrite a submitSuccessHandler
330 328 this.setGlobalSubmitSuccessCallback = function(callback) {
331 329 this.globalSubmitSuccessCallback = callback;
332 330 };
333 331
334 332 // default handler for for submit for main comments
335 333 this.handleFormSubmit = function() {
336 334 var text = self.cm.getValue();
337 335 var status = self.getCommentStatus();
338 336 var commentType = self.getCommentType();
339 337 var isDraft = self.getDraftState();
340 338 var resolvesCommentId = self.getResolvesId();
341 339 var closePullRequest = self.getClosePr();
342 340
343 341 if (text === "" && !status) {
344 342 return;
345 343 }
346 344
347 345 var excludeCancelBtn = false;
348 346 var submitEvent = true;
349 347 self.setActionButtonsDisabled(true, excludeCancelBtn, submitEvent);
350 348 self.cm.setOption("readOnly", true);
351 349
352 350 var postData = {
353 351 'text': text,
354 352 'changeset_status': status,
355 353 'comment_type': commentType,
356 354 'csrf_token': CSRF_TOKEN
357 355 };
358 356
359 357 if (resolvesCommentId) {
360 358 postData['resolves_comment_id'] = resolvesCommentId;
361 359 }
362 360
363 361 if (closePullRequest) {
364 362 postData['close_pull_request'] = true;
365 363 }
366 364
367 365 // submitSuccess for general comments
368 366 var submitSuccessCallback = function(json_data) {
369 367 // reload page if we change status for single commit.
370 368 if (status && self.commitId) {
371 369 location.reload(true);
372 370 } else {
373 371 // inject newly created comments, json_data is {<comment_id>: {}}
374 372 Rhodecode.comments.attachGeneralComment(json_data)
375 373
376 374 self.resetCommentFormState();
377 375 timeagoActivate();
378 376 tooltipActivate();
379 377
380 378 // mark visually which comment was resolved
381 379 if (resolvesCommentId) {
382 380 self.markCommentResolved(resolvesCommentId);
383 381 }
384 382 }
385 383
386 384 // run global callback on submit
387 385 self.globalSubmitSuccessCallback({draft: isDraft, comment_id: comment_id});
388 386
389 387 };
390 388 var submitFailCallback = function(jqXHR, textStatus, errorThrown) {
391 389 var prefix = "Error while submitting comment.\n"
392 390 var message = formatErrorMessage(jqXHR, textStatus, errorThrown, prefix);
393 391 ajaxErrorSwal(message);
394 392 self.resetCommentFormState(text);
395 393 };
396 394 self.submitAjaxPOST(
397 395 self.submitUrl, postData, submitSuccessCallback, submitFailCallback);
398 396 };
399 397
400 398 this.previewSuccessCallback = function(o) {
401 399 $(self.previewBoxSelector).html(o);
402 400 $(self.previewBoxSelector).removeClass('unloaded');
403 401
404 402 // swap buttons, making preview active
405 403 $(self.previewButton).parent().addClass('active');
406 404 $(self.editButton).parent().removeClass('active');
407 405
408 406 // unlock buttons
409 407 self.setActionButtonsDisabled(false);
410 408 };
411 409
412 410 this.setActionButtonsDisabled = function(state, excludeCancelBtn, submitEvent) {
413 411 excludeCancelBtn = excludeCancelBtn || false;
414 412 submitEvent = submitEvent || false;
415 413
416 414 $(this.editButton).prop('disabled', state);
417 415 $(this.previewButton).prop('disabled', state);
418 416
419 417 if (!excludeCancelBtn) {
420 418 $(this.cancelButton).prop('disabled', state);
421 419 }
422 420
423 421 var submitState = state;
424 422 if (!submitEvent && this.getCommentStatus() && !self.isInline()) {
425 423 // if the value of commit review status is set, we allow
426 424 // submit button, but only on Main form, isInline means inline
427 425 submitState = false
428 426 }
429 427
430 428 $(this.submitButton).prop('disabled', submitState);
431 429 $(this.submitDraftButton).prop('disabled', submitState);
432 430
433 431 if (submitEvent) {
434 432 var isDraft = self.getDraftState();
435 433
436 434 if (isDraft) {
437 435 $(this.submitDraftButton).val(_gettext('Saving Draft...'));
438 436 } else {
439 437 $(this.submitButton).val(_gettext('Submitting...'));
440 438 }
441 439
442 440 } else {
443 441 $(this.submitButton).val(this.submitButtonText);
444 442 $(this.submitDraftButton).val(this.submitDraftButtonText);
445 443 }
446 444
447 445 };
448 446
449 447 // lock preview/edit/submit buttons on load, but exclude cancel button
450 448 var excludeCancelBtn = true;
451 449 this.setActionButtonsDisabled(true, excludeCancelBtn);
452 450
453 451 // anonymous users don't have access to initialized CM instance
454 452 if (this.cm !== undefined){
455 453 this.cm.on('change', function(cMirror) {
456 454 if (cMirror.getValue() === "") {
457 455 self.setActionButtonsDisabled(true, excludeCancelBtn)
458 456 } else {
459 457 self.setActionButtonsDisabled(false, excludeCancelBtn)
460 458 }
461 459 });
462 460 }
463 461
464 462 $(this.editButton).on('click', function(e) {
465 463 e.preventDefault();
466 464
467 465 $(self.previewButton).parent().removeClass('active');
468 466 $(self.previewContainer).hide();
469 467
470 468 $(self.editButton).parent().addClass('active');
471 469 $(self.editContainer).show();
472 470
473 471 });
474 472
475 473 $(this.previewButton).on('click', function(e) {
476 474 e.preventDefault();
477 475 var text = self.cm.getValue();
478 476
479 477 if (text === "") {
480 478 return;
481 479 }
482 480
483 481 var postData = {
484 482 'text': text,
485 483 'renderer': templateContext.visual.default_renderer,
486 484 'csrf_token': CSRF_TOKEN
487 485 };
488 486
489 487 // lock ALL buttons on preview
490 488 self.setActionButtonsDisabled(true);
491 489
492 490 $(self.previewBoxSelector).addClass('unloaded');
493 491 $(self.previewBoxSelector).html(_gettext('Loading ...'));
494 492
495 493 $(self.editContainer).hide();
496 494 $(self.previewContainer).show();
497 495
498 496 // by default we reset state of comment preserving the text
499 497 var previewFailCallback = function(jqXHR, textStatus, errorThrown) {
500 498 var prefix = "Error while preview of comment.\n"
501 499 var message = formatErrorMessage(jqXHR, textStatus, errorThrown, prefix);
502 500 ajaxErrorSwal(message);
503 501
504 502 self.resetCommentFormState(text)
505 503 };
506 504 self.submitAjaxPOST(
507 505 self.previewUrl, postData, self.previewSuccessCallback,
508 506 previewFailCallback);
509 507
510 508 $(self.previewButton).parent().addClass('active');
511 509 $(self.editButton).parent().removeClass('active');
512 510 });
513 511
514 512 $(this.submitForm).submit(function(e) {
515 513 e.preventDefault();
516 514 var allowedToSubmit = self.isAllowedToSubmit();
517 515 if (!allowedToSubmit){
518 516 return false;
519 517 }
520 518
521 519 self.handleFormSubmit();
522 520 });
523 521
524 522 }
525 523
526 524 return CommentForm;
527 525 });
528 526
529 527 /* selector for comment versions */
530 528 var initVersionSelector = function(selector, initialData) {
531 529
532 530 var formatResult = function(result, container, query, escapeMarkup) {
533 531
534 532 return renderTemplate('commentVersion', {
535 533 show_disabled: true,
536 534 version: result.comment_version,
537 535 user_name: result.comment_author_username,
538 536 gravatar_url: result.comment_author_gravatar,
539 537 size: 16,
540 538 timeago_component: result.comment_created_on,
541 539 })
542 540 };
543 541
544 542 $(selector).select2({
545 543 placeholder: "Edited",
546 544 containerCssClass: "drop-menu-comment-history",
547 545 dropdownCssClass: "drop-menu-dropdown",
548 546 dropdownAutoWidth: true,
549 547 minimumResultsForSearch: -1,
550 548 data: initialData,
551 549 formatResult: formatResult,
552 550 });
553 551
554 552 $(selector).on('select2-selecting', function (e) {
555 553 // hide the mast as we later do preventDefault()
556 554 $("#select2-drop-mask").click();
557 555 e.preventDefault();
558 556 e.choice.action();
559 557 });
560 558
561 559 $(selector).on("select2-open", function() {
562 560 timeagoActivate();
563 561 });
564 562 };
565 563
566 564 /* comments controller */
567 565 var CommentsController = function() {
568 566 var mainComment = '#text';
569 567 var self = this;
570 568
571 569 this.showVersion = function (comment_id, comment_history_id) {
572 570
573 571 var historyViewUrl = pyroutes.url(
574 572 'repo_commit_comment_history_view',
575 573 {
576 574 'repo_name': templateContext.repo_name,
577 575 'commit_id': comment_id,
578 576 'comment_history_id': comment_history_id,
579 577 }
580 578 );
581 579 successRenderCommit = function (data) {
582 580 SwalNoAnimation.fire({
583 581 html: data,
584 582 title: '',
585 583 });
586 584 };
587 585 failRenderCommit = function () {
588 586 SwalNoAnimation.fire({
589 587 html: 'Error while loading comment history',
590 588 title: '',
591 589 });
592 590 };
593 591 _submitAjaxPOST(
594 592 historyViewUrl, {'csrf_token': CSRF_TOKEN},
595 593 successRenderCommit,
596 594 failRenderCommit
597 595 );
598 596 };
599 597
600 598 this.getLineNumber = function(node) {
601 599 var $node = $(node);
602 600 var lineNo = $node.closest('td').attr('data-line-no');
603 601 if (lineNo === undefined && $node.data('commentInline')){
604 602 lineNo = $node.data('commentLineNo')
605 603 }
606 604
607 605 return lineNo
608 606 };
609 607
610 608 this.scrollToComment = function(node, offset, outdated) {
611 609 if (offset === undefined) {
612 610 offset = 0;
613 611 }
614 612 var outdated = outdated || false;
615 613 var klass = outdated ? 'div.comment-outdated' : 'div.comment-current';
616 614
617 615 if (!node) {
618 616 node = $('.comment-selected');
619 617 if (!node.length) {
620 618 node = $('comment-current')
621 619 }
622 620 }
623 621
624 622 $wrapper = $(node).closest('div.comment');
625 623
626 624 // show hidden comment when referenced.
627 625 if (!$wrapper.is(':visible')){
628 626 $wrapper.show();
629 627 }
630 628
631 629 $comment = $(node).closest(klass);
632 630 $comments = $(klass);
633 631
634 632 $('.comment-selected').removeClass('comment-selected');
635 633
636 634 var nextIdx = $(klass).index($comment) + offset;
637 635 if (nextIdx >= $comments.length) {
638 636 nextIdx = 0;
639 637 }
640 638 var $next = $(klass).eq(nextIdx);
641 639
642 640 var $cb = $next.closest('.cb');
643 641 $cb.removeClass('cb-collapsed');
644 642
645 643 var $filediffCollapseState = $cb.closest('.filediff').prev();
646 644 $filediffCollapseState.prop('checked', false);
647 645 $next.addClass('comment-selected');
648 646 scrollToElement($next);
649 647 return false;
650 648 };
651 649
652 650 this.nextComment = function(node) {
653 651 return self.scrollToComment(node, 1);
654 652 };
655 653
656 654 this.prevComment = function(node) {
657 655 return self.scrollToComment(node, -1);
658 656 };
659 657
660 658 this.nextOutdatedComment = function(node) {
661 659 return self.scrollToComment(node, 1, true);
662 660 };
663 661
664 662 this.prevOutdatedComment = function(node) {
665 663 return self.scrollToComment(node, -1, true);
666 664 };
667 665
668 666 this.cancelComment = function (node) {
669 667 var $node = $(node);
670 668 var edit = $(this).attr('edit');
671 669 var $inlineComments = $node.closest('div.inline-comments');
672 670
673 671 if (edit) {
674 672 var $general_comments = null;
675 673 if (!$inlineComments.length) {
676 674 $general_comments = $('#comments');
677 675 var $comment = $general_comments.parent().find('div.comment:hidden');
678 676 // show hidden general comment form
679 677 $('#cb-comment-general-form-placeholder').show();
680 678 } else {
681 679 var $comment = $inlineComments.find('div.comment:hidden');
682 680 }
683 681 $comment.show();
684 682 }
685 683 var $replyWrapper = $node.closest('.comment-inline-form').closest('.reply-thread-container-wrapper')
686 684 $replyWrapper.removeClass('comment-form-active');
687 685
688 686 var lastComment = $inlineComments.find('.comment-inline').last();
689 687 if ($(lastComment).hasClass('comment-outdated')) {
690 688 $replyWrapper.hide();
691 689 }
692 690
693 691 $node.closest('.comment-inline-form').remove();
694 692 return false;
695 693 };
696 694
697 695 this._deleteComment = function(node) {
698 696 var $node = $(node);
699 697 var $td = $node.closest('td');
700 698 var $comment = $node.closest('.comment');
701 699 var comment_id = $($comment).data('commentId');
702 700 var isDraft = $($comment).data('commentDraft');
703 701
704 702 var pullRequestId = templateContext.pull_request_data.pull_request_id;
705 703 var commitId = templateContext.commit_data.commit_id;
706 704
707 705 if (pullRequestId) {
708 706 var url = pyroutes.url('pullrequest_comment_delete', {"comment_id": comment_id, "repo_name": templateContext.repo_name, "pull_request_id": pullRequestId})
709 707 } else if (commitId) {
710 708 var url = pyroutes.url('repo_commit_comment_delete', {"comment_id": comment_id, "repo_name": templateContext.repo_name, "commit_id": commitId})
711 709 }
712 710
713 711 var postData = {
714 712 'csrf_token': CSRF_TOKEN
715 713 };
716 714
717 715 $comment.addClass('comment-deleting');
718 716 $comment.hide('fast');
719 717
720 718 var success = function(response) {
721 719 $comment.remove();
722 720
723 721 if (window.updateSticky !== undefined) {
724 722 // potentially our comments change the active window size, so we
725 723 // notify sticky elements
726 724 updateSticky()
727 725 }
728 726
729 727 if (window.refreshAllComments !== undefined && !isDraft) {
730 728 // if we have this handler, run it, and refresh all comments boxes
731 729 refreshAllComments()
732 730 }
733 731 else if (window.refreshDraftComments !== undefined && isDraft) {
734 732 // if we have this handler, run it, and refresh all comments boxes
735 733 refreshDraftComments();
736 734 }
737 735 return false;
738 736 };
739 737
740 738 var failure = function(jqXHR, textStatus, errorThrown) {
741 739 var prefix = "Error while deleting this comment.\n"
742 740 var message = formatErrorMessage(jqXHR, textStatus, errorThrown, prefix);
743 741 ajaxErrorSwal(message);
744 742
745 743 $comment.show('fast');
746 744 $comment.removeClass('comment-deleting');
747 745 return false;
748 746 };
749 747 ajaxPOST(url, postData, success, failure);
750 748
751 749 }
752 750
753 751 this.deleteComment = function(node) {
754 752 var $comment = $(node).closest('.comment');
755 753 var comment_id = $comment.attr('data-comment-id');
756 754
757 755 SwalNoAnimation.fire({
758 756 title: 'Delete this comment?',
759 757 icon: 'warning',
760 758 showCancelButton: true,
761 759 confirmButtonText: _gettext('Yes, delete comment #{0}!').format(comment_id),
762 760
763 761 }).then(function(result) {
764 762 if (result.value) {
765 763 self._deleteComment(node);
766 764 }
767 765 })
768 766 };
769 767
770 768 this._finalizeDrafts = function(commentIds) {
771 769
772 770 var pullRequestId = templateContext.pull_request_data.pull_request_id;
773 771 var commitId = templateContext.commit_data.commit_id;
774 772
775 773 if (pullRequestId) {
776 774 var url = pyroutes.url('pullrequest_draft_comments_submit', {"repo_name": templateContext.repo_name, "pull_request_id": pullRequestId})
777 775 } else if (commitId) {
778 776 var url = pyroutes.url('commit_draft_comments_submit', {"repo_name": templateContext.repo_name, "commit_id": commitId})
779 777 }
780 778
781 779 // remove the drafts so we can lock them before submit.
782 780 $.each(commentIds, function(idx, val){
783 781 $('#comment-{0}'.format(val)).remove();
784 782 })
785 783
786 784 var postData = {'comments': commentIds, 'csrf_token': CSRF_TOKEN};
787 785
788 786 var submitSuccessCallback = function(json_data) {
789 787 self.attachInlineComment(json_data);
790 788
791 789 if (window.refreshDraftComments !== undefined) {
792 790 // if we have this handler, run it, and refresh all comments boxes
793 791 refreshDraftComments()
794 792 }
795 793
796 794 return false;
797 795 };
798 796
799 797 ajaxPOST(url, postData, submitSuccessCallback)
800 798
801 799 }
802 800
803 801 this.finalizeDrafts = function(commentIds, callback) {
804 802
805 803 SwalNoAnimation.fire({
806 804 title: _ngettext('Submit {0} draft comment.', 'Submit {0} draft comments.', commentIds.length).format(commentIds.length),
807 805 icon: 'warning',
808 806 showCancelButton: true,
809 807 confirmButtonText: _gettext('Yes'),
810 808
811 809 }).then(function(result) {
812 810 if (result.value) {
813 811 if (callback !== undefined) {
814 812 callback(result)
815 813 }
816 814 self._finalizeDrafts(commentIds);
817 815 }
818 816 })
819 817 };
820 818
821 819 this.toggleWideMode = function (node) {
822 820
823 821 if ($('#content').hasClass('wrapper')) {
824 822 $('#content').removeClass("wrapper");
825 823 $('#content').addClass("wide-mode-wrapper");
826 824 $(node).addClass('btn-success');
827 825 return true
828 826 } else {
829 827 $('#content').removeClass("wide-mode-wrapper");
830 828 $('#content').addClass("wrapper");
831 829 $(node).removeClass('btn-success');
832 830 return false
833 831 }
834 832
835 833 };
836 834
837 835 /**
838 836 * Turn off/on all comments in file diff
839 837 */
840 838 this.toggleDiffComments = function(node) {
841 839 // Find closes filediff container
842 840 var $filediff = $(node).closest('.filediff');
843 841 if ($(node).hasClass('toggle-on')) {
844 842 var show = false;
845 843 } else if ($(node).hasClass('toggle-off')) {
846 844 var show = true;
847 845 }
848 846
849 847 // Toggle each individual comment block, so we can un-toggle single ones
850 848 $.each($filediff.find('.toggle-comment-action'), function(idx, val) {
851 849 self.toggleLineComments($(val), show)
852 850 })
853 851
854 852 // since we change the height of the diff container that has anchor points for upper
855 853 // sticky header, we need to tell it to re-calculate those
856 854 if (window.updateSticky !== undefined) {
857 855 // potentially our comments change the active window size, so we
858 856 // notify sticky elements
859 857 updateSticky()
860 858 }
861 859
862 860 return false;
863 861 }
864 862
865 863 this.toggleLineComments = function(node, show) {
866 864
867 865 var trElem = $(node).closest('tr')
868 866
869 867 if (show === true) {
870 868 // mark outdated comments as visible before the toggle;
871 869 $(trElem).find('.comment-outdated').show();
872 870 $(trElem).removeClass('hide-line-comments');
873 871 } else if (show === false) {
874 872 $(trElem).find('.comment-outdated').hide();
875 873 $(trElem).addClass('hide-line-comments');
876 874 } else {
877 875 // mark outdated comments as visible before the toggle;
878 876 $(trElem).find('.comment-outdated').show();
879 877 $(trElem).toggleClass('hide-line-comments');
880 878 }
881 879
882 880 // since we change the height of the diff container that has anchor points for upper
883 881 // sticky header, we need to tell it to re-calculate those
884 882 if (window.updateSticky !== undefined) {
885 883 // potentially our comments change the active window size, so we
886 884 // notify sticky elements
887 885 updateSticky()
888 886 }
889 887
890 888 };
891 889
892 890 this.createCommentForm = function(formElement, lineno, placeholderText, initAutocompleteActions, resolvesCommentId, edit, comment_id){
893 891 var pullRequestId = templateContext.pull_request_data.pull_request_id;
894 892 var commitId = templateContext.commit_data.commit_id;
895 893
896 894 var commentForm = new CommentForm(
897 895 formElement, commitId, pullRequestId, lineno, initAutocompleteActions, resolvesCommentId, edit, comment_id);
898 896 var cm = commentForm.getCmInstance();
899 897
900 898 if (resolvesCommentId){
901 899 placeholderText = _gettext('Leave a resolution comment, or click resolve button to resolve TODO comment #{0}').format(resolvesCommentId);
902 900 }
903 901
904 902 setTimeout(function() {
905 903 // callbacks
906 904 if (cm !== undefined) {
907 905 commentForm.setPlaceholder(placeholderText);
908 906 if (commentForm.isInline()) {
909 907 cm.focus();
910 908 cm.refresh();
911 909 }
912 910 }
913 911 }, 10);
914 912
915 913 // trigger scrolldown to the resolve comment, since it might be away
916 914 // from the clicked
917 915 if (resolvesCommentId){
918 916 var actionNode = $(commentForm.resolvesActionId).offset();
919 917
920 918 setTimeout(function() {
921 919 if (actionNode) {
922 920 $('body, html').animate({scrollTop: actionNode.top}, 10);
923 921 }
924 922 }, 100);
925 923 }
926 924
927 925 // add dropzone support
928 926 var insertAttachmentText = function (cm, attachmentName, attachmentStoreUrl, isRendered) {
929 927 var renderer = templateContext.visual.default_renderer;
930 928 if (renderer == 'rst') {
931 929 var attachmentUrl = '`#{0} <{1}>`_'.format(attachmentName, attachmentStoreUrl);
932 930 if (isRendered){
933 931 attachmentUrl = '\n.. image:: {0}'.format(attachmentStoreUrl);
934 932 }
935 933 } else if (renderer == 'markdown') {
936 934 var attachmentUrl = '[{0}]({1})'.format(attachmentName, attachmentStoreUrl);
937 935 if (isRendered){
938 936 attachmentUrl = '!' + attachmentUrl;
939 937 }
940 938 } else {
941 939 var attachmentUrl = '{}'.format(attachmentStoreUrl);
942 940 }
943 941 cm.replaceRange(attachmentUrl+'\n', CodeMirror.Pos(cm.lastLine()));
944 942
945 943 return false;
946 944 };
947 945
948 946 //see: https://www.dropzonejs.com/#configuration
949 947 var storeUrl = pyroutes.url('repo_commit_comment_attachment_upload',
950 948 {'repo_name': templateContext.repo_name,
951 949 'commit_id': templateContext.commit_data.commit_id})
952 950
953 951 var previewTmpl = $(formElement).find('.comment-attachment-uploader-template').get(0);
954 952 if (previewTmpl !== undefined){
955 953 var selectLink = $(formElement).find('.pick-attachment').get(0);
956 954 $(formElement).find('.comment-attachment-uploader').dropzone({
957 955 url: storeUrl,
958 956 headers: {"X-CSRF-Token": CSRF_TOKEN},
959 957 paramName: function () {
960 958 return "attachment"
961 959 }, // The name that will be used to transfer the file
962 960 clickable: selectLink,
963 961 parallelUploads: 1,
964 962 maxFiles: 10,
965 963 maxFilesize: templateContext.attachment_store.max_file_size_mb,
966 964 uploadMultiple: false,
967 965 autoProcessQueue: true, // if false queue will not be processed automatically.
968 966 createImageThumbnails: false,
969 967 previewTemplate: previewTmpl.innerHTML,
970 968
971 969 accept: function (file, done) {
972 970 done();
973 971 },
974 972 init: function () {
975 973
976 974 this.on("sending", function (file, xhr, formData) {
977 975 $(formElement).find('.comment-attachment-uploader').find('.dropzone-text').hide();
978 976 $(formElement).find('.comment-attachment-uploader').find('.dropzone-upload').show();
979 977 });
980 978
981 979 this.on("success", function (file, response) {
982 980 $(formElement).find('.comment-attachment-uploader').find('.dropzone-text').show();
983 981 $(formElement).find('.comment-attachment-uploader').find('.dropzone-upload').hide();
984 982
985 983 var isRendered = false;
986 984 var ext = file.name.split('.').pop();
987 985 var imageExts = templateContext.attachment_store.image_ext;
988 986 if (imageExts.indexOf(ext) !== -1){
989 987 isRendered = true;
990 988 }
991 989
992 990 insertAttachmentText(cm, file.name, response.repo_fqn_access_path, isRendered)
993 991 });
994 992
995 993 this.on("error", function (file, errorMessage, xhr) {
996 994 $(formElement).find('.comment-attachment-uploader').find('.dropzone-upload').hide();
997 995
998 996 var error = null;
999 997
1000 998 if (xhr !== undefined){
1001 999 var httpStatus = xhr.status + " " + xhr.statusText;
1002 1000 if (xhr !== undefined && xhr.status >= 500) {
1003 1001 error = httpStatus;
1004 1002 }
1005 1003 }
1006 1004
1007 1005 if (error === null) {
1008 1006 error = errorMessage.error || errorMessage || httpStatus;
1009 1007 }
1010 1008 $(file.previewElement).find('.dz-error-message').html('ERROR: {0}'.format(error));
1011 1009
1012 1010 });
1013 1011 }
1014 1012 });
1015 1013 }
1016 1014 return commentForm;
1017 1015 };
1018 1016
1019 1017 this.createGeneralComment = function (lineNo, placeholderText, resolvesCommentId) {
1020 1018
1021 1019 var tmpl = $('#cb-comment-general-form-template').html();
1022 1020 tmpl = tmpl.format(null, 'general');
1023 1021 var $form = $(tmpl);
1024 1022
1025 1023 var $formPlaceholder = $('#cb-comment-general-form-placeholder');
1026 1024 var curForm = $formPlaceholder.find('form');
1027 1025 if (curForm){
1028 1026 curForm.remove();
1029 1027 }
1030 1028 $formPlaceholder.append($form);
1031 1029
1032 1030 var _form = $($form[0]);
1033 1031 var autocompleteActions = ['approve', 'reject', 'as_note', 'as_todo'];
1034 1032 var edit = false;
1035 1033 var comment_id = null;
1036 1034 var commentForm = this.createCommentForm(
1037 1035 _form, lineNo, placeholderText, autocompleteActions, resolvesCommentId, edit, comment_id);
1038 1036 commentForm.initStatusChangeSelector();
1039 1037
1040 1038 return commentForm;
1041 1039 };
1042 1040
1043 1041 this.editComment = function(node, line_no, f_path) {
1044 1042 self.edit = true;
1045 1043 var $node = $(node);
1046 1044 var $td = $node.closest('td');
1047 1045
1048 1046 var $comment = $(node).closest('.comment');
1049 1047 var comment_id = $($comment).data('commentId');
1050 1048 var isDraft = $($comment).data('commentDraft');
1051 1049 var $editForm = null
1052 1050
1053 1051 var $comments = $node.closest('div.inline-comments');
1054 1052 var $general_comments = null;
1055 1053
1056 1054 if($comments.length){
1057 1055 // inline comments setup
1058 1056 $editForm = $comments.find('.comment-inline-form');
1059 1057 line_no = self.getLineNumber(node)
1060 1058 }
1061 1059 else{
1062 1060 // general comments setup
1063 1061 $comments = $('#comments');
1064 1062 $editForm = $comments.find('.comment-inline-form');
1065 1063 line_no = $comment[0].id
1066 1064 $('#cb-comment-general-form-placeholder').hide();
1067 1065 }
1068 1066
1069 1067 if ($editForm.length === 0) {
1070 1068
1071 1069 // unhide all comments if they are hidden for a proper REPLY mode
1072 1070 var $filediff = $node.closest('.filediff');
1073 1071 $filediff.removeClass('hide-comments');
1074 1072
1075 1073 $editForm = self.createNewFormWrapper(f_path, line_no);
1076 1074 if(f_path && line_no) {
1077 1075 $editForm.addClass('comment-inline-form-edit')
1078 1076 }
1079 1077
1080 1078 $comment.after($editForm)
1081 1079
1082 1080 var _form = $($editForm[0]).find('form');
1083 1081 var autocompleteActions = ['as_note',];
1084 1082 var commentForm = this.createCommentForm(
1085 1083 _form, line_no, '', autocompleteActions, resolvesCommentId,
1086 1084 this.edit, comment_id);
1087 1085 var old_comment_text_binary = $comment.attr('data-comment-text');
1088 1086 var old_comment_text = b64DecodeUnicode(old_comment_text_binary);
1089 1087 commentForm.cm.setValue(old_comment_text);
1090 1088 $comment.hide();
1091 1089 tooltipActivate();
1092 1090
1093 1091 // set a CUSTOM submit handler for inline comment edit action.
1094 1092 commentForm.setHandleFormSubmit(function(o) {
1095 1093 var text = commentForm.cm.getValue();
1096 1094 var commentType = commentForm.getCommentType();
1097 1095
1098 1096 if (text === "") {
1099 1097 return;
1100 1098 }
1101 1099
1102 1100 if (old_comment_text == text) {
1103 1101 SwalNoAnimation.fire({
1104 1102 title: 'Unable to edit comment',
1105 1103 html: _gettext('Comment body was not changed.'),
1106 1104 });
1107 1105 return;
1108 1106 }
1109 1107 var excludeCancelBtn = false;
1110 1108 var submitEvent = true;
1111 1109 commentForm.setActionButtonsDisabled(true, excludeCancelBtn, submitEvent);
1112 1110 commentForm.cm.setOption("readOnly", true);
1113 1111
1114 1112 // Read last version known
1115 1113 var versionSelector = $('#comment_versions_{0}'.format(comment_id));
1116 1114 var version = versionSelector.data('lastVersion');
1117 1115
1118 1116 if (!version) {
1119 1117 version = 0;
1120 1118 }
1121 1119
1122 1120 var postData = {
1123 1121 'text': text,
1124 1122 'f_path': f_path,
1125 1123 'line': line_no,
1126 1124 'comment_type': commentType,
1127 1125 'draft': isDraft,
1128 1126 'version': version,
1129 1127 'csrf_token': CSRF_TOKEN
1130 1128 };
1131 1129
1132 1130 var submitSuccessCallback = function(json_data) {
1133 1131 $editForm.remove();
1134 1132 $comment.show();
1135 1133 var postData = {
1136 1134 'text': text,
1137 1135 'renderer': $comment.attr('data-comment-renderer'),
1138 1136 'csrf_token': CSRF_TOKEN
1139 1137 };
1140 1138
1141 1139 /* Inject new edited version selector */
1142 1140 var updateCommentVersionDropDown = function () {
1143 1141 var versionSelectId = '#comment_versions_'+comment_id;
1144 1142 var preLoadVersionData = [
1145 1143 {
1146 1144 id: json_data['comment_version'],
1147 1145 text: "v{0}".format(json_data['comment_version']),
1148 1146 action: function () {
1149 1147 Rhodecode.comments.showVersion(
1150 1148 json_data['comment_id'],
1151 1149 json_data['comment_history_id']
1152 1150 )
1153 1151 },
1154 1152 comment_version: json_data['comment_version'],
1155 1153 comment_author_username: json_data['comment_author_username'],
1156 1154 comment_author_gravatar: json_data['comment_author_gravatar'],
1157 1155 comment_created_on: json_data['comment_created_on'],
1158 1156 },
1159 1157 ]
1160 1158
1161 1159
1162 1160 if ($(versionSelectId).data('select2')) {
1163 1161 var oldData = $(versionSelectId).data('select2').opts.data.results;
1164 1162 $(versionSelectId).select2("destroy");
1165 1163 preLoadVersionData = oldData.concat(preLoadVersionData)
1166 1164 }
1167 1165
1168 1166 initVersionSelector(versionSelectId, {results: preLoadVersionData});
1169 1167
1170 1168 $comment.attr('data-comment-text', utf8ToB64(text));
1171 1169
1172 1170 var versionSelector = $('#comment_versions_'+comment_id);
1173 1171
1174 1172 // set lastVersion so we know our last edit version
1175 1173 versionSelector.data('lastVersion', json_data['comment_version'])
1176 1174 versionSelector.parent().show();
1177 1175 }
1178 1176 updateCommentVersionDropDown();
1179 1177
1180 1178 // by default we reset state of comment preserving the text
1181 1179 var failRenderCommit = function(jqXHR, textStatus, errorThrown) {
1182 1180 var prefix = "Error while editing this comment.\n"
1183 1181 var message = formatErrorMessage(jqXHR, textStatus, errorThrown, prefix);
1184 1182 ajaxErrorSwal(message);
1185 1183 };
1186 1184
1187 1185 var successRenderCommit = function(o){
1188 1186 $comment.show();
1189 1187 $comment[0].lastElementChild.innerHTML = o;
1190 1188 };
1191 1189
1192 1190 var previewUrl = pyroutes.url(
1193 1191 'repo_commit_comment_preview',
1194 1192 {'repo_name': templateContext.repo_name,
1195 1193 'commit_id': templateContext.commit_data.commit_id});
1196 1194
1197 1195 _submitAjaxPOST(
1198 1196 previewUrl, postData, successRenderCommit, failRenderCommit
1199 1197 );
1200 1198
1201 1199 try {
1202 1200 var html = json_data.rendered_text;
1203 1201 var lineno = json_data.line_no;
1204 1202 var target_id = json_data.target_id;
1205 1203
1206 1204 $comments.find('.cb-comment-add-button').before(html);
1207 1205
1208 1206 // run global callback on submit
1209 1207 commentForm.globalSubmitSuccessCallback({draft: isDraft, comment_id: comment_id});
1210 1208
1211 1209 } catch (e) {
1212 1210 console.error(e);
1213 1211 }
1214 1212
1215 1213 // re trigger the linkification of next/prev navigation
1216 1214 linkifyComments($('.inline-comment-injected'));
1217 1215 timeagoActivate();
1218 1216 tooltipActivate();
1219 1217
1220 1218 if (window.updateSticky !== undefined) {
1221 1219 // potentially our comments change the active window size, so we
1222 1220 // notify sticky elements
1223 1221 updateSticky()
1224 1222 }
1225 1223
1226 1224 if (window.refreshAllComments !== undefined && !isDraft) {
1227 1225 // if we have this handler, run it, and refresh all comments boxes
1228 1226 refreshAllComments()
1229 1227 }
1230 1228 else if (window.refreshDraftComments !== undefined && isDraft) {
1231 1229 // if we have this handler, run it, and refresh all comments boxes
1232 1230 refreshDraftComments();
1233 1231 }
1234 1232
1235 1233 commentForm.setActionButtonsDisabled(false);
1236 1234
1237 1235 };
1238 1236
1239 1237 var submitFailCallback = function(jqXHR, textStatus, errorThrown) {
1240 1238 var prefix = "Error while editing comment.\n"
1241 1239 var message = formatErrorMessage(jqXHR, textStatus, errorThrown, prefix);
1242 1240 if (jqXHR.status == 409){
1243 1241 message = 'This comment was probably changed somewhere else. Please reload the content of this comment.'
1244 1242 ajaxErrorSwal(message, 'Comment version mismatch.');
1245 1243 } else {
1246 1244 ajaxErrorSwal(message);
1247 1245 }
1248 1246
1249 1247 commentForm.resetCommentFormState(text)
1250 1248 };
1251 1249 commentForm.submitAjaxPOST(
1252 1250 commentForm.submitUrl, postData,
1253 1251 submitSuccessCallback,
1254 1252 submitFailCallback);
1255 1253 });
1256 1254 }
1257 1255
1258 1256 $editForm.addClass('comment-inline-form-open');
1259 1257 };
1260 1258
1261 1259 this.attachComment = function(json_data) {
1262 1260 var self = this;
1263 1261 $.each(json_data, function(idx, val) {
1264 1262 var json_data_elem = [val]
1265 1263 var isInline = val.comment_f_path && val.comment_lineno
1266 1264
1267 1265 if (isInline) {
1268 1266 self.attachInlineComment(json_data_elem)
1269 1267 } else {
1270 1268 self.attachGeneralComment(json_data_elem)
1271 1269 }
1272 1270 })
1273 1271
1274 1272 }
1275 1273
1276 1274 this.attachGeneralComment = function(json_data) {
1277 1275 $.each(json_data, function(idx, val) {
1278 1276 $('#injected_page_comments').append(val.rendered_text);
1279 1277 })
1280 1278 }
1281 1279
1282 1280 this.attachInlineComment = function(json_data) {
1283 1281
1284 1282 $.each(json_data, function (idx, val) {
1285 1283 var line_qry = '*[data-line-no="{0}"]'.format(val.line_no);
1286 1284 var html = val.rendered_text;
1287 1285 var $inlineComments = $('#' + val.target_id)
1288 1286 .find(line_qry)
1289 1287 .find('.inline-comments');
1290 1288
1291 1289 var lastComment = $inlineComments.find('.comment-inline').last();
1292 1290
1293 1291 if (lastComment.length === 0) {
1294 1292 // first comment, we append simply
1295 1293 $inlineComments.find('.reply-thread-container-wrapper').before(html);
1296 1294 } else {
1297 1295 $(lastComment).after(html)
1298 1296 }
1299 1297
1300 1298 })
1301 1299
1302 1300 };
1303 1301
1304 1302 this.createNewFormWrapper = function(f_path, line_no) {
1305 1303 // create a new reply HTML form from template
1306 1304 var tmpl = $('#cb-comment-inline-form-template').html();
1307 1305 tmpl = tmpl.format(escapeHtml(f_path), line_no);
1308 1306 return $(tmpl);
1309 1307 }
1310 1308
1309 this.markCommentResolved = function(commentId) {
1310 $('#comment-label-{0}'.format(commentId)).find('.resolved').show();
1311 $('#comment-label-{0}'.format(commentId)).find('.resolve').hide();
1312 };
1313
1311 1314 this.createComment = function(node, f_path, line_no, resolutionComment) {
1312 1315 self.edit = false;
1313 1316 var $node = $(node);
1314 1317 var $td = $node.closest('td');
1315 1318 var resolvesCommentId = resolutionComment || null;
1316 1319
1317 1320 var $replyForm = $td.find('.comment-inline-form');
1318 1321
1319 1322 // if form isn't existing, we're generating a new one and injecting it.
1320 1323 if ($replyForm.length === 0) {
1321 1324
1322 1325 // unhide/expand all comments if they are hidden for a proper REPLY mode
1323 1326 self.toggleLineComments($node, true);
1324 1327
1325 1328 $replyForm = self.createNewFormWrapper(f_path, line_no);
1326 1329
1327 1330 var $comments = $td.find('.inline-comments');
1328 1331
1329 1332 // There aren't any comments, we init the `.inline-comments` with `reply-thread-container` first
1330 1333 if ($comments.length===0) {
1331 1334 var replBtn = '<button class="cb-comment-add-button" onclick="return Rhodecode.comments.createComment(this, \'{0}\', \'{1}\', null)">Reply...</button>'.format(f_path, line_no)
1332 1335 var $reply_container = $('#cb-comments-inline-container-template')
1333 1336 $reply_container.find('button.cb-comment-add-button').replaceWith(replBtn);
1334 1337 $td.append($($reply_container).html());
1335 1338 }
1336 1339
1337 1340 // default comment button exists, so we prepend the form for leaving initial comment
1338 1341 $td.find('.cb-comment-add-button').before($replyForm);
1339 1342 // set marker, that we have a open form
1340 1343 var $replyWrapper = $td.find('.reply-thread-container-wrapper')
1341 1344 $replyWrapper.addClass('comment-form-active');
1342 1345
1343 1346 var lastComment = $comments.find('.comment-inline').last();
1344 1347 if ($(lastComment).hasClass('comment-outdated')) {
1345 1348 $replyWrapper.show();
1346 1349 }
1347 1350
1348 1351 var _form = $($replyForm[0]).find('form');
1349 1352 var autocompleteActions = ['as_note', 'as_todo'];
1350 1353 var comment_id=null;
1351 1354 var placeholderText = _gettext('Leave a comment on file {0} line {1}.').format(f_path, line_no);
1352 1355 var commentForm = self.createCommentForm(
1353 1356 _form, line_no, placeholderText, autocompleteActions, resolvesCommentId,
1354 1357 self.edit, comment_id);
1355 1358
1356 1359 // set a CUSTOM submit handler for inline comments.
1357 1360 commentForm.setHandleFormSubmit(function(o) {
1358 1361 var text = commentForm.cm.getValue();
1359 1362 var commentType = commentForm.getCommentType();
1360 1363 var resolvesCommentId = commentForm.getResolvesId();
1361 1364 var isDraft = commentForm.getDraftState();
1362 1365
1363 1366 if (text === "") {
1364 1367 return;
1365 1368 }
1366 1369
1367 1370 if (line_no === undefined) {
1368 1371 alert('Error: unable to fetch line number for this inline comment !');
1369 1372 return;
1370 1373 }
1371 1374
1372 1375 if (f_path === undefined) {
1373 1376 alert('Error: unable to fetch file path for this inline comment !');
1374 1377 return;
1375 1378 }
1376 1379
1377 1380 var excludeCancelBtn = false;
1378 1381 var submitEvent = true;
1379 1382 commentForm.setActionButtonsDisabled(true, excludeCancelBtn, submitEvent);
1380 1383 commentForm.cm.setOption("readOnly", true);
1381 1384 var postData = {
1382 1385 'text': text,
1383 1386 'f_path': f_path,
1384 1387 'line': line_no,
1385 1388 'comment_type': commentType,
1386 1389 'draft': isDraft,
1387 1390 'csrf_token': CSRF_TOKEN
1388 1391 };
1389 1392 if (resolvesCommentId){
1390 1393 postData['resolves_comment_id'] = resolvesCommentId;
1391 1394 }
1392 1395
1393 1396 // submitSuccess for inline commits
1394 1397 var submitSuccessCallback = function(json_data) {
1395 1398
1396 1399 $replyForm.remove();
1397 1400 $td.find('.reply-thread-container-wrapper').removeClass('comment-form-active');
1398 1401
1399 1402 try {
1400 1403
1401 1404 // inject newly created comments, json_data is {<comment_id>: {}}
1402 1405 self.attachInlineComment(json_data)
1403 1406
1404 1407 //mark visually which comment was resolved
1405 1408 if (resolvesCommentId) {
1406 commentForm.markCommentResolved(resolvesCommentId);
1409 self.markCommentResolved(resolvesCommentId);
1407 1410 }
1408 1411
1409 1412 // run global callback on submit
1410 1413 commentForm.globalSubmitSuccessCallback({
1411 1414 draft: isDraft,
1412 1415 comment_id: comment_id
1413 1416 });
1414 1417
1415 1418 } catch (e) {
1416 1419 console.error(e);
1417 1420 }
1418 1421
1419 1422 if (window.updateSticky !== undefined) {
1420 1423 // potentially our comments change the active window size, so we
1421 1424 // notify sticky elements
1422 1425 updateSticky()
1423 1426 }
1424 1427
1425 1428 if (window.refreshAllComments !== undefined && !isDraft) {
1426 1429 // if we have this handler, run it, and refresh all comments boxes
1427 1430 refreshAllComments()
1428 1431 }
1429 1432 else if (window.refreshDraftComments !== undefined && isDraft) {
1430 1433 // if we have this handler, run it, and refresh all comments boxes
1431 1434 refreshDraftComments();
1432 1435 }
1433 1436
1434 1437 commentForm.setActionButtonsDisabled(false);
1435 1438
1436 1439 // re trigger the linkification of next/prev navigation
1437 1440 linkifyComments($('.inline-comment-injected'));
1438 1441 timeagoActivate();
1439 1442 tooltipActivate();
1440 1443 };
1441 1444
1442 1445 var submitFailCallback = function(jqXHR, textStatus, errorThrown) {
1443 1446 var prefix = "Error while submitting comment.\n"
1444 1447 var message = formatErrorMessage(jqXHR, textStatus, errorThrown, prefix);
1445 1448 ajaxErrorSwal(message);
1446 1449 commentForm.resetCommentFormState(text)
1447 1450 };
1448 1451
1449 1452 commentForm.submitAjaxPOST(
1450 1453 commentForm.submitUrl, postData, submitSuccessCallback, submitFailCallback);
1451 1454 });
1452 1455 }
1453 1456
1454 1457 // Finally "open" our reply form, since we know there are comments and we have the "attached" old form
1455 1458 $replyForm.addClass('comment-inline-form-open');
1456 1459 tooltipActivate();
1457 1460 };
1458 1461
1459 1462 this.createResolutionComment = function(commentId){
1460 1463 // hide the trigger text
1461 1464 $('#resolve-comment-{0}'.format(commentId)).hide();
1462 1465
1463 1466 var comment = $('#comment-'+commentId);
1464 1467 var commentData = comment.data();
1465 1468
1466 1469 if (commentData.commentInline) {
1467 1470 var f_path = commentData.commentFPath;
1468 1471 var line_no = commentData.commentLineNo;
1469 1472 this.createComment(comment, f_path, line_no, commentId)
1470 1473 } else {
1471 1474 this.createGeneralComment('general', "$placeholder", commentId)
1472 1475 }
1473 1476
1474 1477 return false;
1475 1478 };
1476 1479
1477 1480 this.submitResolution = function(commentId){
1478 1481 var form = $('#resolve_comment_{0}'.format(commentId)).closest('form');
1479 1482 var commentForm = form.get(0).CommentForm;
1480 1483
1481 1484 var cm = commentForm.getCmInstance();
1482 1485 var renderer = templateContext.visual.default_renderer;
1483 1486 if (renderer == 'rst'){
1484 1487 var commentUrl = '`#{0} <{1}#comment-{0}>`_'.format(commentId, commentForm.selfUrl);
1485 1488 } else if (renderer == 'markdown') {
1486 1489 var commentUrl = '[#{0}]({1}#comment-{0})'.format(commentId, commentForm.selfUrl);
1487 1490 } else {
1488 1491 var commentUrl = '{1}#comment-{0}'.format(commentId, commentForm.selfUrl);
1489 1492 }
1490 1493
1491 1494 cm.setValue(_gettext('TODO from comment {0} was fixed.').format(commentUrl));
1492 1495 form.submit();
1493 1496 return false;
1494 1497 };
1495 1498
1499 this.resolveTodo = function (elem, todoId) {
1500 var commentId = todoId;
1501
1502 SwalNoAnimation.fire({
1503 title: 'Resolve TODO {0}'.format(todoId),
1504 showCancelButton: true,
1505 confirmButtonText: _gettext('Yes'),
1506 showLoaderOnConfirm: true,
1507
1508 allowOutsideClick: function () {
1509 !Swal.isLoading()
1510 },
1511 preConfirm: function () {
1512 var comment = $('#comment-' + commentId);
1513 var commentData = comment.data();
1514
1515 var f_path = null
1516 var line_no = null
1517 if (commentData.commentInline) {
1518 f_path = commentData.commentFPath;
1519 line_no = commentData.commentLineNo;
1520 }
1521
1522 var renderer = templateContext.visual.default_renderer;
1523 var commentBoxUrl = '{1}#comment-{0}'.format(commentId);
1524
1525 // Pull request case
1526 if (templateContext.pull_request_data.pull_request_id !== null) {
1527 var commentUrl = pyroutes.url('pullrequest_comment_create',
1528 {
1529 'repo_name': templateContext.repo_name,
1530 'pull_request_id': templateContext.pull_request_data.pull_request_id,
1531 'comment_id': commentId
1532 });
1533 } else {
1534 var commentUrl = pyroutes.url('repo_commit_comment_create',
1535 {
1536 'repo_name': templateContext.repo_name,
1537 'commit_id': templateContext.commit_data.commit_id,
1538 'comment_id': commentId
1539 });
1540 }
1541
1542 if (renderer === 'rst') {
1543 commentBoxUrl = '`#{0} <{1}#comment-{0}>`_'.format(commentId, commentUrl);
1544 } else if (renderer === 'markdown') {
1545 commentBoxUrl = '[#{0}]({1}#comment-{0})'.format(commentId, commentUrl);
1546 }
1547 var resolveText = _gettext('TODO from comment {0} was fixed.').format(commentBoxUrl);
1548
1549 var postData = {
1550 text: resolveText,
1551 comment_type: 'note',
1552 draft: false,
1553 csrf_token: CSRF_TOKEN,
1554 resolves_comment_id: commentId
1555 }
1556 if (commentData.commentInline) {
1557 postData['f_path'] = f_path;
1558 postData['line'] = line_no;
1559 }
1560
1561 return new Promise(function (resolve, reject) {
1562 $.ajax({
1563 type: 'POST',
1564 data: postData,
1565 url: commentUrl,
1566 headers: {'X-PARTIAL-XHR': true}
1567 })
1568 .done(function (data) {
1569 resolve(data);
1570 })
1571 .fail(function (jqXHR, textStatus, errorThrown) {
1572 var prefix = "Error while resolving TODO.\n"
1573 var message = formatErrorMessage(jqXHR, textStatus, errorThrown, prefix);
1574 ajaxErrorSwal(message);
1575 });
1576 })
1577 }
1578
1579 })
1580 .then(function (result) {
1581 var success = function (json_data) {
1582 resolvesCommentId = commentId;
1583 var commentResolved = json_data[Object.keys(json_data)[0]]
1584
1585 try {
1586
1587 if (commentResolved.f_path) {
1588 // inject newly created comments, json_data is {<comment_id>: {}}
1589 self.attachInlineComment(json_data)
1590 } else {
1591 self.attachGeneralComment(json_data)
1592 }
1593
1594 //mark visually which comment was resolved
1595 if (resolvesCommentId) {
1596 self.markCommentResolved(resolvesCommentId);
1597 }
1598
1599 // run global callback on submit
1600 if (window.commentFormGlobalSubmitSuccessCallback !== undefined) {
1601 commentFormGlobalSubmitSuccessCallback({
1602 draft: false,
1603 comment_id: commentId
1604 });
1605 }
1606
1607 } catch (e) {
1608 console.error(e);
1609 }
1610
1611 if (window.updateSticky !== undefined) {
1612 // potentially our comments change the active window size, so we
1613 // notify sticky elements
1614 updateSticky()
1615 }
1616
1617 if (window.refreshAllComments !== undefined) {
1618 // if we have this handler, run it, and refresh all comments boxes
1619 refreshAllComments()
1620 }
1621 // re trigger the linkification of next/prev navigation
1622 linkifyComments($('.inline-comment-injected'));
1623 timeagoActivate();
1624 tooltipActivate();
1625 };
1626
1627 if (result.value) {
1628 $(elem).remove();
1629 success(result.value)
1630 }
1631 })
1632 };
1633
1496 1634 };
1497 1635
1498 1636 window.commentHelp = function(renderer) {
1499 1637 var funcData = {'renderer': renderer}
1500 1638 return renderTemplate('commentHelpHovercard', funcData)
1501 } No newline at end of file
1639 }
@@ -1,270 +1,278 b''
1 1 <%text>
2 2 <div style="display: none">
3 3
4 4 <script>
5 5 var CG = new ColorGenerator();
6 6 </script>
7 7
8 8 <script id="ejs_gravatarWithUser" type="text/template" class="ejsTemplate">
9 9
10 10 <%
11 11 if (size > 16) {
12 12 var gravatar_class = 'gravatar gravatar-large';
13 13 } else {
14 14 var gravatar_class = 'gravatar';
15 15 }
16 16
17 17 if (tooltip) {
18 18 var gravatar_class = gravatar_class + ' tooltip-hovercard';
19 19 }
20 20
21 21 var data_hovercard_alt = username;
22 22
23 23 %>
24 24
25 25 <%
26 26 if (show_disabled) {
27 27 var user_cls = 'user user-disabled';
28 28 } else {
29 29 var user_cls = 'user';
30 30 }
31 31 var data_hovercard_url = pyroutes.url('hovercard_user', {"user_id": user_id})
32 32 %>
33 33
34 34 <div class="rc-user">
35 35 <img class="<%= gravatar_class %>" height="<%= size %>" width="<%= size %>" data-hovercard-url="<%= data_hovercard_url %>" data-hovercard-alt="<%= data_hovercard_alt %>" src="<%- gravatar_url -%>">
36 36 <span class="<%= user_cls %>"> <%- user_link -%> </span>
37 37 </div>
38 38
39 39 </script>
40 40
41 41 <script id="ejs_reviewMemberEntry" type="text/template" class="ejsTemplate">
42 42 <%
43 43 if (create) {
44 44 var edit_visibility = 'visible';
45 45 } else {
46 46 var edit_visibility = 'hidden';
47 47 }
48 48
49 49 if (member.user_group && member.user_group.vote_rule) {
50 50 var reviewGroup = '<i class="icon-user-group"></i>';
51 51 var reviewGroupColor = CG.asRGB(CG.getColor(member.user_group.vote_rule));
52 52 } else {
53 53 var reviewGroup = null;
54 54 var reviewGroupColor = 'transparent';
55 55 }
56 56 var rule_show = rule_show || false;
57 57
58 58 if (rule_show) {
59 59 var rule_visibility = 'table-cell';
60 60 } else {
61 61 var rule_visibility = 'none';
62 62 }
63 63
64 64 %>
65 65
66 66 <tr id="reviewer_<%= member.user_id %>" class="reviewer_entry" tooltip="Review Group" data-reviewer-user-id="<%= member.user_id %>">
67 67
68 68 <% if (create) { %>
69 69 <td style="width: 1px"></td>
70 70 <% } else { %>
71 71 <td style="width: 20px">
72 72 <div class="tooltip presence-state" style="display: none; position: absolute; left: 2px" title="This users is currently at this page">
73 73 <i class="icon-eye" style="color: #0ac878"></i>
74 74 </div>
75 75 <% if (role === 'reviewer') { %>
76 76 <div class="reviewer_status tooltip" title="<%= review_status_label %>">
77 77 <i class="icon-circle review-status-<%= review_status %>"></i>
78 78 </div>
79 79 <% } else if (role === 'observer') { %>
80 80 <div class="tooltip" title="Observer without voting right.">
81 81 <i class="icon-circle-thin"></i>
82 82 </div>
83 83 <% } %>
84 84 </td>
85 85 <% } %>
86 86
87 87
88 88 <% if (mandatory) { %>
89 89 <td style="text-align: right;width: 10px;">
90 90 <div class="reviewer_member_mandatory tooltip" title="Mandatory reviewer">
91 91 <i class="icon-lock"></i>
92 92 </div>
93 93 </td>
94 94
95 95 <% } else { %>
96 96 <td style="text-align: right;width: 10px;">
97 97 <% if (allowed_to_update) { %>
98 98 <div class="<%=role %>_member_remove" onclick="reviewersController.removeMember(<%= member.user_id %>, true)" style="visibility: <%= edit_visibility %>;">
99 99 <i class="icon-remove" style="color: #e85e4d;"></i>
100 100 </div>
101 101 <% } %>
102 102 </td>
103 103 <% } %>
104 104
105 105 <td>
106 106 <div id="reviewer_<%= member.user_id %>_name" class="reviewer_name">
107 107 <%-
108 108 renderTemplate('gravatarWithUser', {
109 109 'size': 16,
110 110 'show_disabled': false,
111 111 'tooltip': true,
112 112 'username': member.username,
113 113 'user_id': member.user_id,
114 114 'user_link': member.user_link,
115 115 'gravatar_url': member.gravatar_link
116 116 })
117 117 %>
118 118 </div>
119 119 <% if (reviewGroup !== null) { %>
120 120 <span class="tooltip" title="Member of review group from rule: `<%= member.user_group.name %>`" style="color: <%= reviewGroupColor %>">
121 121 <%- reviewGroup %>
122 122 </span>
123 123 <% } %>
124 124 </td>
125 125
126 126 </tr>
127 127
128 128 <tr id="reviewer_<%= member.user_id %>_rules">
129 129 <td colspan="4" style="display: <%= rule_visibility %>" class="pr-user-rule-container">
130 130 <input type="hidden" name="__start__" value="reviewer:mapping">
131 131
132 132 <%if (member.user_group && member.user_group.vote_rule) { %>
133 133 <div class="reviewer_reason">
134 134
135 135 <%if (member.user_group.vote_rule == -1) {%>
136 136 - group votes required: ALL
137 137 <%} else {%>
138 138 - group votes required: <%= member.user_group.vote_rule %>
139 139 <%}%>
140 140 </div>
141 141 <%} %>
142 142
143 143 <input type="hidden" name="__start__" value="reasons:sequence">
144 144 <% for (var i = 0; i < reasons.length; i++) { %>
145 145 <% var reason = reasons[i] %>
146 146 <div class="reviewer_reason">- <%= reason %></div>
147 147 <input type="hidden" name="reason" value="<%= reason %>">
148 148 <% } %>
149 149 <input type="hidden" name="__end__" value="reasons:sequence">
150 150
151 151 <input type="hidden" name="__start__" value="rules:sequence">
152 152 <% for (var i = 0; i < member.rules.length; i++) { %>
153 153 <% var rule = member.rules[i] %>
154 154 <input type="hidden" name="rule_id" value="<%= rule %>">
155 155 <% } %>
156 156 <input type="hidden" name="__end__" value="rules:sequence">
157 157
158 158 <input id="reviewer_<%= member.user_id %>_input" type="hidden" value="<%= member.user_id %>" name="user_id" />
159 159 <input type="hidden" name="mandatory" value="<%= mandatory %>"/>
160 160 <input type="hidden" name="role" value="<%= role %>"/>
161 161
162 162 <input type="hidden" name="__end__" value="reviewer:mapping">
163 163 </td>
164 164 </tr>
165 165
166 166 </script>
167 167
168 168 <script id="ejs_commentVersion" type="text/template" class="ejsTemplate">
169 169
170 170 <%
171 171 if (size > 16) {
172 172 var gravatar_class = 'gravatar gravatar-large';
173 173 } else {
174 174 var gravatar_class = 'gravatar';
175 175 }
176 176
177 177 %>
178 178
179 179 <%
180 180 if (show_disabled) {
181 181 var user_cls = 'user user-disabled';
182 182 } else {
183 183 var user_cls = 'user';
184 184 }
185 185
186 186 %>
187 187
188 188 <div style='line-height: 20px'>
189 189 <img style="margin: -3px 0" class="<%= gravatar_class %>" height="<%= size %>" width="<%= size %>" src="<%- gravatar_url -%>">
190 190 <strong><%- user_name -%></strong>, <code>v<%- version -%></code> edited <%- timeago_component -%>
191 191 </div>
192 192
193 193 </script>
194 194
195 195
196 196 <script id="ejs_sideBarCommentHovercard" type="text/template" class="ejsTemplate">
197 197
198 198 <div>
199 199
200 200 <% if (is_todo) { %>
201 201 <% if (inline) { %>
202 202 <strong>Inline</strong> TODO (<code>#<%- comment_id -%></code>) on line: <%= line_no %>
203 203 <% if (version_info) { %>
204 204 <%= version_info %>
205 205 <% } %>
206 206 <br/>
207 207 File: <code><%- file_name -%></code>
208 208 <% } else { %>
209 209 <% if (review_status) { %>
210 210 <i class="icon-circle review-status-<%= review_status %>"></i>
211 211 <% } %>
212 212 <strong>General</strong> TODO (<code>#<%- comment_id -%></code>)
213 213 <% if (version_info) { %>
214 214 <%= version_info %>
215 215 <% } %>
216 216 <% } %>
217 217 <% } else { %>
218 218 <% if (inline) { %>
219 219 <strong>Inline</strong> comment (<code>#<%- comment_id -%></code>) on line: <%= line_no %>
220 220 <% if (version_info) { %>
221 221 <%= version_info %>
222 222 <% } %>
223 223 <br/>
224 224 File: <code><%- file_name -%></code>
225 225 <% } else { %>
226 226 <% if (review_status) { %>
227 227 <i class="icon-circle review-status-<%= review_status %>"></i>
228 228 <% } %>
229 229 <strong>General</strong> comment (<code>#<%- comment_id -%></code>)
230 230 <% if (version_info) { %>
231 231 <%= version_info %>
232 232 <% } %>
233 233 <% } %>
234 234 <% } %>
235 235 <br/>
236 236 Created:
237 237 <time class="timeago" title="<%= created_on %>" datetime="<%= datetime %>"><%= $.timeago(datetime) %></time>
238 238
239 <% if (is_todo) { %>
240 <div style="text-align: center; padding-top: 5px">
241 <a class="btn btn-sm" href="#resolveTodo<%- comment_id -%>" onclick="Rhodecode.comments.resolveTodo(this, '<%- comment_id -%>'); return false">
242 <strong>Resolve TODO</strong>
243 </a>
244 </div>
245 <% } %>
246
239 247 </div>
240 248
241 249 </script>
242 250
243 251 <script id="ejs_commentHelpHovercard" type="text/template" class="ejsTemplate">
244 252
245 253 <div>
246 254 Use <strong>@username</strong> mention syntax to send direct notification to this RhodeCode user.<br/>
247 255 Typing / starts autocomplete for certain action, e.g set review status, or comment type. <br/>
248 256 <br/>
249 257 Use <strong>Cmd/ctrl+enter</strong> to submit comment, or <strong>Shift+Cmd/ctrl+enter</strong> to submit a draft.<br/>
250 258 <br/>
251 259 <strong>Draft comments</strong> are private to the author, and trigger no notification to others.<br/>
252 260 They are permanent until deleted, or converted to regular comments.<br/>
253 261 <br/>
254 262 <br/>
255 263 </div>
256 264
257 265 </script>
258 266
259 267
260 268
261 269 ##// END OF EJS Templates
262 270 </div>
263 271
264 272
265 273 <script>
266 274 // registers the templates into global cache
267 275 registerTemplates();
268 276 </script>
269 277
270 278 </%text>
General Comments 0
You need to be logged in to leave comments. Login now