##// END OF EJS Templates
pull-requests: add explicit CLOSE pr action instead of closed status from selector....
marcink -
r1445:934edf37 default
parent child Browse files
Show More
@@ -1,1054 +1,1054 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
70 70 def __before__(self):
71 71 super(PullrequestsController, self).__before__()
72 c.REVIEW_STATUS_APPROVED = ChangesetStatus.STATUS_APPROVED
73 c.REVIEW_STATUS_REJECTED = ChangesetStatus.STATUS_REJECTED
72 74
73 75 def _extract_ordering(self, request):
74 76 column_index = safe_int(request.GET.get('order[0][column]'))
75 77 order_dir = request.GET.get('order[0][dir]', 'desc')
76 78 order_by = request.GET.get(
77 79 'columns[%s][data][sort]' % column_index, 'name_raw')
78 80 return order_by, order_dir
79 81
80 82 @LoginRequired()
81 83 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
82 84 'repository.admin')
83 85 @HasAcceptedRepoType('git', 'hg')
84 86 def show_all(self, repo_name):
85 87 # filter types
86 88 c.active = 'open'
87 89 c.source = str2bool(request.GET.get('source'))
88 90 c.closed = str2bool(request.GET.get('closed'))
89 91 c.my = str2bool(request.GET.get('my'))
90 92 c.awaiting_review = str2bool(request.GET.get('awaiting_review'))
91 93 c.awaiting_my_review = str2bool(request.GET.get('awaiting_my_review'))
92 94 c.repo_name = repo_name
93 95
94 96 opened_by = None
95 97 if c.my:
96 98 c.active = 'my'
97 99 opened_by = [c.rhodecode_user.user_id]
98 100
99 101 statuses = [PullRequest.STATUS_NEW, PullRequest.STATUS_OPEN]
100 102 if c.closed:
101 103 c.active = 'closed'
102 104 statuses = [PullRequest.STATUS_CLOSED]
103 105
104 106 if c.awaiting_review and not c.source:
105 107 c.active = 'awaiting'
106 108 if c.source and not c.awaiting_review:
107 109 c.active = 'source'
108 110 if c.awaiting_my_review:
109 111 c.active = 'awaiting_my'
110 112
111 113 data = self._get_pull_requests_list(
112 114 repo_name=repo_name, opened_by=opened_by, statuses=statuses)
113 115 if not request.is_xhr:
114 116 c.data = json.dumps(data['data'])
115 117 c.records_total = data['recordsTotal']
116 118 return render('/pullrequests/pullrequests.mako')
117 119 else:
118 120 return json.dumps(data)
119 121
120 122 def _get_pull_requests_list(self, repo_name, opened_by, statuses):
121 123 # pagination
122 124 start = safe_int(request.GET.get('start'), 0)
123 125 length = safe_int(request.GET.get('length'), c.visual.dashboard_items)
124 126 order_by, order_dir = self._extract_ordering(request)
125 127
126 128 if c.awaiting_review:
127 129 pull_requests = PullRequestModel().get_awaiting_review(
128 130 repo_name, source=c.source, opened_by=opened_by,
129 131 statuses=statuses, offset=start, length=length,
130 132 order_by=order_by, order_dir=order_dir)
131 133 pull_requests_total_count = PullRequestModel(
132 134 ).count_awaiting_review(
133 135 repo_name, source=c.source, statuses=statuses,
134 136 opened_by=opened_by)
135 137 elif c.awaiting_my_review:
136 138 pull_requests = PullRequestModel().get_awaiting_my_review(
137 139 repo_name, source=c.source, opened_by=opened_by,
138 140 user_id=c.rhodecode_user.user_id, statuses=statuses,
139 141 offset=start, length=length, order_by=order_by,
140 142 order_dir=order_dir)
141 143 pull_requests_total_count = PullRequestModel(
142 144 ).count_awaiting_my_review(
143 145 repo_name, source=c.source, user_id=c.rhodecode_user.user_id,
144 146 statuses=statuses, opened_by=opened_by)
145 147 else:
146 148 pull_requests = PullRequestModel().get_all(
147 149 repo_name, source=c.source, opened_by=opened_by,
148 150 statuses=statuses, offset=start, length=length,
149 151 order_by=order_by, order_dir=order_dir)
150 152 pull_requests_total_count = PullRequestModel().count_all(
151 153 repo_name, source=c.source, statuses=statuses,
152 154 opened_by=opened_by)
153 155
154 156 from rhodecode.lib.utils import PartialRenderer
155 157 _render = PartialRenderer('data_table/_dt_elements.mako')
156 158 data = []
157 159 for pr in pull_requests:
158 160 comments = CommentsModel().get_all_comments(
159 161 c.rhodecode_db_repo.repo_id, pull_request=pr)
160 162
161 163 data.append({
162 164 'name': _render('pullrequest_name',
163 165 pr.pull_request_id, pr.target_repo.repo_name),
164 166 'name_raw': pr.pull_request_id,
165 167 'status': _render('pullrequest_status',
166 168 pr.calculated_review_status()),
167 169 'title': _render(
168 170 'pullrequest_title', pr.title, pr.description),
169 171 'description': h.escape(pr.description),
170 172 'updated_on': _render('pullrequest_updated_on',
171 173 h.datetime_to_time(pr.updated_on)),
172 174 'updated_on_raw': h.datetime_to_time(pr.updated_on),
173 175 'created_on': _render('pullrequest_updated_on',
174 176 h.datetime_to_time(pr.created_on)),
175 177 'created_on_raw': h.datetime_to_time(pr.created_on),
176 178 'author': _render('pullrequest_author',
177 179 pr.author.full_contact, ),
178 180 'author_raw': pr.author.full_name,
179 181 'comments': _render('pullrequest_comments', len(comments)),
180 182 'comments_raw': len(comments),
181 183 'closed': pr.is_closed(),
182 184 })
183 185 # json used to render the grid
184 186 data = ({
185 187 'data': data,
186 188 'recordsTotal': pull_requests_total_count,
187 189 'recordsFiltered': pull_requests_total_count,
188 190 })
189 191 return data
190 192
191 193 @LoginRequired()
192 194 @NotAnonymous()
193 195 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
194 196 'repository.admin')
195 197 @HasAcceptedRepoType('git', 'hg')
196 198 def index(self):
197 199 source_repo = c.rhodecode_db_repo
198 200
199 201 try:
200 202 source_repo.scm_instance().get_commit()
201 203 except EmptyRepositoryError:
202 204 h.flash(h.literal(_('There are no commits yet')),
203 205 category='warning')
204 206 redirect(url('summary_home', repo_name=source_repo.repo_name))
205 207
206 208 commit_id = request.GET.get('commit')
207 209 branch_ref = request.GET.get('branch')
208 210 bookmark_ref = request.GET.get('bookmark')
209 211
210 212 try:
211 213 source_repo_data = PullRequestModel().generate_repo_data(
212 214 source_repo, commit_id=commit_id,
213 215 branch=branch_ref, bookmark=bookmark_ref)
214 216 except CommitDoesNotExistError as e:
215 217 log.exception(e)
216 218 h.flash(_('Commit does not exist'), 'error')
217 219 redirect(url('pullrequest_home', repo_name=source_repo.repo_name))
218 220
219 221 default_target_repo = source_repo
220 222
221 223 if source_repo.parent:
222 224 parent_vcs_obj = source_repo.parent.scm_instance()
223 225 if parent_vcs_obj and not parent_vcs_obj.is_empty():
224 226 # change default if we have a parent repo
225 227 default_target_repo = source_repo.parent
226 228
227 229 target_repo_data = PullRequestModel().generate_repo_data(
228 230 default_target_repo)
229 231
230 232 selected_source_ref = source_repo_data['refs']['selected_ref']
231 233
232 234 title_source_ref = selected_source_ref.split(':', 2)[1]
233 235 c.default_title = PullRequestModel().generate_pullrequest_title(
234 236 source=source_repo.repo_name,
235 237 source_ref=title_source_ref,
236 238 target=default_target_repo.repo_name
237 239 )
238 240
239 241 c.default_repo_data = {
240 242 'source_repo_name': source_repo.repo_name,
241 243 'source_refs_json': json.dumps(source_repo_data),
242 244 'target_repo_name': default_target_repo.repo_name,
243 245 'target_refs_json': json.dumps(target_repo_data),
244 246 }
245 247 c.default_source_ref = selected_source_ref
246 248
247 249 return render('/pullrequests/pullrequest.mako')
248 250
249 251 @LoginRequired()
250 252 @NotAnonymous()
251 253 @XHRRequired()
252 254 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
253 255 'repository.admin')
254 256 @jsonify
255 257 def get_repo_refs(self, repo_name, target_repo_name):
256 258 repo = Repository.get_by_repo_name(target_repo_name)
257 259 if not repo:
258 260 raise HTTPNotFound
259 261 return PullRequestModel().generate_repo_data(repo)
260 262
261 263 @LoginRequired()
262 264 @NotAnonymous()
263 265 @XHRRequired()
264 266 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
265 267 'repository.admin')
266 268 @jsonify
267 269 def get_repo_destinations(self, repo_name):
268 270 repo = Repository.get_by_repo_name(repo_name)
269 271 if not repo:
270 272 raise HTTPNotFound
271 273 filter_query = request.GET.get('query')
272 274
273 275 query = Repository.query() \
274 276 .order_by(func.length(Repository.repo_name)) \
275 277 .filter(or_(
276 278 Repository.repo_name == repo.repo_name,
277 279 Repository.fork_id == repo.repo_id))
278 280
279 281 if filter_query:
280 282 ilike_expression = u'%{}%'.format(safe_unicode(filter_query))
281 283 query = query.filter(
282 284 Repository.repo_name.ilike(ilike_expression))
283 285
284 286 add_parent = False
285 287 if repo.parent:
286 288 if filter_query in repo.parent.repo_name:
287 289 parent_vcs_obj = repo.parent.scm_instance()
288 290 if parent_vcs_obj and not parent_vcs_obj.is_empty():
289 291 add_parent = True
290 292
291 293 limit = 20 - 1 if add_parent else 20
292 294 all_repos = query.limit(limit).all()
293 295 if add_parent:
294 296 all_repos += [repo.parent]
295 297
296 298 repos = []
297 299 for obj in self.scm_model.get_repos(all_repos):
298 300 repos.append({
299 301 'id': obj['name'],
300 302 'text': obj['name'],
301 303 'type': 'repo',
302 304 'obj': obj['dbrepo']
303 305 })
304 306
305 307 data = {
306 308 'more': False,
307 309 'results': [{
308 310 'text': _('Repositories'),
309 311 'children': repos
310 312 }] if repos else []
311 313 }
312 314 return data
313 315
314 316 @LoginRequired()
315 317 @NotAnonymous()
316 318 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
317 319 'repository.admin')
318 320 @HasAcceptedRepoType('git', 'hg')
319 321 @auth.CSRFRequired()
320 322 def create(self, repo_name):
321 323 repo = Repository.get_by_repo_name(repo_name)
322 324 if not repo:
323 325 raise HTTPNotFound
324 326
325 327 controls = peppercorn.parse(request.POST.items())
326 328
327 329 try:
328 330 _form = PullRequestForm(repo.repo_id)().to_python(controls)
329 331 except formencode.Invalid as errors:
330 332 if errors.error_dict.get('revisions'):
331 333 msg = 'Revisions: %s' % errors.error_dict['revisions']
332 334 elif errors.error_dict.get('pullrequest_title'):
333 335 msg = _('Pull request requires a title with min. 3 chars')
334 336 else:
335 337 msg = _('Error creating pull request: {}').format(errors)
336 338 log.exception(msg)
337 339 h.flash(msg, 'error')
338 340
339 341 # would rather just go back to form ...
340 342 return redirect(url('pullrequest_home', repo_name=repo_name))
341 343
342 344 source_repo = _form['source_repo']
343 345 source_ref = _form['source_ref']
344 346 target_repo = _form['target_repo']
345 347 target_ref = _form['target_ref']
346 348 commit_ids = _form['revisions'][::-1]
347 349 reviewers = [
348 350 (r['user_id'], r['reasons']) for r in _form['review_members']]
349 351
350 352 # find the ancestor for this pr
351 353 source_db_repo = Repository.get_by_repo_name(_form['source_repo'])
352 354 target_db_repo = Repository.get_by_repo_name(_form['target_repo'])
353 355
354 356 source_scm = source_db_repo.scm_instance()
355 357 target_scm = target_db_repo.scm_instance()
356 358
357 359 source_commit = source_scm.get_commit(source_ref.split(':')[-1])
358 360 target_commit = target_scm.get_commit(target_ref.split(':')[-1])
359 361
360 362 ancestor = source_scm.get_common_ancestor(
361 363 source_commit.raw_id, target_commit.raw_id, target_scm)
362 364
363 365 target_ref_type, target_ref_name, __ = _form['target_ref'].split(':')
364 366 target_ref = ':'.join((target_ref_type, target_ref_name, ancestor))
365 367
366 368 pullrequest_title = _form['pullrequest_title']
367 369 title_source_ref = source_ref.split(':', 2)[1]
368 370 if not pullrequest_title:
369 371 pullrequest_title = PullRequestModel().generate_pullrequest_title(
370 372 source=source_repo,
371 373 source_ref=title_source_ref,
372 374 target=target_repo
373 375 )
374 376
375 377 description = _form['pullrequest_desc']
376 378 try:
377 379 pull_request = PullRequestModel().create(
378 380 c.rhodecode_user.user_id, source_repo, source_ref, target_repo,
379 381 target_ref, commit_ids, reviewers, pullrequest_title,
380 382 description
381 383 )
382 384 Session().commit()
383 385 h.flash(_('Successfully opened new pull request'),
384 386 category='success')
385 387 except Exception as e:
386 388 msg = _('Error occurred during sending pull request')
387 389 log.exception(msg)
388 390 h.flash(msg, category='error')
389 391 return redirect(url('pullrequest_home', repo_name=repo_name))
390 392
391 393 return redirect(url('pullrequest_show', repo_name=target_repo,
392 394 pull_request_id=pull_request.pull_request_id))
393 395
394 396 @LoginRequired()
395 397 @NotAnonymous()
396 398 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
397 399 'repository.admin')
398 400 @auth.CSRFRequired()
399 401 @jsonify
400 402 def update(self, repo_name, pull_request_id):
401 403 pull_request_id = safe_int(pull_request_id)
402 404 pull_request = PullRequest.get_or_404(pull_request_id)
403 405 # only owner or admin can update it
404 406 allowed_to_update = PullRequestModel().check_user_update(
405 407 pull_request, c.rhodecode_user)
406 408 if allowed_to_update:
407 409 controls = peppercorn.parse(request.POST.items())
408 410
409 411 if 'review_members' in controls:
410 412 self._update_reviewers(
411 413 pull_request_id, controls['review_members'])
412 414 elif str2bool(request.POST.get('update_commits', 'false')):
413 415 self._update_commits(pull_request)
414 416 elif str2bool(request.POST.get('close_pull_request', 'false')):
415 417 self._reject_close(pull_request)
416 418 elif str2bool(request.POST.get('edit_pull_request', 'false')):
417 419 self._edit_pull_request(pull_request)
418 420 else:
419 421 raise HTTPBadRequest()
420 422 return True
421 423 raise HTTPForbidden()
422 424
423 425 def _edit_pull_request(self, pull_request):
424 426 try:
425 427 PullRequestModel().edit(
426 428 pull_request, request.POST.get('title'),
427 429 request.POST.get('description'))
428 430 except ValueError:
429 431 msg = _(u'Cannot update closed pull requests.')
430 432 h.flash(msg, category='error')
431 433 return
432 434 else:
433 435 Session().commit()
434 436
435 437 msg = _(u'Pull request title & description updated.')
436 438 h.flash(msg, category='success')
437 439 return
438 440
439 441 def _update_commits(self, pull_request):
440 442 resp = PullRequestModel().update_commits(pull_request)
441 443
442 444 if resp.executed:
443 445 msg = _(
444 446 u'Pull request updated to "{source_commit_id}" with '
445 447 u'{count_added} added, {count_removed} removed commits.')
446 448 msg = msg.format(
447 449 source_commit_id=pull_request.source_ref_parts.commit_id,
448 450 count_added=len(resp.changes.added),
449 451 count_removed=len(resp.changes.removed))
450 452 h.flash(msg, category='success')
451 453
452 454 registry = get_current_registry()
453 455 rhodecode_plugins = getattr(registry, 'rhodecode_plugins', {})
454 456 channelstream_config = rhodecode_plugins.get('channelstream', {})
455 457 if channelstream_config.get('enabled'):
456 458 message = msg + (
457 459 ' - <a onclick="window.location.reload()">'
458 460 '<strong>{}</strong></a>'.format(_('Reload page')))
459 461 channel = '/repo${}$/pr/{}'.format(
460 462 pull_request.target_repo.repo_name,
461 463 pull_request.pull_request_id
462 464 )
463 465 payload = {
464 466 'type': 'message',
465 467 'user': 'system',
466 468 'exclude_users': [request.user.username],
467 469 'channel': channel,
468 470 'message': {
469 471 'message': message,
470 472 'level': 'success',
471 473 'topic': '/notifications'
472 474 }
473 475 }
474 476 channelstream_request(
475 477 channelstream_config, [payload], '/message',
476 478 raise_exc=False)
477 479 else:
478 480 msg = PullRequestModel.UPDATE_STATUS_MESSAGES[resp.reason]
479 481 warning_reasons = [
480 482 UpdateFailureReason.NO_CHANGE,
481 483 UpdateFailureReason.WRONG_REF_TPYE,
482 484 ]
483 485 category = 'warning' if resp.reason in warning_reasons else 'error'
484 486 h.flash(msg, category=category)
485 487
486 488 @auth.CSRFRequired()
487 489 @LoginRequired()
488 490 @NotAnonymous()
489 491 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
490 492 'repository.admin')
491 493 def merge(self, repo_name, pull_request_id):
492 494 """
493 495 POST /{repo_name}/pull-request/{pull_request_id}
494 496
495 497 Merge will perform a server-side merge of the specified
496 498 pull request, if the pull request is approved and mergeable.
497 499 After successful merging, the pull request is automatically
498 500 closed, with a relevant comment.
499 501 """
500 502 pull_request_id = safe_int(pull_request_id)
501 503 pull_request = PullRequest.get_or_404(pull_request_id)
502 504 user = c.rhodecode_user
503 505
504 506 check = MergeCheck.validate(pull_request, user)
505 507 merge_possible = not check.failed
506 508
507 509 for err_type, error_msg in check.errors:
508 510 h.flash(error_msg, category=err_type)
509 511
510 512 if merge_possible:
511 513 log.debug("Pre-conditions checked, trying to merge.")
512 514 extras = vcs_operation_context(
513 515 request.environ, repo_name=pull_request.target_repo.repo_name,
514 516 username=user.username, action='push',
515 517 scm=pull_request.target_repo.repo_type)
516 518 self._merge_pull_request(pull_request, user, extras)
517 519
518 520 return redirect(url(
519 521 'pullrequest_show',
520 522 repo_name=pull_request.target_repo.repo_name,
521 523 pull_request_id=pull_request.pull_request_id))
522 524
523 525 def _merge_pull_request(self, pull_request, user, extras):
524 526 merge_resp = PullRequestModel().merge(
525 527 pull_request, user, extras=extras)
526 528
527 529 if merge_resp.executed:
528 530 log.debug("The merge was successful, closing the pull request.")
529 531 PullRequestModel().close_pull_request(
530 532 pull_request.pull_request_id, user)
531 533 Session().commit()
532 534 msg = _('Pull request was successfully merged and closed.')
533 535 h.flash(msg, category='success')
534 536 else:
535 537 log.debug(
536 538 "The merge was not successful. Merge response: %s",
537 539 merge_resp)
538 540 msg = PullRequestModel().merge_status_message(
539 541 merge_resp.failure_reason)
540 542 h.flash(msg, category='error')
541 543
542 544 def _update_reviewers(self, pull_request_id, review_members):
543 545 reviewers = [
544 546 (int(r['user_id']), r['reasons']) for r in review_members]
545 547 PullRequestModel().update_reviewers(pull_request_id, reviewers)
546 548 Session().commit()
547 549
548 550 def _reject_close(self, pull_request):
549 551 if pull_request.is_closed():
550 552 raise HTTPForbidden()
551 553
552 554 PullRequestModel().close_pull_request_with_comment(
553 555 pull_request, c.rhodecode_user, c.rhodecode_db_repo)
554 556 Session().commit()
555 557
556 558 @LoginRequired()
557 559 @NotAnonymous()
558 560 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
559 561 'repository.admin')
560 562 @auth.CSRFRequired()
561 563 @jsonify
562 564 def delete(self, repo_name, pull_request_id):
563 565 pull_request_id = safe_int(pull_request_id)
564 566 pull_request = PullRequest.get_or_404(pull_request_id)
565 567 # only owner can delete it !
566 568 if pull_request.author.user_id == c.rhodecode_user.user_id:
567 569 PullRequestModel().delete(pull_request)
568 570 Session().commit()
569 571 h.flash(_('Successfully deleted pull request'),
570 572 category='success')
571 573 return redirect(url('my_account_pullrequests'))
572 574 raise HTTPForbidden()
573 575
574 576 def _get_pr_version(self, pull_request_id, version=None):
575 577 pull_request_id = safe_int(pull_request_id)
576 578 at_version = None
577 579
578 580 if version and version == 'latest':
579 581 pull_request_ver = PullRequest.get(pull_request_id)
580 582 pull_request_obj = pull_request_ver
581 583 _org_pull_request_obj = pull_request_obj
582 584 at_version = 'latest'
583 585 elif version:
584 586 pull_request_ver = PullRequestVersion.get_or_404(version)
585 587 pull_request_obj = pull_request_ver
586 588 _org_pull_request_obj = pull_request_ver.pull_request
587 589 at_version = pull_request_ver.pull_request_version_id
588 590 else:
589 591 _org_pull_request_obj = pull_request_obj = PullRequest.get_or_404(pull_request_id)
590 592
591 593 pull_request_display_obj = PullRequest.get_pr_display_object(
592 594 pull_request_obj, _org_pull_request_obj)
593 595
594 596 return _org_pull_request_obj, pull_request_obj, \
595 597 pull_request_display_obj, at_version
596 598
597 599 def _get_diffset(
598 600 self, source_repo, source_ref_id, target_ref_id, target_commit,
599 601 source_commit, diff_limit, file_limit, display_inline_comments):
600 602 vcs_diff = PullRequestModel().get_diff(
601 603 source_repo, source_ref_id, target_ref_id)
602 604
603 605 diff_processor = diffs.DiffProcessor(
604 606 vcs_diff, format='newdiff', diff_limit=diff_limit,
605 607 file_limit=file_limit, show_full_diff=c.fulldiff)
606 608
607 609 _parsed = diff_processor.prepare()
608 610
609 611 def _node_getter(commit):
610 612 def get_node(fname):
611 613 try:
612 614 return commit.get_node(fname)
613 615 except NodeDoesNotExistError:
614 616 return None
615 617
616 618 return get_node
617 619
618 620 diffset = codeblocks.DiffSet(
619 621 repo_name=c.repo_name,
620 622 source_repo_name=c.source_repo.repo_name,
621 623 source_node_getter=_node_getter(target_commit),
622 624 target_node_getter=_node_getter(source_commit),
623 625 comments=display_inline_comments
624 626 )
625 627 diffset = diffset.render_patchset(
626 628 _parsed, target_commit.raw_id, source_commit.raw_id)
627 629
628 630 return diffset
629 631
630 632 @LoginRequired()
631 633 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
632 634 'repository.admin')
633 635 def show(self, repo_name, pull_request_id):
634 636 pull_request_id = safe_int(pull_request_id)
635 637 version = request.GET.get('version')
636 638 from_version = request.GET.get('from_version') or version
637 639 merge_checks = request.GET.get('merge_checks')
638 640 c.fulldiff = str2bool(request.GET.get('fulldiff'))
639 641
640 642 (pull_request_latest,
641 643 pull_request_at_ver,
642 644 pull_request_display_obj,
643 645 at_version) = self._get_pr_version(
644 646 pull_request_id, version=version)
645 647 versions = pull_request_display_obj.versions()
646 648
647 649 c.at_version = at_version
648 650 c.at_version_num = (at_version
649 651 if at_version and at_version != 'latest'
650 652 else None)
651 653 c.at_version_pos = ChangesetComment.get_index_from_version(
652 654 c.at_version_num, versions)
653 655
654 656 (prev_pull_request_latest,
655 657 prev_pull_request_at_ver,
656 658 prev_pull_request_display_obj,
657 659 prev_at_version) = self._get_pr_version(
658 660 pull_request_id, version=from_version)
659 661
660 662 c.from_version = prev_at_version
661 663 c.from_version_num = (prev_at_version
662 664 if prev_at_version and prev_at_version != 'latest'
663 665 else None)
664 666 c.from_version_pos = ChangesetComment.get_index_from_version(
665 667 c.from_version_num, versions)
666 668
667 669 # define if we're in COMPARE mode or VIEW at version mode
668 670 compare = at_version != prev_at_version
669 671
670 672 # pull_requests repo_name we opened it against
671 673 # ie. target_repo must match
672 674 if repo_name != pull_request_at_ver.target_repo.repo_name:
673 675 raise HTTPNotFound
674 676
675 677 c.shadow_clone_url = PullRequestModel().get_shadow_clone_url(
676 678 pull_request_at_ver)
677 679
678 680 c.ancestor = None # empty ancestor hidden in display
679 681 c.pull_request = pull_request_display_obj
680 682 c.pull_request_latest = pull_request_latest
681 683
682 684 pr_closed = pull_request_latest.is_closed()
683 685 if compare or (at_version and not at_version == 'latest'):
684 686 c.allowed_to_change_status = False
685 687 c.allowed_to_update = False
686 688 c.allowed_to_merge = False
687 689 c.allowed_to_delete = False
688 690 c.allowed_to_comment = False
691 c.allowed_to_close = False
689 692 else:
690 693 c.allowed_to_change_status = PullRequestModel(). \
691 check_user_change_status(pull_request_at_ver, c.rhodecode_user)
694 check_user_change_status(pull_request_at_ver, c.rhodecode_user) \
695 and not pr_closed
696
692 697 c.allowed_to_update = PullRequestModel().check_user_update(
693 698 pull_request_latest, c.rhodecode_user) and not pr_closed
694 699 c.allowed_to_merge = PullRequestModel().check_user_merge(
695 700 pull_request_latest, c.rhodecode_user) and not pr_closed
696 701 c.allowed_to_delete = PullRequestModel().check_user_delete(
697 702 pull_request_latest, c.rhodecode_user) and not pr_closed
698 703 c.allowed_to_comment = not pr_closed
704 c.allowed_to_close = c.allowed_to_change_status and not pr_closed
699 705
700 706 # check merge capabilities
701 707 _merge_check = MergeCheck.validate(
702 708 pull_request_latest, user=c.rhodecode_user)
703 709 c.pr_merge_errors = _merge_check.error_details
704 710 c.pr_merge_possible = not _merge_check.failed
705 711 c.pr_merge_message = _merge_check.merge_msg
706 712
713 c.pull_request_review_status = _merge_check.review_status
707 714 if merge_checks:
708 715 return render('/pullrequests/pullrequest_merge_checks.mako')
709 716
710 717 comments_model = CommentsModel()
711 718
712 719 # reviewers and statuses
713 720 c.pull_request_reviewers = pull_request_at_ver.reviewers_statuses()
714 721 allowed_reviewers = [x[0].user_id for x in c.pull_request_reviewers]
715 c.pull_request_review_status = pull_request_at_ver.calculated_review_status()
716 722
717 723 # GENERAL COMMENTS with versions #
718 724 q = comments_model._all_general_comments_of_pull_request(pull_request_latest)
719 725 q = q.order_by(ChangesetComment.comment_id.asc())
720 726 general_comments = q.order_by(ChangesetComment.pull_request_version_id.asc())
721 727
722 728 # pick comments we want to render at current version
723 729 c.comment_versions = comments_model.aggregate_comments(
724 730 general_comments, versions, c.at_version_num)
725 731 c.comments = c.comment_versions[c.at_version_num]['until']
726 732
727 733 # INLINE COMMENTS with versions #
728 734 q = comments_model._all_inline_comments_of_pull_request(pull_request_latest)
729 735 q = q.order_by(ChangesetComment.comment_id.asc())
730 736 inline_comments = q.order_by(ChangesetComment.pull_request_version_id.asc())
731 737 c.inline_versions = comments_model.aggregate_comments(
732 738 inline_comments, versions, c.at_version_num, inline=True)
733 739
734 740 # inject latest version
735 741 latest_ver = PullRequest.get_pr_display_object(
736 742 pull_request_latest, pull_request_latest)
737 743
738 744 c.versions = versions + [latest_ver]
739 745
740 746 # if we use version, then do not show later comments
741 747 # than current version
742 748 display_inline_comments = collections.defaultdict(
743 749 lambda: collections.defaultdict(list))
744 750 for co in inline_comments:
745 751 if c.at_version_num:
746 752 # pick comments that are at least UPTO given version, so we
747 753 # don't render comments for higher version
748 754 should_render = co.pull_request_version_id and \
749 755 co.pull_request_version_id <= c.at_version_num
750 756 else:
751 757 # showing all, for 'latest'
752 758 should_render = True
753 759
754 760 if should_render:
755 761 display_inline_comments[co.f_path][co.line_no].append(co)
756 762
757 763 # load diff data into template context, if we use compare mode then
758 764 # diff is calculated based on changes between versions of PR
759 765
760 766 source_repo = pull_request_at_ver.source_repo
761 767 source_ref_id = pull_request_at_ver.source_ref_parts.commit_id
762 768
763 769 target_repo = pull_request_at_ver.target_repo
764 770 target_ref_id = pull_request_at_ver.target_ref_parts.commit_id
765 771
766 772 if compare:
767 773 # in compare switch the diff base to latest commit from prev version
768 774 target_ref_id = prev_pull_request_display_obj.revisions[0]
769 775
770 776 # despite opening commits for bookmarks/branches/tags, we always
771 777 # convert this to rev to prevent changes after bookmark or branch change
772 778 c.source_ref_type = 'rev'
773 779 c.source_ref = source_ref_id
774 780
775 781 c.target_ref_type = 'rev'
776 782 c.target_ref = target_ref_id
777 783
778 784 c.source_repo = source_repo
779 785 c.target_repo = target_repo
780 786
781 787 # diff_limit is the old behavior, will cut off the whole diff
782 788 # if the limit is applied otherwise will just hide the
783 789 # big files from the front-end
784 790 diff_limit = self.cut_off_limit_diff
785 791 file_limit = self.cut_off_limit_file
786 792
787 793 c.commit_ranges = []
788 794 source_commit = EmptyCommit()
789 795 target_commit = EmptyCommit()
790 796 c.missing_requirements = False
791 797
792 798 # try first shadow repo, fallback to regular repo
793 799 try:
794 800 commits_source_repo = pull_request_latest.get_shadow_repo()
795 801 except Exception:
796 802 log.debug('Failed to get shadow repo', exc_info=True)
797 803 commits_source_repo = source_repo.scm_instance()
798 804
799 805 c.commits_source_repo = commits_source_repo
800 806 commit_cache = {}
801 807 try:
802 808 pre_load = ["author", "branch", "date", "message"]
803 809 show_revs = pull_request_at_ver.revisions
804 810 for rev in show_revs:
805 811 comm = commits_source_repo.get_commit(
806 812 commit_id=rev, pre_load=pre_load)
807 813 c.commit_ranges.append(comm)
808 814 commit_cache[comm.raw_id] = comm
809 815
810 816 target_commit = commits_source_repo.get_commit(
811 817 commit_id=safe_str(target_ref_id))
812 818 source_commit = commits_source_repo.get_commit(
813 819 commit_id=safe_str(source_ref_id))
814 820 except CommitDoesNotExistError:
815 821 pass
816 822 except RepositoryRequirementError:
817 823 log.warning(
818 824 'Failed to get all required data from repo', exc_info=True)
819 825 c.missing_requirements = True
820 826
821 827 c.statuses = source_repo.statuses(
822 828 [x.raw_id for x in c.commit_ranges])
823 829
824 830 # auto collapse if we have more than limit
825 831 collapse_limit = diffs.DiffProcessor._collapse_commits_over
826 832 c.collapse_all_commits = len(c.commit_ranges) > collapse_limit
827 833 c.compare_mode = compare
828 834
829 835 c.missing_commits = False
830 836 if (c.missing_requirements or isinstance(source_commit, EmptyCommit)
831 837 or source_commit == target_commit):
832 838
833 839 c.missing_commits = True
834 840 else:
835 841
836 842 c.diffset = self._get_diffset(
837 843 commits_source_repo, source_ref_id, target_ref_id,
838 844 target_commit, source_commit,
839 845 diff_limit, file_limit, display_inline_comments)
840 846
841 847 c.limited_diff = c.diffset.limited_diff
842 848
843 849 # calculate removed files that are bound to comments
844 850 comment_deleted_files = [
845 851 fname for fname in display_inline_comments
846 852 if fname not in c.diffset.file_stats]
847 853
848 854 c.deleted_files_comments = collections.defaultdict(dict)
849 855 for fname, per_line_comments in display_inline_comments.items():
850 856 if fname in comment_deleted_files:
851 857 c.deleted_files_comments[fname]['stats'] = 0
852 858 c.deleted_files_comments[fname]['comments'] = list()
853 859 for lno, comments in per_line_comments.items():
854 860 c.deleted_files_comments[fname]['comments'].extend(
855 861 comments)
856 862
857 863 # this is a hack to properly display links, when creating PR, the
858 864 # compare view and others uses different notation, and
859 865 # compare_commits.mako renders links based on the target_repo.
860 866 # We need to swap that here to generate it properly on the html side
861 867 c.target_repo = c.source_repo
862 868
863 if c.allowed_to_update:
864 force_close = ('forced_closed', _('Close Pull Request'))
865 statuses = ChangesetStatus.STATUSES + [force_close]
866 else:
867 statuses = ChangesetStatus.STATUSES
868 c.commit_statuses = statuses
869 c.commit_statuses = ChangesetStatus.STATUSES
869 870
870 871 c.show_version_changes = not pr_closed
871 872 if c.show_version_changes:
872 873 cur_obj = pull_request_at_ver
873 874 prev_obj = prev_pull_request_at_ver
874 875
875 876 old_commit_ids = prev_obj.revisions
876 877 new_commit_ids = cur_obj.revisions
877 878 commit_changes = PullRequestModel()._calculate_commit_id_changes(
878 879 old_commit_ids, new_commit_ids)
879 880 c.commit_changes_summary = commit_changes
880 881
881 882 # calculate the diff for commits between versions
882 883 c.commit_changes = []
883 884 mark = lambda cs, fw: list(
884 885 h.itertools.izip_longest([], cs, fillvalue=fw))
885 886 for c_type, raw_id in mark(commit_changes.added, 'a') \
886 887 + mark(commit_changes.removed, 'r') \
887 888 + mark(commit_changes.common, 'c'):
888 889
889 890 if raw_id in commit_cache:
890 891 commit = commit_cache[raw_id]
891 892 else:
892 893 try:
893 894 commit = commits_source_repo.get_commit(raw_id)
894 895 except CommitDoesNotExistError:
895 896 # in case we fail extracting still use "dummy" commit
896 897 # for display in commit diff
897 898 commit = h.AttributeDict(
898 899 {'raw_id': raw_id,
899 900 'message': 'EMPTY or MISSING COMMIT'})
900 901 c.commit_changes.append([c_type, commit])
901 902
902 903 # current user review statuses for each version
903 904 c.review_versions = {}
904 905 if c.rhodecode_user.user_id in allowed_reviewers:
905 906 for co in general_comments:
906 907 if co.author.user_id == c.rhodecode_user.user_id:
907 908 # each comment has a status change
908 909 status = co.status_change
909 910 if status:
910 911 _ver_pr = status[0].comment.pull_request_version_id
911 912 c.review_versions[_ver_pr] = status[0]
912 913
913 914 return render('/pullrequests/pullrequest_show.mako')
914 915
915 916 @LoginRequired()
916 917 @NotAnonymous()
917 918 @HasRepoPermissionAnyDecorator(
918 919 'repository.read', 'repository.write', 'repository.admin')
919 920 @auth.CSRFRequired()
920 921 @jsonify
921 922 def comment(self, repo_name, pull_request_id):
922 923 pull_request_id = safe_int(pull_request_id)
923 924 pull_request = PullRequest.get_or_404(pull_request_id)
924 925 if pull_request.is_closed():
925 926 raise HTTPForbidden()
926 927
927 # TODO: johbo: Re-think this bit, "approved_closed" does not exist
928 # as a changeset status, still we want to send it in one value.
929 928 status = request.POST.get('changeset_status', None)
930 929 text = request.POST.get('text')
931 930 comment_type = request.POST.get('comment_type')
932 931 resolves_comment_id = request.POST.get('resolves_comment_id', None)
932 close_pull_request = request.POST.get('close_pull_request')
933 933
934 if status and '_closed' in status:
934 close_pr = False
935 if close_pull_request:
935 936 close_pr = True
936 status = status.replace('_closed', '')
937 else:
938 close_pr = False
939
940 forced = (status == 'forced')
941 if forced:
942 status = 'rejected'
937 pull_request_review_status = pull_request.calculated_review_status()
938 if pull_request_review_status == ChangesetStatus.STATUS_APPROVED:
939 # approved only if we have voting consent
940 status = ChangesetStatus.STATUS_APPROVED
941 else:
942 status = ChangesetStatus.STATUS_REJECTED
943 943
944 944 allowed_to_change_status = PullRequestModel().check_user_change_status(
945 945 pull_request, c.rhodecode_user)
946 946
947 947 if status and allowed_to_change_status:
948 948 message = (_('Status change %(transition_icon)s %(status)s')
949 949 % {'transition_icon': '>',
950 950 'status': ChangesetStatus.get_status_lbl(status)})
951 951 if close_pr:
952 952 message = _('Closing with') + ' ' + message
953 953 text = text or message
954 954 comm = CommentsModel().create(
955 955 text=text,
956 956 repo=c.rhodecode_db_repo.repo_id,
957 957 user=c.rhodecode_user.user_id,
958 958 pull_request=pull_request_id,
959 959 f_path=request.POST.get('f_path'),
960 960 line_no=request.POST.get('line'),
961 961 status_change=(ChangesetStatus.get_status_lbl(status)
962 962 if status and allowed_to_change_status else None),
963 963 status_change_type=(status
964 964 if status and allowed_to_change_status else None),
965 965 closing_pr=close_pr,
966 966 comment_type=comment_type,
967 967 resolves_comment_id=resolves_comment_id
968 968 )
969 969
970 970 if allowed_to_change_status:
971 971 old_calculated_status = pull_request.calculated_review_status()
972 972 # get status if set !
973 973 if status:
974 974 ChangesetStatusModel().set_status(
975 975 c.rhodecode_db_repo.repo_id,
976 976 status,
977 977 c.rhodecode_user.user_id,
978 978 comm,
979 979 pull_request=pull_request_id
980 980 )
981 981
982 982 Session().flush()
983 983 events.trigger(events.PullRequestCommentEvent(pull_request, comm))
984 984 # we now calculate the status of pull request, and based on that
985 985 # calculation we set the commits status
986 986 calculated_status = pull_request.calculated_review_status()
987 987 if old_calculated_status != calculated_status:
988 988 PullRequestModel()._trigger_pull_request_hook(
989 989 pull_request, c.rhodecode_user, 'review_status_change')
990 990
991 991 calculated_status_lbl = ChangesetStatus.get_status_lbl(
992 992 calculated_status)
993 993
994 994 if close_pr:
995 995 status_completed = (
996 996 calculated_status in [ChangesetStatus.STATUS_APPROVED,
997 997 ChangesetStatus.STATUS_REJECTED])
998 if forced or status_completed:
998 if close_pull_request or status_completed:
999 999 PullRequestModel().close_pull_request(
1000 1000 pull_request_id, c.rhodecode_user)
1001 1001 else:
1002 1002 h.flash(_('Closing pull request on other statuses than '
1003 1003 'rejected or approved is forbidden. '
1004 1004 'Calculated status from all reviewers '
1005 1005 'is currently: %s') % calculated_status_lbl,
1006 1006 category='warning')
1007 1007
1008 1008 Session().commit()
1009 1009
1010 1010 if not request.is_xhr:
1011 1011 return redirect(h.url('pullrequest_show', repo_name=repo_name,
1012 1012 pull_request_id=pull_request_id))
1013 1013
1014 1014 data = {
1015 1015 'target_id': h.safeid(h.safe_unicode(request.POST.get('f_path'))),
1016 1016 }
1017 1017 if comm:
1018 1018 c.co = comm
1019 1019 c.inline_comment = True if comm.line_no else False
1020 1020 data.update(comm.get_dict())
1021 1021 data.update({'rendered_text':
1022 1022 render('changeset/changeset_comment_block.mako')})
1023 1023
1024 1024 return data
1025 1025
1026 1026 @LoginRequired()
1027 1027 @NotAnonymous()
1028 1028 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
1029 1029 'repository.admin')
1030 1030 @auth.CSRFRequired()
1031 1031 @jsonify
1032 1032 def delete_comment(self, repo_name, comment_id):
1033 1033 return self._delete_comment(comment_id)
1034 1034
1035 1035 def _delete_comment(self, comment_id):
1036 1036 comment_id = safe_int(comment_id)
1037 1037 co = ChangesetComment.get_or_404(comment_id)
1038 1038 if co.pull_request.is_closed():
1039 1039 # don't allow deleting comments on closed pull request
1040 1040 raise HTTPForbidden()
1041 1041
1042 1042 is_owner = co.author.user_id == c.rhodecode_user.user_id
1043 1043 is_repo_admin = h.HasRepoPermissionAny('repository.admin')(c.repo_name)
1044 1044 if h.HasPermissionAny('hg.admin')() or is_repo_admin or is_owner:
1045 1045 old_calculated_status = co.pull_request.calculated_review_status()
1046 1046 CommentsModel().delete(comment=co)
1047 1047 Session().commit()
1048 1048 calculated_status = co.pull_request.calculated_review_status()
1049 1049 if old_calculated_status != calculated_status:
1050 1050 PullRequestModel()._trigger_pull_request_hook(
1051 1051 co.pull_request, c.rhodecode_user, 'review_status_change')
1052 1052 return True
1053 1053 else:
1054 1054 raise HTTPForbidden()
@@ -1,1420 +1,1423 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 """
23 23 pull request model for RhodeCode
24 24 """
25 25
26 26 from collections import namedtuple
27 27 import json
28 28 import logging
29 29 import datetime
30 30 import urllib
31 31
32 32 from pylons.i18n.translation import _
33 33 from pylons.i18n.translation import lazy_ugettext
34 34 from sqlalchemy import or_
35 35
36 36 from rhodecode.lib import helpers as h, hooks_utils, diffs
37 37 from rhodecode.lib.compat import OrderedDict
38 38 from rhodecode.lib.hooks_daemon import prepare_callback_daemon
39 39 from rhodecode.lib.markup_renderer import (
40 40 DEFAULT_COMMENTS_RENDERER, RstTemplateRenderer)
41 41 from rhodecode.lib.utils import action_logger
42 42 from rhodecode.lib.utils2 import safe_unicode, safe_str, md5_safe
43 43 from rhodecode.lib.vcs.backends.base import (
44 44 Reference, MergeResponse, MergeFailureReason, UpdateFailureReason)
45 45 from rhodecode.lib.vcs.conf import settings as vcs_settings
46 46 from rhodecode.lib.vcs.exceptions import (
47 47 CommitDoesNotExistError, EmptyRepositoryError)
48 48 from rhodecode.model import BaseModel
49 49 from rhodecode.model.changeset_status import ChangesetStatusModel
50 50 from rhodecode.model.comment import CommentsModel
51 51 from rhodecode.model.db import (
52 52 PullRequest, PullRequestReviewers, ChangesetStatus,
53 53 PullRequestVersion, ChangesetComment, Repository)
54 54 from rhodecode.model.meta import Session
55 55 from rhodecode.model.notification import NotificationModel, \
56 56 EmailNotificationModel
57 57 from rhodecode.model.scm import ScmModel
58 58 from rhodecode.model.settings import VcsSettingsModel
59 59
60 60
61 61 log = logging.getLogger(__name__)
62 62
63 63
64 64 # Data structure to hold the response data when updating commits during a pull
65 65 # request update.
66 66 UpdateResponse = namedtuple(
67 67 'UpdateResponse', 'executed, reason, new, old, changes')
68 68
69 69
70 70 class PullRequestModel(BaseModel):
71 71
72 72 cls = PullRequest
73 73
74 74 DIFF_CONTEXT = 3
75 75
76 76 MERGE_STATUS_MESSAGES = {
77 77 MergeFailureReason.NONE: lazy_ugettext(
78 78 'This pull request can be automatically merged.'),
79 79 MergeFailureReason.UNKNOWN: lazy_ugettext(
80 80 'This pull request cannot be merged because of an unhandled'
81 81 ' exception.'),
82 82 MergeFailureReason.MERGE_FAILED: lazy_ugettext(
83 83 'This pull request cannot be merged because of merge conflicts.'),
84 84 MergeFailureReason.PUSH_FAILED: lazy_ugettext(
85 85 'This pull request could not be merged because push to target'
86 86 ' failed.'),
87 87 MergeFailureReason.TARGET_IS_NOT_HEAD: lazy_ugettext(
88 88 'This pull request cannot be merged because the target is not a'
89 89 ' head.'),
90 90 MergeFailureReason.HG_SOURCE_HAS_MORE_BRANCHES: lazy_ugettext(
91 91 'This pull request cannot be merged because the source contains'
92 92 ' more branches than the target.'),
93 93 MergeFailureReason.HG_TARGET_HAS_MULTIPLE_HEADS: lazy_ugettext(
94 94 'This pull request cannot be merged because the target has'
95 95 ' multiple heads.'),
96 96 MergeFailureReason.TARGET_IS_LOCKED: lazy_ugettext(
97 97 'This pull request cannot be merged because the target repository'
98 98 ' is locked.'),
99 99 MergeFailureReason._DEPRECATED_MISSING_COMMIT: lazy_ugettext(
100 100 'This pull request cannot be merged because the target or the '
101 101 'source reference is missing.'),
102 102 MergeFailureReason.MISSING_TARGET_REF: lazy_ugettext(
103 103 'This pull request cannot be merged because the target '
104 104 'reference is missing.'),
105 105 MergeFailureReason.MISSING_SOURCE_REF: lazy_ugettext(
106 106 'This pull request cannot be merged because the source '
107 107 'reference is missing.'),
108 108 MergeFailureReason.SUBREPO_MERGE_FAILED: lazy_ugettext(
109 109 'This pull request cannot be merged because of conflicts related '
110 110 'to sub repositories.'),
111 111 }
112 112
113 113 UPDATE_STATUS_MESSAGES = {
114 114 UpdateFailureReason.NONE: lazy_ugettext(
115 115 'Pull request update successful.'),
116 116 UpdateFailureReason.UNKNOWN: lazy_ugettext(
117 117 'Pull request update failed because of an unknown error.'),
118 118 UpdateFailureReason.NO_CHANGE: lazy_ugettext(
119 119 'No update needed because the source reference is already '
120 120 'up to date.'),
121 121 UpdateFailureReason.WRONG_REF_TPYE: lazy_ugettext(
122 122 'Pull request cannot be updated because the reference type is '
123 123 'not supported for an update.'),
124 124 UpdateFailureReason.MISSING_TARGET_REF: lazy_ugettext(
125 125 'This pull request cannot be updated because the target '
126 126 'reference is missing.'),
127 127 UpdateFailureReason.MISSING_SOURCE_REF: lazy_ugettext(
128 128 'This pull request cannot be updated because the source '
129 129 'reference is missing.'),
130 130 }
131 131
132 132 def __get_pull_request(self, pull_request):
133 133 return self._get_instance((
134 134 PullRequest, PullRequestVersion), pull_request)
135 135
136 136 def _check_perms(self, perms, pull_request, user, api=False):
137 137 if not api:
138 138 return h.HasRepoPermissionAny(*perms)(
139 139 user=user, repo_name=pull_request.target_repo.repo_name)
140 140 else:
141 141 return h.HasRepoPermissionAnyApi(*perms)(
142 142 user=user, repo_name=pull_request.target_repo.repo_name)
143 143
144 144 def check_user_read(self, pull_request, user, api=False):
145 145 _perms = ('repository.admin', 'repository.write', 'repository.read',)
146 146 return self._check_perms(_perms, pull_request, user, api)
147 147
148 148 def check_user_merge(self, pull_request, user, api=False):
149 149 _perms = ('repository.admin', 'repository.write', 'hg.admin',)
150 150 return self._check_perms(_perms, pull_request, user, api)
151 151
152 152 def check_user_update(self, pull_request, user, api=False):
153 153 owner = user.user_id == pull_request.user_id
154 154 return self.check_user_merge(pull_request, user, api) or owner
155 155
156 156 def check_user_delete(self, pull_request, user):
157 157 owner = user.user_id == pull_request.user_id
158 158 _perms = ('repository.admin',)
159 159 return self._check_perms(_perms, pull_request, user) or owner
160 160
161 161 def check_user_change_status(self, pull_request, user, api=False):
162 162 reviewer = user.user_id in [x.user_id for x in
163 163 pull_request.reviewers]
164 164 return self.check_user_update(pull_request, user, api) or reviewer
165 165
166 166 def get(self, pull_request):
167 167 return self.__get_pull_request(pull_request)
168 168
169 169 def _prepare_get_all_query(self, repo_name, source=False, statuses=None,
170 170 opened_by=None, order_by=None,
171 171 order_dir='desc'):
172 172 repo = None
173 173 if repo_name:
174 174 repo = self._get_repo(repo_name)
175 175
176 176 q = PullRequest.query()
177 177
178 178 # source or target
179 179 if repo and source:
180 180 q = q.filter(PullRequest.source_repo == repo)
181 181 elif repo:
182 182 q = q.filter(PullRequest.target_repo == repo)
183 183
184 184 # closed,opened
185 185 if statuses:
186 186 q = q.filter(PullRequest.status.in_(statuses))
187 187
188 188 # opened by filter
189 189 if opened_by:
190 190 q = q.filter(PullRequest.user_id.in_(opened_by))
191 191
192 192 if order_by:
193 193 order_map = {
194 194 'name_raw': PullRequest.pull_request_id,
195 195 'title': PullRequest.title,
196 196 'updated_on_raw': PullRequest.updated_on,
197 197 'target_repo': PullRequest.target_repo_id
198 198 }
199 199 if order_dir == 'asc':
200 200 q = q.order_by(order_map[order_by].asc())
201 201 else:
202 202 q = q.order_by(order_map[order_by].desc())
203 203
204 204 return q
205 205
206 206 def count_all(self, repo_name, source=False, statuses=None,
207 207 opened_by=None):
208 208 """
209 209 Count the number of pull requests for a specific repository.
210 210
211 211 :param repo_name: target or source repo
212 212 :param source: boolean flag to specify if repo_name refers to source
213 213 :param statuses: list of pull request statuses
214 214 :param opened_by: author user of the pull request
215 215 :returns: int number of pull requests
216 216 """
217 217 q = self._prepare_get_all_query(
218 218 repo_name, source=source, statuses=statuses, opened_by=opened_by)
219 219
220 220 return q.count()
221 221
222 222 def get_all(self, repo_name, source=False, statuses=None, opened_by=None,
223 223 offset=0, length=None, order_by=None, order_dir='desc'):
224 224 """
225 225 Get all pull requests for a specific repository.
226 226
227 227 :param repo_name: target or source repo
228 228 :param source: boolean flag to specify if repo_name refers to source
229 229 :param statuses: list of pull request statuses
230 230 :param opened_by: author user of the pull request
231 231 :param offset: pagination offset
232 232 :param length: length of returned list
233 233 :param order_by: order of the returned list
234 234 :param order_dir: 'asc' or 'desc' ordering direction
235 235 :returns: list of pull requests
236 236 """
237 237 q = self._prepare_get_all_query(
238 238 repo_name, source=source, statuses=statuses, opened_by=opened_by,
239 239 order_by=order_by, order_dir=order_dir)
240 240
241 241 if length:
242 242 pull_requests = q.limit(length).offset(offset).all()
243 243 else:
244 244 pull_requests = q.all()
245 245
246 246 return pull_requests
247 247
248 248 def count_awaiting_review(self, repo_name, source=False, statuses=None,
249 249 opened_by=None):
250 250 """
251 251 Count the number of pull requests for a specific repository that are
252 252 awaiting review.
253 253
254 254 :param repo_name: target or source repo
255 255 :param source: boolean flag to specify if repo_name refers to source
256 256 :param statuses: list of pull request statuses
257 257 :param opened_by: author user of the pull request
258 258 :returns: int number of pull requests
259 259 """
260 260 pull_requests = self.get_awaiting_review(
261 261 repo_name, source=source, statuses=statuses, opened_by=opened_by)
262 262
263 263 return len(pull_requests)
264 264
265 265 def get_awaiting_review(self, repo_name, source=False, statuses=None,
266 266 opened_by=None, offset=0, length=None,
267 267 order_by=None, order_dir='desc'):
268 268 """
269 269 Get all pull requests for a specific repository that are awaiting
270 270 review.
271 271
272 272 :param repo_name: target or source repo
273 273 :param source: boolean flag to specify if repo_name refers to source
274 274 :param statuses: list of pull request statuses
275 275 :param opened_by: author user of the pull request
276 276 :param offset: pagination offset
277 277 :param length: length of returned list
278 278 :param order_by: order of the returned list
279 279 :param order_dir: 'asc' or 'desc' ordering direction
280 280 :returns: list of pull requests
281 281 """
282 282 pull_requests = self.get_all(
283 283 repo_name, source=source, statuses=statuses, opened_by=opened_by,
284 284 order_by=order_by, order_dir=order_dir)
285 285
286 286 _filtered_pull_requests = []
287 287 for pr in pull_requests:
288 288 status = pr.calculated_review_status()
289 289 if status in [ChangesetStatus.STATUS_NOT_REVIEWED,
290 290 ChangesetStatus.STATUS_UNDER_REVIEW]:
291 291 _filtered_pull_requests.append(pr)
292 292 if length:
293 293 return _filtered_pull_requests[offset:offset+length]
294 294 else:
295 295 return _filtered_pull_requests
296 296
297 297 def count_awaiting_my_review(self, repo_name, source=False, statuses=None,
298 298 opened_by=None, user_id=None):
299 299 """
300 300 Count the number of pull requests for a specific repository that are
301 301 awaiting review from a specific user.
302 302
303 303 :param repo_name: target or source repo
304 304 :param source: boolean flag to specify if repo_name refers to source
305 305 :param statuses: list of pull request statuses
306 306 :param opened_by: author user of the pull request
307 307 :param user_id: reviewer user of the pull request
308 308 :returns: int number of pull requests
309 309 """
310 310 pull_requests = self.get_awaiting_my_review(
311 311 repo_name, source=source, statuses=statuses, opened_by=opened_by,
312 312 user_id=user_id)
313 313
314 314 return len(pull_requests)
315 315
316 316 def get_awaiting_my_review(self, repo_name, source=False, statuses=None,
317 317 opened_by=None, user_id=None, offset=0,
318 318 length=None, order_by=None, order_dir='desc'):
319 319 """
320 320 Get all pull requests for a specific repository that are awaiting
321 321 review from a specific user.
322 322
323 323 :param repo_name: target or source repo
324 324 :param source: boolean flag to specify if repo_name refers to source
325 325 :param statuses: list of pull request statuses
326 326 :param opened_by: author user of the pull request
327 327 :param user_id: reviewer user of the pull request
328 328 :param offset: pagination offset
329 329 :param length: length of returned list
330 330 :param order_by: order of the returned list
331 331 :param order_dir: 'asc' or 'desc' ordering direction
332 332 :returns: list of pull requests
333 333 """
334 334 pull_requests = self.get_all(
335 335 repo_name, source=source, statuses=statuses, opened_by=opened_by,
336 336 order_by=order_by, order_dir=order_dir)
337 337
338 338 _my = PullRequestModel().get_not_reviewed(user_id)
339 339 my_participation = []
340 340 for pr in pull_requests:
341 341 if pr in _my:
342 342 my_participation.append(pr)
343 343 _filtered_pull_requests = my_participation
344 344 if length:
345 345 return _filtered_pull_requests[offset:offset+length]
346 346 else:
347 347 return _filtered_pull_requests
348 348
349 349 def get_not_reviewed(self, user_id):
350 350 return [
351 351 x.pull_request for x in PullRequestReviewers.query().filter(
352 352 PullRequestReviewers.user_id == user_id).all()
353 353 ]
354 354
355 355 def _prepare_participating_query(self, user_id=None, statuses=None,
356 356 order_by=None, order_dir='desc'):
357 357 q = PullRequest.query()
358 358 if user_id:
359 359 reviewers_subquery = Session().query(
360 360 PullRequestReviewers.pull_request_id).filter(
361 361 PullRequestReviewers.user_id == user_id).subquery()
362 362 user_filter= or_(
363 363 PullRequest.user_id == user_id,
364 364 PullRequest.pull_request_id.in_(reviewers_subquery)
365 365 )
366 366 q = PullRequest.query().filter(user_filter)
367 367
368 368 # closed,opened
369 369 if statuses:
370 370 q = q.filter(PullRequest.status.in_(statuses))
371 371
372 372 if order_by:
373 373 order_map = {
374 374 'name_raw': PullRequest.pull_request_id,
375 375 'title': PullRequest.title,
376 376 'updated_on_raw': PullRequest.updated_on,
377 377 'target_repo': PullRequest.target_repo_id
378 378 }
379 379 if order_dir == 'asc':
380 380 q = q.order_by(order_map[order_by].asc())
381 381 else:
382 382 q = q.order_by(order_map[order_by].desc())
383 383
384 384 return q
385 385
386 386 def count_im_participating_in(self, user_id=None, statuses=None):
387 387 q = self._prepare_participating_query(user_id, statuses=statuses)
388 388 return q.count()
389 389
390 390 def get_im_participating_in(
391 391 self, user_id=None, statuses=None, offset=0,
392 392 length=None, order_by=None, order_dir='desc'):
393 393 """
394 394 Get all Pull requests that i'm participating in, or i have opened
395 395 """
396 396
397 397 q = self._prepare_participating_query(
398 398 user_id, statuses=statuses, order_by=order_by,
399 399 order_dir=order_dir)
400 400
401 401 if length:
402 402 pull_requests = q.limit(length).offset(offset).all()
403 403 else:
404 404 pull_requests = q.all()
405 405
406 406 return pull_requests
407 407
408 408 def get_versions(self, pull_request):
409 409 """
410 410 returns version of pull request sorted by ID descending
411 411 """
412 412 return PullRequestVersion.query()\
413 413 .filter(PullRequestVersion.pull_request == pull_request)\
414 414 .order_by(PullRequestVersion.pull_request_version_id.asc())\
415 415 .all()
416 416
417 417 def create(self, created_by, source_repo, source_ref, target_repo,
418 418 target_ref, revisions, reviewers, title, description=None):
419 419 created_by_user = self._get_user(created_by)
420 420 source_repo = self._get_repo(source_repo)
421 421 target_repo = self._get_repo(target_repo)
422 422
423 423 pull_request = PullRequest()
424 424 pull_request.source_repo = source_repo
425 425 pull_request.source_ref = source_ref
426 426 pull_request.target_repo = target_repo
427 427 pull_request.target_ref = target_ref
428 428 pull_request.revisions = revisions
429 429 pull_request.title = title
430 430 pull_request.description = description
431 431 pull_request.author = created_by_user
432 432
433 433 Session().add(pull_request)
434 434 Session().flush()
435 435
436 436 reviewer_ids = set()
437 437 # members / reviewers
438 438 for reviewer_object in reviewers:
439 439 if isinstance(reviewer_object, tuple):
440 440 user_id, reasons = reviewer_object
441 441 else:
442 442 user_id, reasons = reviewer_object, []
443 443
444 444 user = self._get_user(user_id)
445 445 reviewer_ids.add(user.user_id)
446 446
447 447 reviewer = PullRequestReviewers(user, pull_request, reasons)
448 448 Session().add(reviewer)
449 449
450 450 # Set approval status to "Under Review" for all commits which are
451 451 # part of this pull request.
452 452 ChangesetStatusModel().set_status(
453 453 repo=target_repo,
454 454 status=ChangesetStatus.STATUS_UNDER_REVIEW,
455 455 user=created_by_user,
456 456 pull_request=pull_request
457 457 )
458 458
459 459 self.notify_reviewers(pull_request, reviewer_ids)
460 460 self._trigger_pull_request_hook(
461 461 pull_request, created_by_user, 'create')
462 462
463 463 return pull_request
464 464
465 465 def _trigger_pull_request_hook(self, pull_request, user, action):
466 466 pull_request = self.__get_pull_request(pull_request)
467 467 target_scm = pull_request.target_repo.scm_instance()
468 468 if action == 'create':
469 469 trigger_hook = hooks_utils.trigger_log_create_pull_request_hook
470 470 elif action == 'merge':
471 471 trigger_hook = hooks_utils.trigger_log_merge_pull_request_hook
472 472 elif action == 'close':
473 473 trigger_hook = hooks_utils.trigger_log_close_pull_request_hook
474 474 elif action == 'review_status_change':
475 475 trigger_hook = hooks_utils.trigger_log_review_pull_request_hook
476 476 elif action == 'update':
477 477 trigger_hook = hooks_utils.trigger_log_update_pull_request_hook
478 478 else:
479 479 return
480 480
481 481 trigger_hook(
482 482 username=user.username,
483 483 repo_name=pull_request.target_repo.repo_name,
484 484 repo_alias=target_scm.alias,
485 485 pull_request=pull_request)
486 486
487 487 def _get_commit_ids(self, pull_request):
488 488 """
489 489 Return the commit ids of the merged pull request.
490 490
491 491 This method is not dealing correctly yet with the lack of autoupdates
492 492 nor with the implicit target updates.
493 493 For example: if a commit in the source repo is already in the target it
494 494 will be reported anyways.
495 495 """
496 496 merge_rev = pull_request.merge_rev
497 497 if merge_rev is None:
498 498 raise ValueError('This pull request was not merged yet')
499 499
500 500 commit_ids = list(pull_request.revisions)
501 501 if merge_rev not in commit_ids:
502 502 commit_ids.append(merge_rev)
503 503
504 504 return commit_ids
505 505
506 506 def merge(self, pull_request, user, extras):
507 507 log.debug("Merging pull request %s", pull_request.pull_request_id)
508 508 merge_state = self._merge_pull_request(pull_request, user, extras)
509 509 if merge_state.executed:
510 510 log.debug(
511 511 "Merge was successful, updating the pull request comments.")
512 512 self._comment_and_close_pr(pull_request, user, merge_state)
513 513 self._log_action('user_merged_pull_request', user, pull_request)
514 514 else:
515 515 log.warn("Merge failed, not updating the pull request.")
516 516 return merge_state
517 517
518 518 def _merge_pull_request(self, pull_request, user, extras):
519 519 target_vcs = pull_request.target_repo.scm_instance()
520 520 source_vcs = pull_request.source_repo.scm_instance()
521 521 target_ref = self._refresh_reference(
522 522 pull_request.target_ref_parts, target_vcs)
523 523
524 524 message = _(
525 525 'Merge pull request #%(pr_id)s from '
526 526 '%(source_repo)s %(source_ref_name)s\n\n %(pr_title)s') % {
527 527 'pr_id': pull_request.pull_request_id,
528 528 'source_repo': source_vcs.name,
529 529 'source_ref_name': pull_request.source_ref_parts.name,
530 530 'pr_title': pull_request.title
531 531 }
532 532
533 533 workspace_id = self._workspace_id(pull_request)
534 534 use_rebase = self._use_rebase_for_merging(pull_request)
535 535
536 536 callback_daemon, extras = prepare_callback_daemon(
537 537 extras, protocol=vcs_settings.HOOKS_PROTOCOL,
538 538 use_direct_calls=vcs_settings.HOOKS_DIRECT_CALLS)
539 539
540 540 with callback_daemon:
541 541 # TODO: johbo: Implement a clean way to run a config_override
542 542 # for a single call.
543 543 target_vcs.config.set(
544 544 'rhodecode', 'RC_SCM_DATA', json.dumps(extras))
545 545 merge_state = target_vcs.merge(
546 546 target_ref, source_vcs, pull_request.source_ref_parts,
547 547 workspace_id, user_name=user.username,
548 548 user_email=user.email, message=message, use_rebase=use_rebase)
549 549 return merge_state
550 550
551 551 def _comment_and_close_pr(self, pull_request, user, merge_state):
552 552 pull_request.merge_rev = merge_state.merge_ref.commit_id
553 553 pull_request.updated_on = datetime.datetime.now()
554 554
555 555 CommentsModel().create(
556 556 text=unicode(_('Pull request merged and closed')),
557 557 repo=pull_request.target_repo.repo_id,
558 558 user=user.user_id,
559 559 pull_request=pull_request.pull_request_id,
560 560 f_path=None,
561 561 line_no=None,
562 562 closing_pr=True
563 563 )
564 564
565 565 Session().add(pull_request)
566 566 Session().flush()
567 567 # TODO: paris: replace invalidation with less radical solution
568 568 ScmModel().mark_for_invalidation(
569 569 pull_request.target_repo.repo_name)
570 570 self._trigger_pull_request_hook(pull_request, user, 'merge')
571 571
572 572 def has_valid_update_type(self, pull_request):
573 573 source_ref_type = pull_request.source_ref_parts.type
574 574 return source_ref_type in ['book', 'branch', 'tag']
575 575
576 576 def update_commits(self, pull_request):
577 577 """
578 578 Get the updated list of commits for the pull request
579 579 and return the new pull request version and the list
580 580 of commits processed by this update action
581 581 """
582 582 pull_request = self.__get_pull_request(pull_request)
583 583 source_ref_type = pull_request.source_ref_parts.type
584 584 source_ref_name = pull_request.source_ref_parts.name
585 585 source_ref_id = pull_request.source_ref_parts.commit_id
586 586
587 587 if not self.has_valid_update_type(pull_request):
588 588 log.debug(
589 589 "Skipping update of pull request %s due to ref type: %s",
590 590 pull_request, source_ref_type)
591 591 return UpdateResponse(
592 592 executed=False,
593 593 reason=UpdateFailureReason.WRONG_REF_TPYE,
594 594 old=pull_request, new=None, changes=None)
595 595
596 596 source_repo = pull_request.source_repo.scm_instance()
597 597 try:
598 598 source_commit = source_repo.get_commit(commit_id=source_ref_name)
599 599 except CommitDoesNotExistError:
600 600 return UpdateResponse(
601 601 executed=False,
602 602 reason=UpdateFailureReason.MISSING_SOURCE_REF,
603 603 old=pull_request, new=None, changes=None)
604 604
605 605 if source_ref_id == source_commit.raw_id:
606 606 log.debug("Nothing changed in pull request %s", pull_request)
607 607 return UpdateResponse(
608 608 executed=False,
609 609 reason=UpdateFailureReason.NO_CHANGE,
610 610 old=pull_request, new=None, changes=None)
611 611
612 612 # Finally there is a need for an update
613 613 pull_request_version = self._create_version_from_snapshot(pull_request)
614 614 self._link_comments_to_version(pull_request_version)
615 615
616 616 target_ref_type = pull_request.target_ref_parts.type
617 617 target_ref_name = pull_request.target_ref_parts.name
618 618 target_ref_id = pull_request.target_ref_parts.commit_id
619 619 target_repo = pull_request.target_repo.scm_instance()
620 620
621 621 try:
622 622 if target_ref_type in ('tag', 'branch', 'book'):
623 623 target_commit = target_repo.get_commit(target_ref_name)
624 624 else:
625 625 target_commit = target_repo.get_commit(target_ref_id)
626 626 except CommitDoesNotExistError:
627 627 return UpdateResponse(
628 628 executed=False,
629 629 reason=UpdateFailureReason.MISSING_TARGET_REF,
630 630 old=pull_request, new=None, changes=None)
631 631
632 632 # re-compute commit ids
633 633 old_commit_ids = pull_request.revisions
634 634 pre_load = ["author", "branch", "date", "message"]
635 635 commit_ranges = target_repo.compare(
636 636 target_commit.raw_id, source_commit.raw_id, source_repo, merge=True,
637 637 pre_load=pre_load)
638 638
639 639 ancestor = target_repo.get_common_ancestor(
640 640 target_commit.raw_id, source_commit.raw_id, source_repo)
641 641
642 642 pull_request.source_ref = '%s:%s:%s' % (
643 643 source_ref_type, source_ref_name, source_commit.raw_id)
644 644 pull_request.target_ref = '%s:%s:%s' % (
645 645 target_ref_type, target_ref_name, ancestor)
646 646 pull_request.revisions = [
647 647 commit.raw_id for commit in reversed(commit_ranges)]
648 648 pull_request.updated_on = datetime.datetime.now()
649 649 Session().add(pull_request)
650 650 new_commit_ids = pull_request.revisions
651 651
652 652 changes = self._calculate_commit_id_changes(
653 653 old_commit_ids, new_commit_ids)
654 654
655 655 old_diff_data, new_diff_data = self._generate_update_diffs(
656 656 pull_request, pull_request_version)
657 657
658 658 CommentsModel().outdate_comments(
659 659 pull_request, old_diff_data=old_diff_data,
660 660 new_diff_data=new_diff_data)
661 661
662 662 file_changes = self._calculate_file_changes(
663 663 old_diff_data, new_diff_data)
664 664
665 665 # Add an automatic comment to the pull request
666 666 update_comment = CommentsModel().create(
667 667 text=self._render_update_message(changes, file_changes),
668 668 repo=pull_request.target_repo,
669 669 user=pull_request.author,
670 670 pull_request=pull_request,
671 671 send_email=False, renderer=DEFAULT_COMMENTS_RENDERER)
672 672
673 673 # Update status to "Under Review" for added commits
674 674 for commit_id in changes.added:
675 675 ChangesetStatusModel().set_status(
676 676 repo=pull_request.source_repo,
677 677 status=ChangesetStatus.STATUS_UNDER_REVIEW,
678 678 comment=update_comment,
679 679 user=pull_request.author,
680 680 pull_request=pull_request,
681 681 revision=commit_id)
682 682
683 683 log.debug(
684 684 'Updated pull request %s, added_ids: %s, common_ids: %s, '
685 685 'removed_ids: %s', pull_request.pull_request_id,
686 686 changes.added, changes.common, changes.removed)
687 687 log.debug('Updated pull request with the following file changes: %s',
688 688 file_changes)
689 689
690 690 log.info(
691 691 "Updated pull request %s from commit %s to commit %s, "
692 692 "stored new version %s of this pull request.",
693 693 pull_request.pull_request_id, source_ref_id,
694 694 pull_request.source_ref_parts.commit_id,
695 695 pull_request_version.pull_request_version_id)
696 696 Session().commit()
697 697 self._trigger_pull_request_hook(pull_request, pull_request.author,
698 698 'update')
699 699
700 700 return UpdateResponse(
701 701 executed=True, reason=UpdateFailureReason.NONE,
702 702 old=pull_request, new=pull_request_version, changes=changes)
703 703
704 704 def _create_version_from_snapshot(self, pull_request):
705 705 version = PullRequestVersion()
706 706 version.title = pull_request.title
707 707 version.description = pull_request.description
708 708 version.status = pull_request.status
709 709 version.created_on = datetime.datetime.now()
710 710 version.updated_on = pull_request.updated_on
711 711 version.user_id = pull_request.user_id
712 712 version.source_repo = pull_request.source_repo
713 713 version.source_ref = pull_request.source_ref
714 714 version.target_repo = pull_request.target_repo
715 715 version.target_ref = pull_request.target_ref
716 716
717 717 version._last_merge_source_rev = pull_request._last_merge_source_rev
718 718 version._last_merge_target_rev = pull_request._last_merge_target_rev
719 719 version._last_merge_status = pull_request._last_merge_status
720 720 version.shadow_merge_ref = pull_request.shadow_merge_ref
721 721 version.merge_rev = pull_request.merge_rev
722 722
723 723 version.revisions = pull_request.revisions
724 724 version.pull_request = pull_request
725 725 Session().add(version)
726 726 Session().flush()
727 727
728 728 return version
729 729
730 730 def _generate_update_diffs(self, pull_request, pull_request_version):
731 731
732 732 diff_context = (
733 733 self.DIFF_CONTEXT +
734 734 CommentsModel.needed_extra_diff_context())
735 735
736 736 source_repo = pull_request_version.source_repo
737 737 source_ref_id = pull_request_version.source_ref_parts.commit_id
738 738 target_ref_id = pull_request_version.target_ref_parts.commit_id
739 739 old_diff = self._get_diff_from_pr_or_version(
740 740 source_repo, source_ref_id, target_ref_id, context=diff_context)
741 741
742 742 source_repo = pull_request.source_repo
743 743 source_ref_id = pull_request.source_ref_parts.commit_id
744 744 target_ref_id = pull_request.target_ref_parts.commit_id
745 745
746 746 new_diff = self._get_diff_from_pr_or_version(
747 747 source_repo, source_ref_id, target_ref_id, context=diff_context)
748 748
749 749 old_diff_data = diffs.DiffProcessor(old_diff)
750 750 old_diff_data.prepare()
751 751 new_diff_data = diffs.DiffProcessor(new_diff)
752 752 new_diff_data.prepare()
753 753
754 754 return old_diff_data, new_diff_data
755 755
756 756 def _link_comments_to_version(self, pull_request_version):
757 757 """
758 758 Link all unlinked comments of this pull request to the given version.
759 759
760 760 :param pull_request_version: The `PullRequestVersion` to which
761 761 the comments shall be linked.
762 762
763 763 """
764 764 pull_request = pull_request_version.pull_request
765 765 comments = ChangesetComment.query().filter(
766 766 # TODO: johbo: Should we query for the repo at all here?
767 767 # Pending decision on how comments of PRs are to be related
768 768 # to either the source repo, the target repo or no repo at all.
769 769 ChangesetComment.repo_id == pull_request.target_repo.repo_id,
770 770 ChangesetComment.pull_request == pull_request,
771 771 ChangesetComment.pull_request_version == None)
772 772
773 773 # TODO: johbo: Find out why this breaks if it is done in a bulk
774 774 # operation.
775 775 for comment in comments:
776 776 comment.pull_request_version_id = (
777 777 pull_request_version.pull_request_version_id)
778 778 Session().add(comment)
779 779
780 780 def _calculate_commit_id_changes(self, old_ids, new_ids):
781 781 added = [x for x in new_ids if x not in old_ids]
782 782 common = [x for x in new_ids if x in old_ids]
783 783 removed = [x for x in old_ids if x not in new_ids]
784 784 total = new_ids
785 785 return ChangeTuple(added, common, removed, total)
786 786
787 787 def _calculate_file_changes(self, old_diff_data, new_diff_data):
788 788
789 789 old_files = OrderedDict()
790 790 for diff_data in old_diff_data.parsed_diff:
791 791 old_files[diff_data['filename']] = md5_safe(diff_data['raw_diff'])
792 792
793 793 added_files = []
794 794 modified_files = []
795 795 removed_files = []
796 796 for diff_data in new_diff_data.parsed_diff:
797 797 new_filename = diff_data['filename']
798 798 new_hash = md5_safe(diff_data['raw_diff'])
799 799
800 800 old_hash = old_files.get(new_filename)
801 801 if not old_hash:
802 802 # file is not present in old diff, means it's added
803 803 added_files.append(new_filename)
804 804 else:
805 805 if new_hash != old_hash:
806 806 modified_files.append(new_filename)
807 807 # now remove a file from old, since we have seen it already
808 808 del old_files[new_filename]
809 809
810 810 # removed files is when there are present in old, but not in NEW,
811 811 # since we remove old files that are present in new diff, left-overs
812 812 # if any should be the removed files
813 813 removed_files.extend(old_files.keys())
814 814
815 815 return FileChangeTuple(added_files, modified_files, removed_files)
816 816
817 817 def _render_update_message(self, changes, file_changes):
818 818 """
819 819 render the message using DEFAULT_COMMENTS_RENDERER (RST renderer),
820 820 so it's always looking the same disregarding on which default
821 821 renderer system is using.
822 822
823 823 :param changes: changes named tuple
824 824 :param file_changes: file changes named tuple
825 825
826 826 """
827 827 new_status = ChangesetStatus.get_status_lbl(
828 828 ChangesetStatus.STATUS_UNDER_REVIEW)
829 829
830 830 changed_files = (
831 831 file_changes.added + file_changes.modified + file_changes.removed)
832 832
833 833 params = {
834 834 'under_review_label': new_status,
835 835 'added_commits': changes.added,
836 836 'removed_commits': changes.removed,
837 837 'changed_files': changed_files,
838 838 'added_files': file_changes.added,
839 839 'modified_files': file_changes.modified,
840 840 'removed_files': file_changes.removed,
841 841 }
842 842 renderer = RstTemplateRenderer()
843 843 return renderer.render('pull_request_update.mako', **params)
844 844
845 845 def edit(self, pull_request, title, description):
846 846 pull_request = self.__get_pull_request(pull_request)
847 847 if pull_request.is_closed():
848 848 raise ValueError('This pull request is closed')
849 849 if title:
850 850 pull_request.title = title
851 851 pull_request.description = description
852 852 pull_request.updated_on = datetime.datetime.now()
853 853 Session().add(pull_request)
854 854
855 855 def update_reviewers(self, pull_request, reviewer_data):
856 856 """
857 857 Update the reviewers in the pull request
858 858
859 859 :param pull_request: the pr to update
860 860 :param reviewer_data: list of tuples [(user, ['reason1', 'reason2'])]
861 861 """
862 862
863 863 reviewers_reasons = {}
864 864 for user_id, reasons in reviewer_data:
865 865 if isinstance(user_id, (int, basestring)):
866 866 user_id = self._get_user(user_id).user_id
867 867 reviewers_reasons[user_id] = reasons
868 868
869 869 reviewers_ids = set(reviewers_reasons.keys())
870 870 pull_request = self.__get_pull_request(pull_request)
871 871 current_reviewers = PullRequestReviewers.query()\
872 872 .filter(PullRequestReviewers.pull_request ==
873 873 pull_request).all()
874 874 current_reviewers_ids = set([x.user.user_id for x in current_reviewers])
875 875
876 876 ids_to_add = reviewers_ids.difference(current_reviewers_ids)
877 877 ids_to_remove = current_reviewers_ids.difference(reviewers_ids)
878 878
879 879 log.debug("Adding %s reviewers", ids_to_add)
880 880 log.debug("Removing %s reviewers", ids_to_remove)
881 881 changed = False
882 882 for uid in ids_to_add:
883 883 changed = True
884 884 _usr = self._get_user(uid)
885 885 reasons = reviewers_reasons[uid]
886 886 reviewer = PullRequestReviewers(_usr, pull_request, reasons)
887 887 Session().add(reviewer)
888 888
889 889 self.notify_reviewers(pull_request, ids_to_add)
890 890
891 891 for uid in ids_to_remove:
892 892 changed = True
893 893 reviewer = PullRequestReviewers.query()\
894 894 .filter(PullRequestReviewers.user_id == uid,
895 895 PullRequestReviewers.pull_request == pull_request)\
896 896 .scalar()
897 897 if reviewer:
898 898 Session().delete(reviewer)
899 899 if changed:
900 900 pull_request.updated_on = datetime.datetime.now()
901 901 Session().add(pull_request)
902 902
903 903 return ids_to_add, ids_to_remove
904 904
905 905 def get_url(self, pull_request):
906 906 return h.url('pullrequest_show',
907 907 repo_name=safe_str(pull_request.target_repo.repo_name),
908 908 pull_request_id=pull_request.pull_request_id,
909 909 qualified=True)
910 910
911 911 def get_shadow_clone_url(self, pull_request):
912 912 """
913 913 Returns qualified url pointing to the shadow repository. If this pull
914 914 request is closed there is no shadow repository and ``None`` will be
915 915 returned.
916 916 """
917 917 if pull_request.is_closed():
918 918 return None
919 919 else:
920 920 pr_url = urllib.unquote(self.get_url(pull_request))
921 921 return safe_unicode('{pr_url}/repository'.format(pr_url=pr_url))
922 922
923 923 def notify_reviewers(self, pull_request, reviewers_ids):
924 924 # notification to reviewers
925 925 if not reviewers_ids:
926 926 return
927 927
928 928 pull_request_obj = pull_request
929 929 # get the current participants of this pull request
930 930 recipients = reviewers_ids
931 931 notification_type = EmailNotificationModel.TYPE_PULL_REQUEST
932 932
933 933 pr_source_repo = pull_request_obj.source_repo
934 934 pr_target_repo = pull_request_obj.target_repo
935 935
936 936 pr_url = h.url(
937 937 'pullrequest_show',
938 938 repo_name=pr_target_repo.repo_name,
939 939 pull_request_id=pull_request_obj.pull_request_id,
940 940 qualified=True,)
941 941
942 942 # set some variables for email notification
943 943 pr_target_repo_url = h.url(
944 944 'summary_home',
945 945 repo_name=pr_target_repo.repo_name,
946 946 qualified=True)
947 947
948 948 pr_source_repo_url = h.url(
949 949 'summary_home',
950 950 repo_name=pr_source_repo.repo_name,
951 951 qualified=True)
952 952
953 953 # pull request specifics
954 954 pull_request_commits = [
955 955 (x.raw_id, x.message)
956 956 for x in map(pr_source_repo.get_commit, pull_request.revisions)]
957 957
958 958 kwargs = {
959 959 'user': pull_request.author,
960 960 'pull_request': pull_request_obj,
961 961 'pull_request_commits': pull_request_commits,
962 962
963 963 'pull_request_target_repo': pr_target_repo,
964 964 'pull_request_target_repo_url': pr_target_repo_url,
965 965
966 966 'pull_request_source_repo': pr_source_repo,
967 967 'pull_request_source_repo_url': pr_source_repo_url,
968 968
969 969 'pull_request_url': pr_url,
970 970 }
971 971
972 972 # pre-generate the subject for notification itself
973 973 (subject,
974 974 _h, _e, # we don't care about those
975 975 body_plaintext) = EmailNotificationModel().render_email(
976 976 notification_type, **kwargs)
977 977
978 978 # create notification objects, and emails
979 979 NotificationModel().create(
980 980 created_by=pull_request.author,
981 981 notification_subject=subject,
982 982 notification_body=body_plaintext,
983 983 notification_type=notification_type,
984 984 recipients=recipients,
985 985 email_kwargs=kwargs,
986 986 )
987 987
988 988 def delete(self, pull_request):
989 989 pull_request = self.__get_pull_request(pull_request)
990 990 self._cleanup_merge_workspace(pull_request)
991 991 Session().delete(pull_request)
992 992
993 993 def close_pull_request(self, pull_request, user):
994 994 pull_request = self.__get_pull_request(pull_request)
995 995 self._cleanup_merge_workspace(pull_request)
996 996 pull_request.status = PullRequest.STATUS_CLOSED
997 997 pull_request.updated_on = datetime.datetime.now()
998 998 Session().add(pull_request)
999 999 self._trigger_pull_request_hook(
1000 1000 pull_request, pull_request.author, 'close')
1001 1001 self._log_action('user_closed_pull_request', user, pull_request)
1002 1002
1003 1003 def close_pull_request_with_comment(self, pull_request, user, repo,
1004 1004 message=None):
1005 1005 status = ChangesetStatus.STATUS_REJECTED
1006 1006
1007 1007 if not message:
1008 1008 message = (
1009 1009 _('Status change %(transition_icon)s %(status)s') % {
1010 1010 'transition_icon': '>',
1011 1011 'status': ChangesetStatus.get_status_lbl(status)})
1012 1012
1013 1013 internal_message = _('Closing with') + ' ' + message
1014 1014
1015 1015 comm = CommentsModel().create(
1016 1016 text=internal_message,
1017 1017 repo=repo.repo_id,
1018 1018 user=user.user_id,
1019 1019 pull_request=pull_request.pull_request_id,
1020 1020 f_path=None,
1021 1021 line_no=None,
1022 1022 status_change=ChangesetStatus.get_status_lbl(status),
1023 1023 status_change_type=status,
1024 1024 closing_pr=True
1025 1025 )
1026 1026
1027 1027 ChangesetStatusModel().set_status(
1028 1028 repo.repo_id,
1029 1029 status,
1030 1030 user.user_id,
1031 1031 comm,
1032 1032 pull_request=pull_request.pull_request_id
1033 1033 )
1034 1034 Session().flush()
1035 1035
1036 1036 PullRequestModel().close_pull_request(
1037 1037 pull_request.pull_request_id, user)
1038 1038
1039 1039 def merge_status(self, pull_request):
1040 1040 if not self._is_merge_enabled(pull_request):
1041 1041 return False, _('Server-side pull request merging is disabled.')
1042 1042 if pull_request.is_closed():
1043 1043 return False, _('This pull request is closed.')
1044 1044 merge_possible, msg = self._check_repo_requirements(
1045 1045 target=pull_request.target_repo, source=pull_request.source_repo)
1046 1046 if not merge_possible:
1047 1047 return merge_possible, msg
1048 1048
1049 1049 try:
1050 1050 resp = self._try_merge(pull_request)
1051 1051 log.debug("Merge response: %s", resp)
1052 1052 status = resp.possible, self.merge_status_message(
1053 1053 resp.failure_reason)
1054 1054 except NotImplementedError:
1055 1055 status = False, _('Pull request merging is not supported.')
1056 1056
1057 1057 return status
1058 1058
1059 1059 def _check_repo_requirements(self, target, source):
1060 1060 """
1061 1061 Check if `target` and `source` have compatible requirements.
1062 1062
1063 1063 Currently this is just checking for largefiles.
1064 1064 """
1065 1065 target_has_largefiles = self._has_largefiles(target)
1066 1066 source_has_largefiles = self._has_largefiles(source)
1067 1067 merge_possible = True
1068 1068 message = u''
1069 1069
1070 1070 if target_has_largefiles != source_has_largefiles:
1071 1071 merge_possible = False
1072 1072 if source_has_largefiles:
1073 1073 message = _(
1074 1074 'Target repository large files support is disabled.')
1075 1075 else:
1076 1076 message = _(
1077 1077 'Source repository large files support is disabled.')
1078 1078
1079 1079 return merge_possible, message
1080 1080
1081 1081 def _has_largefiles(self, repo):
1082 1082 largefiles_ui = VcsSettingsModel(repo=repo).get_ui_settings(
1083 1083 'extensions', 'largefiles')
1084 1084 return largefiles_ui and largefiles_ui[0].active
1085 1085
1086 1086 def _try_merge(self, pull_request):
1087 1087 """
1088 1088 Try to merge the pull request and return the merge status.
1089 1089 """
1090 1090 log.debug(
1091 1091 "Trying out if the pull request %s can be merged.",
1092 1092 pull_request.pull_request_id)
1093 1093 target_vcs = pull_request.target_repo.scm_instance()
1094 1094
1095 1095 # Refresh the target reference.
1096 1096 try:
1097 1097 target_ref = self._refresh_reference(
1098 1098 pull_request.target_ref_parts, target_vcs)
1099 1099 except CommitDoesNotExistError:
1100 1100 merge_state = MergeResponse(
1101 1101 False, False, None, MergeFailureReason.MISSING_TARGET_REF)
1102 1102 return merge_state
1103 1103
1104 1104 target_locked = pull_request.target_repo.locked
1105 1105 if target_locked and target_locked[0]:
1106 1106 log.debug("The target repository is locked.")
1107 1107 merge_state = MergeResponse(
1108 1108 False, False, None, MergeFailureReason.TARGET_IS_LOCKED)
1109 1109 elif self._needs_merge_state_refresh(pull_request, target_ref):
1110 1110 log.debug("Refreshing the merge status of the repository.")
1111 1111 merge_state = self._refresh_merge_state(
1112 1112 pull_request, target_vcs, target_ref)
1113 1113 else:
1114 1114 possible = pull_request.\
1115 1115 _last_merge_status == MergeFailureReason.NONE
1116 1116 merge_state = MergeResponse(
1117 1117 possible, False, None, pull_request._last_merge_status)
1118 1118
1119 1119 return merge_state
1120 1120
1121 1121 def _refresh_reference(self, reference, vcs_repository):
1122 1122 if reference.type in ('branch', 'book'):
1123 1123 name_or_id = reference.name
1124 1124 else:
1125 1125 name_or_id = reference.commit_id
1126 1126 refreshed_commit = vcs_repository.get_commit(name_or_id)
1127 1127 refreshed_reference = Reference(
1128 1128 reference.type, reference.name, refreshed_commit.raw_id)
1129 1129 return refreshed_reference
1130 1130
1131 1131 def _needs_merge_state_refresh(self, pull_request, target_reference):
1132 1132 return not(
1133 1133 pull_request.revisions and
1134 1134 pull_request.revisions[0] == pull_request._last_merge_source_rev and
1135 1135 target_reference.commit_id == pull_request._last_merge_target_rev)
1136 1136
1137 1137 def _refresh_merge_state(self, pull_request, target_vcs, target_reference):
1138 1138 workspace_id = self._workspace_id(pull_request)
1139 1139 source_vcs = pull_request.source_repo.scm_instance()
1140 1140 use_rebase = self._use_rebase_for_merging(pull_request)
1141 1141 merge_state = target_vcs.merge(
1142 1142 target_reference, source_vcs, pull_request.source_ref_parts,
1143 1143 workspace_id, dry_run=True, use_rebase=use_rebase)
1144 1144
1145 1145 # Do not store the response if there was an unknown error.
1146 1146 if merge_state.failure_reason != MergeFailureReason.UNKNOWN:
1147 1147 pull_request._last_merge_source_rev = \
1148 1148 pull_request.source_ref_parts.commit_id
1149 1149 pull_request._last_merge_target_rev = target_reference.commit_id
1150 1150 pull_request._last_merge_status = merge_state.failure_reason
1151 1151 pull_request.shadow_merge_ref = merge_state.merge_ref
1152 1152 Session().add(pull_request)
1153 1153 Session().commit()
1154 1154
1155 1155 return merge_state
1156 1156
1157 1157 def _workspace_id(self, pull_request):
1158 1158 workspace_id = 'pr-%s' % pull_request.pull_request_id
1159 1159 return workspace_id
1160 1160
1161 1161 def merge_status_message(self, status_code):
1162 1162 """
1163 1163 Return a human friendly error message for the given merge status code.
1164 1164 """
1165 1165 return self.MERGE_STATUS_MESSAGES[status_code]
1166 1166
1167 1167 def generate_repo_data(self, repo, commit_id=None, branch=None,
1168 1168 bookmark=None):
1169 1169 all_refs, selected_ref = \
1170 1170 self._get_repo_pullrequest_sources(
1171 1171 repo.scm_instance(), commit_id=commit_id,
1172 1172 branch=branch, bookmark=bookmark)
1173 1173
1174 1174 refs_select2 = []
1175 1175 for element in all_refs:
1176 1176 children = [{'id': x[0], 'text': x[1]} for x in element[0]]
1177 1177 refs_select2.append({'text': element[1], 'children': children})
1178 1178
1179 1179 return {
1180 1180 'user': {
1181 1181 'user_id': repo.user.user_id,
1182 1182 'username': repo.user.username,
1183 1183 'firstname': repo.user.firstname,
1184 1184 'lastname': repo.user.lastname,
1185 1185 'gravatar_link': h.gravatar_url(repo.user.email, 14),
1186 1186 },
1187 1187 'description': h.chop_at_smart(repo.description, '\n'),
1188 1188 'refs': {
1189 1189 'all_refs': all_refs,
1190 1190 'selected_ref': selected_ref,
1191 1191 'select2_refs': refs_select2
1192 1192 }
1193 1193 }
1194 1194
1195 1195 def generate_pullrequest_title(self, source, source_ref, target):
1196 1196 return u'{source}#{at_ref} to {target}'.format(
1197 1197 source=source,
1198 1198 at_ref=source_ref,
1199 1199 target=target,
1200 1200 )
1201 1201
1202 1202 def _cleanup_merge_workspace(self, pull_request):
1203 1203 # Merging related cleanup
1204 1204 target_scm = pull_request.target_repo.scm_instance()
1205 1205 workspace_id = 'pr-%s' % pull_request.pull_request_id
1206 1206
1207 1207 try:
1208 1208 target_scm.cleanup_merge_workspace(workspace_id)
1209 1209 except NotImplementedError:
1210 1210 pass
1211 1211
1212 1212 def _get_repo_pullrequest_sources(
1213 1213 self, repo, commit_id=None, branch=None, bookmark=None):
1214 1214 """
1215 1215 Return a structure with repo's interesting commits, suitable for
1216 1216 the selectors in pullrequest controller
1217 1217
1218 1218 :param commit_id: a commit that must be in the list somehow
1219 1219 and selected by default
1220 1220 :param branch: a branch that must be in the list and selected
1221 1221 by default - even if closed
1222 1222 :param bookmark: a bookmark that must be in the list and selected
1223 1223 """
1224 1224
1225 1225 commit_id = safe_str(commit_id) if commit_id else None
1226 1226 branch = safe_str(branch) if branch else None
1227 1227 bookmark = safe_str(bookmark) if bookmark else None
1228 1228
1229 1229 selected = None
1230 1230
1231 1231 # order matters: first source that has commit_id in it will be selected
1232 1232 sources = []
1233 1233 sources.append(('book', repo.bookmarks.items(), _('Bookmarks'), bookmark))
1234 1234 sources.append(('branch', repo.branches.items(), _('Branches'), branch))
1235 1235
1236 1236 if commit_id:
1237 1237 ref_commit = (h.short_id(commit_id), commit_id)
1238 1238 sources.append(('rev', [ref_commit], _('Commit IDs'), commit_id))
1239 1239
1240 1240 sources.append(
1241 1241 ('branch', repo.branches_closed.items(), _('Closed Branches'), branch),
1242 1242 )
1243 1243
1244 1244 groups = []
1245 1245 for group_key, ref_list, group_name, match in sources:
1246 1246 group_refs = []
1247 1247 for ref_name, ref_id in ref_list:
1248 1248 ref_key = '%s:%s:%s' % (group_key, ref_name, ref_id)
1249 1249 group_refs.append((ref_key, ref_name))
1250 1250
1251 1251 if not selected:
1252 1252 if set([commit_id, match]) & set([ref_id, ref_name]):
1253 1253 selected = ref_key
1254 1254
1255 1255 if group_refs:
1256 1256 groups.append((group_refs, group_name))
1257 1257
1258 1258 if not selected:
1259 1259 ref = commit_id or branch or bookmark
1260 1260 if ref:
1261 1261 raise CommitDoesNotExistError(
1262 1262 'No commit refs could be found matching: %s' % ref)
1263 1263 elif repo.DEFAULT_BRANCH_NAME in repo.branches:
1264 1264 selected = 'branch:%s:%s' % (
1265 1265 repo.DEFAULT_BRANCH_NAME,
1266 1266 repo.branches[repo.DEFAULT_BRANCH_NAME]
1267 1267 )
1268 1268 elif repo.commit_ids:
1269 1269 rev = repo.commit_ids[0]
1270 1270 selected = 'rev:%s:%s' % (rev, rev)
1271 1271 else:
1272 1272 raise EmptyRepositoryError()
1273 1273 return groups, selected
1274 1274
1275 1275 def get_diff(self, source_repo, source_ref_id, target_ref_id, context=DIFF_CONTEXT):
1276 1276 return self._get_diff_from_pr_or_version(
1277 1277 source_repo, source_ref_id, target_ref_id, context=context)
1278 1278
1279 1279 def _get_diff_from_pr_or_version(
1280 1280 self, source_repo, source_ref_id, target_ref_id, context):
1281 1281 target_commit = source_repo.get_commit(
1282 1282 commit_id=safe_str(target_ref_id))
1283 1283 source_commit = source_repo.get_commit(
1284 1284 commit_id=safe_str(source_ref_id))
1285 1285 if isinstance(source_repo, Repository):
1286 1286 vcs_repo = source_repo.scm_instance()
1287 1287 else:
1288 1288 vcs_repo = source_repo
1289 1289
1290 1290 # TODO: johbo: In the context of an update, we cannot reach
1291 1291 # the old commit anymore with our normal mechanisms. It needs
1292 1292 # some sort of special support in the vcs layer to avoid this
1293 1293 # workaround.
1294 1294 if (source_commit.raw_id == vcs_repo.EMPTY_COMMIT_ID and
1295 1295 vcs_repo.alias == 'git'):
1296 1296 source_commit.raw_id = safe_str(source_ref_id)
1297 1297
1298 1298 log.debug('calculating diff between '
1299 1299 'source_ref:%s and target_ref:%s for repo `%s`',
1300 1300 target_ref_id, source_ref_id,
1301 1301 safe_unicode(vcs_repo.path))
1302 1302
1303 1303 vcs_diff = vcs_repo.get_diff(
1304 1304 commit1=target_commit, commit2=source_commit, context=context)
1305 1305 return vcs_diff
1306 1306
1307 1307 def _is_merge_enabled(self, pull_request):
1308 1308 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
1309 1309 settings = settings_model.get_general_settings()
1310 1310 return settings.get('rhodecode_pr_merge_enabled', False)
1311 1311
1312 1312 def _use_rebase_for_merging(self, pull_request):
1313 1313 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
1314 1314 settings = settings_model.get_general_settings()
1315 1315 return settings.get('rhodecode_hg_use_rebase_for_merging', False)
1316 1316
1317 1317 def _log_action(self, action, user, pull_request):
1318 1318 action_logger(
1319 1319 user,
1320 1320 '{action}:{pr_id}'.format(
1321 1321 action=action, pr_id=pull_request.pull_request_id),
1322 1322 pull_request.target_repo)
1323 1323
1324 1324
1325 1325 class MergeCheck(object):
1326 1326 """
1327 1327 Perform Merge Checks and returns a check object which stores information
1328 1328 about merge errors, and merge conditions
1329 1329 """
1330 1330 TODO_CHECK = 'todo'
1331 1331 PERM_CHECK = 'perm'
1332 1332 REVIEW_CHECK = 'review'
1333 1333 MERGE_CHECK = 'merge'
1334 1334
1335 1335 def __init__(self):
1336 self.review_status = None
1336 1337 self.merge_possible = None
1337 1338 self.merge_msg = ''
1338 1339 self.failed = None
1339 1340 self.errors = []
1340 1341 self.error_details = OrderedDict()
1341 1342
1342 1343 def push_error(self, error_type, message, error_key, details):
1343 1344 self.failed = True
1344 1345 self.errors.append([error_type, message])
1345 1346 self.error_details[error_key] = dict(
1346 1347 details=details,
1347 1348 error_type=error_type,
1348 1349 message=message
1349 1350 )
1350 1351
1351 1352 @classmethod
1352 1353 def validate(cls, pull_request, user, fail_early=False, translator=None):
1353 1354 # if migrated to pyramid...
1354 1355 # _ = lambda: translator or _ # use passed in translator if any
1355 1356
1356 1357 merge_check = cls()
1357 1358
1358 # permissions
1359 # permissions to merge
1359 1360 user_allowed_to_merge = PullRequestModel().check_user_merge(
1360 1361 pull_request, user)
1361 1362 if not user_allowed_to_merge:
1362 1363 log.debug("MergeCheck: cannot merge, approval is pending.")
1363 1364
1364 1365 msg = _('User `{}` not allowed to perform merge.').format(user.username)
1365 1366 merge_check.push_error('error', msg, cls.PERM_CHECK, user.username)
1366 1367 if fail_early:
1367 1368 return merge_check
1368 1369
1369 # review status
1370 # review status, must be always present
1370 1371 review_status = pull_request.calculated_review_status()
1372 merge_check.review_status = review_status
1373
1371 1374 status_approved = review_status == ChangesetStatus.STATUS_APPROVED
1372 1375 if not status_approved:
1373 1376 log.debug("MergeCheck: cannot merge, approval is pending.")
1374 1377
1375 1378 msg = _('Pull request reviewer approval is pending.')
1376 1379
1377 1380 merge_check.push_error(
1378 1381 'warning', msg, cls.REVIEW_CHECK, review_status)
1379 1382
1380 1383 if fail_early:
1381 1384 return merge_check
1382 1385
1383 1386 # left over TODOs
1384 1387 todos = CommentsModel().get_unresolved_todos(pull_request)
1385 1388 if todos:
1386 1389 log.debug("MergeCheck: cannot merge, {} "
1387 1390 "unresolved todos left.".format(len(todos)))
1388 1391
1389 1392 if len(todos) == 1:
1390 1393 msg = _('Cannot merge, {} TODO still not resolved.').format(
1391 1394 len(todos))
1392 1395 else:
1393 1396 msg = _('Cannot merge, {} TODOs still not resolved.').format(
1394 1397 len(todos))
1395 1398
1396 1399 merge_check.push_error('warning', msg, cls.TODO_CHECK, todos)
1397 1400
1398 1401 if fail_early:
1399 1402 return merge_check
1400 1403
1401 1404 # merge possible
1402 1405 merge_status, msg = PullRequestModel().merge_status(pull_request)
1403 1406 merge_check.merge_possible = merge_status
1404 1407 merge_check.merge_msg = msg
1405 1408 if not merge_status:
1406 1409 log.debug(
1407 1410 "MergeCheck: cannot merge, pull request merge not possible.")
1408 1411 merge_check.push_error('warning', msg, cls.MERGE_CHECK, None)
1409 1412
1410 1413 if fail_early:
1411 1414 return merge_check
1412 1415
1413 1416 return merge_check
1414 1417
1415 1418
1416 1419 ChangeTuple = namedtuple('ChangeTuple',
1417 1420 ['added', 'common', 'removed', 'total'])
1418 1421
1419 1422 FileChangeTuple = namedtuple('FileChangeTuple',
1420 1423 ['added', 'modified', 'removed'])
@@ -1,395 +1,408 b''
1 1
2 2
3 3 //BUTTONS
4 4 button,
5 5 .btn,
6 6 input[type="button"] {
7 7 -webkit-appearance: none;
8 8 display: inline-block;
9 9 margin: 0 @padding/3 0 0;
10 10 padding: @button-padding;
11 11 text-align: center;
12 12 font-size: @basefontsize;
13 13 line-height: 1em;
14 14 font-family: @text-light;
15 15 text-decoration: none;
16 16 text-shadow: none;
17 17 color: @grey4;
18 18 background-color: white;
19 19 background-image: none;
20 20 border: none;
21 21 .border ( @border-thickness-buttons, @grey4 );
22 22 .border-radius (@border-radius);
23 23 cursor: pointer;
24 24 white-space: nowrap;
25 25 -webkit-transition: background .3s,color .3s;
26 26 -moz-transition: background .3s,color .3s;
27 27 -o-transition: background .3s,color .3s;
28 28 transition: background .3s,color .3s;
29 29
30 30 a {
31 31 display: block;
32 32 margin: 0;
33 33 padding: 0;
34 34 color: inherit;
35 35 text-decoration: none;
36 36
37 37 &:hover {
38 38 text-decoration: none;
39 39 }
40 40 }
41 41
42 42 &:focus,
43 43 &:active {
44 44 outline:none;
45 45 }
46 46 &:hover {
47 47 color: white;
48 48 background-color: @grey4;
49 49 }
50 50
51 51 .icon-remove-sign {
52 52 display: none;
53 53 }
54 54
55 55 //disabled buttons
56 56 //last; overrides any other styles
57 57 &:disabled {
58 58 opacity: .7;
59 59 cursor: auto;
60 60 background-color: white;
61 61 color: @grey4;
62 62 text-shadow: none;
63 63 }
64 64
65 65 }
66 66
67 67
68 68 .btn-default {
69 69 .border ( @border-thickness-buttons, @rcblue );
70 70 background-image: none;
71 71 color: @rcblue;
72 72
73 73 a {
74 74 color: @rcblue;
75 75 }
76 76
77 77 &:hover,
78 78 &.active {
79 79 color: white;
80 80 background-color: @rcdarkblue;
81 81 .border ( @border-thickness, @rcdarkblue );
82 82
83 83 a {
84 84 color: white;
85 85 }
86 86 }
87 87 &:disabled {
88 88 .border ( @border-thickness-buttons, @grey4 );
89 89 background-color: transparent;
90 90 }
91 91 }
92 92
93 93 .btn-primary,
94 94 .btn-small, /* TODO: anderson: remove .btn-small to not mix with the new btn-sm */
95 95 .btn-success {
96 96 .border ( @border-thickness, @rcblue );
97 97 background-color: @rcblue;
98 98 color: white;
99 99
100 100 a {
101 101 color: white;
102 102 }
103 103
104 104 &:hover,
105 105 &.active {
106 106 .border ( @border-thickness, @rcdarkblue );
107 107 color: white;
108 108 background-color: @rcdarkblue;
109 109
110 110 a {
111 111 color: white;
112 112 }
113 113 }
114 114 &:disabled {
115 115 background-color: @rcblue;
116 116 }
117 117 }
118 118
119 119 .btn-secondary {
120 120 &:extend(.btn-default);
121 121
122 122 background-color: white;
123 123
124 124 &:focus {
125 125 outline: 0;
126 126 }
127 127
128 128 &:hover {
129 129 &:extend(.btn-default:hover);
130 130 }
131 131
132 132 &.btn-link {
133 133 &:extend(.btn-link);
134 134 color: @rcblue;
135 135 }
136 136
137 137 &:disabled {
138 138 color: @rcblue;
139 139 background-color: white;
140 140 }
141 141 }
142 142
143 143 .btn-warning,
144 144 .btn-danger,
145 145 .revoke_perm,
146 146 .btn-x,
147 147 .form .action_button.btn-x {
148 148 .border ( @border-thickness, @alert2 );
149 149 background-color: white;
150 150 color: @alert2;
151 151
152 152 a {
153 153 color: @alert2;
154 154 }
155 155
156 156 &:hover,
157 157 &.active {
158 158 .border ( @border-thickness, @alert2 );
159 159 color: white;
160 160 background-color: @alert2;
161 161
162 162 a {
163 163 color: white;
164 164 }
165 165 }
166 166
167 167 i {
168 168 display:none;
169 169 }
170 170
171 171 &:disabled {
172 172 background-color: white;
173 173 color: @alert2;
174 174 }
175 175 }
176 176
177 .btn-approved-status {
178 .border ( @border-thickness, @alert1 );
179 background-color: white;
180 color: @alert1;
181
182 }
183
184 .btn-rejected-status {
185 .border ( @border-thickness, @alert2 );
186 background-color: white;
187 color: @alert2;
188 }
189
177 190 .btn-sm,
178 191 .btn-mini,
179 192 .field-sm .btn {
180 193 padding: @padding/3;
181 194 }
182 195
183 196 .btn-xs {
184 197 padding: @padding/4;
185 198 }
186 199
187 200 .btn-lg {
188 201 padding: @padding * 1.2;
189 202 }
190 203
191 204 .btn-group {
192 205 display: inline-block;
193 206 .btn {
194 207 float: left;
195 208 margin: 0 0 0 -1px;
196 209 }
197 210 }
198 211
199 212 .btn-link {
200 213 background: transparent;
201 214 border: none;
202 215 padding: 0;
203 216 color: @rcblue;
204 217
205 218 &:hover {
206 219 background: transparent;
207 220 border: none;
208 221 color: @rcdarkblue;
209 222 }
210 223
211 224 //disabled buttons
212 225 //last; overrides any other styles
213 226 &:disabled {
214 227 opacity: .7;
215 228 cursor: auto;
216 229 background-color: white;
217 230 color: @grey4;
218 231 text-shadow: none;
219 232 }
220 233
221 234 // TODO: johbo: Check if we can avoid this, indicates that the structure
222 235 // is not yet good.
223 236 // lisa: The button CSS reflects the button HTML; both need a cleanup.
224 237 &.btn-danger {
225 238 color: @alert2;
226 239
227 240 &:hover {
228 241 color: darken(@alert2,30%);
229 242 }
230 243
231 244 &:disabled {
232 245 color: @alert2;
233 246 }
234 247 }
235 248 }
236 249
237 250 .btn-social {
238 251 &:extend(.btn-default);
239 252 margin: 5px 5px 5px 0px;
240 253 min-width: 150px;
241 254 }
242 255
243 256 // TODO: johbo: check these exceptions
244 257
245 258 .links {
246 259
247 260 .btn + .btn {
248 261 margin-top: @padding;
249 262 }
250 263 }
251 264
252 265
253 266 .action_button {
254 267 display:inline;
255 268 margin: 0;
256 269 padding: 0 1em 0 0;
257 270 font-size: inherit;
258 271 color: @rcblue;
259 272 border: none;
260 273 .border-radius (0);
261 274 background-color: transparent;
262 275
263 276 &:last-child {
264 277 border: none;
265 278 }
266 279
267 280 &:hover {
268 281 color: @rcdarkblue;
269 282 background-color: transparent;
270 283 border: none;
271 284 }
272 285 }
273 286 .grid_delete {
274 287 .action_button {
275 288 border: none;
276 289 }
277 290 }
278 291
279 292
280 293 // TODO: johbo: Form button tweaks, check if we can use the classes instead
281 294 input[type="submit"] {
282 295 &:extend(.btn-primary);
283 296
284 297 &:focus {
285 298 outline: 0;
286 299 }
287 300
288 301 &:hover {
289 302 &:extend(.btn-primary:hover);
290 303 }
291 304
292 305 &.btn-link {
293 306 &:extend(.btn-link);
294 307 color: @rcblue;
295 308
296 309 &:disabled {
297 310 color: @rcblue;
298 311 background-color: transparent;
299 312 }
300 313 }
301 314
302 315 &:disabled {
303 316 .border ( @border-thickness-buttons, @rcblue );
304 317 background-color: @rcblue;
305 318 color: white;
306 319 }
307 320 }
308 321
309 322 input[type="reset"] {
310 323 &:extend(.btn-default);
311 324
312 325 // TODO: johbo: Check if this tweak can be avoided.
313 326 background: transparent;
314 327
315 328 &:focus {
316 329 outline: 0;
317 330 }
318 331
319 332 &:hover {
320 333 &:extend(.btn-default:hover);
321 334 }
322 335
323 336 &.btn-link {
324 337 &:extend(.btn-link);
325 338 color: @rcblue;
326 339
327 340 &:disabled {
328 341 border: none;
329 342 }
330 343 }
331 344
332 345 &:disabled {
333 346 .border ( @border-thickness-buttons, @rcblue );
334 347 background-color: white;
335 348 color: @rcblue;
336 349 }
337 350 }
338 351
339 352 input[type="submit"],
340 353 input[type="reset"] {
341 354 &.btn-danger {
342 355 &:extend(.btn-danger);
343 356
344 357 &:focus {
345 358 outline: 0;
346 359 }
347 360
348 361 &:hover {
349 362 &:extend(.btn-danger:hover);
350 363 }
351 364
352 365 &.btn-link {
353 366 &:extend(.btn-link);
354 367 color: @alert2;
355 368
356 369 &:hover {
357 370 color: darken(@alert2,30%);
358 371 }
359 372 }
360 373
361 374 &:disabled {
362 375 color: @alert2;
363 376 background-color: white;
364 377 }
365 378 }
366 379 &.btn-danger-action {
367 380 .border ( @border-thickness, @alert2 );
368 381 background-color: @alert2;
369 382 color: white;
370 383
371 384 a {
372 385 color: white;
373 386 }
374 387
375 388 &:hover {
376 389 background-color: darken(@alert2,20%);
377 390 }
378 391
379 392 &.active {
380 393 .border ( @border-thickness, @alert2 );
381 394 color: white;
382 395 background-color: @alert2;
383 396
384 397 a {
385 398 color: white;
386 399 }
387 400 }
388 401
389 402 &:disabled {
390 403 background-color: white;
391 404 color: @alert2;
392 405 }
393 406 }
394 407 }
395 408
@@ -1,571 +1,575 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 7 @comment-outdated-opacity: 0.6;
8 8
9 9 .comments {
10 10 width: 100%;
11 11 }
12 12
13 13 tr.inline-comments div {
14 14 max-width: 100%;
15 15
16 16 p {
17 17 white-space: normal;
18 18 }
19 19
20 20 code, pre, .code, dd {
21 21 overflow-x: auto;
22 22 width: 1062px;
23 23 }
24 24
25 25 dd {
26 26 width: auto;
27 27 }
28 28 }
29 29
30 30 #injected_page_comments {
31 31 .comment-previous-link,
32 32 .comment-next-link,
33 33 .comment-links-divider {
34 34 display: none;
35 35 }
36 36 }
37 37
38 38 .add-comment {
39 39 margin-bottom: 10px;
40 40 }
41 41 .hide-comment-button .add-comment {
42 42 display: none;
43 43 }
44 44
45 45 .comment-bubble {
46 46 color: @grey4;
47 47 margin-top: 4px;
48 48 margin-right: 30px;
49 49 visibility: hidden;
50 50 }
51 51
52 52 .comment-label {
53 53 float: left;
54 54
55 55 padding: 0.4em 0.4em;
56 56 margin: 3px 5px 0px -10px;
57 57 display: inline-block;
58 58 min-height: 0;
59 59
60 60 text-align: center;
61 61 font-size: 10px;
62 62 line-height: .8em;
63 63
64 64 font-family: @text-italic;
65 65 background: #fff none;
66 66 color: @grey4;
67 67 border: 1px solid @grey4;
68 68 white-space: nowrap;
69 69
70 70 text-transform: uppercase;
71 71 min-width: 40px;
72 72
73 73 &.todo {
74 74 color: @color5;
75 75 font-family: @text-bold-italic;
76 76 }
77 77
78 78 .resolve {
79 79 cursor: pointer;
80 80 text-decoration: underline;
81 81 }
82 82
83 83 .resolved {
84 84 text-decoration: line-through;
85 85 color: @color1;
86 86 }
87 87 .resolved a {
88 88 text-decoration: line-through;
89 89 color: @color1;
90 90 }
91 91 .resolve-text {
92 92 color: @color1;
93 93 margin: 2px 8px;
94 94 font-family: @text-italic;
95 95 }
96 96 }
97 97
98 98 .has-spacer-after {
99 99 &:after {
100 100 content: ' | ';
101 101 color: @grey5;
102 102 }
103 103 }
104 104
105 105 .has-spacer-before {
106 106 &:before {
107 107 content: ' | ';
108 108 color: @grey5;
109 109 }
110 110 }
111 111
112 112 .comment {
113 113
114 114 &.comment-general {
115 115 border: 1px solid @grey5;
116 116 padding: 5px 5px 5px 5px;
117 117 }
118 118
119 119 margin: @padding 0;
120 120 padding: 4px 0 0 0;
121 121 line-height: 1em;
122 122
123 123 .rc-user {
124 124 min-width: 0;
125 125 margin: 0px .5em 0 0;
126 126
127 127 .user {
128 128 display: inline;
129 129 }
130 130 }
131 131
132 132 .meta {
133 133 position: relative;
134 134 width: 100%;
135 135 border-bottom: 1px solid @grey5;
136 136 margin: -5px 0px;
137 137 line-height: 24px;
138 138
139 139 &:hover .permalink {
140 140 visibility: visible;
141 141 color: @rcblue;
142 142 }
143 143 }
144 144
145 145 .author,
146 146 .date {
147 147 display: inline;
148 148
149 149 &:after {
150 150 content: ' | ';
151 151 color: @grey5;
152 152 }
153 153 }
154 154
155 155 .author-general img {
156 156 top: 3px;
157 157 }
158 158 .author-inline img {
159 159 top: 3px;
160 160 }
161 161
162 162 .status-change,
163 163 .permalink,
164 164 .changeset-status-lbl {
165 165 display: inline;
166 166 }
167 167
168 168 .permalink {
169 169 visibility: hidden;
170 170 }
171 171
172 172 .comment-links-divider {
173 173 display: inline;
174 174 }
175 175
176 176 .comment-links-block {
177 177 float:right;
178 178 text-align: right;
179 179 min-width: 85px;
180 180
181 181 [class^="icon-"]:before,
182 182 [class*=" icon-"]:before {
183 183 margin-left: 0;
184 184 margin-right: 0;
185 185 }
186 186 }
187 187
188 188 .comment-previous-link {
189 189 display: inline-block;
190 190
191 191 .arrow_comment_link{
192 192 cursor: pointer;
193 193 i {
194 194 font-size:10px;
195 195 }
196 196 }
197 197 .arrow_comment_link.disabled {
198 198 cursor: default;
199 199 color: @grey5;
200 200 }
201 201 }
202 202
203 203 .comment-next-link {
204 204 display: inline-block;
205 205
206 206 .arrow_comment_link{
207 207 cursor: pointer;
208 208 i {
209 209 font-size:10px;
210 210 }
211 211 }
212 212 .arrow_comment_link.disabled {
213 213 cursor: default;
214 214 color: @grey5;
215 215 }
216 216 }
217 217
218 218 .flag_status {
219 219 display: inline-block;
220 220 margin: -2px .5em 0 .25em
221 221 }
222 222
223 223 .delete-comment {
224 224 display: inline-block;
225 225 color: @rcblue;
226 226
227 227 &:hover {
228 228 cursor: pointer;
229 229 }
230 230 }
231 231
232 232 .text {
233 233 clear: both;
234 234 .border-radius(@border-radius);
235 235 .box-sizing(border-box);
236 236
237 237 .markdown-block p,
238 238 .rst-block p {
239 239 margin: .5em 0 !important;
240 240 // TODO: lisa: This is needed because of other rst !important rules :[
241 241 }
242 242 }
243 243
244 244 .pr-version {
245 245 float: left;
246 246 margin: 0px 4px;
247 247 }
248 248 .pr-version-inline {
249 249 float: left;
250 250 margin: 0px 4px;
251 251 }
252 252 .pr-version-num {
253 253 font-size: 10px;
254 254 }
255 255 }
256 256
257 257 @comment-padding: 5px;
258 258
259 259 .general-comments {
260 260 .comment-outdated {
261 261 opacity: @comment-outdated-opacity;
262 262 }
263 263 }
264 264
265 265 .inline-comments {
266 266 border-radius: @border-radius;
267 267 .comment {
268 268 margin: 0;
269 269 border-radius: @border-radius;
270 270 }
271 271 .comment-outdated {
272 272 opacity: @comment-outdated-opacity;
273 273 }
274 274
275 275 .comment-inline {
276 276 background: white;
277 277 padding: @comment-padding @comment-padding;
278 278 border: @comment-padding solid @grey6;
279 279
280 280 .text {
281 281 border: none;
282 282 }
283 283 .meta {
284 284 border-bottom: 1px solid @grey6;
285 285 margin: -5px 0px;
286 286 line-height: 24px;
287 287 }
288 288 }
289 289 .comment-selected {
290 290 border-left: 6px solid @comment-highlight-color;
291 291 }
292 292 .comment-inline-form {
293 293 padding: @comment-padding;
294 294 display: none;
295 295 }
296 296 .cb-comment-add-button {
297 297 margin: @comment-padding;
298 298 }
299 299 /* hide add comment button when form is open */
300 300 .comment-inline-form-open ~ .cb-comment-add-button {
301 301 display: none;
302 302 }
303 303 .comment-inline-form-open {
304 304 display: block;
305 305 }
306 306 /* hide add comment button when form but no comments */
307 307 .comment-inline-form:first-child + .cb-comment-add-button {
308 308 display: none;
309 309 }
310 310 /* hide add comment button when no comments or form */
311 311 .cb-comment-add-button:first-child {
312 312 display: none;
313 313 }
314 314 /* hide add comment button when only comment is being deleted */
315 315 .comment-deleting:first-child + .cb-comment-add-button {
316 316 display: none;
317 317 }
318 318 }
319 319
320 320
321 321 .show-outdated-comments {
322 322 display: inline;
323 323 color: @rcblue;
324 324 }
325 325
326 326 // Comment Form
327 327 div.comment-form {
328 328 margin-top: 20px;
329 329 }
330 330
331 331 .comment-form strong {
332 332 display: block;
333 333 margin-bottom: 15px;
334 334 }
335 335
336 336 .comment-form textarea {
337 337 width: 100%;
338 338 height: 100px;
339 339 font-family: 'Monaco', 'Courier', 'Courier New', monospace;
340 340 }
341 341
342 342 form.comment-form {
343 343 margin-top: 10px;
344 344 margin-left: 10px;
345 345 }
346 346
347 347 .comment-inline-form .comment-block-ta,
348 348 .comment-form .comment-block-ta,
349 349 .comment-form .preview-box {
350 350 .border-radius(@border-radius);
351 351 .box-sizing(border-box);
352 352 background-color: white;
353 353 }
354 354
355 355 .comment-form-submit {
356 356 margin-top: 5px;
357 357 margin-left: 525px;
358 358 }
359 359
360 360 .file-comments {
361 361 display: none;
362 362 }
363 363
364 364 .comment-form .preview-box.unloaded,
365 365 .comment-inline-form .preview-box.unloaded {
366 366 height: 50px;
367 367 text-align: center;
368 368 padding: 20px;
369 369 background-color: white;
370 370 }
371 371
372 372 .comment-footer {
373 373 position: relative;
374 374 width: 100%;
375 375 min-height: 42px;
376 376
377 377 .status_box,
378 378 .cancel-button {
379 379 float: left;
380 380 display: inline-block;
381 381 }
382 382
383 383 .action-buttons {
384 384 float: right;
385 385 display: inline-block;
386 386 }
387
388 .action-buttons-extra {
389 display: inline-block;
390 }
387 391 }
388 392
389 393 .comment-form {
390 394
391 395 .comment {
392 396 margin-left: 10px;
393 397 }
394 398
395 399 .comment-help {
396 400 color: @grey4;
397 401 padding: 5px 0 5px 0;
398 402 }
399 403
400 404 .comment-title {
401 405 padding: 5px 0 5px 0;
402 406 }
403 407
404 408 .comment-button {
405 409 display: inline-block;
406 410 }
407 411
408 412 .comment-button-input {
409 413 margin-right: 0;
410 414 }
411 415
412 416 .comment-footer {
413 417 margin-bottom: 110px;
414 418 margin-top: 10px;
415 419 }
416 420 }
417 421
418 422
419 423 .comment-form-login {
420 424 .comment-help {
421 425 padding: 0.9em; //same as the button
422 426 }
423 427
424 428 div.clearfix {
425 429 clear: both;
426 430 width: 100%;
427 431 display: block;
428 432 }
429 433 }
430 434
431 435 .comment-type {
432 436 margin: 0px;
433 437 border-radius: inherit;
434 438 border-color: @grey6;
435 439 }
436 440
437 441 .preview-box {
438 442 min-height: 105px;
439 443 margin-bottom: 15px;
440 444 background-color: white;
441 445 .border-radius(@border-radius);
442 446 .box-sizing(border-box);
443 447 }
444 448
445 449 .add-another-button {
446 450 margin-left: 10px;
447 451 margin-top: 10px;
448 452 margin-bottom: 10px;
449 453 }
450 454
451 455 .comment .buttons {
452 456 float: right;
453 457 margin: -1px 0px 0px 0px;
454 458 }
455 459
456 460 // Inline Comment Form
457 461 .injected_diff .comment-inline-form,
458 462 .comment-inline-form {
459 463 background-color: white;
460 464 margin-top: 10px;
461 465 margin-bottom: 20px;
462 466 }
463 467
464 468 .inline-form {
465 469 padding: 10px 7px;
466 470 }
467 471
468 472 .inline-form div {
469 473 max-width: 100%;
470 474 }
471 475
472 476 .overlay {
473 477 display: none;
474 478 position: absolute;
475 479 width: 100%;
476 480 text-align: center;
477 481 vertical-align: middle;
478 482 font-size: 16px;
479 483 background: none repeat scroll 0 0 white;
480 484
481 485 &.submitting {
482 486 display: block;
483 487 opacity: 0.5;
484 488 z-index: 100;
485 489 }
486 490 }
487 491 .comment-inline-form .overlay.submitting .overlay-text {
488 492 margin-top: 5%;
489 493 }
490 494
491 495 .comment-inline-form .clearfix,
492 496 .comment-form .clearfix {
493 497 .border-radius(@border-radius);
494 498 margin: 0px;
495 499 }
496 500
497 501 .comment-inline-form .comment-footer {
498 502 margin: 10px 0px 0px 0px;
499 503 }
500 504
501 505 .hide-inline-form-button {
502 506 margin-left: 5px;
503 507 }
504 508 .comment-button .hide-inline-form {
505 509 background: white;
506 510 }
507 511
508 512 .comment-area {
509 513 padding: 8px 12px;
510 514 border: 1px solid @grey5;
511 515 .border-radius(@border-radius);
512 516
513 517 .resolve-action {
514 518 padding: 1px 0px 0px 6px;
515 519 }
516 520
517 521 }
518 522
519 523 .comment-area-header .nav-links {
520 524 display: flex;
521 525 flex-flow: row wrap;
522 526 -webkit-flex-flow: row wrap;
523 527 width: 100%;
524 528 }
525 529
526 530 .comment-area-footer {
527 531 display: flex;
528 532 }
529 533
530 534 .comment-footer .toolbar {
531 535
532 536 }
533 537
534 538 .nav-links {
535 539 padding: 0;
536 540 margin: 0;
537 541 list-style: none;
538 542 height: auto;
539 543 border-bottom: 1px solid @grey5;
540 544 }
541 545 .nav-links li {
542 546 display: inline-block;
543 547 }
544 548 .nav-links li:before {
545 549 content: "";
546 550 }
547 551 .nav-links li a.disabled {
548 552 cursor: not-allowed;
549 553 }
550 554
551 555 .nav-links li.active a {
552 556 border-bottom: 2px solid @rcblue;
553 557 color: #000;
554 558 font-weight: 600;
555 559 }
556 560 .nav-links li a {
557 561 display: inline-block;
558 562 padding: 0px 10px 5px 10px;
559 563 margin-bottom: -1px;
560 564 font-size: 14px;
561 565 line-height: 28px;
562 566 color: #8f8f8f;
563 567 border-bottom: 2px solid transparent;
564 568 }
565 569
566 570 .toolbar-text {
567 571 float: left;
568 572 margin: -5px 0px 0px 0px;
569 573 font-size: 12px;
570 574 }
571 575
@@ -1,813 +1,830 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 this.closesPr = '#close_pull_request';
96
95 97 this.cmBox = this.withLineNo('#text');
96 98 this.cm = initCommentBoxCodeMirror(this, this.cmBox, this.initAutocompleteActions);
97 99
98 100 this.statusChange = this.withLineNo('#change_status');
99 101
100 102 this.submitForm = formElement;
101 103 this.submitButton = $(this.submitForm).find('input[type="submit"]');
102 104 this.submitButtonText = this.submitButton.val();
103 105
104 106 this.previewUrl = pyroutes.url('changeset_comment_preview',
105 107 {'repo_name': templateContext.repo_name});
106 108
107 109 if (resolvesCommentId){
108 110 this.resolvesId = '#resolve_comment_{0}'.format(resolvesCommentId);
109 111 this.resolvesActionId = '#resolve_comment_action_{0}'.format(resolvesCommentId);
110 112 $(this.commentType).prop('disabled', true);
111 113 $(this.commentType).addClass('disabled');
112 114
113 115 // disable select
114 116 setTimeout(function() {
115 117 $(self.statusChange).select2('readonly', true);
116 118 }, 10);
117 119
118 120 var resolvedInfo = (
119 121 '<li class="resolve-action">' +
120 122 '<input type="hidden" id="resolve_comment_{0}" name="resolve_comment_{0}" value="{0}">' +
121 123 '<button id="resolve_comment_action_{0}" class="resolve-text btn btn-sm" onclick="return Rhodecode.comments.submitResolution({0})">{1} #{0}</button>' +
122 124 '</li>'
123 125 ).format(resolvesCommentId, _gettext('resolve comment'));
124 126 $(resolvedInfo).insertAfter($(this.commentType).parent());
125 127 }
126 128
127 129 // based on commitId, or pullRequestId decide where do we submit
128 130 // out data
129 131 if (this.commitId){
130 132 this.submitUrl = pyroutes.url('changeset_comment',
131 133 {'repo_name': templateContext.repo_name,
132 134 'revision': this.commitId});
133 135 this.selfUrl = pyroutes.url('changeset_home',
134 136 {'repo_name': templateContext.repo_name,
135 137 'revision': this.commitId});
136 138
137 139 } else if (this.pullRequestId) {
138 140 this.submitUrl = pyroutes.url('pullrequest_comment',
139 141 {'repo_name': templateContext.repo_name,
140 142 'pull_request_id': this.pullRequestId});
141 143 this.selfUrl = pyroutes.url('pullrequest_show',
142 144 {'repo_name': templateContext.repo_name,
143 145 'pull_request_id': this.pullRequestId});
144 146
145 147 } else {
146 148 throw new Error(
147 149 'CommentForm requires pullRequestId, or commitId to be specified.')
148 150 }
149 151
150 152 // FUNCTIONS and helpers
151 153 var self = this;
152 154
153 155 this.isInline = function(){
154 156 return this.lineNo && this.lineNo != 'general';
155 157 };
156 158
157 159 this.getCmInstance = function(){
158 160 return this.cm
159 161 };
160 162
161 163 this.setPlaceholder = function(placeholder) {
162 164 var cm = this.getCmInstance();
163 165 if (cm){
164 166 cm.setOption('placeholder', placeholder);
165 167 }
166 168 };
167 169
168 170 this.getCommentStatus = function() {
169 171 return $(this.submitForm).find(this.statusChange).val();
170 172 };
171 173 this.getCommentType = function() {
172 174 return $(this.submitForm).find(this.commentType).val();
173 175 };
174 176
175 177 this.getResolvesId = function() {
176 178 return $(this.submitForm).find(this.resolvesId).val() || null;
177 179 };
180
181 this.getClosePr = function() {
182 return $(this.submitForm).find(this.closesPr).val() || null;
183 };
184
178 185 this.markCommentResolved = function(resolvedCommentId){
179 186 $('#comment-label-{0}'.format(resolvedCommentId)).find('.resolved').show();
180 187 $('#comment-label-{0}'.format(resolvedCommentId)).find('.resolve').hide();
181 188 };
182 189
183 190 this.isAllowedToSubmit = function() {
184 191 return !$(this.submitButton).prop('disabled');
185 192 };
186 193
187 194 this.initStatusChangeSelector = function(){
188 195 var formatChangeStatus = function(state, escapeMarkup) {
189 196 var originalOption = state.element;
190 197 return '<div class="flag_status ' + $(originalOption).data('status') + ' pull-left"></div>' +
191 198 '<span>' + escapeMarkup(state.text) + '</span>';
192 199 };
193 200 var formatResult = function(result, container, query, escapeMarkup) {
194 201 return formatChangeStatus(result, escapeMarkup);
195 202 };
196 203
197 204 var formatSelection = function(data, container, escapeMarkup) {
198 205 return formatChangeStatus(data, escapeMarkup);
199 206 };
200 207
201 208 $(this.submitForm).find(this.statusChange).select2({
202 209 placeholder: _gettext('Status Review'),
203 210 formatResult: formatResult,
204 211 formatSelection: formatSelection,
205 212 containerCssClass: "drop-menu status_box_menu",
206 213 dropdownCssClass: "drop-menu-dropdown",
207 214 dropdownAutoWidth: true,
208 215 minimumResultsForSearch: -1
209 216 });
210 217 $(this.submitForm).find(this.statusChange).on('change', function() {
211 218 var status = self.getCommentStatus();
219
212 220 if (status && !self.isInline()) {
213 221 $(self.submitButton).prop('disabled', false);
214 222 }
215 223
216 224 var placeholderText = _gettext('Comment text will be set automatically based on currently selected status ({0}) ...').format(status);
217 225 self.setPlaceholder(placeholderText)
218 226 })
219 227 };
220 228
221 229 // reset the comment form into it's original state
222 230 this.resetCommentFormState = function(content) {
223 231 content = content || '';
224 232
225 233 $(this.editContainer).show();
226 234 $(this.editButton).parent().addClass('active');
227 235
228 236 $(this.previewContainer).hide();
229 237 $(this.previewButton).parent().removeClass('active');
230 238
231 239 this.setActionButtonsDisabled(true);
232 240 self.cm.setValue(content);
233 241 self.cm.setOption("readOnly", false);
234 242
235 243 if (this.resolvesId) {
236 244 // destroy the resolve action
237 245 $(this.resolvesId).parent().remove();
238 246 }
247 // reset closingPR flag
248 $('.close-pr-input').remove();
239 249
240 250 $(this.statusChange).select2('readonly', false);
241 251 };
242 252
243 253 this.globalSubmitSuccessCallback = function(){
244 254 // default behaviour is to call GLOBAL hook, if it's registered.
245 255 if (window.commentFormGlobalSubmitSuccessCallback !== undefined){
246 256 commentFormGlobalSubmitSuccessCallback()
247 257 }
248 258 };
249 259
250 260 this.submitAjaxPOST = function(url, postData, successHandler, failHandler) {
251 261 failHandler = failHandler || function() {};
252 262 var postData = toQueryString(postData);
253 263 var request = $.ajax({
254 264 url: url,
255 265 type: 'POST',
256 266 data: postData,
257 267 headers: {'X-PARTIAL-XHR': true}
258 268 })
259 269 .done(function(data) {
260 270 successHandler(data);
261 271 })
262 272 .fail(function(data, textStatus, errorThrown){
263 273 alert(
264 274 "Error while submitting comment.\n" +
265 275 "Error code {0} ({1}).".format(data.status, data.statusText));
266 276 failHandler()
267 277 });
268 278 return request;
269 279 };
270 280
271 281 // overwrite a submitHandler, we need to do it for inline comments
272 282 this.setHandleFormSubmit = function(callback) {
273 283 this.handleFormSubmit = callback;
274 284 };
275 285
276 286 // overwrite a submitSuccessHandler
277 287 this.setGlobalSubmitSuccessCallback = function(callback) {
278 288 this.globalSubmitSuccessCallback = callback;
279 289 };
280 290
281 291 // default handler for for submit for main comments
282 292 this.handleFormSubmit = function() {
283 293 var text = self.cm.getValue();
284 294 var status = self.getCommentStatus();
285 295 var commentType = self.getCommentType();
286 296 var resolvesCommentId = self.getResolvesId();
297 var closePullRequest = self.getClosePr();
287 298
288 299 if (text === "" && !status) {
289 300 return;
290 301 }
291 302
292 303 var excludeCancelBtn = false;
293 304 var submitEvent = true;
294 305 self.setActionButtonsDisabled(true, excludeCancelBtn, submitEvent);
295 306 self.cm.setOption("readOnly", true);
296 307
297 308 var postData = {
298 309 'text': text,
299 310 'changeset_status': status,
300 311 'comment_type': commentType,
301 312 'csrf_token': CSRF_TOKEN
302 313 };
303 if (resolvesCommentId){
314
315 if (resolvesCommentId) {
304 316 postData['resolves_comment_id'] = resolvesCommentId;
305 317 }
306 318
319 if (closePullRequest) {
320 postData['close_pull_request'] = true;
321 }
322
307 323 var submitSuccessCallback = function(o) {
308 324 // reload page if we change status for single commit.
309 325 if (status && self.commitId) {
310 326 location.reload(true);
311 327 } else {
312 328 $('#injected_page_comments').append(o.rendered_text);
313 329 self.resetCommentFormState();
314 330 timeagoActivate();
315 331
316 332 // mark visually which comment was resolved
317 333 if (resolvesCommentId) {
318 334 self.markCommentResolved(resolvesCommentId);
319 335 }
320 336 }
321 337
322 338 // run global callback on submit
323 339 self.globalSubmitSuccessCallback();
324 340
325 341 };
326 342 var submitFailCallback = function(){
327 343 self.resetCommentFormState(text);
328 344 };
329 345 self.submitAjaxPOST(
330 346 self.submitUrl, postData, submitSuccessCallback, submitFailCallback);
331 347 };
332 348
333 349 this.previewSuccessCallback = function(o) {
334 350 $(self.previewBoxSelector).html(o);
335 351 $(self.previewBoxSelector).removeClass('unloaded');
336 352
337 353 // swap buttons, making preview active
338 354 $(self.previewButton).parent().addClass('active');
339 355 $(self.editButton).parent().removeClass('active');
340 356
341 357 // unlock buttons
342 358 self.setActionButtonsDisabled(false);
343 359 };
344 360
345 361 this.setActionButtonsDisabled = function(state, excludeCancelBtn, submitEvent) {
346 362 excludeCancelBtn = excludeCancelBtn || false;
347 363 submitEvent = submitEvent || false;
348 364
349 365 $(this.editButton).prop('disabled', state);
350 366 $(this.previewButton).prop('disabled', state);
351 367
352 368 if (!excludeCancelBtn) {
353 369 $(this.cancelButton).prop('disabled', state);
354 370 }
355 371
356 372 var submitState = state;
357 373 if (!submitEvent && this.getCommentStatus() && !self.isInline()) {
358 374 // if the value of commit review status is set, we allow
359 375 // submit button, but only on Main form, isInline means inline
360 376 submitState = false
361 377 }
378
362 379 $(this.submitButton).prop('disabled', submitState);
363 380 if (submitEvent) {
364 381 $(this.submitButton).val(_gettext('Submitting...'));
365 382 } else {
366 383 $(this.submitButton).val(this.submitButtonText);
367 384 }
368 385
369 386 };
370 387
371 388 // lock preview/edit/submit buttons on load, but exclude cancel button
372 389 var excludeCancelBtn = true;
373 390 this.setActionButtonsDisabled(true, excludeCancelBtn);
374 391
375 392 // anonymous users don't have access to initialized CM instance
376 393 if (this.cm !== undefined){
377 394 this.cm.on('change', function(cMirror) {
378 395 if (cMirror.getValue() === "") {
379 396 self.setActionButtonsDisabled(true, excludeCancelBtn)
380 397 } else {
381 398 self.setActionButtonsDisabled(false, excludeCancelBtn)
382 399 }
383 400 });
384 401 }
385 402
386 403 $(this.editButton).on('click', function(e) {
387 404 e.preventDefault();
388 405
389 406 $(self.previewButton).parent().removeClass('active');
390 407 $(self.previewContainer).hide();
391 408
392 409 $(self.editButton).parent().addClass('active');
393 410 $(self.editContainer).show();
394 411
395 412 });
396 413
397 414 $(this.previewButton).on('click', function(e) {
398 415 e.preventDefault();
399 416 var text = self.cm.getValue();
400 417
401 418 if (text === "") {
402 419 return;
403 420 }
404 421
405 422 var postData = {
406 423 'text': text,
407 424 'renderer': templateContext.visual.default_renderer,
408 425 'csrf_token': CSRF_TOKEN
409 426 };
410 427
411 428 // lock ALL buttons on preview
412 429 self.setActionButtonsDisabled(true);
413 430
414 431 $(self.previewBoxSelector).addClass('unloaded');
415 432 $(self.previewBoxSelector).html(_gettext('Loading ...'));
416 433
417 434 $(self.editContainer).hide();
418 435 $(self.previewContainer).show();
419 436
420 437 // by default we reset state of comment preserving the text
421 438 var previewFailCallback = function(){
422 439 self.resetCommentFormState(text)
423 440 };
424 441 self.submitAjaxPOST(
425 442 self.previewUrl, postData, self.previewSuccessCallback,
426 443 previewFailCallback);
427 444
428 445 $(self.previewButton).parent().addClass('active');
429 446 $(self.editButton).parent().removeClass('active');
430 447 });
431 448
432 449 $(this.submitForm).submit(function(e) {
433 450 e.preventDefault();
434 451 var allowedToSubmit = self.isAllowedToSubmit();
435 452 if (!allowedToSubmit){
436 453 return false;
437 454 }
438 455 self.handleFormSubmit();
439 456 });
440 457
441 458 }
442 459
443 460 return CommentForm;
444 461 });
445 462
446 463 /* comments controller */
447 464 var CommentsController = function() {
448 465 var mainComment = '#text';
449 466 var self = this;
450 467
451 468 this.cancelComment = function(node) {
452 469 var $node = $(node);
453 470 var $td = $node.closest('td');
454 471 $node.closest('.comment-inline-form').remove();
455 472 return false;
456 473 };
457 474
458 475 this.getLineNumber = function(node) {
459 476 var $node = $(node);
460 477 return $node.closest('td').attr('data-line-number');
461 478 };
462 479
463 480 this.scrollToComment = function(node, offset, outdated) {
464 481 if (offset === undefined) {
465 482 offset = 0;
466 483 }
467 484 var outdated = outdated || false;
468 485 var klass = outdated ? 'div.comment-outdated' : 'div.comment-current';
469 486
470 487 if (!node) {
471 488 node = $('.comment-selected');
472 489 if (!node.length) {
473 490 node = $('comment-current')
474 491 }
475 492 }
476 493 $wrapper = $(node).closest('div.comment');
477 494 $comment = $(node).closest(klass);
478 495 $comments = $(klass);
479 496
480 497 // show hidden comment when referenced.
481 498 if (!$wrapper.is(':visible')){
482 499 $wrapper.show();
483 500 }
484 501
485 502 $('.comment-selected').removeClass('comment-selected');
486 503
487 504 var nextIdx = $(klass).index($comment) + offset;
488 505 if (nextIdx >= $comments.length) {
489 506 nextIdx = 0;
490 507 }
491 508 var $next = $(klass).eq(nextIdx);
492 509
493 510 var $cb = $next.closest('.cb');
494 511 $cb.removeClass('cb-collapsed');
495 512
496 513 var $filediffCollapseState = $cb.closest('.filediff').prev();
497 514 $filediffCollapseState.prop('checked', false);
498 515 $next.addClass('comment-selected');
499 516 scrollToElement($next);
500 517 return false;
501 518 };
502 519
503 520 this.nextComment = function(node) {
504 521 return self.scrollToComment(node, 1);
505 522 };
506 523
507 524 this.prevComment = function(node) {
508 525 return self.scrollToComment(node, -1);
509 526 };
510 527
511 528 this.nextOutdatedComment = function(node) {
512 529 return self.scrollToComment(node, 1, true);
513 530 };
514 531
515 532 this.prevOutdatedComment = function(node) {
516 533 return self.scrollToComment(node, -1, true);
517 534 };
518 535
519 536 this.deleteComment = function(node) {
520 537 if (!confirm(_gettext('Delete this comment?'))) {
521 538 return false;
522 539 }
523 540 var $node = $(node);
524 541 var $td = $node.closest('td');
525 542 var $comment = $node.closest('.comment');
526 543 var comment_id = $comment.attr('data-comment-id');
527 544 var url = AJAX_COMMENT_DELETE_URL.replace('__COMMENT_ID__', comment_id);
528 545 var postData = {
529 546 '_method': 'delete',
530 547 'csrf_token': CSRF_TOKEN
531 548 };
532 549
533 550 $comment.addClass('comment-deleting');
534 551 $comment.hide('fast');
535 552
536 553 var success = function(response) {
537 554 $comment.remove();
538 555 return false;
539 556 };
540 557 var failure = function(data, textStatus, xhr) {
541 558 alert("error processing request: " + textStatus);
542 559 $comment.show('fast');
543 560 $comment.removeClass('comment-deleting');
544 561 return false;
545 562 };
546 563 ajaxPOST(url, postData, success, failure);
547 564 };
548 565
549 566 this.toggleWideMode = function (node) {
550 567 if ($('#content').hasClass('wrapper')) {
551 568 $('#content').removeClass("wrapper");
552 569 $('#content').addClass("wide-mode-wrapper");
553 570 $(node).addClass('btn-success');
554 571 } else {
555 572 $('#content').removeClass("wide-mode-wrapper");
556 573 $('#content').addClass("wrapper");
557 574 $(node).removeClass('btn-success');
558 575 }
559 576 return false;
560 577 };
561 578
562 579 this.toggleComments = function(node, show) {
563 580 var $filediff = $(node).closest('.filediff');
564 581 if (show === true) {
565 582 $filediff.removeClass('hide-comments');
566 583 } else if (show === false) {
567 584 $filediff.find('.hide-line-comments').removeClass('hide-line-comments');
568 585 $filediff.addClass('hide-comments');
569 586 } else {
570 587 $filediff.find('.hide-line-comments').removeClass('hide-line-comments');
571 588 $filediff.toggleClass('hide-comments');
572 589 }
573 590 return false;
574 591 };
575 592
576 593 this.toggleLineComments = function(node) {
577 594 self.toggleComments(node, true);
578 595 var $node = $(node);
579 596 $node.closest('tr').toggleClass('hide-line-comments');
580 597 };
581 598
582 599 this.createCommentForm = function(formElement, lineno, placeholderText, initAutocompleteActions, resolvesCommentId){
583 600 var pullRequestId = templateContext.pull_request_data.pull_request_id;
584 601 var commitId = templateContext.commit_data.commit_id;
585 602
586 603 var commentForm = new CommentForm(
587 604 formElement, commitId, pullRequestId, lineno, initAutocompleteActions, resolvesCommentId);
588 605 var cm = commentForm.getCmInstance();
589 606
590 607 if (resolvesCommentId){
591 608 var placeholderText = _gettext('Leave a comment, or click resolve button to resolve TODO comment #{0}').format(resolvesCommentId);
592 609 }
593 610
594 611 setTimeout(function() {
595 612 // callbacks
596 613 if (cm !== undefined) {
597 614 commentForm.setPlaceholder(placeholderText);
598 615 if (commentForm.isInline()) {
599 616 cm.focus();
600 617 cm.refresh();
601 618 }
602 619 }
603 620 }, 10);
604 621
605 622 // trigger scrolldown to the resolve comment, since it might be away
606 623 // from the clicked
607 624 if (resolvesCommentId){
608 625 var actionNode = $(commentForm.resolvesActionId).offset();
609 626
610 627 setTimeout(function() {
611 628 if (actionNode) {
612 629 $('body, html').animate({scrollTop: actionNode.top}, 10);
613 630 }
614 631 }, 100);
615 632 }
616 633
617 634 return commentForm;
618 635 };
619 636
620 637 this.createGeneralComment = function (lineNo, placeholderText, resolvesCommentId) {
621 638
622 639 var tmpl = $('#cb-comment-general-form-template').html();
623 640 tmpl = tmpl.format(null, 'general');
624 641 var $form = $(tmpl);
625 642
626 643 var $formPlaceholder = $('#cb-comment-general-form-placeholder');
627 644 var curForm = $formPlaceholder.find('form');
628 645 if (curForm){
629 646 curForm.remove();
630 647 }
631 648 $formPlaceholder.append($form);
632 649
633 650 var _form = $($form[0]);
634 651 var autocompleteActions = ['approve', 'reject', 'as_note', 'as_todo'];
635 652 var commentForm = this.createCommentForm(
636 653 _form, lineNo, placeholderText, autocompleteActions, resolvesCommentId);
637 654 commentForm.initStatusChangeSelector();
638 655
639 656 return commentForm;
640 657 };
641 658
642 659 this.createComment = function(node, resolutionComment) {
643 660 var resolvesCommentId = resolutionComment || null;
644 661 var $node = $(node);
645 662 var $td = $node.closest('td');
646 663 var $form = $td.find('.comment-inline-form');
647 664
648 665 if (!$form.length) {
649 666
650 667 var $filediff = $node.closest('.filediff');
651 668 $filediff.removeClass('hide-comments');
652 669 var f_path = $filediff.attr('data-f-path');
653 670 var lineno = self.getLineNumber(node);
654 671 // create a new HTML from template
655 672 var tmpl = $('#cb-comment-inline-form-template').html();
656 673 tmpl = tmpl.format(f_path, lineno);
657 674 $form = $(tmpl);
658 675
659 676 var $comments = $td.find('.inline-comments');
660 677 if (!$comments.length) {
661 678 $comments = $(
662 679 $('#cb-comments-inline-container-template').html());
663 680 $td.append($comments);
664 681 }
665 682
666 683 $td.find('.cb-comment-add-button').before($form);
667 684
668 685 var placeholderText = _gettext('Leave a comment on line {0}.').format(lineno);
669 686 var _form = $($form[0]).find('form');
670 687 var autocompleteActions = ['as_note', 'as_todo'];
671 688 var commentForm = this.createCommentForm(
672 689 _form, lineno, placeholderText, autocompleteActions, resolvesCommentId);
673 690
674 691 $.Topic('/ui/plugins/code/comment_form_built').prepareOrPublish({
675 692 form: _form,
676 693 parent: $td[0],
677 694 lineno: lineno,
678 695 f_path: f_path}
679 696 );
680 697
681 698 // set a CUSTOM submit handler for inline comments.
682 699 commentForm.setHandleFormSubmit(function(o) {
683 700 var text = commentForm.cm.getValue();
684 701 var commentType = commentForm.getCommentType();
685 702 var resolvesCommentId = commentForm.getResolvesId();
686 703
687 704 if (text === "") {
688 705 return;
689 706 }
690 707
691 708 if (lineno === undefined) {
692 709 alert('missing line !');
693 710 return;
694 711 }
695 712 if (f_path === undefined) {
696 713 alert('missing file path !');
697 714 return;
698 715 }
699 716
700 717 var excludeCancelBtn = false;
701 718 var submitEvent = true;
702 719 commentForm.setActionButtonsDisabled(true, excludeCancelBtn, submitEvent);
703 720 commentForm.cm.setOption("readOnly", true);
704 721 var postData = {
705 722 'text': text,
706 723 'f_path': f_path,
707 724 'line': lineno,
708 725 'comment_type': commentType,
709 726 'csrf_token': CSRF_TOKEN
710 727 };
711 728 if (resolvesCommentId){
712 729 postData['resolves_comment_id'] = resolvesCommentId;
713 730 }
714 731
715 732 var submitSuccessCallback = function(json_data) {
716 733 $form.remove();
717 734 try {
718 735 var html = json_data.rendered_text;
719 736 var lineno = json_data.line_no;
720 737 var target_id = json_data.target_id;
721 738
722 739 $comments.find('.cb-comment-add-button').before(html);
723 740
724 741 //mark visually which comment was resolved
725 742 if (resolvesCommentId) {
726 743 commentForm.markCommentResolved(resolvesCommentId);
727 744 }
728 745
729 746 // run global callback on submit
730 747 commentForm.globalSubmitSuccessCallback();
731 748
732 749 } catch (e) {
733 750 console.error(e);
734 751 }
735 752
736 753 // re trigger the linkification of next/prev navigation
737 754 linkifyComments($('.inline-comment-injected'));
738 755 timeagoActivate();
739 756 commentForm.setActionButtonsDisabled(false);
740 757
741 758 };
742 759 var submitFailCallback = function(){
743 760 commentForm.resetCommentFormState(text)
744 761 };
745 762 commentForm.submitAjaxPOST(
746 763 commentForm.submitUrl, postData, submitSuccessCallback, submitFailCallback);
747 764 });
748 765 }
749 766
750 767 $form.addClass('comment-inline-form-open');
751 768 };
752 769
753 770 this.createResolutionComment = function(commentId){
754 771 // hide the trigger text
755 772 $('#resolve-comment-{0}'.format(commentId)).hide();
756 773
757 774 var comment = $('#comment-'+commentId);
758 775 var commentData = comment.data();
759 776 if (commentData.commentInline) {
760 777 this.createComment(comment, commentId)
761 778 } else {
762 779 Rhodecode.comments.createGeneralComment('general', "$placeholder", commentId)
763 780 }
764 781
765 782 return false;
766 783 };
767 784
768 785 this.submitResolution = function(commentId){
769 786 var form = $('#resolve_comment_{0}'.format(commentId)).closest('form');
770 787 var commentForm = form.get(0).CommentForm;
771 788
772 789 var cm = commentForm.getCmInstance();
773 790 var renderer = templateContext.visual.default_renderer;
774 791 if (renderer == 'rst'){
775 792 var commentUrl = '`#{0} <{1}#comment-{0}>`_'.format(commentId, commentForm.selfUrl);
776 793 } else if (renderer == 'markdown') {
777 794 var commentUrl = '[#{0}]({1}#comment-{0})'.format(commentId, commentForm.selfUrl);
778 795 } else {
779 796 var commentUrl = '{1}#comment-{0}'.format(commentId, commentForm.selfUrl);
780 797 }
781 798
782 799 cm.setValue(_gettext('TODO from comment {0} was fixed.').format(commentUrl));
783 800 form.submit();
784 801 return false;
785 802 };
786 803
787 804 this.renderInlineComments = function(file_comments) {
788 805 show_add_button = typeof show_add_button !== 'undefined' ? show_add_button : true;
789 806
790 807 for (var i = 0; i < file_comments.length; i++) {
791 808 var box = file_comments[i];
792 809
793 810 var target_id = $(box).attr('target_id');
794 811
795 812 // actually comments with line numbers
796 813 var comments = box.children;
797 814
798 815 for (var j = 0; j < comments.length; j++) {
799 816 var data = {
800 817 'rendered_text': comments[j].outerHTML,
801 818 'line_no': $(comments[j]).attr('line'),
802 819 'target_id': target_id
803 820 };
804 821 }
805 822 }
806 823
807 824 // since order of injection is random, we're now re-iterating
808 825 // from correct order and filling in links
809 826 linkifyComments($('.inline-comment-injected'));
810 827 firefoxAnchorFix();
811 828 };
812 829
813 830 };
@@ -1,395 +1,400 b''
1 1 ## -*- coding: utf-8 -*-
2 2 ## usage:
3 3 ## <%namespace name="comment" file="/changeset/changeset_file_comment.mako"/>
4 4 ## ${comment.comment_block(comment)}
5 5 ##
6 6 <%namespace name="base" file="/base/base.mako"/>
7 7
8 8 <%def name="comment_block(comment, inline=False)">
9 9 <% pr_index_ver = comment.get_index_version(getattr(c, 'versions', [])) %>
10 10 % if inline:
11 11 <% outdated_at_ver = comment.outdated_at_version(getattr(c, 'at_version_num', None)) %>
12 12 % else:
13 13 <% outdated_at_ver = comment.older_than_version(getattr(c, 'at_version_num', None)) %>
14 14 % endif
15 15
16 16
17 17 <div class="comment
18 18 ${'comment-inline' if inline else 'comment-general'}
19 19 ${'comment-outdated' if outdated_at_ver else 'comment-current'}"
20 20 id="comment-${comment.comment_id}"
21 21 line="${comment.line_no}"
22 22 data-comment-id="${comment.comment_id}"
23 23 data-comment-type="${comment.comment_type}"
24 24 data-comment-inline=${h.json.dumps(inline)}
25 25 style="${'display: none;' if outdated_at_ver else ''}">
26 26
27 27 <div class="meta">
28 28 <div class="comment-type-label">
29 29 <div class="comment-label ${comment.comment_type or 'note'}" id="comment-label-${comment.comment_id}">
30 30 % if comment.comment_type == 'todo':
31 31 % if comment.resolved:
32 32 <div class="resolved tooltip" title="${_('Resolved by comment #{}').format(comment.resolved.comment_id)}">
33 33 <a href="#comment-${comment.resolved.comment_id}">${comment.comment_type}</a>
34 34 </div>
35 35 % else:
36 36 <div class="resolved tooltip" style="display: none">
37 37 <span>${comment.comment_type}</span>
38 38 </div>
39 39 <div class="resolve tooltip" onclick="return Rhodecode.comments.createResolutionComment(${comment.comment_id});" title="${_('Click to resolve this comment')}">
40 40 ${comment.comment_type}
41 41 </div>
42 42 % endif
43 43 % else:
44 44 % if comment.resolved_comment:
45 45 fix
46 46 % else:
47 47 ${comment.comment_type or 'note'}
48 48 % endif
49 49 % endif
50 50 </div>
51 51 </div>
52 52
53 53 <div class="author ${'author-inline' if inline else 'author-general'}">
54 54 ${base.gravatar_with_user(comment.author.email, 16)}
55 55 </div>
56 56 <div class="date">
57 57 ${h.age_component(comment.modified_at, time_is_local=True)}
58 58 </div>
59 59 % if inline:
60 60 <span></span>
61 61 % else:
62 62 <div class="status-change">
63 63 % if comment.pull_request:
64 64 <a href="${h.url('pullrequest_show',repo_name=comment.pull_request.target_repo.repo_name,pull_request_id=comment.pull_request.pull_request_id)}">
65 65 % if comment.status_change:
66 66 ${_('pull request #%s') % comment.pull_request.pull_request_id}:
67 67 % else:
68 68 ${_('pull request #%s') % comment.pull_request.pull_request_id}
69 69 % endif
70 70 </a>
71 71 % else:
72 72 % if comment.status_change:
73 73 ${_('Status change on commit')}:
74 74 % endif
75 75 % endif
76 76 </div>
77 77 % endif
78 78
79 79 % if comment.status_change:
80 80 <div class="${'flag_status %s' % comment.status_change[0].status}"></div>
81 81 <div title="${_('Commit status')}" class="changeset-status-lbl">
82 82 ${comment.status_change[0].status_lbl}
83 83 </div>
84 84 % endif
85 85
86 86 % if comment.resolved_comment:
87 87 <a class="has-spacer-before" href="#comment-${comment.resolved_comment.comment_id}" onclick="Rhodecode.comments.scrollToComment($('#comment-${comment.resolved_comment.comment_id}'), 0, ${h.json.dumps(comment.resolved_comment.outdated)})">
88 88 ${_('resolves comment #{}').format(comment.resolved_comment.comment_id)}
89 89 </a>
90 90 % endif
91 91
92 92 <a class="permalink" href="#comment-${comment.comment_id}"> &para;</a>
93 93
94 94 <div class="comment-links-block">
95 95
96 96 % if inline:
97 97 <div class="pr-version-inline">
98 98 <a href="${h.url.current(version=comment.pull_request_version_id, anchor='comment-{}'.format(comment.comment_id))}">
99 99 % if outdated_at_ver:
100 100 <code class="pr-version-num" title="${_('Outdated comment from pull request version {0}').format(pr_index_ver)}">
101 101 outdated ${'v{}'.format(pr_index_ver)} |
102 102 </code>
103 103 % elif pr_index_ver:
104 104 <code class="pr-version-num" title="${_('Comment from pull request version {0}').format(pr_index_ver)}">
105 105 ${'v{}'.format(pr_index_ver)} |
106 106 </code>
107 107 % endif
108 108 </a>
109 109 </div>
110 110 % else:
111 111 % if comment.pull_request_version_id and pr_index_ver:
112 112 |
113 113 <div class="pr-version">
114 114 % if comment.outdated:
115 115 <a href="?version=${comment.pull_request_version_id}#comment-${comment.comment_id}">
116 116 ${_('Outdated comment from pull request version {}').format(pr_index_ver)}
117 117 </a>
118 118 % else:
119 119 <div title="${_('Comment from pull request version {0}').format(pr_index_ver)}">
120 120 <a href="${h.url('pullrequest_show',repo_name=comment.pull_request.target_repo.repo_name,pull_request_id=comment.pull_request.pull_request_id, version=comment.pull_request_version_id)}">
121 121 <code class="pr-version-num">
122 122 ${'v{}'.format(pr_index_ver)}
123 123 </code>
124 124 </a>
125 125 </div>
126 126 % endif
127 127 </div>
128 128 % endif
129 129 % endif
130 130
131 131 ## show delete comment if it's not a PR (regular comments) or it's PR that is not closed
132 132 ## only super-admin, repo admin OR comment owner can delete, also hide delete if currently viewed comment is outdated
133 133 %if not outdated_at_ver and (not comment.pull_request or (comment.pull_request and not comment.pull_request.is_closed())):
134 134 ## permissions to delete
135 135 %if h.HasPermissionAny('hg.admin')() or h.HasRepoPermissionAny('repository.admin')(c.repo_name) or comment.author.user_id == c.rhodecode_user.user_id:
136 136 ## TODO: dan: add edit comment here
137 137 <a onclick="return Rhodecode.comments.deleteComment(this);" class="delete-comment"> ${_('Delete')}</a>
138 138 %else:
139 139 <button class="btn-link" disabled="disabled"> ${_('Delete')}</button>
140 140 %endif
141 141 %else:
142 142 <button class="btn-link" disabled="disabled"> ${_('Delete')}</button>
143 143 %endif
144 144
145 145 % if outdated_at_ver:
146 146 | <a onclick="return Rhodecode.comments.prevOutdatedComment(this);" class="prev-comment"> ${_('Prev')}</a>
147 147 | <a onclick="return Rhodecode.comments.nextOutdatedComment(this);" class="next-comment"> ${_('Next')}</a>
148 148 % else:
149 149 | <a onclick="return Rhodecode.comments.prevComment(this);" class="prev-comment"> ${_('Prev')}</a>
150 150 | <a onclick="return Rhodecode.comments.nextComment(this);" class="next-comment"> ${_('Next')}</a>
151 151 % endif
152 152
153 153 </div>
154 154 </div>
155 155 <div class="text">
156 156 ${comment.render(mentions=True)|n}
157 157 </div>
158 158
159 159 </div>
160 160 </%def>
161 161
162 162 ## generate main comments
163 163 <%def name="generate_comments(comments, include_pull_request=False, is_pull_request=False)">
164 164 <div class="general-comments" id="comments">
165 165 %for comment in comments:
166 166 <div id="comment-tr-${comment.comment_id}">
167 167 ## only render comments that are not from pull request, or from
168 168 ## pull request and a status change
169 169 %if not comment.pull_request or (comment.pull_request and comment.status_change) or include_pull_request:
170 170 ${comment_block(comment)}
171 171 %endif
172 172 </div>
173 173 %endfor
174 174 ## to anchor ajax comments
175 175 <div id="injected_page_comments"></div>
176 176 </div>
177 177 </%def>
178 178
179 179
180 180 <%def name="comments(post_url, cur_status, is_pull_request=False, is_compare=False, change_status=True, form_extras=None)">
181 181
182 182 <div class="comments">
183 183 <%
184 184 if is_pull_request:
185 185 placeholder = _('Leave a comment on this Pull Request.')
186 186 elif is_compare:
187 187 placeholder = _('Leave a comment on {} commits in this range.').format(len(form_extras))
188 188 else:
189 189 placeholder = _('Leave a comment on this Commit.')
190 190 %>
191 191
192 192 % if c.rhodecode_user.username != h.DEFAULT_USER:
193 193 <div class="js-template" id="cb-comment-general-form-template">
194 194 ## template generated for injection
195 195 ${comment_form(form_type='general', review_statuses=c.commit_statuses, form_extras=form_extras)}
196 196 </div>
197 197
198 198 <div id="cb-comment-general-form-placeholder" class="comment-form ac">
199 199 ## inject form here
200 200 </div>
201 201 <script type="text/javascript">
202 202 var lineNo = 'general';
203 203 var resolvesCommentId = null;
204 204 var generalCommentForm = Rhodecode.comments.createGeneralComment(
205 205 lineNo, "${placeholder}", resolvesCommentId);
206 206
207 207 // set custom success callback on rangeCommit
208 208 % if is_compare:
209 209 generalCommentForm.setHandleFormSubmit(function(o) {
210 210 var self = generalCommentForm;
211 211
212 212 var text = self.cm.getValue();
213 213 var status = self.getCommentStatus();
214 214 var commentType = self.getCommentType();
215 215
216 216 if (text === "" && !status) {
217 217 return;
218 218 }
219 219
220 220 // we can pick which commits we want to make the comment by
221 221 // selecting them via click on preview pane, this will alter the hidden inputs
222 222 var cherryPicked = $('#changeset_compare_view_content .compare_select.hl').length > 0;
223 223
224 224 var commitIds = [];
225 225 $('#changeset_compare_view_content .compare_select').each(function(el) {
226 226 var commitId = this.id.replace('row-', '');
227 227 if ($(this).hasClass('hl') || !cherryPicked) {
228 228 $("input[data-commit-id='{0}']".format(commitId)).val(commitId);
229 229 commitIds.push(commitId);
230 230 } else {
231 231 $("input[data-commit-id='{0}']".format(commitId)).val('')
232 232 }
233 233 });
234 234
235 235 self.setActionButtonsDisabled(true);
236 236 self.cm.setOption("readOnly", true);
237 237 var postData = {
238 238 'text': text,
239 239 'changeset_status': status,
240 240 'comment_type': commentType,
241 241 'commit_ids': commitIds,
242 242 'csrf_token': CSRF_TOKEN
243 243 };
244 244
245 245 var submitSuccessCallback = function(o) {
246 246 location.reload(true);
247 247 };
248 248 var submitFailCallback = function(){
249 249 self.resetCommentFormState(text)
250 250 };
251 251 self.submitAjaxPOST(
252 252 self.submitUrl, postData, submitSuccessCallback, submitFailCallback);
253 253 });
254 254 % endif
255 255
256 256
257 257 </script>
258 258 % else:
259 259 ## form state when not logged in
260 260 <div class="comment-form ac">
261 261
262 262 <div class="comment-area">
263 263 <div class="comment-area-header">
264 264 <ul class="nav-links clearfix">
265 265 <li class="active">
266 266 <a class="disabled" href="#edit-btn" disabled="disabled" onclick="return false">${_('Write')}</a>
267 267 </li>
268 268 <li class="">
269 269 <a class="disabled" href="#preview-btn" disabled="disabled" onclick="return false">${_('Preview')}</a>
270 270 </li>
271 271 </ul>
272 272 </div>
273 273
274 274 <div class="comment-area-write" style="display: block;">
275 275 <div id="edit-container">
276 276 <div style="padding: 40px 0">
277 277 ${_('You need to be logged in to leave comments.')}
278 278 <a href="${h.route_path('login', _query={'came_from': h.url.current()})}">${_('Login now')}</a>
279 279 </div>
280 280 </div>
281 281 <div id="preview-container" class="clearfix" style="display: none;">
282 282 <div id="preview-box" class="preview-box"></div>
283 283 </div>
284 284 </div>
285 285
286 286 <div class="comment-area-footer">
287 287 <div class="toolbar">
288 288 <div class="toolbar-text">
289 289 </div>
290 290 </div>
291 291 </div>
292 292 </div>
293 293
294 294 <div class="comment-footer">
295 295 </div>
296 296
297 297 </div>
298 298 % endif
299 299
300 300 <script type="text/javascript">
301 301 bindToggleButtons();
302 302 </script>
303 303 </div>
304 304 </%def>
305 305
306 306
307 307 <%def name="comment_form(form_type, form_id='', lineno_id='{1}', review_statuses=None, form_extras=None)">
308 308 ## comment injected based on assumption that user is logged in
309 309
310 310 <form ${'id="{}"'.format(form_id) if form_id else '' |n} action="#" method="GET">
311 311
312 312 <div class="comment-area">
313 313 <div class="comment-area-header">
314 314 <ul class="nav-links clearfix">
315 315 <li class="active">
316 316 <a href="#edit-btn" tabindex="-1" id="edit-btn_${lineno_id}">${_('Write')}</a>
317 317 </li>
318 318 <li class="">
319 319 <a href="#preview-btn" tabindex="-1" id="preview-btn_${lineno_id}">${_('Preview')}</a>
320 320 </li>
321 321 <li class="pull-right">
322 322 <select class="comment-type" id="comment_type_${lineno_id}" name="comment_type">
323 323 % for val in c.visual.comment_types:
324 324 <option value="${val}">${val.upper()}</option>
325 325 % endfor
326 326 </select>
327 327 </li>
328 328 </ul>
329 329 </div>
330 330
331 331 <div class="comment-area-write" style="display: block;">
332 332 <div id="edit-container_${lineno_id}">
333 333 <textarea id="text_${lineno_id}" name="text" class="comment-block-ta ac-input"></textarea>
334 334 </div>
335 335 <div id="preview-container_${lineno_id}" class="clearfix" style="display: none;">
336 336 <div id="preview-box_${lineno_id}" class="preview-box"></div>
337 337 </div>
338 338 </div>
339 339
340 340 <div class="comment-area-footer">
341 341 <div class="toolbar">
342 342 <div class="toolbar-text">
343 343 ${(_('Comments parsed using %s syntax with %s, and %s actions support.') % (
344 344 ('<a href="%s">%s</a>' % (h.url('%s_help' % c.visual.default_renderer), c.visual.default_renderer.upper())),
345 345 ('<span class="tooltip" title="%s">@mention</span>' % _('Use @username inside this text to send notification to this RhodeCode user')),
346 346 ('<span class="tooltip" title="%s">`/`</span>' % _('Start typing with / for certain actions to be triggered via text box.'))
347 347 )
348 348 )|n}
349 349 </div>
350 350 </div>
351 351 </div>
352 352 </div>
353 353
354 354 <div class="comment-footer">
355 355
356 356 % if review_statuses:
357 357 <div class="status_box">
358 358 <select id="change_status_${lineno_id}" name="changeset_status">
359 359 <option></option> ## Placeholder
360 360 % for status, lbl in review_statuses:
361 361 <option value="${status}" data-status="${status}">${lbl}</option>
362 362 %if is_pull_request and change_status and status in ('approved', 'rejected'):
363 363 <option value="${status}_closed" data-status="${status}">${lbl} & ${_('Closed')}</option>
364 364 %endif
365 365 % endfor
366 366 </select>
367 367 </div>
368 368 % endif
369 369
370 370 ## inject extra inputs into the form
371 371 % if form_extras and isinstance(form_extras, (list, tuple)):
372 372 <div id="comment_form_extras">
373 373 % for form_ex_el in form_extras:
374 374 ${form_ex_el|n}
375 375 % endfor
376 376 </div>
377 377 % endif
378 378
379 379 <div class="action-buttons">
380 380 ## inline for has a file, and line-number together with cancel hide button.
381 381 % if form_type == 'inline':
382 382 <input type="hidden" name="f_path" value="{0}">
383 383 <input type="hidden" name="line" value="${lineno_id}">
384 384 <button type="button" class="cb-comment-cancel" onclick="return Rhodecode.comments.cancelComment(this);">
385 385 ${_('Cancel')}
386 386 </button>
387 387 % endif
388
389 % if form_type != 'inline':
390 <div class="action-buttons-extra"></div>
391 % endif
392
388 393 ${h.submit('save', _('Comment'), class_='btn btn-success comment-button-input')}
389 394
390 395 </div>
391 396 </div>
392 397
393 398 </form>
394 399
395 400 </%def> No newline at end of file
@@ -1,50 +1,63 b''
1 1
2 2 <div class="pull-request-wrap">
3 3
4
5 4 % if c.pr_merge_possible:
6 5 <h2 class="merge-status">
7 6 <span class="merge-icon success"><i class="icon-true"></i></span>
8 7 ${_('This pull request can be merged automatically.')}
9 8 </h2>
10 9 % else:
11 10 <h2 class="merge-status">
12 11 <span class="merge-icon warning"><i class="icon-false"></i></span>
13 12 ${_('Merge is not currently possible because of below failed checks.')}
14 13 </h2>
15 14 % endif
16 15
17 16 <ul>
18 17 % for pr_check_key, pr_check_details in c.pr_merge_errors.items():
19 18 <% pr_check_type = pr_check_details['error_type'] %>
20 19 <li>
21 20 <span class="merge-message ${pr_check_type}" data-role="merge-message">
22 21 - ${pr_check_details['message']}
23 22 % if pr_check_key == 'todo':
24 23 % for co in pr_check_details['details']:
25 24 <a class="permalink" href="#comment-${co.comment_id}" onclick="Rhodecode.comments.scrollToComment($('#comment-${co.comment_id}'), 0, ${h.json.dumps(co.outdated)})"> #${co.comment_id}</a>${'' if loop.last else ','}
26 25 % endfor
27 26 % endif
28 27 </span>
29 28 </li>
30 29 % endfor
31 30 </ul>
32 31
33 32 <div class="pull-request-merge-actions">
34 33 % if c.allowed_to_merge:
35 34 <div class="pull-right">
36 35 ${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')}
37 36 <% merge_disabled = ' disabled' if c.pr_merge_possible is False else '' %>
38 37 <a class="btn" href="#" onclick="refreshMergeChecks(); return false;">${_('refresh checks')}</a>
39 38 <input type="submit" id="merge_pull_request" value="${_('Merge Pull Request')}" class="btn${merge_disabled}"${merge_disabled}>
40 39 ${h.end_form()}
41 40 </div>
42 41 % elif c.rhodecode_user.username != h.DEFAULT_USER:
43 42 <a class="btn" href="#" onclick="refreshMergeChecks(); return false;">${_('refresh checks')}</a>
44 43 <input type="submit" value="${_('Merge Pull Request')}" class="btn disabled" disabled="disabled" title="${_('You are not allowed to merge this pull request.')}">
45 44 % else:
46 45 <input type="submit" value="${_('Login to Merge this Pull Request')}" class="btn disabled" disabled="disabled">
47 46 % endif
48 47 </div>
48
49 % if c.allowed_to_close:
50 ## close PR action, injected later next to COMMENT button
51 <div id="close-pull-request-action" style="display: none">
52 % if c.pull_request_review_status == c.REVIEW_STATUS_APPROVED:
53 <a class="btn btn-approved-status" href="#close-as-approved" onclick="closePullRequest('${c.REVIEW_STATUS_APPROVED}'); return false;">
54 ${_('Close with status {}').format(h.commit_status_lbl(c.REVIEW_STATUS_APPROVED))}
55 </a>
56 % else:
57 <a class="btn btn-rejected-status" href="#close-as-rejected" onclick="closePullRequest('${c.REVIEW_STATUS_REJECTED}'); return false;">
58 ${_('Close with status {}').format(h.commit_status_lbl(c.REVIEW_STATUS_REJECTED))}
59 </a>
60 % endif
61 </div>
62 % endif
49 63 </div>
50
@@ -1,796 +1,818 b''
1 1 <%inherit file="/base/base.mako"/>
2 2 <%namespace name="base" file="/base/base.mako"/>
3 3
4 4 <%def name="title()">
5 5 ${_('%s Pull Request #%s') % (c.repo_name, c.pull_request.pull_request_id)}
6 6 %if c.rhodecode_name:
7 7 &middot; ${h.branding(c.rhodecode_name)}
8 8 %endif
9 9 </%def>
10 10
11 11 <%def name="breadcrumbs_links()">
12 12 <span id="pr-title">
13 13 ${c.pull_request.title}
14 14 %if c.pull_request.is_closed():
15 15 (${_('Closed')})
16 16 %endif
17 17 </span>
18 18 <div id="pr-title-edit" class="input" style="display: none;">
19 19 ${h.text('pullrequest_title', id_="pr-title-input", class_="large", value=c.pull_request.title)}
20 20 </div>
21 21 </%def>
22 22
23 23 <%def name="menu_bar_nav()">
24 24 ${self.menu_items(active='repositories')}
25 25 </%def>
26 26
27 27 <%def name="menu_bar_subnav()">
28 28 ${self.repo_menu(active='showpullrequest')}
29 29 </%def>
30 30
31 31 <%def name="main()">
32 32
33 33 <script type="text/javascript">
34 34 // TODO: marcink switch this to pyroutes
35 35 AJAX_COMMENT_DELETE_URL = "${url('pullrequest_comment_delete',repo_name=c.repo_name,comment_id='__COMMENT_ID__')}";
36 36 templateContext.pull_request_data.pull_request_id = ${c.pull_request.pull_request_id};
37 37 </script>
38 38 <div class="box">
39 39
40 40 <div class="title">
41 41 ${self.repo_page_title(c.rhodecode_db_repo)}
42 42 </div>
43 43
44 44 ${self.breadcrumbs()}
45 45
46 46 <div class="box pr-summary">
47 47
48 48 <div class="summary-details block-left">
49 49 <% summary = lambda n:{False:'summary-short'}.get(n) %>
50 50 <div class="pr-details-title">
51 51 <a href="${h.url('pull_requests_global', pull_request_id=c.pull_request.pull_request_id)}">${_('Pull request #%s') % c.pull_request.pull_request_id}</a> ${_('From')} ${h.format_date(c.pull_request.created_on)}
52 52 %if c.allowed_to_update:
53 53 <div id="delete_pullrequest" class="pull-right action_button ${'' if c.allowed_to_delete else 'disabled' }" style="clear:inherit;padding: 0">
54 54 % if c.allowed_to_delete:
55 55 ${h.secure_form(url('pullrequest_delete', repo_name=c.pull_request.target_repo.repo_name, pull_request_id=c.pull_request.pull_request_id),method='delete')}
56 56 ${h.submit('remove_%s' % c.pull_request.pull_request_id, _('Delete'),
57 57 class_="btn btn-link btn-danger",onclick="return confirm('"+_('Confirm to delete this pull request')+"');")}
58 58 ${h.end_form()}
59 59 % else:
60 60 ${_('Delete')}
61 61 % endif
62 62 </div>
63 63 <div id="open_edit_pullrequest" class="pull-right action_button">${_('Edit')}</div>
64 64 <div id="close_edit_pullrequest" class="pull-right action_button" style="display: none;padding: 0">${_('Cancel')}</div>
65 65 %endif
66 66 </div>
67 67
68 68 <div id="summary" class="fields pr-details-content">
69 69 <div class="field">
70 70 <div class="label-summary">
71 71 <label>${_('Origin')}:</label>
72 72 </div>
73 73 <div class="input">
74 74 <div class="pr-origininfo">
75 75 ## branch link is only valid if it is a branch
76 76 <span class="tag">
77 77 %if c.pull_request.source_ref_parts.type == 'branch':
78 78 <a href="${h.url('changelog_home', repo_name=c.pull_request.source_repo.repo_name, branch=c.pull_request.source_ref_parts.name)}">${c.pull_request.source_ref_parts.type}: ${c.pull_request.source_ref_parts.name}</a>
79 79 %else:
80 80 ${c.pull_request.source_ref_parts.type}: ${c.pull_request.source_ref_parts.name}
81 81 %endif
82 82 </span>
83 83 <span class="clone-url">
84 84 <a href="${h.url('summary_home', repo_name=c.pull_request.source_repo.repo_name)}">${c.pull_request.source_repo.clone_url()}</a>
85 85 </span>
86 86 </div>
87 87 <div class="pr-pullinfo">
88 88 %if h.is_hg(c.pull_request.source_repo):
89 89 <input type="text" value="hg pull -r ${h.short_id(c.source_ref)} ${c.pull_request.source_repo.clone_url()}" readonly="readonly">
90 90 %elif h.is_git(c.pull_request.source_repo):
91 91 <input type="text" value="git pull ${c.pull_request.source_repo.clone_url()} ${c.pull_request.source_ref_parts.name}" readonly="readonly">
92 92 %endif
93 93 </div>
94 94 </div>
95 95 </div>
96 96 <div class="field">
97 97 <div class="label-summary">
98 98 <label>${_('Target')}:</label>
99 99 </div>
100 100 <div class="input">
101 101 <div class="pr-targetinfo">
102 102 ## branch link is only valid if it is a branch
103 103 <span class="tag">
104 104 %if c.pull_request.target_ref_parts.type == 'branch':
105 105 <a href="${h.url('changelog_home', repo_name=c.pull_request.target_repo.repo_name, branch=c.pull_request.target_ref_parts.name)}">${c.pull_request.target_ref_parts.type}: ${c.pull_request.target_ref_parts.name}</a>
106 106 %else:
107 107 ${c.pull_request.target_ref_parts.type}: ${c.pull_request.target_ref_parts.name}
108 108 %endif
109 109 </span>
110 110 <span class="clone-url">
111 111 <a href="${h.url('summary_home', repo_name=c.pull_request.target_repo.repo_name)}">${c.pull_request.target_repo.clone_url()}</a>
112 112 </span>
113 113 </div>
114 114 </div>
115 115 </div>
116 116
117 117 ## Link to the shadow repository.
118 118 <div class="field">
119 119 <div class="label-summary">
120 120 <label>${_('Merge')}:</label>
121 121 </div>
122 122 <div class="input">
123 123 % if not c.pull_request.is_closed() and c.pull_request.shadow_merge_ref:
124 124 <div class="pr-mergeinfo">
125 125 %if h.is_hg(c.pull_request.target_repo):
126 126 <input type="text" value="hg clone -u ${c.pull_request.shadow_merge_ref.name} ${c.shadow_clone_url} pull-request-${c.pull_request.pull_request_id}" readonly="readonly">
127 127 %elif h.is_git(c.pull_request.target_repo):
128 128 <input type="text" value="git clone --branch ${c.pull_request.shadow_merge_ref.name} ${c.shadow_clone_url} pull-request-${c.pull_request.pull_request_id}" readonly="readonly">
129 129 %endif
130 130 </div>
131 131 % else:
132 132 <div class="">
133 133 ${_('Shadow repository data not available')}.
134 134 </div>
135 135 % endif
136 136 </div>
137 137 </div>
138 138
139 139 <div class="field">
140 140 <div class="label-summary">
141 141 <label>${_('Review')}:</label>
142 142 </div>
143 143 <div class="input">
144 144 %if c.pull_request_review_status:
145 145 <div class="${'flag_status %s' % c.pull_request_review_status} tooltip pull-left"></div>
146 146 <span class="changeset-status-lbl tooltip">
147 147 %if c.pull_request.is_closed():
148 148 ${_('Closed')},
149 149 %endif
150 150 ${h.commit_status_lbl(c.pull_request_review_status)}
151 151 </span>
152 152 - ${ungettext('calculated based on %s reviewer vote', 'calculated based on %s reviewers votes', len(c.pull_request_reviewers)) % len(c.pull_request_reviewers)}
153 153 %endif
154 154 </div>
155 155 </div>
156 156 <div class="field">
157 157 <div class="pr-description-label label-summary">
158 158 <label>${_('Description')}:</label>
159 159 </div>
160 160 <div id="pr-desc" class="input">
161 161 <div class="pr-description">${h.urlify_commit_message(c.pull_request.description, c.repo_name)}</div>
162 162 </div>
163 163 <div id="pr-desc-edit" class="input textarea editor" style="display: none;">
164 164 <textarea id="pr-description-input" size="30">${c.pull_request.description}</textarea>
165 165 </div>
166 166 </div>
167 167
168 168 <div class="field">
169 169 <div class="label-summary">
170 170 <label>${_('Versions')}:</label>
171 171 </div>
172 172
173 173 <% outdated_comm_count_ver = len(c.inline_versions[None]['outdated']) %>
174 174 <% general_outdated_comm_count_ver = len(c.comment_versions[None]['outdated']) %>
175 175
176 176 <div class="pr-versions">
177 177 % if c.show_version_changes:
178 178 <% outdated_comm_count_ver = len(c.inline_versions[c.at_version_num]['outdated']) %>
179 179 <% general_outdated_comm_count_ver = len(c.comment_versions[c.at_version_num]['outdated']) %>
180 180 <a id="show-pr-versions" class="input" onclick="return versionController.toggleVersionView(this)" href="#show-pr-versions"
181 181 data-toggle-on="${ungettext('{} version available for this pull request, show it.', '{} versions available for this pull request, show them.', len(c.versions)).format(len(c.versions))}"
182 182 data-toggle-off="${_('Hide all versions of this pull request')}">
183 183 ${ungettext('{} version available for this pull request, show it.', '{} versions available for this pull request, show them.', len(c.versions)).format(len(c.versions))}
184 184 </a>
185 185 <table>
186 186 ## SHOW ALL VERSIONS OF PR
187 187 <% ver_pr = None %>
188 188
189 189 % for data in reversed(list(enumerate(c.versions, 1))):
190 190 <% ver_pos = data[0] %>
191 191 <% ver = data[1] %>
192 192 <% ver_pr = ver.pull_request_version_id %>
193 193 <% display_row = '' if c.at_version and (c.at_version_num == ver_pr or c.from_version_num == ver_pr) else 'none' %>
194 194
195 195 <tr class="version-pr" style="display: ${display_row}">
196 196 <td>
197 197 <code>
198 198 <a href="${h.url.current(version=ver_pr or 'latest')}">v${ver_pos}</a>
199 199 </code>
200 200 </td>
201 201 <td>
202 202 <input ${'checked="checked"' if c.from_version_num == ver_pr else ''} class="compare-radio-button" type="radio" name="ver_source" value="${ver_pr or 'latest'}" data-ver-pos="${ver_pos}"/>
203 203 <input ${'checked="checked"' if c.at_version_num == ver_pr else ''} class="compare-radio-button" type="radio" name="ver_target" value="${ver_pr or 'latest'}" data-ver-pos="${ver_pos}"/>
204 204 </td>
205 205 <td>
206 206 <% review_status = c.review_versions[ver_pr].status if ver_pr in c.review_versions else 'not_reviewed' %>
207 207 <div class="${'flag_status %s' % review_status} tooltip pull-left" title="${_('Your review status at this version')}">
208 208 </div>
209 209 </td>
210 210 <td>
211 211 % if c.at_version_num != ver_pr:
212 212 <i class="icon-comment"></i>
213 213 <code class="tooltip" title="${_('Comment from pull request version {0}, general:{1} inline:{2}').format(ver_pos, len(c.comment_versions[ver_pr]['at']), len(c.inline_versions[ver_pr]['at']))}">
214 214 G:${len(c.comment_versions[ver_pr]['at'])} / I:${len(c.inline_versions[ver_pr]['at'])}
215 215 </code>
216 216 % endif
217 217 </td>
218 218 <td>
219 219 ##<code>${ver.source_ref_parts.commit_id[:6]}</code>
220 220 </td>
221 221 <td>
222 222 ${h.age_component(ver.updated_on)}
223 223 </td>
224 224 </tr>
225 225 % endfor
226 226
227 227 <tr>
228 228 <td colspan="6">
229 229 <button id="show-version-diff" onclick="return versionController.showVersionDiff()" class="btn btn-sm" style="display: none"
230 230 data-label-text-locked="${_('select versions to show changes')}"
231 231 data-label-text-diff="${_('show changes between versions')}"
232 232 data-label-text-show="${_('show pull request for this version')}"
233 233 >
234 234 ${_('select versions to show changes')}
235 235 </button>
236 236 </td>
237 237 </tr>
238 238
239 239 ## show comment/inline comments summary
240 240 <%def name="comments_summary()">
241 241 <tr>
242 242 <td colspan="6" class="comments-summary-td">
243 243
244 244 % if c.at_version:
245 245 <% inline_comm_count_ver = len(c.inline_versions[c.at_version_num]['display']) %>
246 246 <% general_comm_count_ver = len(c.comment_versions[c.at_version_num]['display']) %>
247 247 ${_('Comments at this version')}:
248 248 % else:
249 249 <% inline_comm_count_ver = len(c.inline_versions[c.at_version_num]['until']) %>
250 250 <% general_comm_count_ver = len(c.comment_versions[c.at_version_num]['until']) %>
251 251 ${_('Comments for this pull request')}:
252 252 % endif
253 253
254 254
255 255 %if general_comm_count_ver:
256 256 <a href="#comments">${_("%d General ") % general_comm_count_ver}</a>
257 257 %else:
258 258 ${_("%d General ") % general_comm_count_ver}
259 259 %endif
260 260
261 261 %if inline_comm_count_ver:
262 262 , <a href="#" onclick="return Rhodecode.comments.nextComment();" id="inline-comments-counter">${_("%d Inline") % inline_comm_count_ver}</a>
263 263 %else:
264 264 , ${_("%d Inline") % inline_comm_count_ver}
265 265 %endif
266 266
267 267 %if outdated_comm_count_ver:
268 268 , <a href="#" onclick="showOutdated(); Rhodecode.comments.nextOutdatedComment(); return false;">${_("%d Outdated") % outdated_comm_count_ver}</a>
269 269 <a href="#" class="showOutdatedComments" onclick="showOutdated(this); return false;"> | ${_('show outdated comments')}</a>
270 270 <a href="#" class="hideOutdatedComments" style="display: none" onclick="hideOutdated(this); return false;"> | ${_('hide outdated comments')}</a>
271 271 %else:
272 272 , ${_("%d Outdated") % outdated_comm_count_ver}
273 273 %endif
274 274 </td>
275 275 </tr>
276 276 </%def>
277 277 ${comments_summary()}
278 278 </table>
279 279 % else:
280 280 <div class="input">
281 281 ${_('Pull request versions not available')}.
282 282 </div>
283 283 <div>
284 284 <table>
285 285 ${comments_summary()}
286 286 </table>
287 287 </div>
288 288 % endif
289 289 </div>
290 290 </div>
291 291
292 292 <div id="pr-save" class="field" style="display: none;">
293 293 <div class="label-summary"></div>
294 294 <div class="input">
295 295 <span id="edit_pull_request" class="btn btn-small">${_('Save Changes')}</span>
296 296 </div>
297 297 </div>
298 298 </div>
299 299 </div>
300 300 <div>
301 301 ## AUTHOR
302 302 <div class="reviewers-title block-right">
303 303 <div class="pr-details-title">
304 304 ${_('Author')}
305 305 </div>
306 306 </div>
307 307 <div class="block-right pr-details-content reviewers">
308 308 <ul class="group_members">
309 309 <li>
310 310 ${self.gravatar_with_user(c.pull_request.author.email, 16)}
311 311 </li>
312 312 </ul>
313 313 </div>
314 314 ## REVIEWERS
315 315 <div class="reviewers-title block-right">
316 316 <div class="pr-details-title">
317 317 ${_('Pull request reviewers')}
318 318 %if c.allowed_to_update:
319 319 <span id="open_edit_reviewers" class="block-right action_button">${_('Edit')}</span>
320 320 <span id="close_edit_reviewers" class="block-right action_button" style="display: none;">${_('Close')}</span>
321 321 %endif
322 322 </div>
323 323 </div>
324 324 <div id="reviewers" class="block-right pr-details-content reviewers">
325 325 ## members goes here !
326 326 <input type="hidden" name="__start__" value="review_members:sequence">
327 327 <ul id="review_members" class="group_members">
328 328 %for member,reasons,status in c.pull_request_reviewers:
329 329 <li id="reviewer_${member.user_id}">
330 330 <div class="reviewers_member">
331 331 <div class="reviewer_status tooltip" title="${h.tooltip(h.commit_status_lbl(status[0][1].status if status else 'not_reviewed'))}">
332 332 <div class="${'flag_status %s' % (status[0][1].status if status else 'not_reviewed')} pull-left reviewer_member_status"></div>
333 333 </div>
334 334 <div id="reviewer_${member.user_id}_name" class="reviewer_name">
335 335 ${self.gravatar_with_user(member.email, 16)}
336 336 </div>
337 337 <input type="hidden" name="__start__" value="reviewer:mapping">
338 338 <input type="hidden" name="__start__" value="reasons:sequence">
339 339 %for reason in reasons:
340 340 <div class="reviewer_reason">- ${reason}</div>
341 341 <input type="hidden" name="reason" value="${reason}">
342 342
343 343 %endfor
344 344 <input type="hidden" name="__end__" value="reasons:sequence">
345 345 <input id="reviewer_${member.user_id}_input" type="hidden" value="${member.user_id}" name="user_id" />
346 346 <input type="hidden" name="__end__" value="reviewer:mapping">
347 347 %if c.allowed_to_update:
348 348 <div class="reviewer_member_remove action_button" onclick="removeReviewMember(${member.user_id}, true)" style="visibility: hidden;">
349 349 <i class="icon-remove-sign" ></i>
350 350 </div>
351 351 %endif
352 352 </div>
353 353 </li>
354 354 %endfor
355 355 </ul>
356 356 <input type="hidden" name="__end__" value="review_members:sequence">
357 357 %if not c.pull_request.is_closed():
358 358 <div id="add_reviewer_input" class='ac' style="display: none;">
359 359 %if c.allowed_to_update:
360 360 <div class="reviewer_ac">
361 361 ${h.text('user', class_='ac-input', placeholder=_('Add reviewer'))}
362 362 <div id="reviewers_container"></div>
363 363 </div>
364 364 <div>
365 365 <span id="update_pull_request" class="btn btn-small">${_('Save Changes')}</span>
366 366 </div>
367 367 %endif
368 368 </div>
369 369 %endif
370 370 </div>
371 371 </div>
372 372 </div>
373 373 <div class="box">
374 374 ##DIFF
375 375 <div class="table" >
376 376 <div id="changeset_compare_view_content">
377 377 ##CS
378 378 % if c.missing_requirements:
379 379 <div class="box">
380 380 <div class="alert alert-warning">
381 381 <div>
382 382 <strong>${_('Missing requirements:')}</strong>
383 383 ${_('These commits cannot be displayed, because this repository uses the Mercurial largefiles extension, which was not enabled.')}
384 384 </div>
385 385 </div>
386 386 </div>
387 387 % elif c.missing_commits:
388 388 <div class="box">
389 389 <div class="alert alert-warning">
390 390 <div>
391 391 <strong>${_('Missing commits')}:</strong>
392 392 ${_('This pull request cannot be displayed, because one or more commits no longer exist in the source repository.')}
393 393 ${_('Please update this pull request, push the commits back into the source repository, or consider closing this pull request.')}
394 394 </div>
395 395 </div>
396 396 </div>
397 397 % endif
398 398
399 399 <div class="compare_view_commits_title">
400 400 % if not c.compare_mode:
401 401
402 402 % if c.at_version_pos:
403 403 <h4>
404 404 ${_('Showing changes at v%d, commenting is disabled.') % c.at_version_pos}
405 405 </h4>
406 406 % endif
407 407
408 408 <div class="pull-left">
409 409 <div class="btn-group">
410 410 <a
411 411 class="btn"
412 412 href="#"
413 413 onclick="$('.compare_select').show();$('.compare_select_hidden').hide(); return false">
414 414 ${ungettext('Expand %s commit','Expand %s commits', len(c.commit_ranges)) % len(c.commit_ranges)}
415 415 </a>
416 416 <a
417 417 class="btn"
418 418 href="#"
419 419 onclick="$('.compare_select').hide();$('.compare_select_hidden').show(); return false">
420 420 ${ungettext('Collapse %s commit','Collapse %s commits', len(c.commit_ranges)) % len(c.commit_ranges)}
421 421 </a>
422 422 </div>
423 423 </div>
424 424
425 425 <div class="pull-right">
426 426 % if c.allowed_to_update and not c.pull_request.is_closed():
427 427 <a id="update_commits" class="btn btn-primary pull-right">${_('Update commits')}</a>
428 428 % else:
429 429 <a class="tooltip btn disabled pull-right" disabled="disabled" title="${_('Update is disabled for current view')}">${_('Update commits')}</a>
430 430 % endif
431 431
432 432 </div>
433 433 % endif
434 434 </div>
435 435
436 436 % if not c.missing_commits:
437 437 % if c.compare_mode:
438 438 % if c.at_version:
439 439 <h4>
440 440 ${_('Commits and changes between v{ver_from} and {ver_to} of this pull request, commenting is disabled').format(ver_from=c.from_version_pos, ver_to=c.at_version_pos if c.at_version_pos else 'latest')}:
441 441 </h4>
442 442
443 443 <div class="subtitle-compare">
444 444 ${_('commits added: {}, removed: {}').format(len(c.commit_changes_summary.added), len(c.commit_changes_summary.removed))}
445 445 </div>
446 446
447 447 <div class="container">
448 448 <table class="rctable compare_view_commits">
449 449 <tr>
450 450 <th></th>
451 451 <th>${_('Time')}</th>
452 452 <th>${_('Author')}</th>
453 453 <th>${_('Commit')}</th>
454 454 <th></th>
455 455 <th>${_('Description')}</th>
456 456 </tr>
457 457
458 458 % for c_type, commit in c.commit_changes:
459 459 % if c_type in ['a', 'r']:
460 460 <%
461 461 if c_type == 'a':
462 462 cc_title = _('Commit added in displayed changes')
463 463 elif c_type == 'r':
464 464 cc_title = _('Commit removed in displayed changes')
465 465 else:
466 466 cc_title = ''
467 467 %>
468 468 <tr id="row-${commit.raw_id}" commit_id="${commit.raw_id}" class="compare_select">
469 469 <td>
470 470 <div class="commit-change-indicator color-${c_type}-border">
471 471 <div class="commit-change-content color-${c_type} tooltip" title="${cc_title}">
472 472 ${c_type.upper()}
473 473 </div>
474 474 </div>
475 475 </td>
476 476 <td class="td-time">
477 477 ${h.age_component(commit.date)}
478 478 </td>
479 479 <td class="td-user">
480 480 ${base.gravatar_with_user(commit.author, 16)}
481 481 </td>
482 482 <td class="td-hash">
483 483 <code>
484 484 <a href="${h.url('changeset_home', repo_name=c.target_repo.repo_name, revision=commit.raw_id)}">
485 485 r${commit.revision}:${h.short_id(commit.raw_id)}
486 486 </a>
487 487 ${h.hidden('revisions', commit.raw_id)}
488 488 </code>
489 489 </td>
490 490 <td class="expand_commit" data-commit-id="${commit.raw_id}" title="${_( 'Expand commit message')}">
491 491 <div class="show_more_col">
492 492 <i class="show_more"></i>
493 493 </div>
494 494 </td>
495 495 <td class="mid td-description">
496 496 <div class="log-container truncate-wrap">
497 497 <div class="message truncate" id="c-${commit.raw_id}" data-message-raw="${commit.message}">
498 498 ${h.urlify_commit_message(commit.message, c.repo_name)}
499 499 </div>
500 500 </div>
501 501 </td>
502 502 </tr>
503 503 % endif
504 504 % endfor
505 505 </table>
506 506 </div>
507 507
508 508 <script>
509 509 $('.expand_commit').on('click',function(e){
510 510 var target_expand = $(this);
511 511 var cid = target_expand.data('commitId');
512 512
513 513 if (target_expand.hasClass('open')){
514 514 $('#c-'+cid).css({
515 515 'height': '1.5em',
516 516 'white-space': 'nowrap',
517 517 'text-overflow': 'ellipsis',
518 518 'overflow':'hidden'
519 519 });
520 520 target_expand.removeClass('open');
521 521 }
522 522 else {
523 523 $('#c-'+cid).css({
524 524 'height': 'auto',
525 525 'white-space': 'pre-line',
526 526 'text-overflow': 'initial',
527 527 'overflow':'visible'
528 528 });
529 529 target_expand.addClass('open');
530 530 }
531 531 });
532 532 </script>
533 533
534 534 % endif
535 535
536 536 % else:
537 537 <%include file="/compare/compare_commits.mako" />
538 538 % endif
539 539
540 540 <div class="cs_files">
541 541 <%namespace name="cbdiffs" file="/codeblocks/diffs.mako"/>
542 542 ${cbdiffs.render_diffset_menu()}
543 543 ${cbdiffs.render_diffset(
544 544 c.diffset, use_comments=True,
545 545 collapse_when_files_over=30,
546 546 disable_new_comments=not c.allowed_to_comment,
547 547 deleted_files_comments=c.deleted_files_comments)}
548 548 </div>
549 549 % else:
550 550 ## skipping commits we need to clear the view for missing commits
551 551 <div style="clear:both;"></div>
552 552 % endif
553 553
554 554 </div>
555 555 </div>
556 556
557 557 ## template for inline comment form
558 558 <%namespace name="comment" file="/changeset/changeset_file_comment.mako"/>
559 559
560 560 ## render general comments
561 561
562 562 <div id="comment-tr-show">
563 563 <div class="comment">
564 564 % if general_outdated_comm_count_ver:
565 565 <div class="meta">
566 566 % if general_outdated_comm_count_ver == 1:
567 567 ${_('there is {num} general comment from older versions').format(num=general_outdated_comm_count_ver)},
568 568 <a href="#show-hidden-comments" onclick="$('.comment-general.comment-outdated').show(); $(this).parent().hide(); return false;">${_('show it')}</a>
569 569 % else:
570 570 ${_('there are {num} general comments from older versions').format(num=general_outdated_comm_count_ver)},
571 571 <a href="#show-hidden-comments" onclick="$('.comment-general.comment-outdated').show(); $(this).parent().hide(); return false;">${_('show them')}</a>
572 572 % endif
573 573 </div>
574 574 % endif
575 575 </div>
576 576 </div>
577 577
578 578 ${comment.generate_comments(c.comments, include_pull_request=True, is_pull_request=True)}
579 579
580 580 % if not c.pull_request.is_closed():
581 581 ## merge status, and merge action
582 582 <div class="pull-request-merge">
583 583 <%include file="/pullrequests/pullrequest_merge_checks.mako"/>
584 584 </div>
585 585
586 586 ## main comment form and it status
587 587 ${comment.comments(h.url('pullrequest_comment', repo_name=c.repo_name,
588 588 pull_request_id=c.pull_request.pull_request_id),
589 589 c.pull_request_review_status,
590 590 is_pull_request=True, change_status=c.allowed_to_change_status)}
591 591 %endif
592 592
593 593 <script type="text/javascript">
594 594 if (location.hash) {
595 595 var result = splitDelimitedHash(location.hash);
596 596 var line = $('html').find(result.loc);
597 597 // show hidden comments if we use location.hash
598 598 if (line.hasClass('comment-general')) {
599 599 $(line).show();
600 600 } else if (line.hasClass('comment-inline')) {
601 601 $(line).show();
602 602 var $cb = $(line).closest('.cb');
603 603 $cb.removeClass('cb-collapsed')
604 604 }
605 605 if (line.length > 0){
606 606 offsetScroll(line, 70);
607 607 }
608 608 }
609 609
610 610 versionController = new VersionController();
611 611 versionController.init();
612 612
613 613
614 614 $(function(){
615 615 ReviewerAutoComplete('user');
616 616 // custom code mirror
617 617 var codeMirrorInstance = initPullRequestsCodeMirror('#pr-description-input');
618 618
619 619 var PRDetails = {
620 620 editButton: $('#open_edit_pullrequest'),
621 621 closeButton: $('#close_edit_pullrequest'),
622 622 deleteButton: $('#delete_pullrequest'),
623 623 viewFields: $('#pr-desc, #pr-title'),
624 624 editFields: $('#pr-desc-edit, #pr-title-edit, #pr-save'),
625 625
626 626 init: function() {
627 627 var that = this;
628 628 this.editButton.on('click', function(e) { that.edit(); });
629 629 this.closeButton.on('click', function(e) { that.view(); });
630 630 },
631 631
632 632 edit: function(event) {
633 633 this.viewFields.hide();
634 634 this.editButton.hide();
635 635 this.deleteButton.hide();
636 636 this.closeButton.show();
637 637 this.editFields.show();
638 638 codeMirrorInstance.refresh();
639 639 },
640 640
641 641 view: function(event) {
642 642 this.editButton.show();
643 643 this.deleteButton.show();
644 644 this.editFields.hide();
645 645 this.closeButton.hide();
646 646 this.viewFields.show();
647 647 }
648 648 };
649 649
650 650 var ReviewersPanel = {
651 651 editButton: $('#open_edit_reviewers'),
652 652 closeButton: $('#close_edit_reviewers'),
653 653 addButton: $('#add_reviewer_input'),
654 654 removeButtons: $('.reviewer_member_remove'),
655 655
656 656 init: function() {
657 657 var that = this;
658 658 this.editButton.on('click', function(e) { that.edit(); });
659 659 this.closeButton.on('click', function(e) { that.close(); });
660 660 },
661 661
662 662 edit: function(event) {
663 663 this.editButton.hide();
664 664 this.closeButton.show();
665 665 this.addButton.show();
666 666 this.removeButtons.css('visibility', 'visible');
667 667 },
668 668
669 669 close: function(event) {
670 670 this.editButton.show();
671 671 this.closeButton.hide();
672 672 this.addButton.hide();
673 673 this.removeButtons.css('visibility', 'hidden');
674 674 }
675 675 };
676 676
677 677 PRDetails.init();
678 678 ReviewersPanel.init();
679 679
680 680 showOutdated = function(self){
681 681 $('.comment-inline.comment-outdated').show();
682 682 $('.filediff-outdated').show();
683 683 $('.showOutdatedComments').hide();
684 684 $('.hideOutdatedComments').show();
685 685 };
686 686
687 687 hideOutdated = function(self){
688 688 $('.comment-inline.comment-outdated').hide();
689 689 $('.filediff-outdated').hide();
690 690 $('.hideOutdatedComments').hide();
691 691 $('.showOutdatedComments').show();
692 692 };
693 693
694 694 refreshMergeChecks = function(){
695 695 var loadUrl = "${h.url.current(merge_checks=1)}";
696 696 $('.pull-request-merge').css('opacity', 0.3);
697 $('.action-buttons-extra').css('opacity', 0.3);
698
697 699 $('.pull-request-merge').load(
698 loadUrl,function() {
700 loadUrl, function() {
699 701 $('.pull-request-merge').css('opacity', 1);
702
703 $('.action-buttons-extra').css('opacity', 1);
704 injectCloseAction();
700 705 }
701 706 );
702 707 };
703 708
709 injectCloseAction = function() {
710 var closeAction = $('#close-pull-request-action').html();
711 var $actionButtons = $('.action-buttons-extra');
712 // clear the action before
713 $actionButtons.html("");
714 $actionButtons.html(closeAction);
715 };
716
717 closePullRequest = function (status) {
718 // inject closing flag
719 $('.action-buttons-extra').append('<input type="hidden" class="close-pr-input" id="close_pull_request" value="1">');
720 $(generalCommentForm.statusChange).select2("val", status).trigger('change');
721 $(generalCommentForm.submitForm).submit();
722 };
723
704 724 $('#show-outdated-comments').on('click', function(e){
705 725 var button = $(this);
706 726 var outdated = $('.comment-outdated');
707 727
708 728 if (button.html() === "(Show)") {
709 729 button.html("(Hide)");
710 730 outdated.show();
711 731 } else {
712 732 button.html("(Show)");
713 733 outdated.hide();
714 734 }
715 735 });
716 736
717 737 $('.show-inline-comments').on('change', function(e){
718 738 var show = 'none';
719 739 var target = e.currentTarget;
720 740 if(target.checked){
721 741 show = ''
722 742 }
723 743 var boxid = $(target).attr('id_for');
724 744 var comments = $('#{0} .inline-comments'.format(boxid));
725 745 var fn_display = function(idx){
726 746 $(this).css('display', show);
727 747 };
728 748 $(comments).each(fn_display);
729 749 var btns = $('#{0} .inline-comments-button'.format(boxid));
730 750 $(btns).each(fn_display);
731 751 });
732 752
733 753 $('#merge_pull_request_form').submit(function() {
734 754 if (!$('#merge_pull_request').attr('disabled')) {
735 755 $('#merge_pull_request').attr('disabled', 'disabled');
736 756 }
737 757 return true;
738 758 });
739 759
740 760 $('#edit_pull_request').on('click', function(e){
741 761 var title = $('#pr-title-input').val();
742 762 var description = codeMirrorInstance.getValue();
743 763 editPullRequest(
744 764 "${c.repo_name}", "${c.pull_request.pull_request_id}",
745 765 title, description);
746 766 });
747 767
748 768 $('#update_pull_request').on('click', function(e){
749 769 updateReviewers(undefined, "${c.repo_name}", "${c.pull_request.pull_request_id}");
750 770 });
751 771
752 772 $('#update_commits').on('click', function(e){
753 773 var isDisabled = !$(e.currentTarget).attr('disabled');
754 774 $(e.currentTarget).text(_gettext('Updating...'));
755 775 $(e.currentTarget).attr('disabled', 'disabled');
756 776 if(isDisabled){
757 777 updateCommits("${c.repo_name}", "${c.pull_request.pull_request_id}");
758 778 }
759 779
760 780 });
761 781 // fixing issue with caches on firefox
762 782 $('#update_commits').removeAttr("disabled");
763 783
764 784 $('#close_pull_request').on('click', function(e){
765 785 closePullRequest("${c.repo_name}", "${c.pull_request.pull_request_id}");
766 786 });
767 787
768 788 $('.show-inline-comments').on('click', function(e){
769 789 var boxid = $(this).attr('data-comment-id');
770 790 var button = $(this);
771 791
772 792 if(button.hasClass("comments-visible")) {
773 793 $('#{0} .inline-comments'.format(boxid)).each(function(index){
774 794 $(this).hide();
775 795 });
776 796 button.removeClass("comments-visible");
777 797 } else {
778 798 $('#{0} .inline-comments'.format(boxid)).each(function(index){
779 799 $(this).show();
780 800 });
781 801 button.addClass("comments-visible");
782 802 }
783 803 });
784 804
785 805 // register submit callback on commentForm form to track TODOs
786 806 window.commentFormGlobalSubmitSuccessCallback = function(){
787 807 refreshMergeChecks();
788 808 };
809 // initial injection
810 injectCloseAction();
789 811
790 812 })
791 813 </script>
792 814
793 815 </div>
794 816 </div>
795 817
796 818 </%def>
@@ -1,1067 +1,1077 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-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 import mock
22 22 import pytest
23 23 from webob.exc import HTTPNotFound
24 24
25 25 import rhodecode
26 26 from rhodecode.lib.vcs.nodes import FileNode
27 27 from rhodecode.model.changeset_status import ChangesetStatusModel
28 28 from rhodecode.model.db import (
29 29 PullRequest, ChangesetStatus, UserLog, Notification)
30 30 from rhodecode.model.meta import Session
31 31 from rhodecode.model.pull_request import PullRequestModel
32 32 from rhodecode.model.user import UserModel
33 33 from rhodecode.model.repo import RepoModel
34 34 from rhodecode.tests import assert_session_flash, url, TEST_USER_ADMIN_LOGIN
35 35 from rhodecode.tests.utils import AssertResponse
36 36
37 37
38 38 @pytest.mark.usefixtures('app', 'autologin_user')
39 39 @pytest.mark.backends("git", "hg")
40 40 class TestPullrequestsController:
41 41
42 42 def test_index(self, backend):
43 43 self.app.get(url(
44 44 controller='pullrequests', action='index',
45 45 repo_name=backend.repo_name))
46 46
47 47 def test_option_menu_create_pull_request_exists(self, backend):
48 48 repo_name = backend.repo_name
49 49 response = self.app.get(url('summary_home', repo_name=repo_name))
50 50
51 51 create_pr_link = '<a href="%s">Create Pull Request</a>' % url(
52 52 'pullrequest', repo_name=repo_name)
53 53 response.mustcontain(create_pr_link)
54 54
55 55 def test_global_redirect_of_pr(self, backend, pr_util):
56 56 pull_request = pr_util.create_pull_request()
57 57
58 58 response = self.app.get(
59 59 url('pull_requests_global',
60 60 pull_request_id=pull_request.pull_request_id))
61 61
62 62 repo_name = pull_request.target_repo.repo_name
63 63 redirect_url = url('pullrequest_show', repo_name=repo_name,
64 64 pull_request_id=pull_request.pull_request_id)
65 65 assert response.status == '302 Found'
66 66 assert redirect_url in response.location
67 67
68 68 def test_create_pr_form_with_raw_commit_id(self, backend):
69 69 repo = backend.repo
70 70
71 71 self.app.get(
72 72 url(controller='pullrequests', action='index',
73 73 repo_name=repo.repo_name,
74 74 commit=repo.get_commit().raw_id),
75 75 status=200)
76 76
77 77 @pytest.mark.parametrize('pr_merge_enabled', [True, False])
78 78 def test_show(self, pr_util, pr_merge_enabled):
79 79 pull_request = pr_util.create_pull_request(
80 80 mergeable=pr_merge_enabled, enable_notifications=False)
81 81
82 82 response = self.app.get(url(
83 83 controller='pullrequests', action='show',
84 84 repo_name=pull_request.target_repo.scm_instance().name,
85 85 pull_request_id=str(pull_request.pull_request_id)))
86 86
87 87 for commit_id in pull_request.revisions:
88 88 response.mustcontain(commit_id)
89 89
90 90 assert pull_request.target_ref_parts.type in response
91 91 assert pull_request.target_ref_parts.name in response
92 92 target_clone_url = pull_request.target_repo.clone_url()
93 93 assert target_clone_url in response
94 94
95 95 assert 'class="pull-request-merge"' in response
96 96 assert (
97 97 'Server-side pull request merging is disabled.'
98 98 in response) != pr_merge_enabled
99 99
100 100 def test_close_status_visibility(self, pr_util, csrf_token):
101 101 from rhodecode.tests.functional.test_login import login_url, logut_url
102 102 # Logout
103 103 response = self.app.post(
104 104 logut_url,
105 105 params={'csrf_token': csrf_token})
106 106 # Login as regular user
107 107 response = self.app.post(login_url,
108 108 {'username': 'test_regular',
109 109 'password': 'test12'})
110 110
111 111 pull_request = pr_util.create_pull_request(author='test_regular')
112 112
113 113 response = self.app.get(url(
114 114 controller='pullrequests', action='show',
115 115 repo_name=pull_request.target_repo.scm_instance().name,
116 116 pull_request_id=str(pull_request.pull_request_id)))
117 117
118 assert 'Server-side pull request merging is disabled.' in response
119 assert 'value="forced_closed"' in response
118 response.mustcontain('Server-side pull request merging is disabled.')
119
120 assert_response = response.assert_response()
121 assert_response.one_element_exists('#close-pull-request-action')
120 122
121 123 def test_show_invalid_commit_id(self, pr_util):
122 124 # Simulating invalid revisions which will cause a lookup error
123 125 pull_request = pr_util.create_pull_request()
124 126 pull_request.revisions = ['invalid']
125 127 Session().add(pull_request)
126 128 Session().commit()
127 129
128 130 response = self.app.get(url(
129 131 controller='pullrequests', action='show',
130 132 repo_name=pull_request.target_repo.scm_instance().name,
131 133 pull_request_id=str(pull_request.pull_request_id)))
132 134
133 135 for commit_id in pull_request.revisions:
134 136 response.mustcontain(commit_id)
135 137
136 138 def test_show_invalid_source_reference(self, pr_util):
137 139 pull_request = pr_util.create_pull_request()
138 140 pull_request.source_ref = 'branch:b:invalid'
139 141 Session().add(pull_request)
140 142 Session().commit()
141 143
142 144 self.app.get(url(
143 145 controller='pullrequests', action='show',
144 146 repo_name=pull_request.target_repo.scm_instance().name,
145 147 pull_request_id=str(pull_request.pull_request_id)))
146 148
147 149 def test_edit_title_description(self, pr_util, csrf_token):
148 150 pull_request = pr_util.create_pull_request()
149 151 pull_request_id = pull_request.pull_request_id
150 152
151 153 response = self.app.post(
152 154 url(controller='pullrequests', action='update',
153 155 repo_name=pull_request.target_repo.repo_name,
154 156 pull_request_id=str(pull_request_id)),
155 157 params={
156 158 'edit_pull_request': 'true',
157 159 '_method': 'put',
158 160 'title': 'New title',
159 161 'description': 'New description',
160 162 'csrf_token': csrf_token})
161 163
162 164 assert_session_flash(
163 165 response, u'Pull request title & description updated.',
164 166 category='success')
165 167
166 168 pull_request = PullRequest.get(pull_request_id)
167 169 assert pull_request.title == 'New title'
168 170 assert pull_request.description == 'New description'
169 171
170 172 def test_edit_title_description_closed(self, pr_util, csrf_token):
171 173 pull_request = pr_util.create_pull_request()
172 174 pull_request_id = pull_request.pull_request_id
173 175 pr_util.close()
174 176
175 177 response = self.app.post(
176 178 url(controller='pullrequests', action='update',
177 179 repo_name=pull_request.target_repo.repo_name,
178 180 pull_request_id=str(pull_request_id)),
179 181 params={
180 182 'edit_pull_request': 'true',
181 183 '_method': 'put',
182 184 'title': 'New title',
183 185 'description': 'New description',
184 186 'csrf_token': csrf_token})
185 187
186 188 assert_session_flash(
187 189 response, u'Cannot update closed pull requests.',
188 190 category='error')
189 191
190 192 def test_update_invalid_source_reference(self, pr_util, csrf_token):
191 193 from rhodecode.lib.vcs.backends.base import UpdateFailureReason
192 194
193 195 pull_request = pr_util.create_pull_request()
194 196 pull_request.source_ref = 'branch:invalid-branch:invalid-commit-id'
195 197 Session().add(pull_request)
196 198 Session().commit()
197 199
198 200 pull_request_id = pull_request.pull_request_id
199 201
200 202 response = self.app.post(
201 203 url(controller='pullrequests', action='update',
202 204 repo_name=pull_request.target_repo.repo_name,
203 205 pull_request_id=str(pull_request_id)),
204 206 params={'update_commits': 'true', '_method': 'put',
205 207 'csrf_token': csrf_token})
206 208
207 209 expected_msg = PullRequestModel.UPDATE_STATUS_MESSAGES[
208 210 UpdateFailureReason.MISSING_SOURCE_REF]
209 211 assert_session_flash(response, expected_msg, category='error')
210 212
211 213 def test_missing_target_reference(self, pr_util, csrf_token):
212 214 from rhodecode.lib.vcs.backends.base import MergeFailureReason
213 215 pull_request = pr_util.create_pull_request(
214 216 approved=True, mergeable=True)
215 217 pull_request.target_ref = 'branch:invalid-branch:invalid-commit-id'
216 218 Session().add(pull_request)
217 219 Session().commit()
218 220
219 221 pull_request_id = pull_request.pull_request_id
220 222 pull_request_url = url(
221 223 controller='pullrequests', action='show',
222 224 repo_name=pull_request.target_repo.repo_name,
223 225 pull_request_id=str(pull_request_id))
224 226
225 227 response = self.app.get(pull_request_url)
226 228
227 229 assertr = AssertResponse(response)
228 230 expected_msg = PullRequestModel.MERGE_STATUS_MESSAGES[
229 231 MergeFailureReason.MISSING_TARGET_REF]
230 232 assertr.element_contains(
231 233 'span[data-role="merge-message"]', str(expected_msg))
232 234
233 235 def test_comment_and_close_pull_request(self, pr_util, csrf_token):
234 236 pull_request = pr_util.create_pull_request(approved=True)
235 237 pull_request_id = pull_request.pull_request_id
236 238 author = pull_request.user_id
237 239 repo = pull_request.target_repo.repo_id
238 240
239 241 self.app.post(
240 242 url(controller='pullrequests',
241 243 action='comment',
242 244 repo_name=pull_request.target_repo.scm_instance().name,
243 245 pull_request_id=str(pull_request_id)),
244 246 params={
245 'changeset_status':
246 ChangesetStatus.STATUS_APPROVED + '_closed',
247 'change_changeset_status': 'on',
248 'text': '',
247 'changeset_status': ChangesetStatus.STATUS_APPROVED,
248 'close_pull_request': '1',
249 'text': 'Closing a PR',
249 250 'csrf_token': csrf_token},
250 251 status=302)
251 252
252 253 action = 'user_closed_pull_request:%d' % pull_request_id
253 254 journal = UserLog.query()\
254 255 .filter(UserLog.user_id == author)\
255 256 .filter(UserLog.repository_id == repo)\
256 257 .filter(UserLog.action == action)\
257 258 .all()
258 259 assert len(journal) == 1
259 260
261 pull_request = PullRequest.get(pull_request_id)
262 assert pull_request.is_closed()
263
264 # check only the latest status, not the review status
265 status = ChangesetStatusModel().get_status(
266 pull_request.source_repo, pull_request=pull_request)
267 assert status == ChangesetStatus.STATUS_APPROVED
268
260 269 def test_reject_and_close_pull_request(self, pr_util, csrf_token):
261 270 pull_request = pr_util.create_pull_request()
262 271 pull_request_id = pull_request.pull_request_id
263 272 response = self.app.post(
264 273 url(controller='pullrequests',
265 274 action='update',
266 275 repo_name=pull_request.target_repo.scm_instance().name,
267 276 pull_request_id=str(pull_request.pull_request_id)),
268 277 params={'close_pull_request': 'true', '_method': 'put',
269 278 'csrf_token': csrf_token})
270 279
271 280 pull_request = PullRequest.get(pull_request_id)
272 281
273 282 assert response.json is True
274 283 assert pull_request.is_closed()
275 284
276 285 # check only the latest status, not the review status
277 286 status = ChangesetStatusModel().get_status(
278 287 pull_request.source_repo, pull_request=pull_request)
279 288 assert status == ChangesetStatus.STATUS_REJECTED
280 289
281 290 def test_comment_force_close_pull_request(self, pr_util, csrf_token):
282 291 pull_request = pr_util.create_pull_request()
283 292 pull_request_id = pull_request.pull_request_id
284 293 reviewers_data = [(1, ['reason']), (2, ['reason2'])]
285 294 PullRequestModel().update_reviewers(pull_request_id, reviewers_data)
286 295 author = pull_request.user_id
287 296 repo = pull_request.target_repo.repo_id
288 297 self.app.post(
289 298 url(controller='pullrequests',
290 299 action='comment',
291 300 repo_name=pull_request.target_repo.scm_instance().name,
292 301 pull_request_id=str(pull_request_id)),
293 302 params={
294 'changeset_status': 'forced_closed',
303 'changeset_status': 'rejected',
304 'close_pull_request': '1',
295 305 'csrf_token': csrf_token},
296 306 status=302)
297 307
298 308 pull_request = PullRequest.get(pull_request_id)
299 309
300 310 action = 'user_closed_pull_request:%d' % pull_request_id
301 311 journal = UserLog.query().filter(
302 312 UserLog.user_id == author,
303 313 UserLog.repository_id == repo,
304 314 UserLog.action == action).all()
305 315 assert len(journal) == 1
306 316
307 317 # check only the latest status, not the review status
308 318 status = ChangesetStatusModel().get_status(
309 319 pull_request.source_repo, pull_request=pull_request)
310 320 assert status == ChangesetStatus.STATUS_REJECTED
311 321
312 322 def test_create_pull_request(self, backend, csrf_token):
313 323 commits = [
314 324 {'message': 'ancestor'},
315 325 {'message': 'change'},
316 326 {'message': 'change2'},
317 327 ]
318 328 commit_ids = backend.create_master_repo(commits)
319 329 target = backend.create_repo(heads=['ancestor'])
320 330 source = backend.create_repo(heads=['change2'])
321 331
322 332 response = self.app.post(
323 333 url(
324 334 controller='pullrequests',
325 335 action='create',
326 336 repo_name=source.repo_name
327 337 ),
328 338 [
329 339 ('source_repo', source.repo_name),
330 340 ('source_ref', 'branch:default:' + commit_ids['change2']),
331 341 ('target_repo', target.repo_name),
332 342 ('target_ref', 'branch:default:' + commit_ids['ancestor']),
333 343 ('pullrequest_desc', 'Description'),
334 344 ('pullrequest_title', 'Title'),
335 345 ('__start__', 'review_members:sequence'),
336 346 ('__start__', 'reviewer:mapping'),
337 347 ('user_id', '1'),
338 348 ('__start__', 'reasons:sequence'),
339 349 ('reason', 'Some reason'),
340 350 ('__end__', 'reasons:sequence'),
341 351 ('__end__', 'reviewer:mapping'),
342 352 ('__end__', 'review_members:sequence'),
343 353 ('__start__', 'revisions:sequence'),
344 354 ('revisions', commit_ids['change']),
345 355 ('revisions', commit_ids['change2']),
346 356 ('__end__', 'revisions:sequence'),
347 357 ('user', ''),
348 358 ('csrf_token', csrf_token),
349 359 ],
350 360 status=302)
351 361
352 362 location = response.headers['Location']
353 363 pull_request_id = int(location.rsplit('/', 1)[1])
354 364 pull_request = PullRequest.get(pull_request_id)
355 365
356 366 # check that we have now both revisions
357 367 assert pull_request.revisions == [commit_ids['change2'], commit_ids['change']]
358 368 assert pull_request.source_ref == 'branch:default:' + commit_ids['change2']
359 369 expected_target_ref = 'branch:default:' + commit_ids['ancestor']
360 370 assert pull_request.target_ref == expected_target_ref
361 371
362 372 def test_reviewer_notifications(self, backend, csrf_token):
363 373 # We have to use the app.post for this test so it will create the
364 374 # notifications properly with the new PR
365 375 commits = [
366 376 {'message': 'ancestor',
367 377 'added': [FileNode('file_A', content='content_of_ancestor')]},
368 378 {'message': 'change',
369 379 'added': [FileNode('file_a', content='content_of_change')]},
370 380 {'message': 'change-child'},
371 381 {'message': 'ancestor-child', 'parents': ['ancestor'],
372 382 'added': [
373 383 FileNode('file_B', content='content_of_ancestor_child')]},
374 384 {'message': 'ancestor-child-2'},
375 385 ]
376 386 commit_ids = backend.create_master_repo(commits)
377 387 target = backend.create_repo(heads=['ancestor-child'])
378 388 source = backend.create_repo(heads=['change'])
379 389
380 390 response = self.app.post(
381 391 url(
382 392 controller='pullrequests',
383 393 action='create',
384 394 repo_name=source.repo_name
385 395 ),
386 396 [
387 397 ('source_repo', source.repo_name),
388 398 ('source_ref', 'branch:default:' + commit_ids['change']),
389 399 ('target_repo', target.repo_name),
390 400 ('target_ref', 'branch:default:' + commit_ids['ancestor-child']),
391 401 ('pullrequest_desc', 'Description'),
392 402 ('pullrequest_title', 'Title'),
393 403 ('__start__', 'review_members:sequence'),
394 404 ('__start__', 'reviewer:mapping'),
395 405 ('user_id', '2'),
396 406 ('__start__', 'reasons:sequence'),
397 407 ('reason', 'Some reason'),
398 408 ('__end__', 'reasons:sequence'),
399 409 ('__end__', 'reviewer:mapping'),
400 410 ('__end__', 'review_members:sequence'),
401 411 ('__start__', 'revisions:sequence'),
402 412 ('revisions', commit_ids['change']),
403 413 ('__end__', 'revisions:sequence'),
404 414 ('user', ''),
405 415 ('csrf_token', csrf_token),
406 416 ],
407 417 status=302)
408 418
409 419 location = response.headers['Location']
410 420 pull_request_id = int(location.rsplit('/', 1)[1])
411 421 pull_request = PullRequest.get(pull_request_id)
412 422
413 423 # Check that a notification was made
414 424 notifications = Notification.query()\
415 425 .filter(Notification.created_by == pull_request.author.user_id,
416 426 Notification.type_ == Notification.TYPE_PULL_REQUEST,
417 427 Notification.subject.contains("wants you to review "
418 428 "pull request #%d"
419 429 % pull_request_id))
420 430 assert len(notifications.all()) == 1
421 431
422 432 # Change reviewers and check that a notification was made
423 433 PullRequestModel().update_reviewers(
424 434 pull_request.pull_request_id, [(1, [])])
425 435 assert len(notifications.all()) == 2
426 436
427 437 def test_create_pull_request_stores_ancestor_commit_id(self, backend,
428 438 csrf_token):
429 439 commits = [
430 440 {'message': 'ancestor',
431 441 'added': [FileNode('file_A', content='content_of_ancestor')]},
432 442 {'message': 'change',
433 443 'added': [FileNode('file_a', content='content_of_change')]},
434 444 {'message': 'change-child'},
435 445 {'message': 'ancestor-child', 'parents': ['ancestor'],
436 446 'added': [
437 447 FileNode('file_B', content='content_of_ancestor_child')]},
438 448 {'message': 'ancestor-child-2'},
439 449 ]
440 450 commit_ids = backend.create_master_repo(commits)
441 451 target = backend.create_repo(heads=['ancestor-child'])
442 452 source = backend.create_repo(heads=['change'])
443 453
444 454 response = self.app.post(
445 455 url(
446 456 controller='pullrequests',
447 457 action='create',
448 458 repo_name=source.repo_name
449 459 ),
450 460 [
451 461 ('source_repo', source.repo_name),
452 462 ('source_ref', 'branch:default:' + commit_ids['change']),
453 463 ('target_repo', target.repo_name),
454 464 ('target_ref', 'branch:default:' + commit_ids['ancestor-child']),
455 465 ('pullrequest_desc', 'Description'),
456 466 ('pullrequest_title', 'Title'),
457 467 ('__start__', 'review_members:sequence'),
458 468 ('__start__', 'reviewer:mapping'),
459 469 ('user_id', '1'),
460 470 ('__start__', 'reasons:sequence'),
461 471 ('reason', 'Some reason'),
462 472 ('__end__', 'reasons:sequence'),
463 473 ('__end__', 'reviewer:mapping'),
464 474 ('__end__', 'review_members:sequence'),
465 475 ('__start__', 'revisions:sequence'),
466 476 ('revisions', commit_ids['change']),
467 477 ('__end__', 'revisions:sequence'),
468 478 ('user', ''),
469 479 ('csrf_token', csrf_token),
470 480 ],
471 481 status=302)
472 482
473 483 location = response.headers['Location']
474 484 pull_request_id = int(location.rsplit('/', 1)[1])
475 485 pull_request = PullRequest.get(pull_request_id)
476 486
477 487 # target_ref has to point to the ancestor's commit_id in order to
478 488 # show the correct diff
479 489 expected_target_ref = 'branch:default:' + commit_ids['ancestor']
480 490 assert pull_request.target_ref == expected_target_ref
481 491
482 492 # Check generated diff contents
483 493 response = response.follow()
484 494 assert 'content_of_ancestor' not in response.body
485 495 assert 'content_of_ancestor-child' not in response.body
486 496 assert 'content_of_change' in response.body
487 497
488 498 def test_merge_pull_request_enabled(self, pr_util, csrf_token):
489 499 # Clear any previous calls to rcextensions
490 500 rhodecode.EXTENSIONS.calls.clear()
491 501
492 502 pull_request = pr_util.create_pull_request(
493 503 approved=True, mergeable=True)
494 504 pull_request_id = pull_request.pull_request_id
495 505 repo_name = pull_request.target_repo.scm_instance().name,
496 506
497 507 response = self.app.post(
498 508 url(controller='pullrequests',
499 509 action='merge',
500 510 repo_name=str(repo_name[0]),
501 511 pull_request_id=str(pull_request_id)),
502 512 params={'csrf_token': csrf_token}).follow()
503 513
504 514 pull_request = PullRequest.get(pull_request_id)
505 515
506 516 assert response.status_int == 200
507 517 assert pull_request.is_closed()
508 518 assert_pull_request_status(
509 519 pull_request, ChangesetStatus.STATUS_APPROVED)
510 520
511 521 # Check the relevant log entries were added
512 522 user_logs = UserLog.query().order_by('-user_log_id').limit(4)
513 523 actions = [log.action for log in user_logs]
514 524 pr_commit_ids = PullRequestModel()._get_commit_ids(pull_request)
515 525 expected_actions = [
516 526 u'user_closed_pull_request:%d' % pull_request_id,
517 527 u'user_merged_pull_request:%d' % pull_request_id,
518 528 # The action below reflect that the post push actions were executed
519 529 u'user_commented_pull_request:%d' % pull_request_id,
520 530 u'push:%s' % ','.join(pr_commit_ids),
521 531 ]
522 532 assert actions == expected_actions
523 533
524 534 # Check post_push rcextension was really executed
525 535 push_calls = rhodecode.EXTENSIONS.calls['post_push']
526 536 assert len(push_calls) == 1
527 537 unused_last_call_args, last_call_kwargs = push_calls[0]
528 538 assert last_call_kwargs['action'] == 'push'
529 539 assert last_call_kwargs['pushed_revs'] == pr_commit_ids
530 540
531 541 def test_merge_pull_request_disabled(self, pr_util, csrf_token):
532 542 pull_request = pr_util.create_pull_request(mergeable=False)
533 543 pull_request_id = pull_request.pull_request_id
534 544 pull_request = PullRequest.get(pull_request_id)
535 545
536 546 response = self.app.post(
537 547 url(controller='pullrequests',
538 548 action='merge',
539 549 repo_name=pull_request.target_repo.scm_instance().name,
540 550 pull_request_id=str(pull_request.pull_request_id)),
541 551 params={'csrf_token': csrf_token}).follow()
542 552
543 553 assert response.status_int == 200
544 554 response.mustcontain(
545 555 'Merge is not currently possible because of below failed checks.')
546 556 response.mustcontain('Server-side pull request merging is disabled.')
547 557
548 558 @pytest.mark.skip_backends('svn')
549 559 def test_merge_pull_request_not_approved(self, pr_util, csrf_token):
550 560 pull_request = pr_util.create_pull_request(mergeable=True)
551 561 pull_request_id = pull_request.pull_request_id
552 562 repo_name = pull_request.target_repo.scm_instance().name,
553 563
554 564 response = self.app.post(
555 565 url(controller='pullrequests',
556 566 action='merge',
557 567 repo_name=str(repo_name[0]),
558 568 pull_request_id=str(pull_request_id)),
559 569 params={'csrf_token': csrf_token}).follow()
560 570
561 571 assert response.status_int == 200
562 572
563 573 response.mustcontain(
564 574 'Merge is not currently possible because of below failed checks.')
565 575 response.mustcontain('Pull request reviewer approval is pending.')
566 576
567 577 def test_update_source_revision(self, backend, csrf_token):
568 578 commits = [
569 579 {'message': 'ancestor'},
570 580 {'message': 'change'},
571 581 {'message': 'change-2'},
572 582 ]
573 583 commit_ids = backend.create_master_repo(commits)
574 584 target = backend.create_repo(heads=['ancestor'])
575 585 source = backend.create_repo(heads=['change'])
576 586
577 587 # create pr from a in source to A in target
578 588 pull_request = PullRequest()
579 589 pull_request.source_repo = source
580 590 # TODO: johbo: Make sure that we write the source ref this way!
581 591 pull_request.source_ref = 'branch:{branch}:{commit_id}'.format(
582 592 branch=backend.default_branch_name, commit_id=commit_ids['change'])
583 593 pull_request.target_repo = target
584 594
585 595 pull_request.target_ref = 'branch:{branch}:{commit_id}'.format(
586 596 branch=backend.default_branch_name,
587 597 commit_id=commit_ids['ancestor'])
588 598 pull_request.revisions = [commit_ids['change']]
589 599 pull_request.title = u"Test"
590 600 pull_request.description = u"Description"
591 601 pull_request.author = UserModel().get_by_username(
592 602 TEST_USER_ADMIN_LOGIN)
593 603 Session().add(pull_request)
594 604 Session().commit()
595 605 pull_request_id = pull_request.pull_request_id
596 606
597 607 # source has ancestor - change - change-2
598 608 backend.pull_heads(source, heads=['change-2'])
599 609
600 610 # update PR
601 611 self.app.post(
602 612 url(controller='pullrequests', action='update',
603 613 repo_name=target.repo_name,
604 614 pull_request_id=str(pull_request_id)),
605 615 params={'update_commits': 'true', '_method': 'put',
606 616 'csrf_token': csrf_token})
607 617
608 618 # check that we have now both revisions
609 619 pull_request = PullRequest.get(pull_request_id)
610 620 assert pull_request.revisions == [
611 621 commit_ids['change-2'], commit_ids['change']]
612 622
613 623 # TODO: johbo: this should be a test on its own
614 624 response = self.app.get(url(
615 625 controller='pullrequests', action='index',
616 626 repo_name=target.repo_name))
617 627 assert response.status_int == 200
618 628 assert 'Pull request updated to' in response.body
619 629 assert 'with 1 added, 0 removed commits.' in response.body
620 630
621 631 def test_update_target_revision(self, backend, csrf_token):
622 632 commits = [
623 633 {'message': 'ancestor'},
624 634 {'message': 'change'},
625 635 {'message': 'ancestor-new', 'parents': ['ancestor']},
626 636 {'message': 'change-rebased'},
627 637 ]
628 638 commit_ids = backend.create_master_repo(commits)
629 639 target = backend.create_repo(heads=['ancestor'])
630 640 source = backend.create_repo(heads=['change'])
631 641
632 642 # create pr from a in source to A in target
633 643 pull_request = PullRequest()
634 644 pull_request.source_repo = source
635 645 # TODO: johbo: Make sure that we write the source ref this way!
636 646 pull_request.source_ref = 'branch:{branch}:{commit_id}'.format(
637 647 branch=backend.default_branch_name, commit_id=commit_ids['change'])
638 648 pull_request.target_repo = target
639 649 # TODO: johbo: Target ref should be branch based, since tip can jump
640 650 # from branch to branch
641 651 pull_request.target_ref = 'branch:{branch}:{commit_id}'.format(
642 652 branch=backend.default_branch_name,
643 653 commit_id=commit_ids['ancestor'])
644 654 pull_request.revisions = [commit_ids['change']]
645 655 pull_request.title = u"Test"
646 656 pull_request.description = u"Description"
647 657 pull_request.author = UserModel().get_by_username(
648 658 TEST_USER_ADMIN_LOGIN)
649 659 Session().add(pull_request)
650 660 Session().commit()
651 661 pull_request_id = pull_request.pull_request_id
652 662
653 663 # target has ancestor - ancestor-new
654 664 # source has ancestor - ancestor-new - change-rebased
655 665 backend.pull_heads(target, heads=['ancestor-new'])
656 666 backend.pull_heads(source, heads=['change-rebased'])
657 667
658 668 # update PR
659 669 self.app.post(
660 670 url(controller='pullrequests', action='update',
661 671 repo_name=target.repo_name,
662 672 pull_request_id=str(pull_request_id)),
663 673 params={'update_commits': 'true', '_method': 'put',
664 674 'csrf_token': csrf_token},
665 675 status=200)
666 676
667 677 # check that we have now both revisions
668 678 pull_request = PullRequest.get(pull_request_id)
669 679 assert pull_request.revisions == [commit_ids['change-rebased']]
670 680 assert pull_request.target_ref == 'branch:{branch}:{commit_id}'.format(
671 681 branch=backend.default_branch_name,
672 682 commit_id=commit_ids['ancestor-new'])
673 683
674 684 # TODO: johbo: This should be a test on its own
675 685 response = self.app.get(url(
676 686 controller='pullrequests', action='index',
677 687 repo_name=target.repo_name))
678 688 assert response.status_int == 200
679 689 assert 'Pull request updated to' in response.body
680 690 assert 'with 1 added, 1 removed commits.' in response.body
681 691
682 692 def test_update_of_ancestor_reference(self, backend, csrf_token):
683 693 commits = [
684 694 {'message': 'ancestor'},
685 695 {'message': 'change'},
686 696 {'message': 'change-2'},
687 697 {'message': 'ancestor-new', 'parents': ['ancestor']},
688 698 {'message': 'change-rebased'},
689 699 ]
690 700 commit_ids = backend.create_master_repo(commits)
691 701 target = backend.create_repo(heads=['ancestor'])
692 702 source = backend.create_repo(heads=['change'])
693 703
694 704 # create pr from a in source to A in target
695 705 pull_request = PullRequest()
696 706 pull_request.source_repo = source
697 707 # TODO: johbo: Make sure that we write the source ref this way!
698 708 pull_request.source_ref = 'branch:{branch}:{commit_id}'.format(
699 709 branch=backend.default_branch_name,
700 710 commit_id=commit_ids['change'])
701 711 pull_request.target_repo = target
702 712 # TODO: johbo: Target ref should be branch based, since tip can jump
703 713 # from branch to branch
704 714 pull_request.target_ref = 'branch:{branch}:{commit_id}'.format(
705 715 branch=backend.default_branch_name,
706 716 commit_id=commit_ids['ancestor'])
707 717 pull_request.revisions = [commit_ids['change']]
708 718 pull_request.title = u"Test"
709 719 pull_request.description = u"Description"
710 720 pull_request.author = UserModel().get_by_username(
711 721 TEST_USER_ADMIN_LOGIN)
712 722 Session().add(pull_request)
713 723 Session().commit()
714 724 pull_request_id = pull_request.pull_request_id
715 725
716 726 # target has ancestor - ancestor-new
717 727 # source has ancestor - ancestor-new - change-rebased
718 728 backend.pull_heads(target, heads=['ancestor-new'])
719 729 backend.pull_heads(source, heads=['change-rebased'])
720 730
721 731 # update PR
722 732 self.app.post(
723 733 url(controller='pullrequests', action='update',
724 734 repo_name=target.repo_name,
725 735 pull_request_id=str(pull_request_id)),
726 736 params={'update_commits': 'true', '_method': 'put',
727 737 'csrf_token': csrf_token},
728 738 status=200)
729 739
730 740 # Expect the target reference to be updated correctly
731 741 pull_request = PullRequest.get(pull_request_id)
732 742 assert pull_request.revisions == [commit_ids['change-rebased']]
733 743 expected_target_ref = 'branch:{branch}:{commit_id}'.format(
734 744 branch=backend.default_branch_name,
735 745 commit_id=commit_ids['ancestor-new'])
736 746 assert pull_request.target_ref == expected_target_ref
737 747
738 748 def test_remove_pull_request_branch(self, backend_git, csrf_token):
739 749 branch_name = 'development'
740 750 commits = [
741 751 {'message': 'initial-commit'},
742 752 {'message': 'old-feature'},
743 753 {'message': 'new-feature', 'branch': branch_name},
744 754 ]
745 755 repo = backend_git.create_repo(commits)
746 756 commit_ids = backend_git.commit_ids
747 757
748 758 pull_request = PullRequest()
749 759 pull_request.source_repo = repo
750 760 pull_request.target_repo = repo
751 761 pull_request.source_ref = 'branch:{branch}:{commit_id}'.format(
752 762 branch=branch_name, commit_id=commit_ids['new-feature'])
753 763 pull_request.target_ref = 'branch:{branch}:{commit_id}'.format(
754 764 branch=backend_git.default_branch_name,
755 765 commit_id=commit_ids['old-feature'])
756 766 pull_request.revisions = [commit_ids['new-feature']]
757 767 pull_request.title = u"Test"
758 768 pull_request.description = u"Description"
759 769 pull_request.author = UserModel().get_by_username(
760 770 TEST_USER_ADMIN_LOGIN)
761 771 Session().add(pull_request)
762 772 Session().commit()
763 773
764 774 vcs = repo.scm_instance()
765 775 vcs.remove_ref('refs/heads/{}'.format(branch_name))
766 776
767 777 response = self.app.get(url(
768 778 controller='pullrequests', action='show',
769 779 repo_name=repo.repo_name,
770 780 pull_request_id=str(pull_request.pull_request_id)))
771 781
772 782 assert response.status_int == 200
773 783 assert_response = AssertResponse(response)
774 784 assert_response.element_contains(
775 785 '#changeset_compare_view_content .alert strong',
776 786 'Missing commits')
777 787 assert_response.element_contains(
778 788 '#changeset_compare_view_content .alert',
779 789 'This pull request cannot be displayed, because one or more'
780 790 ' commits no longer exist in the source repository.')
781 791
782 792 def test_strip_commits_from_pull_request(
783 793 self, backend, pr_util, csrf_token):
784 794 commits = [
785 795 {'message': 'initial-commit'},
786 796 {'message': 'old-feature'},
787 797 {'message': 'new-feature', 'parents': ['initial-commit']},
788 798 ]
789 799 pull_request = pr_util.create_pull_request(
790 800 commits, target_head='initial-commit', source_head='new-feature',
791 801 revisions=['new-feature'])
792 802
793 803 vcs = pr_util.source_repository.scm_instance()
794 804 if backend.alias == 'git':
795 805 vcs.strip(pr_util.commit_ids['new-feature'], branch_name='master')
796 806 else:
797 807 vcs.strip(pr_util.commit_ids['new-feature'])
798 808
799 809 response = self.app.get(url(
800 810 controller='pullrequests', action='show',
801 811 repo_name=pr_util.target_repository.repo_name,
802 812 pull_request_id=str(pull_request.pull_request_id)))
803 813
804 814 assert response.status_int == 200
805 815 assert_response = AssertResponse(response)
806 816 assert_response.element_contains(
807 817 '#changeset_compare_view_content .alert strong',
808 818 'Missing commits')
809 819 assert_response.element_contains(
810 820 '#changeset_compare_view_content .alert',
811 821 'This pull request cannot be displayed, because one or more'
812 822 ' commits no longer exist in the source repository.')
813 823 assert_response.element_contains(
814 824 '#update_commits',
815 825 'Update commits')
816 826
817 827 def test_strip_commits_and_update(
818 828 self, backend, pr_util, csrf_token):
819 829 commits = [
820 830 {'message': 'initial-commit'},
821 831 {'message': 'old-feature'},
822 832 {'message': 'new-feature', 'parents': ['old-feature']},
823 833 ]
824 834 pull_request = pr_util.create_pull_request(
825 835 commits, target_head='old-feature', source_head='new-feature',
826 836 revisions=['new-feature'], mergeable=True)
827 837
828 838 vcs = pr_util.source_repository.scm_instance()
829 839 if backend.alias == 'git':
830 840 vcs.strip(pr_util.commit_ids['new-feature'], branch_name='master')
831 841 else:
832 842 vcs.strip(pr_util.commit_ids['new-feature'])
833 843
834 844 response = self.app.post(
835 845 url(controller='pullrequests', action='update',
836 846 repo_name=pull_request.target_repo.repo_name,
837 847 pull_request_id=str(pull_request.pull_request_id)),
838 848 params={'update_commits': 'true', '_method': 'put',
839 849 'csrf_token': csrf_token})
840 850
841 851 assert response.status_int == 200
842 852 assert response.body == 'true'
843 853
844 854 # Make sure that after update, it won't raise 500 errors
845 855 response = self.app.get(url(
846 856 controller='pullrequests', action='show',
847 857 repo_name=pr_util.target_repository.repo_name,
848 858 pull_request_id=str(pull_request.pull_request_id)))
849 859
850 860 assert response.status_int == 200
851 861 assert_response = AssertResponse(response)
852 862 assert_response.element_contains(
853 863 '#changeset_compare_view_content .alert strong',
854 864 'Missing commits')
855 865
856 866 def test_branch_is_a_link(self, pr_util):
857 867 pull_request = pr_util.create_pull_request()
858 868 pull_request.source_ref = 'branch:origin:1234567890abcdef'
859 869 pull_request.target_ref = 'branch:target:abcdef1234567890'
860 870 Session().add(pull_request)
861 871 Session().commit()
862 872
863 873 response = self.app.get(url(
864 874 controller='pullrequests', action='show',
865 875 repo_name=pull_request.target_repo.scm_instance().name,
866 876 pull_request_id=str(pull_request.pull_request_id)))
867 877 assert response.status_int == 200
868 878 assert_response = AssertResponse(response)
869 879
870 880 origin = assert_response.get_element('.pr-origininfo .tag')
871 881 origin_children = origin.getchildren()
872 882 assert len(origin_children) == 1
873 883 target = assert_response.get_element('.pr-targetinfo .tag')
874 884 target_children = target.getchildren()
875 885 assert len(target_children) == 1
876 886
877 887 expected_origin_link = url(
878 888 'changelog_home',
879 889 repo_name=pull_request.source_repo.scm_instance().name,
880 890 branch='origin')
881 891 expected_target_link = url(
882 892 'changelog_home',
883 893 repo_name=pull_request.target_repo.scm_instance().name,
884 894 branch='target')
885 895 assert origin_children[0].attrib['href'] == expected_origin_link
886 896 assert origin_children[0].text == 'branch: origin'
887 897 assert target_children[0].attrib['href'] == expected_target_link
888 898 assert target_children[0].text == 'branch: target'
889 899
890 900 def test_bookmark_is_not_a_link(self, pr_util):
891 901 pull_request = pr_util.create_pull_request()
892 902 pull_request.source_ref = 'bookmark:origin:1234567890abcdef'
893 903 pull_request.target_ref = 'bookmark:target:abcdef1234567890'
894 904 Session().add(pull_request)
895 905 Session().commit()
896 906
897 907 response = self.app.get(url(
898 908 controller='pullrequests', action='show',
899 909 repo_name=pull_request.target_repo.scm_instance().name,
900 910 pull_request_id=str(pull_request.pull_request_id)))
901 911 assert response.status_int == 200
902 912 assert_response = AssertResponse(response)
903 913
904 914 origin = assert_response.get_element('.pr-origininfo .tag')
905 915 assert origin.text.strip() == 'bookmark: origin'
906 916 assert origin.getchildren() == []
907 917
908 918 target = assert_response.get_element('.pr-targetinfo .tag')
909 919 assert target.text.strip() == 'bookmark: target'
910 920 assert target.getchildren() == []
911 921
912 922 def test_tag_is_not_a_link(self, pr_util):
913 923 pull_request = pr_util.create_pull_request()
914 924 pull_request.source_ref = 'tag:origin:1234567890abcdef'
915 925 pull_request.target_ref = 'tag:target:abcdef1234567890'
916 926 Session().add(pull_request)
917 927 Session().commit()
918 928
919 929 response = self.app.get(url(
920 930 controller='pullrequests', action='show',
921 931 repo_name=pull_request.target_repo.scm_instance().name,
922 932 pull_request_id=str(pull_request.pull_request_id)))
923 933 assert response.status_int == 200
924 934 assert_response = AssertResponse(response)
925 935
926 936 origin = assert_response.get_element('.pr-origininfo .tag')
927 937 assert origin.text.strip() == 'tag: origin'
928 938 assert origin.getchildren() == []
929 939
930 940 target = assert_response.get_element('.pr-targetinfo .tag')
931 941 assert target.text.strip() == 'tag: target'
932 942 assert target.getchildren() == []
933 943
934 944 def test_description_is_escaped_on_index_page(self, backend, pr_util):
935 945 xss_description = "<script>alert('Hi!')</script>"
936 946 pull_request = pr_util.create_pull_request(description=xss_description)
937 947 response = self.app.get(url(
938 948 controller='pullrequests', action='show_all',
939 949 repo_name=pull_request.target_repo.repo_name))
940 950 response.mustcontain(
941 951 "&lt;script&gt;alert(&#39;Hi!&#39;)&lt;/script&gt;")
942 952
943 953 @pytest.mark.parametrize('mergeable', [True, False])
944 954 def test_shadow_repository_link(
945 955 self, mergeable, pr_util, http_host_stub):
946 956 """
947 957 Check that the pull request summary page displays a link to the shadow
948 958 repository if the pull request is mergeable. If it is not mergeable
949 959 the link should not be displayed.
950 960 """
951 961 pull_request = pr_util.create_pull_request(
952 962 mergeable=mergeable, enable_notifications=False)
953 963 target_repo = pull_request.target_repo.scm_instance()
954 964 pr_id = pull_request.pull_request_id
955 965 shadow_url = '{host}/{repo}/pull-request/{pr_id}/repository'.format(
956 966 host=http_host_stub, repo=target_repo.name, pr_id=pr_id)
957 967
958 968 response = self.app.get(url(
959 969 controller='pullrequests', action='show',
960 970 repo_name=target_repo.name,
961 971 pull_request_id=str(pr_id)))
962 972
963 973 assertr = AssertResponse(response)
964 974 if mergeable:
965 975 assertr.element_value_contains(
966 976 'div.pr-mergeinfo input', shadow_url)
967 977 assertr.element_value_contains(
968 978 'div.pr-mergeinfo input', 'pr-merge')
969 979 else:
970 980 assertr.no_element_exists('div.pr-mergeinfo')
971 981
972 982
973 983 @pytest.mark.usefixtures('app')
974 984 @pytest.mark.backends("git", "hg")
975 985 class TestPullrequestsControllerDelete(object):
976 986 def test_pull_request_delete_button_permissions_admin(
977 987 self, autologin_user, user_admin, pr_util):
978 988 pull_request = pr_util.create_pull_request(
979 989 author=user_admin.username, enable_notifications=False)
980 990
981 991 response = self.app.get(url(
982 992 controller='pullrequests', action='show',
983 993 repo_name=pull_request.target_repo.scm_instance().name,
984 994 pull_request_id=str(pull_request.pull_request_id)))
985 995
986 996 response.mustcontain('id="delete_pullrequest"')
987 997 response.mustcontain('Confirm to delete this pull request')
988 998
989 999 def test_pull_request_delete_button_permissions_owner(
990 1000 self, autologin_regular_user, user_regular, pr_util):
991 1001 pull_request = pr_util.create_pull_request(
992 1002 author=user_regular.username, enable_notifications=False)
993 1003
994 1004 response = self.app.get(url(
995 1005 controller='pullrequests', action='show',
996 1006 repo_name=pull_request.target_repo.scm_instance().name,
997 1007 pull_request_id=str(pull_request.pull_request_id)))
998 1008
999 1009 response.mustcontain('id="delete_pullrequest"')
1000 1010 response.mustcontain('Confirm to delete this pull request')
1001 1011
1002 1012 def test_pull_request_delete_button_permissions_forbidden(
1003 1013 self, autologin_regular_user, user_regular, user_admin, pr_util):
1004 1014 pull_request = pr_util.create_pull_request(
1005 1015 author=user_admin.username, enable_notifications=False)
1006 1016
1007 1017 response = self.app.get(url(
1008 1018 controller='pullrequests', action='show',
1009 1019 repo_name=pull_request.target_repo.scm_instance().name,
1010 1020 pull_request_id=str(pull_request.pull_request_id)))
1011 1021 response.mustcontain(no=['id="delete_pullrequest"'])
1012 1022 response.mustcontain(no=['Confirm to delete this pull request'])
1013 1023
1014 1024 def test_pull_request_delete_button_permissions_can_update_cannot_delete(
1015 1025 self, autologin_regular_user, user_regular, user_admin, pr_util,
1016 1026 user_util):
1017 1027
1018 1028 pull_request = pr_util.create_pull_request(
1019 1029 author=user_admin.username, enable_notifications=False)
1020 1030
1021 1031 user_util.grant_user_permission_to_repo(
1022 1032 pull_request.target_repo, user_regular,
1023 1033 'repository.write')
1024 1034
1025 1035 response = self.app.get(url(
1026 1036 controller='pullrequests', action='show',
1027 1037 repo_name=pull_request.target_repo.scm_instance().name,
1028 1038 pull_request_id=str(pull_request.pull_request_id)))
1029 1039
1030 1040 response.mustcontain('id="open_edit_pullrequest"')
1031 1041 response.mustcontain('id="delete_pullrequest"')
1032 1042 response.mustcontain(no=['Confirm to delete this pull request'])
1033 1043
1034 1044
1035 1045 def assert_pull_request_status(pull_request, expected_status):
1036 1046 status = ChangesetStatusModel().calculated_review_status(
1037 1047 pull_request=pull_request)
1038 1048 assert status == expected_status
1039 1049
1040 1050
1041 1051 @pytest.mark.parametrize('action', ['show_all', 'index', 'create'])
1042 1052 @pytest.mark.usefixtures("autologin_user")
1043 1053 def test_redirects_to_repo_summary_for_svn_repositories(
1044 1054 backend_svn, app, action):
1045 1055 denied_actions = ['show_all', 'index', 'create']
1046 1056 for action in denied_actions:
1047 1057 response = app.get(url(
1048 1058 controller='pullrequests', action=action,
1049 1059 repo_name=backend_svn.repo_name))
1050 1060 assert response.status_int == 302
1051 1061
1052 1062 # Not allowed, redirect to the summary
1053 1063 redirected = response.follow()
1054 1064 summary_url = url('summary_home', repo_name=backend_svn.repo_name)
1055 1065
1056 1066 # URL adds leading slash and path doesn't have it
1057 1067 assert redirected.req.path == summary_url
1058 1068
1059 1069
1060 1070 def test_delete_comment_returns_404_if_comment_does_not_exist(pylonsapp):
1061 1071 # TODO: johbo: Global import not possible because models.forms blows up
1062 1072 from rhodecode.controllers.pullrequests import PullrequestsController
1063 1073 controller = PullrequestsController()
1064 1074 patcher = mock.patch(
1065 1075 'rhodecode.model.db.BaseModel.get', return_value=None)
1066 1076 with pytest.raises(HTTPNotFound), patcher:
1067 1077 controller._delete_comment(1)
General Comments 0
You need to be logged in to leave comments. Login now