##// END OF EJS Templates
comments: show links to unresolved todos...
marcink -
r1344:639b2044 default
parent child Browse files
Show More
@@ -1,1009 +1,1009 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2012-2017 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 """
22 22 pull requests controller for rhodecode for initializing pull requests
23 23 """
24 24 import types
25 25
26 26 import peppercorn
27 27 import formencode
28 28 import logging
29 29 import collections
30 30
31 31 from webob.exc import HTTPNotFound, HTTPForbidden, HTTPBadRequest
32 32 from pylons import request, tmpl_context as c, url
33 33 from pylons.controllers.util import redirect
34 34 from pylons.i18n.translation import _
35 35 from pyramid.threadlocal import get_current_registry
36 36 from sqlalchemy.sql import func
37 37 from sqlalchemy.sql.expression import or_
38 38
39 39 from rhodecode import events
40 40 from rhodecode.lib import auth, diffs, helpers as h, codeblocks
41 41 from rhodecode.lib.ext_json import json
42 42 from rhodecode.lib.base import (
43 43 BaseRepoController, render, vcs_operation_context)
44 44 from rhodecode.lib.auth import (
45 45 LoginRequired, HasRepoPermissionAnyDecorator, NotAnonymous,
46 46 HasAcceptedRepoType, XHRRequired)
47 47 from rhodecode.lib.channelstream import channelstream_request
48 48 from rhodecode.lib.utils import jsonify
49 49 from rhodecode.lib.utils2 import (
50 50 safe_int, safe_str, str2bool, safe_unicode)
51 51 from rhodecode.lib.vcs.backends.base import (
52 52 EmptyCommit, UpdateFailureReason, EmptyRepository)
53 53 from rhodecode.lib.vcs.exceptions import (
54 54 EmptyRepositoryError, CommitDoesNotExistError, RepositoryRequirementError,
55 55 NodeDoesNotExistError)
56 56
57 57 from rhodecode.model.changeset_status import ChangesetStatusModel
58 58 from rhodecode.model.comment import CommentsModel
59 59 from rhodecode.model.db import (PullRequest, ChangesetStatus, ChangesetComment,
60 60 Repository, PullRequestVersion)
61 61 from rhodecode.model.forms import PullRequestForm
62 62 from rhodecode.model.meta import Session
63 63 from rhodecode.model.pull_request import PullRequestModel, MergeCheck
64 64
65 65 log = logging.getLogger(__name__)
66 66
67 67
68 68 class PullrequestsController(BaseRepoController):
69 69 def __before__(self):
70 70 super(PullrequestsController, self).__before__()
71 71
72 72 def _load_compare_data(self, pull_request, inline_comments):
73 73 """
74 74 Load context data needed for generating compare diff
75 75
76 76 :param pull_request: object related to the request
77 77 :param enable_comments: flag to determine if comments are included
78 78 """
79 79 source_repo = pull_request.source_repo
80 80 source_ref_id = pull_request.source_ref_parts.commit_id
81 81
82 82 target_repo = pull_request.target_repo
83 83 target_ref_id = pull_request.target_ref_parts.commit_id
84 84
85 85 # despite opening commits for bookmarks/branches/tags, we always
86 86 # convert this to rev to prevent changes after bookmark or branch change
87 87 c.source_ref_type = 'rev'
88 88 c.source_ref = source_ref_id
89 89
90 90 c.target_ref_type = 'rev'
91 91 c.target_ref = target_ref_id
92 92
93 93 c.source_repo = source_repo
94 94 c.target_repo = target_repo
95 95
96 96 c.fulldiff = bool(request.GET.get('fulldiff'))
97 97
98 98 # diff_limit is the old behavior, will cut off the whole diff
99 99 # if the limit is applied otherwise will just hide the
100 100 # big files from the front-end
101 101 diff_limit = self.cut_off_limit_diff
102 102 file_limit = self.cut_off_limit_file
103 103
104 104 pre_load = ["author", "branch", "date", "message"]
105 105
106 106 c.commit_ranges = []
107 107 source_commit = EmptyCommit()
108 108 target_commit = EmptyCommit()
109 109 c.missing_requirements = False
110 110 try:
111 111 c.commit_ranges = [
112 112 source_repo.get_commit(commit_id=rev, pre_load=pre_load)
113 113 for rev in pull_request.revisions]
114 114
115 115 c.statuses = source_repo.statuses(
116 116 [x.raw_id for x in c.commit_ranges])
117 117
118 118 target_commit = source_repo.get_commit(
119 119 commit_id=safe_str(target_ref_id))
120 120 source_commit = source_repo.get_commit(
121 121 commit_id=safe_str(source_ref_id))
122 122 except RepositoryRequirementError:
123 123 c.missing_requirements = True
124 124
125 125 # auto collapse if we have more than limit
126 126 collapse_limit = diffs.DiffProcessor._collapse_commits_over
127 127 c.collapse_all_commits = len(c.commit_ranges) > collapse_limit
128 128
129 129 c.changes = {}
130 130 c.missing_commits = False
131 131 if (c.missing_requirements or
132 132 isinstance(source_commit, EmptyCommit) or
133 133 source_commit == target_commit):
134 134 _parsed = []
135 135 c.missing_commits = True
136 136 else:
137 137 vcs_diff = PullRequestModel().get_diff(pull_request)
138 138 diff_processor = diffs.DiffProcessor(
139 139 vcs_diff, format='newdiff', diff_limit=diff_limit,
140 140 file_limit=file_limit, show_full_diff=c.fulldiff)
141 141
142 142 _parsed = diff_processor.prepare()
143 143 c.limited_diff = isinstance(_parsed, diffs.LimitedDiffContainer)
144 144
145 145 included_files = {}
146 146 for f in _parsed:
147 147 included_files[f['filename']] = f['stats']
148 148
149 149 c.deleted_files = [fname for fname in inline_comments if
150 150 fname not in included_files]
151 151
152 152 c.deleted_files_comments = collections.defaultdict(dict)
153 153 for fname, per_line_comments in inline_comments.items():
154 154 if fname in c.deleted_files:
155 155 c.deleted_files_comments[fname]['stats'] = 0
156 156 c.deleted_files_comments[fname]['comments'] = list()
157 157 for lno, comments in per_line_comments.items():
158 158 c.deleted_files_comments[fname]['comments'].extend(comments)
159 159
160 160 def _node_getter(commit):
161 161 def get_node(fname):
162 162 try:
163 163 return commit.get_node(fname)
164 164 except NodeDoesNotExistError:
165 165 return None
166 166 return get_node
167 167
168 168 c.diffset = codeblocks.DiffSet(
169 169 repo_name=c.repo_name,
170 170 source_repo_name=c.source_repo.repo_name,
171 171 source_node_getter=_node_getter(target_commit),
172 172 target_node_getter=_node_getter(source_commit),
173 173 comments=inline_comments
174 174 ).render_patchset(_parsed, target_commit.raw_id, source_commit.raw_id)
175 175
176 176 def _extract_ordering(self, request):
177 177 column_index = safe_int(request.GET.get('order[0][column]'))
178 178 order_dir = request.GET.get('order[0][dir]', 'desc')
179 179 order_by = request.GET.get(
180 180 'columns[%s][data][sort]' % column_index, 'name_raw')
181 181 return order_by, order_dir
182 182
183 183 @LoginRequired()
184 184 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
185 185 'repository.admin')
186 186 @HasAcceptedRepoType('git', 'hg')
187 187 def show_all(self, repo_name):
188 188 # filter types
189 189 c.active = 'open'
190 190 c.source = str2bool(request.GET.get('source'))
191 191 c.closed = str2bool(request.GET.get('closed'))
192 192 c.my = str2bool(request.GET.get('my'))
193 193 c.awaiting_review = str2bool(request.GET.get('awaiting_review'))
194 194 c.awaiting_my_review = str2bool(request.GET.get('awaiting_my_review'))
195 195 c.repo_name = repo_name
196 196
197 197 opened_by = None
198 198 if c.my:
199 199 c.active = 'my'
200 200 opened_by = [c.rhodecode_user.user_id]
201 201
202 202 statuses = [PullRequest.STATUS_NEW, PullRequest.STATUS_OPEN]
203 203 if c.closed:
204 204 c.active = 'closed'
205 205 statuses = [PullRequest.STATUS_CLOSED]
206 206
207 207 if c.awaiting_review and not c.source:
208 208 c.active = 'awaiting'
209 209 if c.source and not c.awaiting_review:
210 210 c.active = 'source'
211 211 if c.awaiting_my_review:
212 212 c.active = 'awaiting_my'
213 213
214 214 data = self._get_pull_requests_list(
215 215 repo_name=repo_name, opened_by=opened_by, statuses=statuses)
216 216 if not request.is_xhr:
217 217 c.data = json.dumps(data['data'])
218 218 c.records_total = data['recordsTotal']
219 219 return render('/pullrequests/pullrequests.mako')
220 220 else:
221 221 return json.dumps(data)
222 222
223 223 def _get_pull_requests_list(self, repo_name, opened_by, statuses):
224 224 # pagination
225 225 start = safe_int(request.GET.get('start'), 0)
226 226 length = safe_int(request.GET.get('length'), c.visual.dashboard_items)
227 227 order_by, order_dir = self._extract_ordering(request)
228 228
229 229 if c.awaiting_review:
230 230 pull_requests = PullRequestModel().get_awaiting_review(
231 231 repo_name, source=c.source, opened_by=opened_by,
232 232 statuses=statuses, offset=start, length=length,
233 233 order_by=order_by, order_dir=order_dir)
234 234 pull_requests_total_count = PullRequestModel(
235 235 ).count_awaiting_review(
236 236 repo_name, source=c.source, statuses=statuses,
237 237 opened_by=opened_by)
238 238 elif c.awaiting_my_review:
239 239 pull_requests = PullRequestModel().get_awaiting_my_review(
240 240 repo_name, source=c.source, opened_by=opened_by,
241 241 user_id=c.rhodecode_user.user_id, statuses=statuses,
242 242 offset=start, length=length, order_by=order_by,
243 243 order_dir=order_dir)
244 244 pull_requests_total_count = PullRequestModel(
245 245 ).count_awaiting_my_review(
246 246 repo_name, source=c.source, user_id=c.rhodecode_user.user_id,
247 247 statuses=statuses, opened_by=opened_by)
248 248 else:
249 249 pull_requests = PullRequestModel().get_all(
250 250 repo_name, source=c.source, opened_by=opened_by,
251 251 statuses=statuses, offset=start, length=length,
252 252 order_by=order_by, order_dir=order_dir)
253 253 pull_requests_total_count = PullRequestModel().count_all(
254 254 repo_name, source=c.source, statuses=statuses,
255 255 opened_by=opened_by)
256 256
257 257 from rhodecode.lib.utils import PartialRenderer
258 258 _render = PartialRenderer('data_table/_dt_elements.mako')
259 259 data = []
260 260 for pr in pull_requests:
261 261 comments = CommentsModel().get_all_comments(
262 262 c.rhodecode_db_repo.repo_id, pull_request=pr)
263 263
264 264 data.append({
265 265 'name': _render('pullrequest_name',
266 266 pr.pull_request_id, pr.target_repo.repo_name),
267 267 'name_raw': pr.pull_request_id,
268 268 'status': _render('pullrequest_status',
269 269 pr.calculated_review_status()),
270 270 'title': _render(
271 271 'pullrequest_title', pr.title, pr.description),
272 272 'description': h.escape(pr.description),
273 273 'updated_on': _render('pullrequest_updated_on',
274 274 h.datetime_to_time(pr.updated_on)),
275 275 'updated_on_raw': h.datetime_to_time(pr.updated_on),
276 276 'created_on': _render('pullrequest_updated_on',
277 277 h.datetime_to_time(pr.created_on)),
278 278 'created_on_raw': h.datetime_to_time(pr.created_on),
279 279 'author': _render('pullrequest_author',
280 280 pr.author.full_contact, ),
281 281 'author_raw': pr.author.full_name,
282 282 'comments': _render('pullrequest_comments', len(comments)),
283 283 'comments_raw': len(comments),
284 284 'closed': pr.is_closed(),
285 285 })
286 286 # json used to render the grid
287 287 data = ({
288 288 'data': data,
289 289 'recordsTotal': pull_requests_total_count,
290 290 'recordsFiltered': pull_requests_total_count,
291 291 })
292 292 return data
293 293
294 294 @LoginRequired()
295 295 @NotAnonymous()
296 296 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
297 297 'repository.admin')
298 298 @HasAcceptedRepoType('git', 'hg')
299 299 def index(self):
300 300 source_repo = c.rhodecode_db_repo
301 301
302 302 try:
303 303 source_repo.scm_instance().get_commit()
304 304 except EmptyRepositoryError:
305 305 h.flash(h.literal(_('There are no commits yet')),
306 306 category='warning')
307 307 redirect(url('summary_home', repo_name=source_repo.repo_name))
308 308
309 309 commit_id = request.GET.get('commit')
310 310 branch_ref = request.GET.get('branch')
311 311 bookmark_ref = request.GET.get('bookmark')
312 312
313 313 try:
314 314 source_repo_data = PullRequestModel().generate_repo_data(
315 315 source_repo, commit_id=commit_id,
316 316 branch=branch_ref, bookmark=bookmark_ref)
317 317 except CommitDoesNotExistError as e:
318 318 log.exception(e)
319 319 h.flash(_('Commit does not exist'), 'error')
320 320 redirect(url('pullrequest_home', repo_name=source_repo.repo_name))
321 321
322 322 default_target_repo = source_repo
323 323
324 324 if source_repo.parent:
325 325 parent_vcs_obj = source_repo.parent.scm_instance()
326 326 if parent_vcs_obj and not parent_vcs_obj.is_empty():
327 327 # change default if we have a parent repo
328 328 default_target_repo = source_repo.parent
329 329
330 330 target_repo_data = PullRequestModel().generate_repo_data(
331 331 default_target_repo)
332 332
333 333 selected_source_ref = source_repo_data['refs']['selected_ref']
334 334
335 335 title_source_ref = selected_source_ref.split(':', 2)[1]
336 336 c.default_title = PullRequestModel().generate_pullrequest_title(
337 337 source=source_repo.repo_name,
338 338 source_ref=title_source_ref,
339 339 target=default_target_repo.repo_name
340 340 )
341 341
342 342 c.default_repo_data = {
343 343 'source_repo_name': source_repo.repo_name,
344 344 'source_refs_json': json.dumps(source_repo_data),
345 345 'target_repo_name': default_target_repo.repo_name,
346 346 'target_refs_json': json.dumps(target_repo_data),
347 347 }
348 348 c.default_source_ref = selected_source_ref
349 349
350 350 return render('/pullrequests/pullrequest.mako')
351 351
352 352 @LoginRequired()
353 353 @NotAnonymous()
354 354 @XHRRequired()
355 355 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
356 356 'repository.admin')
357 357 @jsonify
358 358 def get_repo_refs(self, repo_name, target_repo_name):
359 359 repo = Repository.get_by_repo_name(target_repo_name)
360 360 if not repo:
361 361 raise HTTPNotFound
362 362 return PullRequestModel().generate_repo_data(repo)
363 363
364 364 @LoginRequired()
365 365 @NotAnonymous()
366 366 @XHRRequired()
367 367 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
368 368 'repository.admin')
369 369 @jsonify
370 370 def get_repo_destinations(self, repo_name):
371 371 repo = Repository.get_by_repo_name(repo_name)
372 372 if not repo:
373 373 raise HTTPNotFound
374 374 filter_query = request.GET.get('query')
375 375
376 376 query = Repository.query() \
377 377 .order_by(func.length(Repository.repo_name)) \
378 378 .filter(or_(
379 379 Repository.repo_name == repo.repo_name,
380 380 Repository.fork_id == repo.repo_id))
381 381
382 382 if filter_query:
383 383 ilike_expression = u'%{}%'.format(safe_unicode(filter_query))
384 384 query = query.filter(
385 385 Repository.repo_name.ilike(ilike_expression))
386 386
387 387 add_parent = False
388 388 if repo.parent:
389 389 if filter_query in repo.parent.repo_name:
390 390 parent_vcs_obj = repo.parent.scm_instance()
391 391 if parent_vcs_obj and not parent_vcs_obj.is_empty():
392 392 add_parent = True
393 393
394 394 limit = 20 - 1 if add_parent else 20
395 395 all_repos = query.limit(limit).all()
396 396 if add_parent:
397 397 all_repos += [repo.parent]
398 398
399 399 repos = []
400 400 for obj in self.scm_model.get_repos(all_repos):
401 401 repos.append({
402 402 'id': obj['name'],
403 403 'text': obj['name'],
404 404 'type': 'repo',
405 405 'obj': obj['dbrepo']
406 406 })
407 407
408 408 data = {
409 409 'more': False,
410 410 'results': [{
411 411 'text': _('Repositories'),
412 412 'children': repos
413 413 }] if repos else []
414 414 }
415 415 return data
416 416
417 417 @LoginRequired()
418 418 @NotAnonymous()
419 419 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
420 420 'repository.admin')
421 421 @HasAcceptedRepoType('git', 'hg')
422 422 @auth.CSRFRequired()
423 423 def create(self, repo_name):
424 424 repo = Repository.get_by_repo_name(repo_name)
425 425 if not repo:
426 426 raise HTTPNotFound
427 427
428 428 controls = peppercorn.parse(request.POST.items())
429 429
430 430 try:
431 431 _form = PullRequestForm(repo.repo_id)().to_python(controls)
432 432 except formencode.Invalid as errors:
433 433 if errors.error_dict.get('revisions'):
434 434 msg = 'Revisions: %s' % errors.error_dict['revisions']
435 435 elif errors.error_dict.get('pullrequest_title'):
436 436 msg = _('Pull request requires a title with min. 3 chars')
437 437 else:
438 438 msg = _('Error creating pull request: {}').format(errors)
439 439 log.exception(msg)
440 440 h.flash(msg, 'error')
441 441
442 442 # would rather just go back to form ...
443 443 return redirect(url('pullrequest_home', repo_name=repo_name))
444 444
445 445 source_repo = _form['source_repo']
446 446 source_ref = _form['source_ref']
447 447 target_repo = _form['target_repo']
448 448 target_ref = _form['target_ref']
449 449 commit_ids = _form['revisions'][::-1]
450 450 reviewers = [
451 451 (r['user_id'], r['reasons']) for r in _form['review_members']]
452 452
453 453 # find the ancestor for this pr
454 454 source_db_repo = Repository.get_by_repo_name(_form['source_repo'])
455 455 target_db_repo = Repository.get_by_repo_name(_form['target_repo'])
456 456
457 457 source_scm = source_db_repo.scm_instance()
458 458 target_scm = target_db_repo.scm_instance()
459 459
460 460 source_commit = source_scm.get_commit(source_ref.split(':')[-1])
461 461 target_commit = target_scm.get_commit(target_ref.split(':')[-1])
462 462
463 463 ancestor = source_scm.get_common_ancestor(
464 464 source_commit.raw_id, target_commit.raw_id, target_scm)
465 465
466 466 target_ref_type, target_ref_name, __ = _form['target_ref'].split(':')
467 467 target_ref = ':'.join((target_ref_type, target_ref_name, ancestor))
468 468
469 469 pullrequest_title = _form['pullrequest_title']
470 470 title_source_ref = source_ref.split(':', 2)[1]
471 471 if not pullrequest_title:
472 472 pullrequest_title = PullRequestModel().generate_pullrequest_title(
473 473 source=source_repo,
474 474 source_ref=title_source_ref,
475 475 target=target_repo
476 476 )
477 477
478 478 description = _form['pullrequest_desc']
479 479 try:
480 480 pull_request = PullRequestModel().create(
481 481 c.rhodecode_user.user_id, source_repo, source_ref, target_repo,
482 482 target_ref, commit_ids, reviewers, pullrequest_title,
483 483 description
484 484 )
485 485 Session().commit()
486 486 h.flash(_('Successfully opened new pull request'),
487 487 category='success')
488 488 except Exception as e:
489 489 msg = _('Error occurred during sending pull request')
490 490 log.exception(msg)
491 491 h.flash(msg, category='error')
492 492 return redirect(url('pullrequest_home', repo_name=repo_name))
493 493
494 494 return redirect(url('pullrequest_show', repo_name=target_repo,
495 495 pull_request_id=pull_request.pull_request_id))
496 496
497 497 @LoginRequired()
498 498 @NotAnonymous()
499 499 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
500 500 'repository.admin')
501 501 @auth.CSRFRequired()
502 502 @jsonify
503 503 def update(self, repo_name, pull_request_id):
504 504 pull_request_id = safe_int(pull_request_id)
505 505 pull_request = PullRequest.get_or_404(pull_request_id)
506 506 # only owner or admin can update it
507 507 allowed_to_update = PullRequestModel().check_user_update(
508 508 pull_request, c.rhodecode_user)
509 509 if allowed_to_update:
510 510 controls = peppercorn.parse(request.POST.items())
511 511
512 512 if 'review_members' in controls:
513 513 self._update_reviewers(
514 514 pull_request_id, controls['review_members'])
515 515 elif str2bool(request.POST.get('update_commits', 'false')):
516 516 self._update_commits(pull_request)
517 517 elif str2bool(request.POST.get('close_pull_request', 'false')):
518 518 self._reject_close(pull_request)
519 519 elif str2bool(request.POST.get('edit_pull_request', 'false')):
520 520 self._edit_pull_request(pull_request)
521 521 else:
522 522 raise HTTPBadRequest()
523 523 return True
524 524 raise HTTPForbidden()
525 525
526 526 def _edit_pull_request(self, pull_request):
527 527 try:
528 528 PullRequestModel().edit(
529 529 pull_request, request.POST.get('title'),
530 530 request.POST.get('description'))
531 531 except ValueError:
532 532 msg = _(u'Cannot update closed pull requests.')
533 533 h.flash(msg, category='error')
534 534 return
535 535 else:
536 536 Session().commit()
537 537
538 538 msg = _(u'Pull request title & description updated.')
539 539 h.flash(msg, category='success')
540 540 return
541 541
542 542 def _update_commits(self, pull_request):
543 543 resp = PullRequestModel().update_commits(pull_request)
544 544
545 545 if resp.executed:
546 546 msg = _(
547 547 u'Pull request updated to "{source_commit_id}" with '
548 548 u'{count_added} added, {count_removed} removed commits.')
549 549 msg = msg.format(
550 550 source_commit_id=pull_request.source_ref_parts.commit_id,
551 551 count_added=len(resp.changes.added),
552 552 count_removed=len(resp.changes.removed))
553 553 h.flash(msg, category='success')
554 554
555 555 registry = get_current_registry()
556 556 rhodecode_plugins = getattr(registry, 'rhodecode_plugins', {})
557 557 channelstream_config = rhodecode_plugins.get('channelstream', {})
558 558 if channelstream_config.get('enabled'):
559 559 message = msg + (
560 560 ' - <a onclick="window.location.reload()">'
561 561 '<strong>{}</strong></a>'.format(_('Reload page')))
562 562 channel = '/repo${}$/pr/{}'.format(
563 563 pull_request.target_repo.repo_name,
564 564 pull_request.pull_request_id
565 565 )
566 566 payload = {
567 567 'type': 'message',
568 568 'user': 'system',
569 569 'exclude_users': [request.user.username],
570 570 'channel': channel,
571 571 'message': {
572 572 'message': message,
573 573 'level': 'success',
574 574 'topic': '/notifications'
575 575 }
576 576 }
577 577 channelstream_request(
578 578 channelstream_config, [payload], '/message',
579 579 raise_exc=False)
580 580 else:
581 581 msg = PullRequestModel.UPDATE_STATUS_MESSAGES[resp.reason]
582 582 warning_reasons = [
583 583 UpdateFailureReason.NO_CHANGE,
584 584 UpdateFailureReason.WRONG_REF_TPYE,
585 585 ]
586 586 category = 'warning' if resp.reason in warning_reasons else 'error'
587 587 h.flash(msg, category=category)
588 588
589 589 @auth.CSRFRequired()
590 590 @LoginRequired()
591 591 @NotAnonymous()
592 592 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
593 593 'repository.admin')
594 594 def merge(self, repo_name, pull_request_id):
595 595 """
596 596 POST /{repo_name}/pull-request/{pull_request_id}
597 597
598 598 Merge will perform a server-side merge of the specified
599 599 pull request, if the pull request is approved and mergeable.
600 600 After successful merging, the pull request is automatically
601 601 closed, with a relevant comment.
602 602 """
603 603 pull_request_id = safe_int(pull_request_id)
604 604 pull_request = PullRequest.get_or_404(pull_request_id)
605 605 user = c.rhodecode_user
606 606
607 607 check = MergeCheck.validate(pull_request, user)
608 608 merge_possible = not check.failed
609 609
610 610 for err_type, error_msg in check.errors:
611 611 h.flash(error_msg, category=err_type)
612 612
613 613 if merge_possible:
614 614 log.debug("Pre-conditions checked, trying to merge.")
615 615 extras = vcs_operation_context(
616 616 request.environ, repo_name=pull_request.target_repo.repo_name,
617 617 username=user.username, action='push',
618 618 scm=pull_request.target_repo.repo_type)
619 619 self._merge_pull_request(pull_request, user, extras)
620 620
621 621 return redirect(url(
622 622 'pullrequest_show',
623 623 repo_name=pull_request.target_repo.repo_name,
624 624 pull_request_id=pull_request.pull_request_id))
625 625
626 626 def _merge_pull_request(self, pull_request, user, extras):
627 627 merge_resp = PullRequestModel().merge(
628 628 pull_request, user, extras=extras)
629 629
630 630 if merge_resp.executed:
631 631 log.debug("The merge was successful, closing the pull request.")
632 632 PullRequestModel().close_pull_request(
633 633 pull_request.pull_request_id, user)
634 634 Session().commit()
635 635 msg = _('Pull request was successfully merged and closed.')
636 636 h.flash(msg, category='success')
637 637 else:
638 638 log.debug(
639 639 "The merge was not successful. Merge response: %s",
640 640 merge_resp)
641 641 msg = PullRequestModel().merge_status_message(
642 642 merge_resp.failure_reason)
643 643 h.flash(msg, category='error')
644 644
645 645 def _update_reviewers(self, pull_request_id, review_members):
646 646 reviewers = [
647 647 (int(r['user_id']), r['reasons']) for r in review_members]
648 648 PullRequestModel().update_reviewers(pull_request_id, reviewers)
649 649 Session().commit()
650 650
651 651 def _reject_close(self, pull_request):
652 652 if pull_request.is_closed():
653 653 raise HTTPForbidden()
654 654
655 655 PullRequestModel().close_pull_request_with_comment(
656 656 pull_request, c.rhodecode_user, c.rhodecode_db_repo)
657 657 Session().commit()
658 658
659 659 @LoginRequired()
660 660 @NotAnonymous()
661 661 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
662 662 'repository.admin')
663 663 @auth.CSRFRequired()
664 664 @jsonify
665 665 def delete(self, repo_name, pull_request_id):
666 666 pull_request_id = safe_int(pull_request_id)
667 667 pull_request = PullRequest.get_or_404(pull_request_id)
668 668 # only owner can delete it !
669 669 if pull_request.author.user_id == c.rhodecode_user.user_id:
670 670 PullRequestModel().delete(pull_request)
671 671 Session().commit()
672 672 h.flash(_('Successfully deleted pull request'),
673 673 category='success')
674 674 return redirect(url('my_account_pullrequests'))
675 675 raise HTTPForbidden()
676 676
677 677 def _get_pr_version(self, pull_request_id, version=None):
678 678 pull_request_id = safe_int(pull_request_id)
679 679 at_version = None
680 680
681 681 if version and version == 'latest':
682 682 pull_request_ver = PullRequest.get(pull_request_id)
683 683 pull_request_obj = pull_request_ver
684 684 _org_pull_request_obj = pull_request_obj
685 685 at_version = 'latest'
686 686 elif version:
687 687 pull_request_ver = PullRequestVersion.get_or_404(version)
688 688 pull_request_obj = pull_request_ver
689 689 _org_pull_request_obj = pull_request_ver.pull_request
690 690 at_version = pull_request_ver.pull_request_version_id
691 691 else:
692 692 _org_pull_request_obj = pull_request_obj = PullRequest.get_or_404(pull_request_id)
693 693
694 694 pull_request_display_obj = PullRequest.get_pr_display_object(
695 695 pull_request_obj, _org_pull_request_obj)
696 696 return _org_pull_request_obj, pull_request_obj, \
697 697 pull_request_display_obj, at_version
698 698
699 699 def _get_pr_version_changes(self, version, pull_request_latest):
700 700 """
701 701 Generate changes commits, and diff data based on the current pr version
702 702 """
703 703
704 704 #TODO(marcink): save those changes as JSON metadata for chaching later.
705 705
706 706 # fake the version to add the "initial" state object
707 707 pull_request_initial = PullRequest.get_pr_display_object(
708 708 pull_request_latest, pull_request_latest,
709 709 internal_methods=['get_commit', 'versions'])
710 710 pull_request_initial.revisions = []
711 711 pull_request_initial.source_repo.get_commit = types.MethodType(
712 712 lambda *a, **k: EmptyCommit(), pull_request_initial)
713 713 pull_request_initial.source_repo.scm_instance = types.MethodType(
714 714 lambda *a, **k: EmptyRepository(), pull_request_initial)
715 715
716 716 _changes_versions = [pull_request_latest] + \
717 717 list(reversed(c.versions)) + \
718 718 [pull_request_initial]
719 719
720 720 if version == 'latest':
721 721 index = 0
722 722 else:
723 723 for pos, prver in enumerate(_changes_versions):
724 724 ver = getattr(prver, 'pull_request_version_id', -1)
725 725 if ver == safe_int(version):
726 726 index = pos
727 727 break
728 728 else:
729 729 index = 0
730 730
731 731 cur_obj = _changes_versions[index]
732 732 prev_obj = _changes_versions[index + 1]
733 733
734 734 old_commit_ids = set(prev_obj.revisions)
735 735 new_commit_ids = set(cur_obj.revisions)
736 736
737 737 changes = PullRequestModel()._calculate_commit_id_changes(
738 738 old_commit_ids, new_commit_ids)
739 739
740 740 old_diff_data, new_diff_data = PullRequestModel()._generate_update_diffs(
741 741 cur_obj, prev_obj)
742 742 file_changes = PullRequestModel()._calculate_file_changes(
743 743 old_diff_data, new_diff_data)
744 744 return changes, file_changes
745 745
746 746 @LoginRequired()
747 747 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
748 748 'repository.admin')
749 749 def show(self, repo_name, pull_request_id):
750 750 pull_request_id = safe_int(pull_request_id)
751 751 version = request.GET.get('version')
752 752 merge_checks = request.GET.get('merge_checks')
753 753
754 754 (pull_request_latest,
755 755 pull_request_at_ver,
756 756 pull_request_display_obj,
757 757 at_version) = self._get_pr_version(pull_request_id, version=version)
758 758
759 759 c.template_context['pull_request_data']['pull_request_id'] = \
760 760 pull_request_id
761 761
762 762 # pull_requests repo_name we opened it against
763 763 # ie. target_repo must match
764 764 if repo_name != pull_request_at_ver.target_repo.repo_name:
765 765 raise HTTPNotFound
766 766
767 767 c.shadow_clone_url = PullRequestModel().get_shadow_clone_url(
768 768 pull_request_at_ver)
769 769
770 770 c.ancestor = None # TODO: add ancestor here
771 771 c.pull_request = pull_request_display_obj
772 772 c.pull_request_latest = pull_request_latest
773 773
774 774 pr_closed = pull_request_latest.is_closed()
775 775 if at_version and not at_version == 'latest':
776 776 c.allowed_to_change_status = False
777 777 c.allowed_to_update = False
778 778 c.allowed_to_merge = False
779 779 c.allowed_to_delete = False
780 780 c.allowed_to_comment = False
781 781 else:
782 782 c.allowed_to_change_status = PullRequestModel(). \
783 783 check_user_change_status(pull_request_at_ver, c.rhodecode_user)
784 784 c.allowed_to_update = PullRequestModel().check_user_update(
785 785 pull_request_latest, c.rhodecode_user) and not pr_closed
786 786 c.allowed_to_merge = PullRequestModel().check_user_merge(
787 787 pull_request_latest, c.rhodecode_user) and not pr_closed
788 788 c.allowed_to_delete = PullRequestModel().check_user_delete(
789 789 pull_request_latest, c.rhodecode_user) and not pr_closed
790 790 c.allowed_to_comment = not pr_closed
791 791
792 792 cc_model = CommentsModel()
793 793
794 794 c.pull_request_reviewers = pull_request_at_ver.reviewers_statuses()
795 795 c.pull_request_review_status = pull_request_at_ver.calculated_review_status()
796 796
797 797 c.versions = pull_request_display_obj.versions()
798 798 c.at_version = at_version
799 799 c.at_version_num = at_version if at_version and at_version != 'latest' else None
800 800 c.at_version_pos = ChangesetComment.get_index_from_version(
801 801 c.at_version_num, c.versions)
802 802
803 803 # GENERAL COMMENTS with versions #
804 804 q = cc_model._all_general_comments_of_pull_request(pull_request_latest)
805 805 general_comments = q.order_by(ChangesetComment.pull_request_version_id.asc())
806 806
807 807 # pick comments we want to render at current version
808 808 c.comment_versions = cc_model.aggregate_comments(
809 809 general_comments, c.versions, c.at_version_num)
810 810 c.comments = c.comment_versions[c.at_version_num]['until']
811 811
812 812 # INLINE COMMENTS with versions #
813 813 q = cc_model._all_inline_comments_of_pull_request(pull_request_latest)
814 814 inline_comments = q.order_by(ChangesetComment.pull_request_version_id.asc())
815 815 c.inline_versions = cc_model.aggregate_comments(
816 816 inline_comments, c.versions, c.at_version_num, inline=True)
817 817
818 818 # if we use version, then do not show later comments
819 819 # than current version
820 820 display_inline_comments = collections.defaultdict(lambda: collections.defaultdict(list))
821 821 for co in inline_comments:
822 822 if c.at_version_num:
823 823 # pick comments that are at least UPTO given version, so we
824 824 # don't render comments for higher version
825 825 should_render = co.pull_request_version_id and \
826 826 co.pull_request_version_id <= c.at_version_num
827 827 else:
828 828 # showing all, for 'latest'
829 829 should_render = True
830 830
831 831 if should_render:
832 832 display_inline_comments[co.f_path][co.line_no].append(co)
833 833
834 834 _merge_check = MergeCheck.validate(
835 835 pull_request_latest, user=c.rhodecode_user)
836 c.pr_merge_errors = _merge_check.errors
836 c.pr_merge_errors = _merge_check.error_details
837 837 c.pr_merge_possible = not _merge_check.failed
838 838 c.pr_merge_message = _merge_check.merge_msg
839 839
840 840 if merge_checks:
841 841 return render('/pullrequests/pullrequest_merge_checks.mako')
842 842
843 843 # load compare data into template context
844 844 self._load_compare_data(pull_request_at_ver, display_inline_comments)
845 845
846 846 # this is a hack to properly display links, when creating PR, the
847 847 # compare view and others uses different notation, and
848 848 # compare_commits.mako renders links based on the target_repo.
849 849 # We need to swap that here to generate it properly on the html side
850 850 c.target_repo = c.source_repo
851 851
852 852 if c.allowed_to_update:
853 853 force_close = ('forced_closed', _('Close Pull Request'))
854 854 statuses = ChangesetStatus.STATUSES + [force_close]
855 855 else:
856 856 statuses = ChangesetStatus.STATUSES
857 857 c.commit_statuses = statuses
858 858
859 859 c.changes = None
860 860 c.file_changes = None
861 861
862 862 c.show_version_changes = 1 # control flag, not used yet
863 863
864 864 if at_version and c.show_version_changes:
865 865 c.changes, c.file_changes = self._get_pr_version_changes(
866 866 version, pull_request_latest)
867 867
868 868 return render('/pullrequests/pullrequest_show.mako')
869 869
870 870 @LoginRequired()
871 871 @NotAnonymous()
872 872 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
873 873 'repository.admin')
874 874 @auth.CSRFRequired()
875 875 @jsonify
876 876 def comment(self, repo_name, pull_request_id):
877 877 pull_request_id = safe_int(pull_request_id)
878 878 pull_request = PullRequest.get_or_404(pull_request_id)
879 879 if pull_request.is_closed():
880 880 raise HTTPForbidden()
881 881
882 882 # TODO: johbo: Re-think this bit, "approved_closed" does not exist
883 883 # as a changeset status, still we want to send it in one value.
884 884 status = request.POST.get('changeset_status', None)
885 885 text = request.POST.get('text')
886 886 comment_type = request.POST.get('comment_type')
887 887 resolves_comment_id = request.POST.get('resolves_comment_id', None)
888 888
889 889 if status and '_closed' in status:
890 890 close_pr = True
891 891 status = status.replace('_closed', '')
892 892 else:
893 893 close_pr = False
894 894
895 895 forced = (status == 'forced')
896 896 if forced:
897 897 status = 'rejected'
898 898
899 899 allowed_to_change_status = PullRequestModel().check_user_change_status(
900 900 pull_request, c.rhodecode_user)
901 901
902 902 if status and allowed_to_change_status:
903 903 message = (_('Status change %(transition_icon)s %(status)s')
904 904 % {'transition_icon': '>',
905 905 'status': ChangesetStatus.get_status_lbl(status)})
906 906 if close_pr:
907 907 message = _('Closing with') + ' ' + message
908 908 text = text or message
909 909 comm = CommentsModel().create(
910 910 text=text,
911 911 repo=c.rhodecode_db_repo.repo_id,
912 912 user=c.rhodecode_user.user_id,
913 913 pull_request=pull_request_id,
914 914 f_path=request.POST.get('f_path'),
915 915 line_no=request.POST.get('line'),
916 916 status_change=(ChangesetStatus.get_status_lbl(status)
917 917 if status and allowed_to_change_status else None),
918 918 status_change_type=(status
919 919 if status and allowed_to_change_status else None),
920 920 closing_pr=close_pr,
921 921 comment_type=comment_type,
922 922 resolves_comment_id=resolves_comment_id
923 923 )
924 924
925 925 if allowed_to_change_status:
926 926 old_calculated_status = pull_request.calculated_review_status()
927 927 # get status if set !
928 928 if status:
929 929 ChangesetStatusModel().set_status(
930 930 c.rhodecode_db_repo.repo_id,
931 931 status,
932 932 c.rhodecode_user.user_id,
933 933 comm,
934 934 pull_request=pull_request_id
935 935 )
936 936
937 937 Session().flush()
938 938 events.trigger(events.PullRequestCommentEvent(pull_request, comm))
939 939 # we now calculate the status of pull request, and based on that
940 940 # calculation we set the commits status
941 941 calculated_status = pull_request.calculated_review_status()
942 942 if old_calculated_status != calculated_status:
943 943 PullRequestModel()._trigger_pull_request_hook(
944 944 pull_request, c.rhodecode_user, 'review_status_change')
945 945
946 946 calculated_status_lbl = ChangesetStatus.get_status_lbl(
947 947 calculated_status)
948 948
949 949 if close_pr:
950 950 status_completed = (
951 951 calculated_status in [ChangesetStatus.STATUS_APPROVED,
952 952 ChangesetStatus.STATUS_REJECTED])
953 953 if forced or status_completed:
954 954 PullRequestModel().close_pull_request(
955 955 pull_request_id, c.rhodecode_user)
956 956 else:
957 957 h.flash(_('Closing pull request on other statuses than '
958 958 'rejected or approved is forbidden. '
959 959 'Calculated status from all reviewers '
960 960 'is currently: %s') % calculated_status_lbl,
961 961 category='warning')
962 962
963 963 Session().commit()
964 964
965 965 if not request.is_xhr:
966 966 return redirect(h.url('pullrequest_show', repo_name=repo_name,
967 967 pull_request_id=pull_request_id))
968 968
969 969 data = {
970 970 'target_id': h.safeid(h.safe_unicode(request.POST.get('f_path'))),
971 971 }
972 972 if comm:
973 973 c.co = comm
974 974 c.inline_comment = True if comm.line_no else False
975 975 data.update(comm.get_dict())
976 976 data.update({'rendered_text':
977 977 render('changeset/changeset_comment_block.mako')})
978 978
979 979 return data
980 980
981 981 @LoginRequired()
982 982 @NotAnonymous()
983 983 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
984 984 'repository.admin')
985 985 @auth.CSRFRequired()
986 986 @jsonify
987 987 def delete_comment(self, repo_name, comment_id):
988 988 return self._delete_comment(comment_id)
989 989
990 990 def _delete_comment(self, comment_id):
991 991 comment_id = safe_int(comment_id)
992 992 co = ChangesetComment.get_or_404(comment_id)
993 993 if co.pull_request.is_closed():
994 994 # don't allow deleting comments on closed pull request
995 995 raise HTTPForbidden()
996 996
997 997 is_owner = co.author.user_id == c.rhodecode_user.user_id
998 998 is_repo_admin = h.HasRepoPermissionAny('repository.admin')(c.repo_name)
999 999 if h.HasPermissionAny('hg.admin')() or is_repo_admin or is_owner:
1000 1000 old_calculated_status = co.pull_request.calculated_review_status()
1001 1001 CommentsModel().delete(comment=co)
1002 1002 Session().commit()
1003 1003 calculated_status = co.pull_request.calculated_review_status()
1004 1004 if old_calculated_status != calculated_status:
1005 1005 PullRequestModel()._trigger_pull_request_hook(
1006 1006 co.pull_request, c.rhodecode_user, 'review_status_change')
1007 1007 return True
1008 1008 else:
1009 1009 raise HTTPForbidden()
@@ -1,547 +1,558 b''
1 1 // comments.less
2 2 // For use in RhodeCode applications;
3 3 // see style guide documentation for guidelines.
4 4
5 5
6 6 // Comments
7 @comment-outdated-opacity: 0.6;
8
7 9 .comments {
8 10 width: 100%;
9 11 }
10 12
11 13 tr.inline-comments div {
12 14 max-width: 100%;
13 15
14 16 p {
15 17 white-space: normal;
16 18 }
17 19
18 20 code, pre, .code, dd {
19 21 overflow-x: auto;
20 22 width: 1062px;
21 23 }
22 24
23 25 dd {
24 26 width: auto;
25 27 }
26 28 }
27 29
28 30 #injected_page_comments {
29 31 .comment-previous-link,
30 32 .comment-next-link,
31 33 .comment-links-divider {
32 34 display: none;
33 35 }
34 36 }
35 37
36 38 .add-comment {
37 39 margin-bottom: 10px;
38 40 }
39 41 .hide-comment-button .add-comment {
40 42 display: none;
41 43 }
42 44
43 45 .comment-bubble {
44 46 color: @grey4;
45 47 margin-top: 4px;
46 48 margin-right: 30px;
47 49 visibility: hidden;
48 50 }
49 51
50 52 .comment-label {
51 53 float: left;
52 54
53 55 padding: 0.4em 0.4em;
54 56 margin: 3px 5px 0px -10px;
55 57 display: inline-block;
56 58 min-height: 0;
57 59
58 60 text-align: center;
59 61 font-size: 10px;
60 62 line-height: .8em;
61 63
62 64 font-family: @text-italic;
63 65 background: #fff none;
64 66 color: @grey4;
65 67 border: 1px solid @grey4;
66 68 white-space: nowrap;
67 69
68 70 text-transform: uppercase;
69 71 min-width: 40px;
70 72
71 73 &.todo {
72 74 color: @color5;
73 75 font-family: @text-bold-italic;
74 76 }
75 77
76 78 .resolve {
77 79 cursor: pointer;
78 80 text-decoration: underline;
79 81 }
80 82
81 83 .resolved {
82 84 text-decoration: line-through;
83 85 color: @color1;
84 86 }
85 87 .resolved a {
86 88 text-decoration: line-through;
87 89 color: @color1;
88 90 }
89 91 .resolve-text {
90 92 color: @color1;
91 93 margin: 2px 8px;
92 94 font-family: @text-italic;
93 95 }
94
95 96 }
96 97
97 98
98 99 .comment {
99 100
100 101 &.comment-general {
101 102 border: 1px solid @grey5;
102 103 padding: 5px 5px 5px 5px;
103 104 }
104 105
105 106 margin: @padding 0;
106 107 padding: 4px 0 0 0;
107 108 line-height: 1em;
108 109
109 110 .rc-user {
110 111 min-width: 0;
111 112 margin: 0px .5em 0 0;
112 113
113 114 .user {
114 115 display: inline;
115 116 }
116 117 }
117 118
118 119 .meta {
119 120 position: relative;
120 121 width: 100%;
121 122 border-bottom: 1px solid @grey5;
122 123 margin: -5px 0px;
123 124 line-height: 24px;
124 125
125 126 &:hover .permalink {
126 127 visibility: visible;
127 128 color: @rcblue;
128 129 }
129 130 }
130 131
131 132 .author,
132 133 .date {
133 134 display: inline;
134 135
135 136 &:after {
136 137 content: ' | ';
137 138 color: @grey5;
138 139 }
139 140 }
140 141
141 142 .author-general img {
142 143 top: 3px;
143 144 }
144 145 .author-inline img {
145 146 top: 3px;
146 147 }
147 148
148 149 .status-change,
149 150 .permalink,
150 151 .changeset-status-lbl {
151 152 display: inline;
152 153 }
153 154
154 155 .permalink {
155 156 visibility: hidden;
156 157 }
157 158
158 159 .comment-links-divider {
159 160 display: inline;
160 161 }
161 162
162 163 .comment-links-block {
163 164 float:right;
164 165 text-align: right;
165 166 min-width: 85px;
166 167
167 168 [class^="icon-"]:before,
168 169 [class*=" icon-"]:before {
169 170 margin-left: 0;
170 171 margin-right: 0;
171 172 }
172 173 }
173 174
174 175 .comment-previous-link {
175 176 display: inline-block;
176 177
177 178 .arrow_comment_link{
178 179 cursor: pointer;
179 180 i {
180 181 font-size:10px;
181 182 }
182 183 }
183 184 .arrow_comment_link.disabled {
184 185 cursor: default;
185 186 color: @grey5;
186 187 }
187 188 }
188 189
189 190 .comment-next-link {
190 191 display: inline-block;
191 192
192 193 .arrow_comment_link{
193 194 cursor: pointer;
194 195 i {
195 196 font-size:10px;
196 197 }
197 198 }
198 199 .arrow_comment_link.disabled {
199 200 cursor: default;
200 201 color: @grey5;
201 202 }
202 203 }
203 204
204 205 .flag_status {
205 206 display: inline-block;
206 207 margin: -2px .5em 0 .25em
207 208 }
208 209
209 210 .delete-comment {
210 211 display: inline-block;
211 212 color: @rcblue;
212 213
213 214 &:hover {
214 215 cursor: pointer;
215 216 }
216 217 }
217 218
218 219 .text {
219 220 clear: both;
220 221 .border-radius(@border-radius);
221 222 .box-sizing(border-box);
222 223
223 224 .markdown-block p,
224 225 .rst-block p {
225 226 margin: .5em 0 !important;
226 227 // TODO: lisa: This is needed because of other rst !important rules :[
227 228 }
228 229 }
229 230
230 231 .pr-version {
231 232 float: left;
232 233 margin: 0px 4px;
233 234 }
234 235 .pr-version-inline {
235 236 float: left;
236 237 margin: 0px 4px;
237 238 }
238 239 .pr-version-num {
239 240 font-size: 10px;
240 241 }
241
242 242 }
243 243
244 244 @comment-padding: 5px;
245 245
246 .general-comments {
247 .comment-outdated {
248 opacity: @comment-outdated-opacity;
249 }
250 }
251
246 252 .inline-comments {
247 253 border-radius: @border-radius;
248 254 .comment {
249 255 margin: 0;
250 256 border-radius: @border-radius;
251 257 }
252 258 .comment-outdated {
253 opacity: 0.5;
259 opacity: @comment-outdated-opacity;
254 260 }
255 261
256 262 .comment-inline {
257 263 background: white;
258 264 padding: @comment-padding @comment-padding;
259 265 border: @comment-padding solid @grey6;
260 266
261 267 .text {
262 268 border: none;
263 269 }
264 270 .meta {
265 271 border-bottom: 1px solid @grey6;
266 272 margin: -5px 0px;
267 273 line-height: 24px;
268 274 }
269 275 }
270 276 .comment-selected {
271 277 border-left: 6px solid @comment-highlight-color;
272 278 }
273 279 .comment-inline-form {
274 280 padding: @comment-padding;
275 281 display: none;
276 282 }
277 283 .cb-comment-add-button {
278 284 margin: @comment-padding;
279 285 }
280 286 /* hide add comment button when form is open */
281 287 .comment-inline-form-open ~ .cb-comment-add-button {
282 288 display: none;
283 289 }
284 290 .comment-inline-form-open {
285 291 display: block;
286 292 }
287 293 /* hide add comment button when form but no comments */
288 294 .comment-inline-form:first-child + .cb-comment-add-button {
289 295 display: none;
290 296 }
291 297 /* hide add comment button when no comments or form */
292 298 .cb-comment-add-button:first-child {
293 299 display: none;
294 300 }
295 301 /* hide add comment button when only comment is being deleted */
296 302 .comment-deleting:first-child + .cb-comment-add-button {
297 303 display: none;
298 304 }
299 305 }
300 306
301 307
302 308 .show-outdated-comments {
303 309 display: inline;
304 310 color: @rcblue;
305 311 }
306 312
307 313 // Comment Form
308 314 div.comment-form {
309 315 margin-top: 20px;
310 316 }
311 317
312 318 .comment-form strong {
313 319 display: block;
314 320 margin-bottom: 15px;
315 321 }
316 322
317 323 .comment-form textarea {
318 324 width: 100%;
319 325 height: 100px;
320 326 font-family: 'Monaco', 'Courier', 'Courier New', monospace;
321 327 }
322 328
323 329 form.comment-form {
324 330 margin-top: 10px;
325 331 margin-left: 10px;
326 332 }
327 333
328 334 .comment-inline-form .comment-block-ta,
329 335 .comment-form .comment-block-ta,
330 336 .comment-form .preview-box {
331 337 .border-radius(@border-radius);
332 338 .box-sizing(border-box);
333 339 background-color: white;
334 340 }
335 341
336 342 .comment-form-submit {
337 343 margin-top: 5px;
338 344 margin-left: 525px;
339 345 }
340 346
341 347 .file-comments {
342 348 display: none;
343 349 }
344 350
345 351 .comment-form .preview-box.unloaded,
346 352 .comment-inline-form .preview-box.unloaded {
347 353 height: 50px;
348 354 text-align: center;
349 355 padding: 20px;
350 356 background-color: white;
351 357 }
352 358
353 359 .comment-footer {
354 360 position: relative;
355 361 width: 100%;
356 362 min-height: 42px;
357 363
358 364 .status_box,
359 365 .cancel-button {
360 366 float: left;
361 367 display: inline-block;
362 368 }
363 369
364 370 .action-buttons {
365 371 float: right;
366 372 display: inline-block;
367 373 }
368 374 }
369 375
370 376 .comment-form {
371 377
372 378 .comment {
373 379 margin-left: 10px;
374 380 }
375 381
376 382 .comment-help {
377 383 color: @grey4;
378 384 padding: 5px 0 5px 0;
379 385 }
380 386
381 387 .comment-title {
382 388 padding: 5px 0 5px 0;
383 389 }
384 390
385 391 .comment-button {
386 392 display: inline-block;
387 393 }
388 394
389 395 .comment-button-input {
390 396 margin-right: 0;
391 397 }
392 398
393 399 .comment-footer {
394 400 margin-bottom: 110px;
395 401 margin-top: 10px;
396 402 }
397 403 }
398 404
399 405
400 406 .comment-form-login {
401 407 .comment-help {
402 408 padding: 0.9em; //same as the button
403 409 }
404 410
405 411 div.clearfix {
406 412 clear: both;
407 413 width: 100%;
408 414 display: block;
409 415 }
410 416 }
411 417
412 418 .comment-type {
413 419 margin: 0px;
414 420 border-radius: inherit;
415 421 border-color: @grey6;
416 422 }
417 423
418 424 .preview-box {
419 425 min-height: 105px;
420 426 margin-bottom: 15px;
421 427 background-color: white;
422 428 .border-radius(@border-radius);
423 429 .box-sizing(border-box);
424 430 }
425 431
426 432 .add-another-button {
427 433 margin-left: 10px;
428 434 margin-top: 10px;
429 435 margin-bottom: 10px;
430 436 }
431 437
432 438 .comment .buttons {
433 439 float: right;
434 440 margin: -1px 0px 0px 0px;
435 441 }
436 442
437 443 // Inline Comment Form
438 444 .injected_diff .comment-inline-form,
439 445 .comment-inline-form {
440 446 background-color: white;
441 447 margin-top: 10px;
442 448 margin-bottom: 20px;
443 449 }
444 450
445 451 .inline-form {
446 452 padding: 10px 7px;
447 453 }
448 454
449 455 .inline-form div {
450 456 max-width: 100%;
451 457 }
452 458
453 459 .overlay {
454 460 display: none;
455 461 position: absolute;
456 462 width: 100%;
457 463 text-align: center;
458 464 vertical-align: middle;
459 465 font-size: 16px;
460 466 background: none repeat scroll 0 0 white;
461 467
462 468 &.submitting {
463 469 display: block;
464 470 opacity: 0.5;
465 471 z-index: 100;
466 472 }
467 473 }
468 474 .comment-inline-form .overlay.submitting .overlay-text {
469 475 margin-top: 5%;
470 476 }
471 477
472 478 .comment-inline-form .clearfix,
473 479 .comment-form .clearfix {
474 480 .border-radius(@border-radius);
475 481 margin: 0px;
476 482 }
477 483
478 484 .comment-inline-form .comment-footer {
479 485 margin: 10px 0px 0px 0px;
480 486 }
481 487
482 488 .hide-inline-form-button {
483 489 margin-left: 5px;
484 490 }
485 491 .comment-button .hide-inline-form {
486 492 background: white;
487 493 }
488 494
489 495 .comment-area {
490 496 padding: 8px 12px;
491 497 border: 1px solid @grey5;
492 498 .border-radius(@border-radius);
499
500 .resolve-action {
501 padding: 1px 0px 0px 6px;
502 }
503
493 504 }
494 505
495 506 .comment-area-header .nav-links {
496 507 display: flex;
497 508 flex-flow: row wrap;
498 509 -webkit-flex-flow: row wrap;
499 510 width: 100%;
500 511 }
501 512
502 513 .comment-area-footer {
503 514 display: flex;
504 515 }
505 516
506 517 .comment-footer .toolbar {
507 518
508 519 }
509 520
510 521 .nav-links {
511 522 padding: 0;
512 523 margin: 0;
513 524 list-style: none;
514 525 height: auto;
515 526 border-bottom: 1px solid @grey5;
516 527 }
517 528 .nav-links li {
518 529 display: inline-block;
519 530 }
520 531 .nav-links li:before {
521 532 content: "";
522 533 }
523 534 .nav-links li a.disabled {
524 535 cursor: not-allowed;
525 536 }
526 537
527 538 .nav-links li.active a {
528 539 border-bottom: 2px solid @rcblue;
529 540 color: #000;
530 541 font-weight: 600;
531 542 }
532 543 .nav-links li a {
533 544 display: inline-block;
534 545 padding: 0px 10px 5px 10px;
535 546 margin-bottom: -1px;
536 547 font-size: 14px;
537 548 line-height: 28px;
538 549 color: #8f8f8f;
539 550 border-bottom: 2px solid transparent;
540 551 }
541 552
542 553 .toolbar-text {
543 554 float: left;
544 555 margin: -5px 0px 0px 0px;
545 556 font-size: 12px;
546 557 }
547 558
@@ -1,802 +1,808 b''
1 1 // # Copyright (C) 2010-2017 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 var linkifyComments = function(comments) {
29 29 var firstCommentId = null;
30 30 if (comments) {
31 31 firstCommentId = $(comments[0]).data('comment-id');
32 32 }
33 33
34 34 if (firstCommentId){
35 35 $('#inline-comments-counter').attr('href', '#comment-' + firstCommentId);
36 36 }
37 37 };
38 38
39 39 var bindToggleButtons = function() {
40 40 $('.comment-toggle').on('click', function() {
41 41 $(this).parent().nextUntil('tr.line').toggle('inline-comments');
42 42 });
43 43 };
44 44
45 45 /* Comment form for main and inline comments */
46 46 (function(mod) {
47 47
48 48 if (typeof exports == "object" && typeof module == "object") {
49 49 // CommonJS
50 50 module.exports = mod();
51 51 }
52 52 else {
53 53 // Plain browser env
54 54 (this || window).CommentForm = mod();
55 55 }
56 56
57 57 })(function() {
58 58 "use strict";
59 59
60 60 function CommentForm(formElement, commitId, pullRequestId, lineNo, initAutocompleteActions, resolvesCommentId) {
61 61 if (!(this instanceof CommentForm)) {
62 62 return new CommentForm(formElement, commitId, pullRequestId, lineNo, initAutocompleteActions, resolvesCommentId);
63 63 }
64 64
65 65 // bind the element instance to our Form
66 66 $(formElement).get(0).CommentForm = this;
67 67
68 68 this.withLineNo = function(selector) {
69 69 var lineNo = this.lineNo;
70 70 if (lineNo === undefined) {
71 71 return selector
72 72 } else {
73 73 return selector + '_' + lineNo;
74 74 }
75 75 };
76 76
77 77 this.commitId = commitId;
78 78 this.pullRequestId = pullRequestId;
79 79 this.lineNo = lineNo;
80 80 this.initAutocompleteActions = initAutocompleteActions;
81 81
82 82 this.previewButton = this.withLineNo('#preview-btn');
83 83 this.previewContainer = this.withLineNo('#preview-container');
84 84
85 85 this.previewBoxSelector = this.withLineNo('#preview-box');
86 86
87 87 this.editButton = this.withLineNo('#edit-btn');
88 88 this.editContainer = this.withLineNo('#edit-container');
89 89 this.cancelButton = this.withLineNo('#cancel-btn');
90 90 this.commentType = this.withLineNo('#comment_type');
91 91
92 92 this.resolvesId = null;
93 93 this.resolvesActionId = null;
94 94
95 95 this.cmBox = this.withLineNo('#text');
96 96 this.cm = initCommentBoxCodeMirror(this.cmBox, this.initAutocompleteActions);
97 97
98 98 this.statusChange = this.withLineNo('#change_status');
99 99
100 100 this.submitForm = formElement;
101 101 this.submitButton = $(this.submitForm).find('input[type="submit"]');
102 102 this.submitButtonText = this.submitButton.val();
103 103
104 104 this.previewUrl = pyroutes.url('changeset_comment_preview',
105 105 {'repo_name': templateContext.repo_name});
106 106
107 107 if (resolvesCommentId){
108 108 this.resolvesId = '#resolve_comment_{0}'.format(resolvesCommentId);
109 109 this.resolvesActionId = '#resolve_comment_action_{0}'.format(resolvesCommentId);
110 110 $(this.commentType).prop('disabled', true);
111 111 $(this.commentType).addClass('disabled');
112 112
113 113 // disable select
114 114 setTimeout(function() {
115 115 $(self.statusChange).select2('readonly', true);
116 116 }, 10);
117 117
118 118 var resolvedInfo = (
119 '<li class="">' +
119 '<li class="resolve-action">' +
120 120 '<input type="hidden" id="resolve_comment_{0}" name="resolve_comment_{0}" value="{0}">' +
121 121 '<button id="resolve_comment_action_{0}" class="resolve-text btn btn-sm" onclick="return Rhodecode.comments.submitResolution({0})">{1} #{0}</button>' +
122 122 '</li>'
123 123 ).format(resolvesCommentId, _gettext('resolve comment'));
124 124 $(resolvedInfo).insertAfter($(this.commentType).parent());
125 125 }
126 126
127 127 // based on commitId, or pullRequestId decide where do we submit
128 128 // out data
129 129 if (this.commitId){
130 130 this.submitUrl = pyroutes.url('changeset_comment',
131 131 {'repo_name': templateContext.repo_name,
132 132 'revision': this.commitId});
133 133 this.selfUrl = pyroutes.url('changeset_home',
134 134 {'repo_name': templateContext.repo_name,
135 135 'revision': this.commitId});
136 136
137 137 } else if (this.pullRequestId) {
138 138 this.submitUrl = pyroutes.url('pullrequest_comment',
139 139 {'repo_name': templateContext.repo_name,
140 140 'pull_request_id': this.pullRequestId});
141 141 this.selfUrl = pyroutes.url('pullrequest_show',
142 142 {'repo_name': templateContext.repo_name,
143 143 'pull_request_id': this.pullRequestId});
144 144
145 145 } else {
146 146 throw new Error(
147 147 'CommentForm requires pullRequestId, or commitId to be specified.')
148 148 }
149 149
150 150 // FUNCTIONS and helpers
151 151 var self = this;
152 152
153 153 this.isInline = function(){
154 154 return this.lineNo && this.lineNo != 'general';
155 155 };
156 156
157 157 this.getCmInstance = function(){
158 158 return this.cm
159 159 };
160 160
161 161 this.setPlaceholder = function(placeholder) {
162 162 var cm = this.getCmInstance();
163 163 if (cm){
164 164 cm.setOption('placeholder', placeholder);
165 165 }
166 166 };
167 167
168 168 this.getCommentStatus = function() {
169 169 return $(this.submitForm).find(this.statusChange).val();
170 170 };
171 171 this.getCommentType = function() {
172 172 return $(this.submitForm).find(this.commentType).val();
173 173 };
174 174
175 175 this.getResolvesId = function() {
176 176 return $(this.submitForm).find(this.resolvesId).val() || null;
177 177 };
178 178 this.markCommentResolved = function(resolvedCommentId){
179 179 $('#comment-label-{0}'.format(resolvedCommentId)).find('.resolved').show();
180 180 $('#comment-label-{0}'.format(resolvedCommentId)).find('.resolve').hide();
181 181 };
182 182
183 183 this.isAllowedToSubmit = function() {
184 184 return !$(this.submitButton).prop('disabled');
185 185 };
186 186
187 187 this.initStatusChangeSelector = function(){
188 188 var formatChangeStatus = function(state, escapeMarkup) {
189 189 var originalOption = state.element;
190 190 return '<div class="flag_status ' + $(originalOption).data('status') + ' pull-left"></div>' +
191 191 '<span>' + escapeMarkup(state.text) + '</span>';
192 192 };
193 193 var formatResult = function(result, container, query, escapeMarkup) {
194 194 return formatChangeStatus(result, escapeMarkup);
195 195 };
196 196
197 197 var formatSelection = function(data, container, escapeMarkup) {
198 198 return formatChangeStatus(data, escapeMarkup);
199 199 };
200 200
201 201 $(this.submitForm).find(this.statusChange).select2({
202 202 placeholder: _gettext('Status Review'),
203 203 formatResult: formatResult,
204 204 formatSelection: formatSelection,
205 205 containerCssClass: "drop-menu status_box_menu",
206 206 dropdownCssClass: "drop-menu-dropdown",
207 207 dropdownAutoWidth: true,
208 208 minimumResultsForSearch: -1
209 209 });
210 210 $(this.submitForm).find(this.statusChange).on('change', function() {
211 211 var status = self.getCommentStatus();
212 212 if (status && !self.isInline()) {
213 213 $(self.submitButton).prop('disabled', false);
214 214 }
215 215
216 216 var placeholderText = _gettext('Comment text will be set automatically based on currently selected status ({0}) ...').format(status);
217 217 self.setPlaceholder(placeholderText)
218 218 })
219 219 };
220 220
221 221 // reset the comment form into it's original state
222 222 this.resetCommentFormState = function(content) {
223 223 content = content || '';
224 224
225 225 $(this.editContainer).show();
226 226 $(this.editButton).parent().addClass('active');
227 227
228 228 $(this.previewContainer).hide();
229 229 $(this.previewButton).parent().removeClass('active');
230 230
231 231 this.setActionButtonsDisabled(true);
232 232 self.cm.setValue(content);
233 233 self.cm.setOption("readOnly", false);
234 234
235 235 if (this.resolvesId) {
236 236 // destroy the resolve action
237 237 $(this.resolvesId).parent().remove();
238 238 }
239 239
240 240 $(this.statusChange).select2('readonly', false);
241 241 };
242 242
243 243 this.globalSubmitSuccessCallback = function(){
244 244 // default behaviour is to call GLOBAL hook, if it's registered.
245 245 if (window.commentFormGlobalSubmitSuccessCallback !== undefined){
246 246 commentFormGlobalSubmitSuccessCallback()
247 247 }
248 248 };
249 249
250 250 this.submitAjaxPOST = function(url, postData, successHandler, failHandler) {
251 251 failHandler = failHandler || function() {};
252 252 var postData = toQueryString(postData);
253 253 var request = $.ajax({
254 254 url: url,
255 255 type: 'POST',
256 256 data: postData,
257 257 headers: {'X-PARTIAL-XHR': true}
258 258 })
259 259 .done(function(data) {
260 260 successHandler(data);
261 261 })
262 262 .fail(function(data, textStatus, errorThrown){
263 263 alert(
264 264 "Error while submitting comment.\n" +
265 265 "Error code {0} ({1}).".format(data.status, data.statusText));
266 266 failHandler()
267 267 });
268 268 return request;
269 269 };
270 270
271 271 // overwrite a submitHandler, we need to do it for inline comments
272 272 this.setHandleFormSubmit = function(callback) {
273 273 this.handleFormSubmit = callback;
274 274 };
275 275
276 276 // overwrite a submitSuccessHandler
277 277 this.setGlobalSubmitSuccessCallback = function(callback) {
278 278 this.globalSubmitSuccessCallback = callback;
279 279 };
280 280
281 281 // default handler for for submit for main comments
282 282 this.handleFormSubmit = function() {
283 283 var text = self.cm.getValue();
284 284 var status = self.getCommentStatus();
285 285 var commentType = self.getCommentType();
286 286 var resolvesCommentId = self.getResolvesId();
287 287
288 288 if (text === "" && !status) {
289 289 return;
290 290 }
291 291
292 292 var excludeCancelBtn = false;
293 293 var submitEvent = true;
294 294 self.setActionButtonsDisabled(true, excludeCancelBtn, submitEvent);
295 295 self.cm.setOption("readOnly", true);
296 296
297 297 var postData = {
298 298 'text': text,
299 299 'changeset_status': status,
300 300 'comment_type': commentType,
301 301 'csrf_token': CSRF_TOKEN
302 302 };
303 303 if (resolvesCommentId){
304 304 postData['resolves_comment_id'] = resolvesCommentId;
305 305 }
306 306
307 307 var submitSuccessCallback = function(o) {
308 308 // reload page if we change status for single commit.
309 309 if (status && self.commitId) {
310 310 location.reload(true);
311 311 } else {
312 312 $('#injected_page_comments').append(o.rendered_text);
313 313 self.resetCommentFormState();
314 314 timeagoActivate();
315 315
316 316 // mark visually which comment was resolved
317 317 if (resolvesCommentId) {
318 318 self.markCommentResolved(resolvesCommentId);
319 319 }
320 320 }
321 321
322 322 // run global callback on submit
323 323 self.globalSubmitSuccessCallback();
324 324
325 325 };
326 326 var submitFailCallback = function(){
327 327 self.resetCommentFormState(text);
328 328 };
329 329 self.submitAjaxPOST(
330 330 self.submitUrl, postData, submitSuccessCallback, submitFailCallback);
331 331 };
332 332
333 333 this.previewSuccessCallback = function(o) {
334 334 $(self.previewBoxSelector).html(o);
335 335 $(self.previewBoxSelector).removeClass('unloaded');
336 336
337 337 // swap buttons, making preview active
338 338 $(self.previewButton).parent().addClass('active');
339 339 $(self.editButton).parent().removeClass('active');
340 340
341 341 // unlock buttons
342 342 self.setActionButtonsDisabled(false);
343 343 };
344 344
345 345 this.setActionButtonsDisabled = function(state, excludeCancelBtn, submitEvent) {
346 346 excludeCancelBtn = excludeCancelBtn || false;
347 347 submitEvent = submitEvent || false;
348 348
349 349 $(this.editButton).prop('disabled', state);
350 350 $(this.previewButton).prop('disabled', state);
351 351
352 352 if (!excludeCancelBtn) {
353 353 $(this.cancelButton).prop('disabled', state);
354 354 }
355 355
356 356 var submitState = state;
357 357 if (!submitEvent && this.getCommentStatus() && !this.lineNo) {
358 358 // if the value of commit review status is set, we allow
359 359 // submit button, but only on Main form, lineNo means inline
360 360 submitState = false
361 361 }
362 362 $(this.submitButton).prop('disabled', submitState);
363 363 if (submitEvent) {
364 364 $(this.submitButton).val(_gettext('Submitting...'));
365 365 } else {
366 366 $(this.submitButton).val(this.submitButtonText);
367 367 }
368 368
369 369 };
370 370
371 371 // lock preview/edit/submit buttons on load, but exclude cancel button
372 372 var excludeCancelBtn = true;
373 373 this.setActionButtonsDisabled(true, excludeCancelBtn);
374 374
375 375 // anonymous users don't have access to initialized CM instance
376 376 if (this.cm !== undefined){
377 377 this.cm.on('change', function(cMirror) {
378 378 if (cMirror.getValue() === "") {
379 379 self.setActionButtonsDisabled(true, excludeCancelBtn)
380 380 } else {
381 381 self.setActionButtonsDisabled(false, excludeCancelBtn)
382 382 }
383 383 });
384 384 }
385 385
386 386 $(this.editButton).on('click', function(e) {
387 387 e.preventDefault();
388 388
389 389 $(self.previewButton).parent().removeClass('active');
390 390 $(self.previewContainer).hide();
391 391
392 392 $(self.editButton).parent().addClass('active');
393 393 $(self.editContainer).show();
394 394
395 395 });
396 396
397 397 $(this.previewButton).on('click', function(e) {
398 398 e.preventDefault();
399 399 var text = self.cm.getValue();
400 400
401 401 if (text === "") {
402 402 return;
403 403 }
404 404
405 405 var postData = {
406 406 'text': text,
407 407 'renderer': templateContext.visual.default_renderer,
408 408 'csrf_token': CSRF_TOKEN
409 409 };
410 410
411 411 // lock ALL buttons on preview
412 412 self.setActionButtonsDisabled(true);
413 413
414 414 $(self.previewBoxSelector).addClass('unloaded');
415 415 $(self.previewBoxSelector).html(_gettext('Loading ...'));
416 416
417 417 $(self.editContainer).hide();
418 418 $(self.previewContainer).show();
419 419
420 420 // by default we reset state of comment preserving the text
421 421 var previewFailCallback = function(){
422 422 self.resetCommentFormState(text)
423 423 };
424 424 self.submitAjaxPOST(
425 425 self.previewUrl, postData, self.previewSuccessCallback,
426 426 previewFailCallback);
427 427
428 428 $(self.previewButton).parent().addClass('active');
429 429 $(self.editButton).parent().removeClass('active');
430 430 });
431 431
432 432 $(this.submitForm).submit(function(e) {
433 433 e.preventDefault();
434 434 var allowedToSubmit = self.isAllowedToSubmit();
435 435 if (!allowedToSubmit){
436 436 return false;
437 437 }
438 438 self.handleFormSubmit();
439 439 });
440 440
441 441 }
442 442
443 443 return CommentForm;
444 444 });
445 445
446 446 /* comments controller */
447 447 var CommentsController = function() {
448 448 var mainComment = '#text';
449 449 var self = this;
450 450
451 451 this.cancelComment = function(node) {
452 452 var $node = $(node);
453 453 var $td = $node.closest('td');
454 454 $node.closest('.comment-inline-form').remove();
455 455 return false;
456 456 };
457 457
458 458 this.getLineNumber = function(node) {
459 459 var $node = $(node);
460 460 return $node.closest('td').attr('data-line-number');
461 461 };
462 462
463 463 this.scrollToComment = function(node, offset, outdated) {
464 464 var outdated = outdated || false;
465 465 var klass = outdated ? 'div.comment-outdated' : 'div.comment-current';
466 466
467 467 if (!node) {
468 468 node = $('.comment-selected');
469 469 if (!node.length) {
470 470 node = $('comment-current')
471 471 }
472 472 }
473 $wrapper = $(node).closest('div.comment');
473 474 $comment = $(node).closest(klass);
474 475 $comments = $(klass);
475 476
477 // show hidden comment when referenced.
478 if (!$wrapper.is(':visible')){
479 $wrapper.show();
480 }
481
476 482 $('.comment-selected').removeClass('comment-selected');
477 483
478 484 var nextIdx = $(klass).index($comment) + offset;
479 485 if (nextIdx >= $comments.length) {
480 486 nextIdx = 0;
481 487 }
482 488 var $next = $(klass).eq(nextIdx);
483 489 var $cb = $next.closest('.cb');
484 490 $cb.removeClass('cb-collapsed');
485 491
486 492 var $filediffCollapseState = $cb.closest('.filediff').prev();
487 493 $filediffCollapseState.prop('checked', false);
488 494 $next.addClass('comment-selected');
489 495 scrollToElement($next);
490 496 return false;
491 497 };
492 498
493 499 this.nextComment = function(node) {
494 500 return self.scrollToComment(node, 1);
495 501 };
496 502
497 503 this.prevComment = function(node) {
498 504 return self.scrollToComment(node, -1);
499 505 };
500 506
501 507 this.nextOutdatedComment = function(node) {
502 508 return self.scrollToComment(node, 1, true);
503 509 };
504 510
505 511 this.prevOutdatedComment = function(node) {
506 512 return self.scrollToComment(node, -1, true);
507 513 };
508 514
509 515 this.deleteComment = function(node) {
510 516 if (!confirm(_gettext('Delete this comment?'))) {
511 517 return false;
512 518 }
513 519 var $node = $(node);
514 520 var $td = $node.closest('td');
515 521 var $comment = $node.closest('.comment');
516 522 var comment_id = $comment.attr('data-comment-id');
517 523 var url = AJAX_COMMENT_DELETE_URL.replace('__COMMENT_ID__', comment_id);
518 524 var postData = {
519 525 '_method': 'delete',
520 526 'csrf_token': CSRF_TOKEN
521 527 };
522 528
523 529 $comment.addClass('comment-deleting');
524 530 $comment.hide('fast');
525 531
526 532 var success = function(response) {
527 533 $comment.remove();
528 534 return false;
529 535 };
530 536 var failure = function(data, textStatus, xhr) {
531 537 alert("error processing request: " + textStatus);
532 538 $comment.show('fast');
533 539 $comment.removeClass('comment-deleting');
534 540 return false;
535 541 };
536 542 ajaxPOST(url, postData, success, failure);
537 543 };
538 544
539 545 this.toggleWideMode = function (node) {
540 546 if ($('#content').hasClass('wrapper')) {
541 547 $('#content').removeClass("wrapper");
542 548 $('#content').addClass("wide-mode-wrapper");
543 549 $(node).addClass('btn-success');
544 550 } else {
545 551 $('#content').removeClass("wide-mode-wrapper");
546 552 $('#content').addClass("wrapper");
547 553 $(node).removeClass('btn-success');
548 554 }
549 555 return false;
550 556 };
551 557
552 558 this.toggleComments = function(node, show) {
553 559 var $filediff = $(node).closest('.filediff');
554 560 if (show === true) {
555 561 $filediff.removeClass('hide-comments');
556 562 } else if (show === false) {
557 563 $filediff.find('.hide-line-comments').removeClass('hide-line-comments');
558 564 $filediff.addClass('hide-comments');
559 565 } else {
560 566 $filediff.find('.hide-line-comments').removeClass('hide-line-comments');
561 567 $filediff.toggleClass('hide-comments');
562 568 }
563 569 return false;
564 570 };
565 571
566 572 this.toggleLineComments = function(node) {
567 573 self.toggleComments(node, true);
568 574 var $node = $(node);
569 575 $node.closest('tr').toggleClass('hide-line-comments');
570 576 };
571 577
572 578 this.createCommentForm = function(formElement, lineno, placeholderText, initAutocompleteActions, resolvesCommentId){
573 579 var pullRequestId = templateContext.pull_request_data.pull_request_id;
574 580 var commitId = templateContext.commit_data.commit_id;
575 581
576 582 var commentForm = new CommentForm(
577 583 formElement, commitId, pullRequestId, lineno, initAutocompleteActions, resolvesCommentId);
578 584 var cm = commentForm.getCmInstance();
579 585
580 586 if (resolvesCommentId){
581 587 var placeholderText = _gettext('Leave a comment, or click resolve button to resolve TODO comment #{0}').format(resolvesCommentId);
582 588 }
583 589
584 590 setTimeout(function() {
585 591 // callbacks
586 592 if (cm !== undefined) {
587 593 commentForm.setPlaceholder(placeholderText);
588 594 if (commentForm.isInline()) {
589 595 cm.focus();
590 596 cm.refresh();
591 597 }
592 598 }
593 599 }, 10);
594 600
595 601 // trigger scrolldown to the resolve comment, since it might be away
596 602 // from the clicked
597 603 if (resolvesCommentId){
598 604 var actionNode = $(commentForm.resolvesActionId).offset();
599 605
600 606 setTimeout(function() {
601 607 if (actionNode) {
602 608 $('body, html').animate({scrollTop: actionNode.top}, 10);
603 609 }
604 610 }, 100);
605 611 }
606 612
607 613 return commentForm;
608 614 };
609 615
610 616 this.createGeneralComment = function (lineNo, placeholderText, resolvesCommentId) {
611 617
612 618 var tmpl = $('#cb-comment-general-form-template').html();
613 619 tmpl = tmpl.format(null, 'general');
614 620 var $form = $(tmpl);
615 621
616 622 var $formPlaceholder = $('#cb-comment-general-form-placeholder');
617 623 var curForm = $formPlaceholder.find('form');
618 624 if (curForm){
619 625 curForm.remove();
620 626 }
621 627 $formPlaceholder.append($form);
622 628
623 629 var _form = $($form[0]);
624 630 var commentForm = this.createCommentForm(
625 631 _form, lineNo, placeholderText, true, resolvesCommentId);
626 632 commentForm.initStatusChangeSelector();
627 633
628 634 return commentForm;
629 635 };
630 636
631 637 this.createComment = function(node, resolutionComment) {
632 638 var resolvesCommentId = resolutionComment || null;
633 639 var $node = $(node);
634 640 var $td = $node.closest('td');
635 641 var $form = $td.find('.comment-inline-form');
636 642
637 643 if (!$form.length) {
638 644
639 645 var $filediff = $node.closest('.filediff');
640 646 $filediff.removeClass('hide-comments');
641 647 var f_path = $filediff.attr('data-f-path');
642 648 var lineno = self.getLineNumber(node);
643 649 // create a new HTML from template
644 650 var tmpl = $('#cb-comment-inline-form-template').html();
645 651 tmpl = tmpl.format(f_path, lineno);
646 652 $form = $(tmpl);
647 653
648 654 var $comments = $td.find('.inline-comments');
649 655 if (!$comments.length) {
650 656 $comments = $(
651 657 $('#cb-comments-inline-container-template').html());
652 658 $td.append($comments);
653 659 }
654 660
655 661 $td.find('.cb-comment-add-button').before($form);
656 662
657 663 var placeholderText = _gettext('Leave a comment on line {0}.').format(lineno);
658 664 var _form = $($form[0]).find('form');
659 665
660 666 var commentForm = this.createCommentForm(
661 667 _form, lineno, placeholderText, false, resolvesCommentId);
662 668
663 669 $.Topic('/ui/plugins/code/comment_form_built').prepareOrPublish({
664 670 form: _form,
665 671 parent: $td[0],
666 672 lineno: lineno,
667 673 f_path: f_path}
668 674 );
669 675
670 676 // set a CUSTOM submit handler for inline comments.
671 677 commentForm.setHandleFormSubmit(function(o) {
672 678 var text = commentForm.cm.getValue();
673 679 var commentType = commentForm.getCommentType();
674 680 var resolvesCommentId = commentForm.getResolvesId();
675 681
676 682 if (text === "") {
677 683 return;
678 684 }
679 685
680 686 if (lineno === undefined) {
681 687 alert('missing line !');
682 688 return;
683 689 }
684 690 if (f_path === undefined) {
685 691 alert('missing file path !');
686 692 return;
687 693 }
688 694
689 695 var excludeCancelBtn = false;
690 696 var submitEvent = true;
691 697 commentForm.setActionButtonsDisabled(true, excludeCancelBtn, submitEvent);
692 698 commentForm.cm.setOption("readOnly", true);
693 699 var postData = {
694 700 'text': text,
695 701 'f_path': f_path,
696 702 'line': lineno,
697 703 'comment_type': commentType,
698 704 'csrf_token': CSRF_TOKEN
699 705 };
700 706 if (resolvesCommentId){
701 707 postData['resolves_comment_id'] = resolvesCommentId;
702 708 }
703 709
704 710 var submitSuccessCallback = function(json_data) {
705 711 $form.remove();
706 712 try {
707 713 var html = json_data.rendered_text;
708 714 var lineno = json_data.line_no;
709 715 var target_id = json_data.target_id;
710 716
711 717 $comments.find('.cb-comment-add-button').before(html);
712 718
713 719 //mark visually which comment was resolved
714 720 if (resolvesCommentId) {
715 721 commentForm.markCommentResolved(resolvesCommentId);
716 722 }
717 723
718 724 // run global callback on submit
719 725 commentForm.globalSubmitSuccessCallback();
720 726
721 727 } catch (e) {
722 728 console.error(e);
723 729 }
724 730
725 731 // re trigger the linkification of next/prev navigation
726 732 linkifyComments($('.inline-comment-injected'));
727 733 timeagoActivate();
728 734 commentForm.setActionButtonsDisabled(false);
729 735
730 736 };
731 737 var submitFailCallback = function(){
732 738 commentForm.resetCommentFormState(text)
733 739 };
734 740 commentForm.submitAjaxPOST(
735 741 commentForm.submitUrl, postData, submitSuccessCallback, submitFailCallback);
736 742 });
737 743 }
738 744
739 745 $form.addClass('comment-inline-form-open');
740 746 };
741 747
742 748 this.createResolutionComment = function(commentId){
743 749 // hide the trigger text
744 750 $('#resolve-comment-{0}'.format(commentId)).hide();
745 751
746 752 var comment = $('#comment-'+commentId);
747 753 var commentData = comment.data();
748 754 if (commentData.commentInline) {
749 755 this.createComment(comment, commentId)
750 756 } else {
751 757 Rhodecode.comments.createGeneralComment('general', "$placeholder", commentId)
752 758 }
753 759
754 760 return false;
755 761 };
756 762
757 763 this.submitResolution = function(commentId){
758 764 var form = $('#resolve_comment_{0}'.format(commentId)).closest('form');
759 765 var commentForm = form.get(0).CommentForm;
760 766
761 767 var cm = commentForm.getCmInstance();
762 768 var renderer = templateContext.visual.default_renderer;
763 769 if (renderer == 'rst'){
764 770 var commentUrl = '`#{0} <{1}#comment-{0}>`_'.format(commentId, commentForm.selfUrl);
765 771 } else if (renderer == 'markdown') {
766 772 var commentUrl = '[#{0}]({1}#comment-{0})'.format(commentId, commentForm.selfUrl);
767 773 } else {
768 774 var commentUrl = '{1}#comment-{0}'.format(commentId, commentForm.selfUrl);
769 775 }
770 776
771 777 cm.setValue(_gettext('TODO from comment {0} was fixed.').format(commentUrl));
772 778 form.submit();
773 779 return false;
774 780 };
775 781
776 782 this.renderInlineComments = function(file_comments) {
777 783 show_add_button = typeof show_add_button !== 'undefined' ? show_add_button : true;
778 784
779 785 for (var i = 0; i < file_comments.length; i++) {
780 786 var box = file_comments[i];
781 787
782 788 var target_id = $(box).attr('target_id');
783 789
784 790 // actually comments with line numbers
785 791 var comments = box.children;
786 792
787 793 for (var j = 0; j < comments.length; j++) {
788 794 var data = {
789 795 'rendered_text': comments[j].outerHTML,
790 796 'line_no': $(comments[j]).attr('line'),
791 797 'target_id': target_id
792 798 };
793 799 }
794 800 }
795 801
796 802 // since order of injection is random, we're now re-iterating
797 803 // from correct order and filling in links
798 804 linkifyComments($('.inline-comment-injected'));
799 805 firefoxAnchorFix();
800 806 };
801 807
802 808 };
@@ -1,44 +1,50 b''
1 1
2 2 <div class="pull-request-wrap">
3 3
4 4
5 5 % if c.pr_merge_possible:
6 6 <h2 class="merge-status">
7 7 <span class="merge-icon success"><i class="icon-true"></i></span>
8 8 ${_('This pull request can be merged automatically.')}
9 9 </h2>
10 10 % else:
11 11 <h2 class="merge-status">
12 12 <span class="merge-icon warning"><i class="icon-false"></i></span>
13 13 ${_('Merge is not currently possible because of below failed checks.')}
14 14 </h2>
15 15 % endif
16 16
17 17 <ul>
18 % for pr_check_type, pr_check_msg in c.pr_merge_errors:
18 % for pr_check_key, pr_check_details in c.pr_merge_errors.items():
19 <% pr_check_type = pr_check_details['error_type'] %>
19 20 <li>
20 21 <span class="merge-message ${pr_check_type}" data-role="merge-message">
21 - ${pr_check_msg}
22 - ${pr_check_details['message']}
23 % if pr_check_key == 'todo':
24 % for co in pr_check_details['details']:
25 <a class="permalink" href="#comment-${co.comment_id}" onclick="Rhodecode.comments.scrollToComment($('#comment-${co.comment_id}'))"> #${co.comment_id}</a>${'' if loop.last else ','}
26 % endfor
27 % endif
22 28 </span>
23 29 </li>
24 30 % endfor
25 31 </ul>
26 32
27 33 <div class="pull-request-merge-actions">
28 34 % if c.allowed_to_merge:
29 35 <div class="pull-right">
30 36 ${h.secure_form(url('pullrequest_merge', repo_name=c.repo_name, pull_request_id=c.pull_request.pull_request_id), id='merge_pull_request_form')}
31 37 <% merge_disabled = ' disabled' if c.pr_merge_possible is False else '' %>
32 38 <a class="btn" href="#" onclick="refreshMergeChecks(); return false;">${_('refresh checks')}</a>
33 39 <input type="submit" id="merge_pull_request" value="${_('Merge Pull Request')}" class="btn${merge_disabled}"${merge_disabled}>
34 40 ${h.end_form()}
35 41 </div>
36 42 % elif c.rhodecode_user.username != h.DEFAULT_USER:
37 43 <a class="btn" href="#" onclick="refreshMergeChecks(); return false;">${_('refresh checks')}</a>
38 44 <input type="submit" value="${_('Merge Pull Request')}" class="btn disabled" disabled="disabled" title="${_('You are not allowed to merge this pull request.')}">
39 45 % else:
40 46 <input type="submit" value="${_('Login to Merge this Pull Request')}" class="btn disabled" disabled="disabled">
41 47 % endif
42 48 </div>
43 49 </div>
44 50
General Comments 0
You need to be logged in to leave comments. Login now