##// END OF EJS Templates
pr: Display link to shadow repository on pull request page.
Martin Bornhold -
r896:a4f1049a default
parent child Browse files
Show More
@@ -1,891 +1,893 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2012-2016 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
25 25 import peppercorn
26 26 import formencode
27 27 import logging
28 28
29 29 from webob.exc import HTTPNotFound, HTTPForbidden, HTTPBadRequest
30 30 from pylons import request, tmpl_context as c, url
31 31 from pylons.controllers.util import redirect
32 32 from pylons.i18n.translation import _
33 33 from pyramid.threadlocal import get_current_registry
34 34 from sqlalchemy.sql import func
35 35 from sqlalchemy.sql.expression import or_
36 36
37 37 from rhodecode import events
38 38 from rhodecode.lib import auth, diffs, helpers as h
39 39 from rhodecode.lib.ext_json import json
40 40 from rhodecode.lib.base import (
41 41 BaseRepoController, render, vcs_operation_context)
42 42 from rhodecode.lib.auth import (
43 43 LoginRequired, HasRepoPermissionAnyDecorator, NotAnonymous,
44 44 HasAcceptedRepoType, XHRRequired)
45 45 from rhodecode.lib.channelstream import channelstream_request
46 46 from rhodecode.lib.utils import jsonify
47 47 from rhodecode.lib.utils2 import safe_int, safe_str, str2bool, safe_unicode
48 48 from rhodecode.lib.vcs.backends.base import EmptyCommit
49 49 from rhodecode.lib.vcs.exceptions import (
50 50 EmptyRepositoryError, CommitDoesNotExistError, RepositoryRequirementError)
51 51 from rhodecode.lib.diffs import LimitedDiffContainer
52 52 from rhodecode.model.changeset_status import ChangesetStatusModel
53 53 from rhodecode.model.comment import ChangesetCommentsModel
54 54 from rhodecode.model.db import PullRequest, ChangesetStatus, ChangesetComment, \
55 55 Repository
56 56 from rhodecode.model.forms import PullRequestForm
57 57 from rhodecode.model.meta import Session
58 58 from rhodecode.model.pull_request import PullRequestModel
59 59
60 60 log = logging.getLogger(__name__)
61 61
62 62
63 63 class PullrequestsController(BaseRepoController):
64 64 def __before__(self):
65 65 super(PullrequestsController, self).__before__()
66 66
67 67 def _load_compare_data(self, pull_request, enable_comments=True):
68 68 """
69 69 Load context data needed for generating compare diff
70 70
71 71 :param pull_request: object related to the request
72 72 :param enable_comments: flag to determine if comments are included
73 73 """
74 74 source_repo = pull_request.source_repo
75 75 source_ref_id = pull_request.source_ref_parts.commit_id
76 76
77 77 target_repo = pull_request.target_repo
78 78 target_ref_id = pull_request.target_ref_parts.commit_id
79 79
80 80 # despite opening commits for bookmarks/branches/tags, we always
81 81 # convert this to rev to prevent changes after bookmark or branch change
82 82 c.source_ref_type = 'rev'
83 83 c.source_ref = source_ref_id
84 84
85 85 c.target_ref_type = 'rev'
86 86 c.target_ref = target_ref_id
87 87
88 88 c.source_repo = source_repo
89 89 c.target_repo = target_repo
90 90
91 91 c.fulldiff = bool(request.GET.get('fulldiff'))
92 92
93 93 # diff_limit is the old behavior, will cut off the whole diff
94 94 # if the limit is applied otherwise will just hide the
95 95 # big files from the front-end
96 96 diff_limit = self.cut_off_limit_diff
97 97 file_limit = self.cut_off_limit_file
98 98
99 99 pre_load = ["author", "branch", "date", "message"]
100 100
101 101 c.commit_ranges = []
102 102 source_commit = EmptyCommit()
103 103 target_commit = EmptyCommit()
104 104 c.missing_requirements = False
105 105 try:
106 106 c.commit_ranges = [
107 107 source_repo.get_commit(commit_id=rev, pre_load=pre_load)
108 108 for rev in pull_request.revisions]
109 109
110 110 c.statuses = source_repo.statuses(
111 111 [x.raw_id for x in c.commit_ranges])
112 112
113 113 target_commit = source_repo.get_commit(
114 114 commit_id=safe_str(target_ref_id))
115 115 source_commit = source_repo.get_commit(
116 116 commit_id=safe_str(source_ref_id))
117 117 except RepositoryRequirementError:
118 118 c.missing_requirements = True
119 119
120 120 c.missing_commits = False
121 121 if (c.missing_requirements or
122 122 isinstance(source_commit, EmptyCommit) or
123 123 source_commit == target_commit):
124 124 _parsed = []
125 125 c.missing_commits = True
126 126 else:
127 127 vcs_diff = PullRequestModel().get_diff(pull_request)
128 128 diff_processor = diffs.DiffProcessor(
129 129 vcs_diff, format='gitdiff', diff_limit=diff_limit,
130 130 file_limit=file_limit, show_full_diff=c.fulldiff)
131 131 _parsed = diff_processor.prepare()
132 132
133 133 c.limited_diff = isinstance(_parsed, LimitedDiffContainer)
134 134
135 135 c.files = []
136 136 c.changes = {}
137 137 c.lines_added = 0
138 138 c.lines_deleted = 0
139 139 c.included_files = []
140 140 c.deleted_files = []
141 141
142 142 for f in _parsed:
143 143 st = f['stats']
144 144 c.lines_added += st['added']
145 145 c.lines_deleted += st['deleted']
146 146
147 147 fid = h.FID('', f['filename'])
148 148 c.files.append([fid, f['operation'], f['filename'], f['stats']])
149 149 c.included_files.append(f['filename'])
150 150 html_diff = diff_processor.as_html(enable_comments=enable_comments,
151 151 parsed_lines=[f])
152 152 c.changes[fid] = [f['operation'], f['filename'], html_diff, f]
153 153
154 154 def _extract_ordering(self, request):
155 155 column_index = safe_int(request.GET.get('order[0][column]'))
156 156 order_dir = request.GET.get('order[0][dir]', 'desc')
157 157 order_by = request.GET.get(
158 158 'columns[%s][data][sort]' % column_index, 'name_raw')
159 159 return order_by, order_dir
160 160
161 161 @LoginRequired()
162 162 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
163 163 'repository.admin')
164 164 @HasAcceptedRepoType('git', 'hg')
165 165 def show_all(self, repo_name):
166 166 # filter types
167 167 c.active = 'open'
168 168 c.source = str2bool(request.GET.get('source'))
169 169 c.closed = str2bool(request.GET.get('closed'))
170 170 c.my = str2bool(request.GET.get('my'))
171 171 c.awaiting_review = str2bool(request.GET.get('awaiting_review'))
172 172 c.awaiting_my_review = str2bool(request.GET.get('awaiting_my_review'))
173 173 c.repo_name = repo_name
174 174
175 175 opened_by = None
176 176 if c.my:
177 177 c.active = 'my'
178 178 opened_by = [c.rhodecode_user.user_id]
179 179
180 180 statuses = [PullRequest.STATUS_NEW, PullRequest.STATUS_OPEN]
181 181 if c.closed:
182 182 c.active = 'closed'
183 183 statuses = [PullRequest.STATUS_CLOSED]
184 184
185 185 if c.awaiting_review and not c.source:
186 186 c.active = 'awaiting'
187 187 if c.source and not c.awaiting_review:
188 188 c.active = 'source'
189 189 if c.awaiting_my_review:
190 190 c.active = 'awaiting_my'
191 191
192 192 data = self._get_pull_requests_list(
193 193 repo_name=repo_name, opened_by=opened_by, statuses=statuses)
194 194 if not request.is_xhr:
195 195 c.data = json.dumps(data['data'])
196 196 c.records_total = data['recordsTotal']
197 197 return render('/pullrequests/pullrequests.html')
198 198 else:
199 199 return json.dumps(data)
200 200
201 201 def _get_pull_requests_list(self, repo_name, opened_by, statuses):
202 202 # pagination
203 203 start = safe_int(request.GET.get('start'), 0)
204 204 length = safe_int(request.GET.get('length'), c.visual.dashboard_items)
205 205 order_by, order_dir = self._extract_ordering(request)
206 206
207 207 if c.awaiting_review:
208 208 pull_requests = PullRequestModel().get_awaiting_review(
209 209 repo_name, source=c.source, opened_by=opened_by,
210 210 statuses=statuses, offset=start, length=length,
211 211 order_by=order_by, order_dir=order_dir)
212 212 pull_requests_total_count = PullRequestModel(
213 213 ).count_awaiting_review(
214 214 repo_name, source=c.source, statuses=statuses,
215 215 opened_by=opened_by)
216 216 elif c.awaiting_my_review:
217 217 pull_requests = PullRequestModel().get_awaiting_my_review(
218 218 repo_name, source=c.source, opened_by=opened_by,
219 219 user_id=c.rhodecode_user.user_id, statuses=statuses,
220 220 offset=start, length=length, order_by=order_by,
221 221 order_dir=order_dir)
222 222 pull_requests_total_count = PullRequestModel(
223 223 ).count_awaiting_my_review(
224 224 repo_name, source=c.source, user_id=c.rhodecode_user.user_id,
225 225 statuses=statuses, opened_by=opened_by)
226 226 else:
227 227 pull_requests = PullRequestModel().get_all(
228 228 repo_name, source=c.source, opened_by=opened_by,
229 229 statuses=statuses, offset=start, length=length,
230 230 order_by=order_by, order_dir=order_dir)
231 231 pull_requests_total_count = PullRequestModel().count_all(
232 232 repo_name, source=c.source, statuses=statuses,
233 233 opened_by=opened_by)
234 234
235 235 from rhodecode.lib.utils import PartialRenderer
236 236 _render = PartialRenderer('data_table/_dt_elements.html')
237 237 data = []
238 238 for pr in pull_requests:
239 239 comments = ChangesetCommentsModel().get_all_comments(
240 240 c.rhodecode_db_repo.repo_id, pull_request=pr)
241 241
242 242 data.append({
243 243 'name': _render('pullrequest_name',
244 244 pr.pull_request_id, pr.target_repo.repo_name),
245 245 'name_raw': pr.pull_request_id,
246 246 'status': _render('pullrequest_status',
247 247 pr.calculated_review_status()),
248 248 'title': _render(
249 249 'pullrequest_title', pr.title, pr.description),
250 250 'description': h.escape(pr.description),
251 251 'updated_on': _render('pullrequest_updated_on',
252 252 h.datetime_to_time(pr.updated_on)),
253 253 'updated_on_raw': h.datetime_to_time(pr.updated_on),
254 254 'created_on': _render('pullrequest_updated_on',
255 255 h.datetime_to_time(pr.created_on)),
256 256 'created_on_raw': h.datetime_to_time(pr.created_on),
257 257 'author': _render('pullrequest_author',
258 258 pr.author.full_contact, ),
259 259 'author_raw': pr.author.full_name,
260 260 'comments': _render('pullrequest_comments', len(comments)),
261 261 'comments_raw': len(comments),
262 262 'closed': pr.is_closed(),
263 263 })
264 264 # json used to render the grid
265 265 data = ({
266 266 'data': data,
267 267 'recordsTotal': pull_requests_total_count,
268 268 'recordsFiltered': pull_requests_total_count,
269 269 })
270 270 return data
271 271
272 272 @LoginRequired()
273 273 @NotAnonymous()
274 274 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
275 275 'repository.admin')
276 276 @HasAcceptedRepoType('git', 'hg')
277 277 def index(self):
278 278 source_repo = c.rhodecode_db_repo
279 279
280 280 try:
281 281 source_repo.scm_instance().get_commit()
282 282 except EmptyRepositoryError:
283 283 h.flash(h.literal(_('There are no commits yet')),
284 284 category='warning')
285 285 redirect(url('summary_home', repo_name=source_repo.repo_name))
286 286
287 287 commit_id = request.GET.get('commit')
288 288 branch_ref = request.GET.get('branch')
289 289 bookmark_ref = request.GET.get('bookmark')
290 290
291 291 try:
292 292 source_repo_data = PullRequestModel().generate_repo_data(
293 293 source_repo, commit_id=commit_id,
294 294 branch=branch_ref, bookmark=bookmark_ref)
295 295 except CommitDoesNotExistError as e:
296 296 log.exception(e)
297 297 h.flash(_('Commit does not exist'), 'error')
298 298 redirect(url('pullrequest_home', repo_name=source_repo.repo_name))
299 299
300 300 default_target_repo = source_repo
301 301 if (source_repo.parent and
302 302 not source_repo.parent.scm_instance().is_empty()):
303 303 # change default if we have a parent repo
304 304 default_target_repo = source_repo.parent
305 305
306 306 target_repo_data = PullRequestModel().generate_repo_data(
307 307 default_target_repo)
308 308
309 309 selected_source_ref = source_repo_data['refs']['selected_ref']
310 310
311 311 title_source_ref = selected_source_ref.split(':', 2)[1]
312 312 c.default_title = PullRequestModel().generate_pullrequest_title(
313 313 source=source_repo.repo_name,
314 314 source_ref=title_source_ref,
315 315 target=default_target_repo.repo_name
316 316 )
317 317
318 318 c.default_repo_data = {
319 319 'source_repo_name': source_repo.repo_name,
320 320 'source_refs_json': json.dumps(source_repo_data),
321 321 'target_repo_name': default_target_repo.repo_name,
322 322 'target_refs_json': json.dumps(target_repo_data),
323 323 }
324 324 c.default_source_ref = selected_source_ref
325 325
326 326 return render('/pullrequests/pullrequest.html')
327 327
328 328 @LoginRequired()
329 329 @NotAnonymous()
330 330 @XHRRequired()
331 331 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
332 332 'repository.admin')
333 333 @jsonify
334 334 def get_repo_refs(self, repo_name, target_repo_name):
335 335 repo = Repository.get_by_repo_name(target_repo_name)
336 336 if not repo:
337 337 raise HTTPNotFound
338 338 return PullRequestModel().generate_repo_data(repo)
339 339
340 340 @LoginRequired()
341 341 @NotAnonymous()
342 342 @XHRRequired()
343 343 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
344 344 'repository.admin')
345 345 @jsonify
346 346 def get_repo_destinations(self, repo_name):
347 347 repo = Repository.get_by_repo_name(repo_name)
348 348 if not repo:
349 349 raise HTTPNotFound
350 350 filter_query = request.GET.get('query')
351 351
352 352 query = Repository.query() \
353 353 .order_by(func.length(Repository.repo_name)) \
354 354 .filter(or_(
355 355 Repository.repo_name == repo.repo_name,
356 356 Repository.fork_id == repo.repo_id))
357 357
358 358 if filter_query:
359 359 ilike_expression = u'%{}%'.format(safe_unicode(filter_query))
360 360 query = query.filter(
361 361 Repository.repo_name.ilike(ilike_expression))
362 362
363 363 add_parent = False
364 364 if repo.parent:
365 365 if filter_query in repo.parent.repo_name:
366 366 if not repo.parent.scm_instance().is_empty():
367 367 add_parent = True
368 368
369 369 limit = 20 - 1 if add_parent else 20
370 370 all_repos = query.limit(limit).all()
371 371 if add_parent:
372 372 all_repos += [repo.parent]
373 373
374 374 repos = []
375 375 for obj in self.scm_model.get_repos(all_repos):
376 376 repos.append({
377 377 'id': obj['name'],
378 378 'text': obj['name'],
379 379 'type': 'repo',
380 380 'obj': obj['dbrepo']
381 381 })
382 382
383 383 data = {
384 384 'more': False,
385 385 'results': [{
386 386 'text': _('Repositories'),
387 387 'children': repos
388 388 }] if repos else []
389 389 }
390 390 return data
391 391
392 392 @LoginRequired()
393 393 @NotAnonymous()
394 394 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
395 395 'repository.admin')
396 396 @HasAcceptedRepoType('git', 'hg')
397 397 @auth.CSRFRequired()
398 398 def create(self, repo_name):
399 399 repo = Repository.get_by_repo_name(repo_name)
400 400 if not repo:
401 401 raise HTTPNotFound
402 402
403 403 controls = peppercorn.parse(request.POST.items())
404 404
405 405 try:
406 406 _form = PullRequestForm(repo.repo_id)().to_python(controls)
407 407 except formencode.Invalid as errors:
408 408 if errors.error_dict.get('revisions'):
409 409 msg = 'Revisions: %s' % errors.error_dict['revisions']
410 410 elif errors.error_dict.get('pullrequest_title'):
411 411 msg = _('Pull request requires a title with min. 3 chars')
412 412 else:
413 413 msg = _('Error creating pull request: {}').format(errors)
414 414 log.exception(msg)
415 415 h.flash(msg, 'error')
416 416
417 417 # would rather just go back to form ...
418 418 return redirect(url('pullrequest_home', repo_name=repo_name))
419 419
420 420 source_repo = _form['source_repo']
421 421 source_ref = _form['source_ref']
422 422 target_repo = _form['target_repo']
423 423 target_ref = _form['target_ref']
424 424 commit_ids = _form['revisions'][::-1]
425 425 reviewers = [
426 426 (r['user_id'], r['reasons']) for r in _form['review_members']]
427 427
428 428 # find the ancestor for this pr
429 429 source_db_repo = Repository.get_by_repo_name(_form['source_repo'])
430 430 target_db_repo = Repository.get_by_repo_name(_form['target_repo'])
431 431
432 432 source_scm = source_db_repo.scm_instance()
433 433 target_scm = target_db_repo.scm_instance()
434 434
435 435 source_commit = source_scm.get_commit(source_ref.split(':')[-1])
436 436 target_commit = target_scm.get_commit(target_ref.split(':')[-1])
437 437
438 438 ancestor = source_scm.get_common_ancestor(
439 439 source_commit.raw_id, target_commit.raw_id, target_scm)
440 440
441 441 target_ref_type, target_ref_name, __ = _form['target_ref'].split(':')
442 442 target_ref = ':'.join((target_ref_type, target_ref_name, ancestor))
443 443
444 444 pullrequest_title = _form['pullrequest_title']
445 445 title_source_ref = source_ref.split(':', 2)[1]
446 446 if not pullrequest_title:
447 447 pullrequest_title = PullRequestModel().generate_pullrequest_title(
448 448 source=source_repo,
449 449 source_ref=title_source_ref,
450 450 target=target_repo
451 451 )
452 452
453 453 description = _form['pullrequest_desc']
454 454 try:
455 455 pull_request = PullRequestModel().create(
456 456 c.rhodecode_user.user_id, source_repo, source_ref, target_repo,
457 457 target_ref, commit_ids, reviewers, pullrequest_title,
458 458 description
459 459 )
460 460 Session().commit()
461 461 h.flash(_('Successfully opened new pull request'),
462 462 category='success')
463 463 except Exception as e:
464 464 msg = _('Error occurred during sending pull request')
465 465 log.exception(msg)
466 466 h.flash(msg, category='error')
467 467 return redirect(url('pullrequest_home', repo_name=repo_name))
468 468
469 469 return redirect(url('pullrequest_show', repo_name=target_repo,
470 470 pull_request_id=pull_request.pull_request_id))
471 471
472 472 @LoginRequired()
473 473 @NotAnonymous()
474 474 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
475 475 'repository.admin')
476 476 @auth.CSRFRequired()
477 477 @jsonify
478 478 def update(self, repo_name, pull_request_id):
479 479 pull_request_id = safe_int(pull_request_id)
480 480 pull_request = PullRequest.get_or_404(pull_request_id)
481 481 # only owner or admin can update it
482 482 allowed_to_update = PullRequestModel().check_user_update(
483 483 pull_request, c.rhodecode_user)
484 484 if allowed_to_update:
485 485 controls = peppercorn.parse(request.POST.items())
486 486
487 487 if 'review_members' in controls:
488 488 self._update_reviewers(
489 489 pull_request_id, controls['review_members'])
490 490 elif str2bool(request.POST.get('update_commits', 'false')):
491 491 self._update_commits(pull_request)
492 492 elif str2bool(request.POST.get('close_pull_request', 'false')):
493 493 self._reject_close(pull_request)
494 494 elif str2bool(request.POST.get('edit_pull_request', 'false')):
495 495 self._edit_pull_request(pull_request)
496 496 else:
497 497 raise HTTPBadRequest()
498 498 return True
499 499 raise HTTPForbidden()
500 500
501 501 def _edit_pull_request(self, pull_request):
502 502 try:
503 503 PullRequestModel().edit(
504 504 pull_request, request.POST.get('title'),
505 505 request.POST.get('description'))
506 506 except ValueError:
507 507 msg = _(u'Cannot update closed pull requests.')
508 508 h.flash(msg, category='error')
509 509 return
510 510 else:
511 511 Session().commit()
512 512
513 513 msg = _(u'Pull request title & description updated.')
514 514 h.flash(msg, category='success')
515 515 return
516 516
517 517 def _update_commits(self, pull_request):
518 518 try:
519 519 if PullRequestModel().has_valid_update_type(pull_request):
520 520 updated_version, changes = PullRequestModel().update_commits(
521 521 pull_request)
522 522 if updated_version:
523 523 msg = _(
524 524 u'Pull request updated to "{source_commit_id}" with '
525 525 u'{count_added} added, {count_removed} removed '
526 526 u'commits.'
527 527 ).format(
528 528 source_commit_id=pull_request.source_ref_parts.commit_id,
529 529 count_added=len(changes.added),
530 530 count_removed=len(changes.removed))
531 531 h.flash(msg, category='success')
532 532 registry = get_current_registry()
533 533 rhodecode_plugins = getattr(registry,
534 534 'rhodecode_plugins', {})
535 535 channelstream_config = rhodecode_plugins.get(
536 536 'channelstream', {})
537 537 if channelstream_config.get('enabled'):
538 538 message = msg + ' - <a onclick="' \
539 539 'window.location.reload()">' \
540 540 '<strong>{}</strong></a>'.format(
541 541 _('Reload page')
542 542 )
543 543 channel = '/repo${}$/pr/{}'.format(
544 544 pull_request.target_repo.repo_name,
545 545 pull_request.pull_request_id
546 546 )
547 547 payload = {
548 548 'type': 'message',
549 549 'user': 'system',
550 550 'exclude_users': [request.user.username],
551 551 'channel': channel,
552 552 'message': {
553 553 'message': message,
554 554 'level': 'success',
555 555 'topic': '/notifications'
556 556 }
557 557 }
558 558 channelstream_request(channelstream_config, [payload],
559 559 '/message', raise_exc=False)
560 560 else:
561 561 h.flash(_("Nothing changed in pull request."),
562 562 category='warning')
563 563 else:
564 564 msg = _(
565 565 u"Skipping update of pull request due to reference "
566 566 u"type: {reference_type}"
567 567 ).format(reference_type=pull_request.source_ref_parts.type)
568 568 h.flash(msg, category='warning')
569 569 except CommitDoesNotExistError:
570 570 h.flash(
571 571 _(u'Update failed due to missing commits.'), category='error')
572 572
573 573 @auth.CSRFRequired()
574 574 @LoginRequired()
575 575 @NotAnonymous()
576 576 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
577 577 'repository.admin')
578 578 def merge(self, repo_name, pull_request_id):
579 579 """
580 580 POST /{repo_name}/pull-request/{pull_request_id}
581 581
582 582 Merge will perform a server-side merge of the specified
583 583 pull request, if the pull request is approved and mergeable.
584 584 After succesfull merging, the pull request is automatically
585 585 closed, with a relevant comment.
586 586 """
587 587 pull_request_id = safe_int(pull_request_id)
588 588 pull_request = PullRequest.get_or_404(pull_request_id)
589 589 user = c.rhodecode_user
590 590
591 591 if self._meets_merge_pre_conditions(pull_request, user):
592 592 log.debug("Pre-conditions checked, trying to merge.")
593 593 extras = vcs_operation_context(
594 594 request.environ, repo_name=pull_request.target_repo.repo_name,
595 595 username=user.username, action='push',
596 596 scm=pull_request.target_repo.repo_type)
597 597 self._merge_pull_request(pull_request, user, extras)
598 598
599 599 return redirect(url(
600 600 'pullrequest_show',
601 601 repo_name=pull_request.target_repo.repo_name,
602 602 pull_request_id=pull_request.pull_request_id))
603 603
604 604 def _meets_merge_pre_conditions(self, pull_request, user):
605 605 if not PullRequestModel().check_user_merge(pull_request, user):
606 606 raise HTTPForbidden()
607 607
608 608 merge_status, msg = PullRequestModel().merge_status(pull_request)
609 609 if not merge_status:
610 610 log.debug("Cannot merge, not mergeable.")
611 611 h.flash(msg, category='error')
612 612 return False
613 613
614 614 if (pull_request.calculated_review_status()
615 615 is not ChangesetStatus.STATUS_APPROVED):
616 616 log.debug("Cannot merge, approval is pending.")
617 617 msg = _('Pull request reviewer approval is pending.')
618 618 h.flash(msg, category='error')
619 619 return False
620 620 return True
621 621
622 622 def _merge_pull_request(self, pull_request, user, extras):
623 623 merge_resp = PullRequestModel().merge(
624 624 pull_request, user, extras=extras)
625 625
626 626 if merge_resp.executed:
627 627 log.debug("The merge was successful, closing the pull request.")
628 628 PullRequestModel().close_pull_request(
629 629 pull_request.pull_request_id, user)
630 630 Session().commit()
631 631 msg = _('Pull request was successfully merged and closed.')
632 632 h.flash(msg, category='success')
633 633 else:
634 634 log.debug(
635 635 "The merge was not successful. Merge response: %s",
636 636 merge_resp)
637 637 msg = PullRequestModel().merge_status_message(
638 638 merge_resp.failure_reason)
639 639 h.flash(msg, category='error')
640 640
641 641 def _update_reviewers(self, pull_request_id, review_members):
642 642 reviewers = [
643 643 (int(r['user_id']), r['reasons']) for r in review_members]
644 644 PullRequestModel().update_reviewers(pull_request_id, reviewers)
645 645 Session().commit()
646 646
647 647 def _reject_close(self, pull_request):
648 648 if pull_request.is_closed():
649 649 raise HTTPForbidden()
650 650
651 651 PullRequestModel().close_pull_request_with_comment(
652 652 pull_request, c.rhodecode_user, c.rhodecode_db_repo)
653 653 Session().commit()
654 654
655 655 @LoginRequired()
656 656 @NotAnonymous()
657 657 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
658 658 'repository.admin')
659 659 @auth.CSRFRequired()
660 660 @jsonify
661 661 def delete(self, repo_name, pull_request_id):
662 662 pull_request_id = safe_int(pull_request_id)
663 663 pull_request = PullRequest.get_or_404(pull_request_id)
664 664 # only owner can delete it !
665 665 if pull_request.author.user_id == c.rhodecode_user.user_id:
666 666 PullRequestModel().delete(pull_request)
667 667 Session().commit()
668 668 h.flash(_('Successfully deleted pull request'),
669 669 category='success')
670 670 return redirect(url('my_account_pullrequests'))
671 671 raise HTTPForbidden()
672 672
673 673 @LoginRequired()
674 674 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
675 675 'repository.admin')
676 676 def show(self, repo_name, pull_request_id):
677 677 pull_request_id = safe_int(pull_request_id)
678 678 c.pull_request = PullRequest.get_or_404(pull_request_id)
679 679
680 680 c.template_context['pull_request_data']['pull_request_id'] = \
681 681 pull_request_id
682 682
683 683 # pull_requests repo_name we opened it against
684 684 # ie. target_repo must match
685 685 if repo_name != c.pull_request.target_repo.repo_name:
686 686 raise HTTPNotFound
687 687
688 688 c.allowed_to_change_status = PullRequestModel(). \
689 689 check_user_change_status(c.pull_request, c.rhodecode_user)
690 690 c.allowed_to_update = PullRequestModel().check_user_update(
691 691 c.pull_request, c.rhodecode_user) and not c.pull_request.is_closed()
692 692 c.allowed_to_merge = PullRequestModel().check_user_merge(
693 693 c.pull_request, c.rhodecode_user) and not c.pull_request.is_closed()
694 c.shadow_clone_url = PullRequestModel().get_shadow_clone_url(
695 c.pull_request)
694 696
695 697 cc_model = ChangesetCommentsModel()
696 698
697 699 c.pull_request_reviewers = c.pull_request.reviewers_statuses()
698 700
699 701 c.pull_request_review_status = c.pull_request.calculated_review_status()
700 702 c.pr_merge_status, c.pr_merge_msg = PullRequestModel().merge_status(
701 703 c.pull_request)
702 704 c.approval_msg = None
703 705 if c.pull_request_review_status != ChangesetStatus.STATUS_APPROVED:
704 706 c.approval_msg = _('Reviewer approval is pending.')
705 707 c.pr_merge_status = False
706 708 # load compare data into template context
707 709 enable_comments = not c.pull_request.is_closed()
708 710 self._load_compare_data(c.pull_request, enable_comments=enable_comments)
709 711
710 712 # this is a hack to properly display links, when creating PR, the
711 713 # compare view and others uses different notation, and
712 714 # compare_commits.html renders links based on the target_repo.
713 715 # We need to swap that here to generate it properly on the html side
714 716 c.target_repo = c.source_repo
715 717
716 718 # inline comments
717 719 c.inline_cnt = 0
718 720 c.inline_comments = cc_model.get_inline_comments(
719 721 c.rhodecode_db_repo.repo_id,
720 722 pull_request=pull_request_id).items()
721 723 # count inline comments
722 724 for __, lines in c.inline_comments:
723 725 for comments in lines.values():
724 726 c.inline_cnt += len(comments)
725 727
726 728 # outdated comments
727 729 c.outdated_cnt = 0
728 730 if ChangesetCommentsModel.use_outdated_comments(c.pull_request):
729 731 c.outdated_comments = cc_model.get_outdated_comments(
730 732 c.rhodecode_db_repo.repo_id,
731 733 pull_request=c.pull_request)
732 734 # Count outdated comments and check for deleted files
733 735 for file_name, lines in c.outdated_comments.iteritems():
734 736 for comments in lines.values():
735 737 c.outdated_cnt += len(comments)
736 738 if file_name not in c.included_files:
737 739 c.deleted_files.append(file_name)
738 740 else:
739 741 c.outdated_comments = {}
740 742
741 743 # comments
742 744 c.comments = cc_model.get_comments(c.rhodecode_db_repo.repo_id,
743 745 pull_request=pull_request_id)
744 746
745 747 if c.allowed_to_update:
746 748 force_close = ('forced_closed', _('Close Pull Request'))
747 749 statuses = ChangesetStatus.STATUSES + [force_close]
748 750 else:
749 751 statuses = ChangesetStatus.STATUSES
750 752 c.commit_statuses = statuses
751 753
752 754 c.ancestor = None # TODO: add ancestor here
753 755
754 756 return render('/pullrequests/pullrequest_show.html')
755 757
756 758 @LoginRequired()
757 759 @NotAnonymous()
758 760 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
759 761 'repository.admin')
760 762 @auth.CSRFRequired()
761 763 @jsonify
762 764 def comment(self, repo_name, pull_request_id):
763 765 pull_request_id = safe_int(pull_request_id)
764 766 pull_request = PullRequest.get_or_404(pull_request_id)
765 767 if pull_request.is_closed():
766 768 raise HTTPForbidden()
767 769
768 770 # TODO: johbo: Re-think this bit, "approved_closed" does not exist
769 771 # as a changeset status, still we want to send it in one value.
770 772 status = request.POST.get('changeset_status', None)
771 773 text = request.POST.get('text')
772 774 if status and '_closed' in status:
773 775 close_pr = True
774 776 status = status.replace('_closed', '')
775 777 else:
776 778 close_pr = False
777 779
778 780 forced = (status == 'forced')
779 781 if forced:
780 782 status = 'rejected'
781 783
782 784 allowed_to_change_status = PullRequestModel().check_user_change_status(
783 785 pull_request, c.rhodecode_user)
784 786
785 787 if status and allowed_to_change_status:
786 788 message = (_('Status change %(transition_icon)s %(status)s')
787 789 % {'transition_icon': '>',
788 790 'status': ChangesetStatus.get_status_lbl(status)})
789 791 if close_pr:
790 792 message = _('Closing with') + ' ' + message
791 793 text = text or message
792 794 comm = ChangesetCommentsModel().create(
793 795 text=text,
794 796 repo=c.rhodecode_db_repo.repo_id,
795 797 user=c.rhodecode_user.user_id,
796 798 pull_request=pull_request_id,
797 799 f_path=request.POST.get('f_path'),
798 800 line_no=request.POST.get('line'),
799 801 status_change=(ChangesetStatus.get_status_lbl(status)
800 802 if status and allowed_to_change_status else None),
801 803 status_change_type=(status
802 804 if status and allowed_to_change_status else None),
803 805 closing_pr=close_pr
804 806 )
805 807
806 808
807 809
808 810 if allowed_to_change_status:
809 811 old_calculated_status = pull_request.calculated_review_status()
810 812 # get status if set !
811 813 if status:
812 814 ChangesetStatusModel().set_status(
813 815 c.rhodecode_db_repo.repo_id,
814 816 status,
815 817 c.rhodecode_user.user_id,
816 818 comm,
817 819 pull_request=pull_request_id
818 820 )
819 821
820 822 Session().flush()
821 823 events.trigger(events.PullRequestCommentEvent(pull_request, comm))
822 824 # we now calculate the status of pull request, and based on that
823 825 # calculation we set the commits status
824 826 calculated_status = pull_request.calculated_review_status()
825 827 if old_calculated_status != calculated_status:
826 828 PullRequestModel()._trigger_pull_request_hook(
827 829 pull_request, c.rhodecode_user, 'review_status_change')
828 830
829 831 calculated_status_lbl = ChangesetStatus.get_status_lbl(
830 832 calculated_status)
831 833
832 834 if close_pr:
833 835 status_completed = (
834 836 calculated_status in [ChangesetStatus.STATUS_APPROVED,
835 837 ChangesetStatus.STATUS_REJECTED])
836 838 if forced or status_completed:
837 839 PullRequestModel().close_pull_request(
838 840 pull_request_id, c.rhodecode_user)
839 841 else:
840 842 h.flash(_('Closing pull request on other statuses than '
841 843 'rejected or approved is forbidden. '
842 844 'Calculated status from all reviewers '
843 845 'is currently: %s') % calculated_status_lbl,
844 846 category='warning')
845 847
846 848 Session().commit()
847 849
848 850 if not request.is_xhr:
849 851 return redirect(h.url('pullrequest_show', repo_name=repo_name,
850 852 pull_request_id=pull_request_id))
851 853
852 854 data = {
853 855 'target_id': h.safeid(h.safe_unicode(request.POST.get('f_path'))),
854 856 }
855 857 if comm:
856 858 c.co = comm
857 859 data.update(comm.get_dict())
858 860 data.update({'rendered_text':
859 861 render('changeset/changeset_comment_block.html')})
860 862
861 863 return data
862 864
863 865 @LoginRequired()
864 866 @NotAnonymous()
865 867 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
866 868 'repository.admin')
867 869 @auth.CSRFRequired()
868 870 @jsonify
869 871 def delete_comment(self, repo_name, comment_id):
870 872 return self._delete_comment(comment_id)
871 873
872 874 def _delete_comment(self, comment_id):
873 875 comment_id = safe_int(comment_id)
874 876 co = ChangesetComment.get_or_404(comment_id)
875 877 if co.pull_request.is_closed():
876 878 # don't allow deleting comments on closed pull request
877 879 raise HTTPForbidden()
878 880
879 881 is_owner = co.author.user_id == c.rhodecode_user.user_id
880 882 is_repo_admin = h.HasRepoPermissionAny('repository.admin')(c.repo_name)
881 883 if h.HasPermissionAny('hg.admin')() or is_repo_admin or is_owner:
882 884 old_calculated_status = co.pull_request.calculated_review_status()
883 885 ChangesetCommentsModel().delete(comment=co)
884 886 Session().commit()
885 887 calculated_status = co.pull_request.calculated_review_status()
886 888 if old_calculated_status != calculated_status:
887 889 PullRequestModel()._trigger_pull_request_hook(
888 890 co.pull_request, c.rhodecode_user, 'review_status_change')
889 891 return True
890 892 else:
891 893 raise HTTPForbidden()
@@ -1,1177 +1,1180 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2012-2016 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
31 31 from pylons.i18n.translation import _
32 32 from pylons.i18n.translation import lazy_ugettext
33 33
34 34 from rhodecode.lib import helpers as h, hooks_utils, diffs
35 35 from rhodecode.lib.compat import OrderedDict
36 36 from rhodecode.lib.hooks_daemon import prepare_callback_daemon
37 37 from rhodecode.lib.markup_renderer import (
38 38 DEFAULT_COMMENTS_RENDERER, RstTemplateRenderer)
39 39 from rhodecode.lib.utils import action_logger
40 40 from rhodecode.lib.utils2 import safe_unicode, safe_str, md5_safe
41 41 from rhodecode.lib.vcs.backends.base import (
42 42 Reference, MergeResponse, MergeFailureReason)
43 43 from rhodecode.lib.vcs.conf import settings as vcs_settings
44 44 from rhodecode.lib.vcs.exceptions import (
45 45 CommitDoesNotExistError, EmptyRepositoryError)
46 46 from rhodecode.model import BaseModel
47 47 from rhodecode.model.changeset_status import ChangesetStatusModel
48 48 from rhodecode.model.comment import ChangesetCommentsModel
49 49 from rhodecode.model.db import (
50 50 PullRequest, PullRequestReviewers, ChangesetStatus,
51 51 PullRequestVersion, ChangesetComment)
52 52 from rhodecode.model.meta import Session
53 53 from rhodecode.model.notification import NotificationModel, \
54 54 EmailNotificationModel
55 55 from rhodecode.model.scm import ScmModel
56 56 from rhodecode.model.settings import VcsSettingsModel
57 57
58 58
59 59 log = logging.getLogger(__name__)
60 60
61 61
62 62 class PullRequestModel(BaseModel):
63 63
64 64 cls = PullRequest
65 65
66 66 DIFF_CONTEXT = 3
67 67
68 68 MERGE_STATUS_MESSAGES = {
69 69 MergeFailureReason.NONE: lazy_ugettext(
70 70 'This pull request can be automatically merged.'),
71 71 MergeFailureReason.UNKNOWN: lazy_ugettext(
72 72 'This pull request cannot be merged because of an unhandled'
73 73 ' exception.'),
74 74 MergeFailureReason.MERGE_FAILED: lazy_ugettext(
75 75 'This pull request cannot be merged because of conflicts.'),
76 76 MergeFailureReason.PUSH_FAILED: lazy_ugettext(
77 77 'This pull request could not be merged because push to target'
78 78 ' failed.'),
79 79 MergeFailureReason.TARGET_IS_NOT_HEAD: lazy_ugettext(
80 80 'This pull request cannot be merged because the target is not a'
81 81 ' head.'),
82 82 MergeFailureReason.HG_SOURCE_HAS_MORE_BRANCHES: lazy_ugettext(
83 83 'This pull request cannot be merged because the source contains'
84 84 ' more branches than the target.'),
85 85 MergeFailureReason.HG_TARGET_HAS_MULTIPLE_HEADS: lazy_ugettext(
86 86 'This pull request cannot be merged because the target has'
87 87 ' multiple heads.'),
88 88 MergeFailureReason.TARGET_IS_LOCKED: lazy_ugettext(
89 89 'This pull request cannot be merged because the target repository'
90 90 ' is locked.'),
91 91 MergeFailureReason.MISSING_COMMIT: lazy_ugettext(
92 92 'This pull request cannot be merged because the target or the '
93 93 'source reference is missing.'),
94 94 }
95 95
96 96 def __get_pull_request(self, pull_request):
97 97 return self._get_instance(PullRequest, pull_request)
98 98
99 99 def _check_perms(self, perms, pull_request, user, api=False):
100 100 if not api:
101 101 return h.HasRepoPermissionAny(*perms)(
102 102 user=user, repo_name=pull_request.target_repo.repo_name)
103 103 else:
104 104 return h.HasRepoPermissionAnyApi(*perms)(
105 105 user=user, repo_name=pull_request.target_repo.repo_name)
106 106
107 107 def check_user_read(self, pull_request, user, api=False):
108 108 _perms = ('repository.admin', 'repository.write', 'repository.read',)
109 109 return self._check_perms(_perms, pull_request, user, api)
110 110
111 111 def check_user_merge(self, pull_request, user, api=False):
112 112 _perms = ('repository.admin', 'repository.write', 'hg.admin',)
113 113 return self._check_perms(_perms, pull_request, user, api)
114 114
115 115 def check_user_update(self, pull_request, user, api=False):
116 116 owner = user.user_id == pull_request.user_id
117 117 return self.check_user_merge(pull_request, user, api) or owner
118 118
119 119 def check_user_change_status(self, pull_request, user, api=False):
120 120 reviewer = user.user_id in [x.user_id for x in
121 121 pull_request.reviewers]
122 122 return self.check_user_update(pull_request, user, api) or reviewer
123 123
124 124 def get(self, pull_request):
125 125 return self.__get_pull_request(pull_request)
126 126
127 127 def _prepare_get_all_query(self, repo_name, source=False, statuses=None,
128 128 opened_by=None, order_by=None,
129 129 order_dir='desc'):
130 130 repo = self._get_repo(repo_name)
131 131 q = PullRequest.query()
132 132 # source or target
133 133 if source:
134 134 q = q.filter(PullRequest.source_repo == repo)
135 135 else:
136 136 q = q.filter(PullRequest.target_repo == repo)
137 137
138 138 # closed,opened
139 139 if statuses:
140 140 q = q.filter(PullRequest.status.in_(statuses))
141 141
142 142 # opened by filter
143 143 if opened_by:
144 144 q = q.filter(PullRequest.user_id.in_(opened_by))
145 145
146 146 if order_by:
147 147 order_map = {
148 148 'name_raw': PullRequest.pull_request_id,
149 149 'title': PullRequest.title,
150 150 'updated_on_raw': PullRequest.updated_on
151 151 }
152 152 if order_dir == 'asc':
153 153 q = q.order_by(order_map[order_by].asc())
154 154 else:
155 155 q = q.order_by(order_map[order_by].desc())
156 156
157 157 return q
158 158
159 159 def count_all(self, repo_name, source=False, statuses=None,
160 160 opened_by=None):
161 161 """
162 162 Count the number of pull requests for a specific repository.
163 163
164 164 :param repo_name: target or source repo
165 165 :param source: boolean flag to specify if repo_name refers to source
166 166 :param statuses: list of pull request statuses
167 167 :param opened_by: author user of the pull request
168 168 :returns: int number of pull requests
169 169 """
170 170 q = self._prepare_get_all_query(
171 171 repo_name, source=source, statuses=statuses, opened_by=opened_by)
172 172
173 173 return q.count()
174 174
175 175 def get_all(self, repo_name, source=False, statuses=None, opened_by=None,
176 176 offset=0, length=None, order_by=None, order_dir='desc'):
177 177 """
178 178 Get all pull requests for a specific repository.
179 179
180 180 :param repo_name: target or source repo
181 181 :param source: boolean flag to specify if repo_name refers to source
182 182 :param statuses: list of pull request statuses
183 183 :param opened_by: author user of the pull request
184 184 :param offset: pagination offset
185 185 :param length: length of returned list
186 186 :param order_by: order of the returned list
187 187 :param order_dir: 'asc' or 'desc' ordering direction
188 188 :returns: list of pull requests
189 189 """
190 190 q = self._prepare_get_all_query(
191 191 repo_name, source=source, statuses=statuses, opened_by=opened_by,
192 192 order_by=order_by, order_dir=order_dir)
193 193
194 194 if length:
195 195 pull_requests = q.limit(length).offset(offset).all()
196 196 else:
197 197 pull_requests = q.all()
198 198
199 199 return pull_requests
200 200
201 201 def count_awaiting_review(self, repo_name, source=False, statuses=None,
202 202 opened_by=None):
203 203 """
204 204 Count the number of pull requests for a specific repository that are
205 205 awaiting review.
206 206
207 207 :param repo_name: target or source repo
208 208 :param source: boolean flag to specify if repo_name refers to source
209 209 :param statuses: list of pull request statuses
210 210 :param opened_by: author user of the pull request
211 211 :returns: int number of pull requests
212 212 """
213 213 pull_requests = self.get_awaiting_review(
214 214 repo_name, source=source, statuses=statuses, opened_by=opened_by)
215 215
216 216 return len(pull_requests)
217 217
218 218 def get_awaiting_review(self, repo_name, source=False, statuses=None,
219 219 opened_by=None, offset=0, length=None,
220 220 order_by=None, order_dir='desc'):
221 221 """
222 222 Get all pull requests for a specific repository that are awaiting
223 223 review.
224 224
225 225 :param repo_name: target or source repo
226 226 :param source: boolean flag to specify if repo_name refers to source
227 227 :param statuses: list of pull request statuses
228 228 :param opened_by: author user of the pull request
229 229 :param offset: pagination offset
230 230 :param length: length of returned list
231 231 :param order_by: order of the returned list
232 232 :param order_dir: 'asc' or 'desc' ordering direction
233 233 :returns: list of pull requests
234 234 """
235 235 pull_requests = self.get_all(
236 236 repo_name, source=source, statuses=statuses, opened_by=opened_by,
237 237 order_by=order_by, order_dir=order_dir)
238 238
239 239 _filtered_pull_requests = []
240 240 for pr in pull_requests:
241 241 status = pr.calculated_review_status()
242 242 if status in [ChangesetStatus.STATUS_NOT_REVIEWED,
243 243 ChangesetStatus.STATUS_UNDER_REVIEW]:
244 244 _filtered_pull_requests.append(pr)
245 245 if length:
246 246 return _filtered_pull_requests[offset:offset+length]
247 247 else:
248 248 return _filtered_pull_requests
249 249
250 250 def count_awaiting_my_review(self, repo_name, source=False, statuses=None,
251 251 opened_by=None, user_id=None):
252 252 """
253 253 Count the number of pull requests for a specific repository that are
254 254 awaiting review from a specific user.
255 255
256 256 :param repo_name: target or source repo
257 257 :param source: boolean flag to specify if repo_name refers to source
258 258 :param statuses: list of pull request statuses
259 259 :param opened_by: author user of the pull request
260 260 :param user_id: reviewer user of the pull request
261 261 :returns: int number of pull requests
262 262 """
263 263 pull_requests = self.get_awaiting_my_review(
264 264 repo_name, source=source, statuses=statuses, opened_by=opened_by,
265 265 user_id=user_id)
266 266
267 267 return len(pull_requests)
268 268
269 269 def get_awaiting_my_review(self, repo_name, source=False, statuses=None,
270 270 opened_by=None, user_id=None, offset=0,
271 271 length=None, order_by=None, order_dir='desc'):
272 272 """
273 273 Get all pull requests for a specific repository that are awaiting
274 274 review from a specific user.
275 275
276 276 :param repo_name: target or source repo
277 277 :param source: boolean flag to specify if repo_name refers to source
278 278 :param statuses: list of pull request statuses
279 279 :param opened_by: author user of the pull request
280 280 :param user_id: reviewer user of the pull request
281 281 :param offset: pagination offset
282 282 :param length: length of returned list
283 283 :param order_by: order of the returned list
284 284 :param order_dir: 'asc' or 'desc' ordering direction
285 285 :returns: list of pull requests
286 286 """
287 287 pull_requests = self.get_all(
288 288 repo_name, source=source, statuses=statuses, opened_by=opened_by,
289 289 order_by=order_by, order_dir=order_dir)
290 290
291 291 _my = PullRequestModel().get_not_reviewed(user_id)
292 292 my_participation = []
293 293 for pr in pull_requests:
294 294 if pr in _my:
295 295 my_participation.append(pr)
296 296 _filtered_pull_requests = my_participation
297 297 if length:
298 298 return _filtered_pull_requests[offset:offset+length]
299 299 else:
300 300 return _filtered_pull_requests
301 301
302 302 def get_not_reviewed(self, user_id):
303 303 return [
304 304 x.pull_request for x in PullRequestReviewers.query().filter(
305 305 PullRequestReviewers.user_id == user_id).all()
306 306 ]
307 307
308 308 def get_versions(self, pull_request):
309 309 """
310 310 returns version of pull request sorted by ID descending
311 311 """
312 312 return PullRequestVersion.query()\
313 313 .filter(PullRequestVersion.pull_request == pull_request)\
314 314 .order_by(PullRequestVersion.pull_request_version_id.asc())\
315 315 .all()
316 316
317 317 def create(self, created_by, source_repo, source_ref, target_repo,
318 318 target_ref, revisions, reviewers, title, description=None):
319 319 created_by_user = self._get_user(created_by)
320 320 source_repo = self._get_repo(source_repo)
321 321 target_repo = self._get_repo(target_repo)
322 322
323 323 pull_request = PullRequest()
324 324 pull_request.source_repo = source_repo
325 325 pull_request.source_ref = source_ref
326 326 pull_request.target_repo = target_repo
327 327 pull_request.target_ref = target_ref
328 328 pull_request.revisions = revisions
329 329 pull_request.title = title
330 330 pull_request.description = description
331 331 pull_request.author = created_by_user
332 332
333 333 Session().add(pull_request)
334 334 Session().flush()
335 335
336 336 reviewer_ids = set()
337 337 # members / reviewers
338 338 for reviewer_object in reviewers:
339 339 if isinstance(reviewer_object, tuple):
340 340 user_id, reasons = reviewer_object
341 341 else:
342 342 user_id, reasons = reviewer_object, []
343 343
344 344 user = self._get_user(user_id)
345 345 reviewer_ids.add(user.user_id)
346 346
347 347 reviewer = PullRequestReviewers(user, pull_request, reasons)
348 348 Session().add(reviewer)
349 349
350 350 # Set approval status to "Under Review" for all commits which are
351 351 # part of this pull request.
352 352 ChangesetStatusModel().set_status(
353 353 repo=target_repo,
354 354 status=ChangesetStatus.STATUS_UNDER_REVIEW,
355 355 user=created_by_user,
356 356 pull_request=pull_request
357 357 )
358 358
359 359 self.notify_reviewers(pull_request, reviewer_ids)
360 360 self._trigger_pull_request_hook(
361 361 pull_request, created_by_user, 'create')
362 362
363 363 return pull_request
364 364
365 365 def _trigger_pull_request_hook(self, pull_request, user, action):
366 366 pull_request = self.__get_pull_request(pull_request)
367 367 target_scm = pull_request.target_repo.scm_instance()
368 368 if action == 'create':
369 369 trigger_hook = hooks_utils.trigger_log_create_pull_request_hook
370 370 elif action == 'merge':
371 371 trigger_hook = hooks_utils.trigger_log_merge_pull_request_hook
372 372 elif action == 'close':
373 373 trigger_hook = hooks_utils.trigger_log_close_pull_request_hook
374 374 elif action == 'review_status_change':
375 375 trigger_hook = hooks_utils.trigger_log_review_pull_request_hook
376 376 elif action == 'update':
377 377 trigger_hook = hooks_utils.trigger_log_update_pull_request_hook
378 378 else:
379 379 return
380 380
381 381 trigger_hook(
382 382 username=user.username,
383 383 repo_name=pull_request.target_repo.repo_name,
384 384 repo_alias=target_scm.alias,
385 385 pull_request=pull_request)
386 386
387 387 def _get_commit_ids(self, pull_request):
388 388 """
389 389 Return the commit ids of the merged pull request.
390 390
391 391 This method is not dealing correctly yet with the lack of autoupdates
392 392 nor with the implicit target updates.
393 393 For example: if a commit in the source repo is already in the target it
394 394 will be reported anyways.
395 395 """
396 396 merge_rev = pull_request.merge_rev
397 397 if merge_rev is None:
398 398 raise ValueError('This pull request was not merged yet')
399 399
400 400 commit_ids = list(pull_request.revisions)
401 401 if merge_rev not in commit_ids:
402 402 commit_ids.append(merge_rev)
403 403
404 404 return commit_ids
405 405
406 406 def merge(self, pull_request, user, extras):
407 407 log.debug("Merging pull request %s", pull_request.pull_request_id)
408 408 merge_state = self._merge_pull_request(pull_request, user, extras)
409 409 if merge_state.executed:
410 410 log.debug(
411 411 "Merge was successful, updating the pull request comments.")
412 412 self._comment_and_close_pr(pull_request, user, merge_state)
413 413 self._log_action('user_merged_pull_request', user, pull_request)
414 414 else:
415 415 log.warn("Merge failed, not updating the pull request.")
416 416 return merge_state
417 417
418 418 def _merge_pull_request(self, pull_request, user, extras):
419 419 target_vcs = pull_request.target_repo.scm_instance()
420 420 source_vcs = pull_request.source_repo.scm_instance()
421 421 target_ref = self._refresh_reference(
422 422 pull_request.target_ref_parts, target_vcs)
423 423
424 424 message = _(
425 425 'Merge pull request #%(pr_id)s from '
426 426 '%(source_repo)s %(source_ref_name)s\n\n %(pr_title)s') % {
427 427 'pr_id': pull_request.pull_request_id,
428 428 'source_repo': source_vcs.name,
429 429 'source_ref_name': pull_request.source_ref_parts.name,
430 430 'pr_title': pull_request.title
431 431 }
432 432
433 433 workspace_id = self._workspace_id(pull_request)
434 434 use_rebase = self._use_rebase_for_merging(pull_request)
435 435
436 436 callback_daemon, extras = prepare_callback_daemon(
437 437 extras, protocol=vcs_settings.HOOKS_PROTOCOL,
438 438 use_direct_calls=vcs_settings.HOOKS_DIRECT_CALLS)
439 439
440 440 with callback_daemon:
441 441 # TODO: johbo: Implement a clean way to run a config_override
442 442 # for a single call.
443 443 target_vcs.config.set(
444 444 'rhodecode', 'RC_SCM_DATA', json.dumps(extras))
445 445 merge_state = target_vcs.merge(
446 446 target_ref, source_vcs, pull_request.source_ref_parts,
447 447 workspace_id, user_name=user.username,
448 448 user_email=user.email, message=message, use_rebase=use_rebase)
449 449 return merge_state
450 450
451 451 def _comment_and_close_pr(self, pull_request, user, merge_state):
452 452 pull_request.merge_rev = merge_state.merge_commit_id
453 453 pull_request.updated_on = datetime.datetime.now()
454 454
455 455 ChangesetCommentsModel().create(
456 456 text=unicode(_('Pull request merged and closed')),
457 457 repo=pull_request.target_repo.repo_id,
458 458 user=user.user_id,
459 459 pull_request=pull_request.pull_request_id,
460 460 f_path=None,
461 461 line_no=None,
462 462 closing_pr=True
463 463 )
464 464
465 465 Session().add(pull_request)
466 466 Session().flush()
467 467 # TODO: paris: replace invalidation with less radical solution
468 468 ScmModel().mark_for_invalidation(
469 469 pull_request.target_repo.repo_name)
470 470 self._trigger_pull_request_hook(pull_request, user, 'merge')
471 471
472 472 def has_valid_update_type(self, pull_request):
473 473 source_ref_type = pull_request.source_ref_parts.type
474 474 return source_ref_type in ['book', 'branch', 'tag']
475 475
476 476 def update_commits(self, pull_request):
477 477 """
478 478 Get the updated list of commits for the pull request
479 479 and return the new pull request version and the list
480 480 of commits processed by this update action
481 481 """
482 482
483 483 pull_request = self.__get_pull_request(pull_request)
484 484 source_ref_type = pull_request.source_ref_parts.type
485 485 source_ref_name = pull_request.source_ref_parts.name
486 486 source_ref_id = pull_request.source_ref_parts.commit_id
487 487
488 488 if not self.has_valid_update_type(pull_request):
489 489 log.debug(
490 490 "Skipping update of pull request %s due to ref type: %s",
491 491 pull_request, source_ref_type)
492 492 return (None, None)
493 493
494 494 source_repo = pull_request.source_repo.scm_instance()
495 495 source_commit = source_repo.get_commit(commit_id=source_ref_name)
496 496 if source_ref_id == source_commit.raw_id:
497 497 log.debug("Nothing changed in pull request %s", pull_request)
498 498 return (None, None)
499 499
500 500 # Finally there is a need for an update
501 501 pull_request_version = self._create_version_from_snapshot(pull_request)
502 502 self._link_comments_to_version(pull_request_version)
503 503
504 504 target_ref_type = pull_request.target_ref_parts.type
505 505 target_ref_name = pull_request.target_ref_parts.name
506 506 target_ref_id = pull_request.target_ref_parts.commit_id
507 507 target_repo = pull_request.target_repo.scm_instance()
508 508
509 509 if target_ref_type in ('tag', 'branch', 'book'):
510 510 target_commit = target_repo.get_commit(target_ref_name)
511 511 else:
512 512 target_commit = target_repo.get_commit(target_ref_id)
513 513
514 514 # re-compute commit ids
515 515 old_commit_ids = set(pull_request.revisions)
516 516 pre_load = ["author", "branch", "date", "message"]
517 517 commit_ranges = target_repo.compare(
518 518 target_commit.raw_id, source_commit.raw_id, source_repo, merge=True,
519 519 pre_load=pre_load)
520 520
521 521 ancestor = target_repo.get_common_ancestor(
522 522 target_commit.raw_id, source_commit.raw_id, source_repo)
523 523
524 524 pull_request.source_ref = '%s:%s:%s' % (
525 525 source_ref_type, source_ref_name, source_commit.raw_id)
526 526 pull_request.target_ref = '%s:%s:%s' % (
527 527 target_ref_type, target_ref_name, ancestor)
528 528 pull_request.revisions = [
529 529 commit.raw_id for commit in reversed(commit_ranges)]
530 530 pull_request.updated_on = datetime.datetime.now()
531 531 Session().add(pull_request)
532 532 new_commit_ids = set(pull_request.revisions)
533 533
534 534 changes = self._calculate_commit_id_changes(
535 535 old_commit_ids, new_commit_ids)
536 536
537 537 old_diff_data, new_diff_data = self._generate_update_diffs(
538 538 pull_request, pull_request_version)
539 539
540 540 ChangesetCommentsModel().outdate_comments(
541 541 pull_request, old_diff_data=old_diff_data,
542 542 new_diff_data=new_diff_data)
543 543
544 544 file_changes = self._calculate_file_changes(
545 545 old_diff_data, new_diff_data)
546 546
547 547 # Add an automatic comment to the pull request
548 548 update_comment = ChangesetCommentsModel().create(
549 549 text=self._render_update_message(changes, file_changes),
550 550 repo=pull_request.target_repo,
551 551 user=pull_request.author,
552 552 pull_request=pull_request,
553 553 send_email=False, renderer=DEFAULT_COMMENTS_RENDERER)
554 554
555 555 # Update status to "Under Review" for added commits
556 556 for commit_id in changes.added:
557 557 ChangesetStatusModel().set_status(
558 558 repo=pull_request.source_repo,
559 559 status=ChangesetStatus.STATUS_UNDER_REVIEW,
560 560 comment=update_comment,
561 561 user=pull_request.author,
562 562 pull_request=pull_request,
563 563 revision=commit_id)
564 564
565 565 log.debug(
566 566 'Updated pull request %s, added_ids: %s, common_ids: %s, '
567 567 'removed_ids: %s', pull_request.pull_request_id,
568 568 changes.added, changes.common, changes.removed)
569 569 log.debug('Updated pull request with the following file changes: %s',
570 570 file_changes)
571 571
572 572 log.info(
573 573 "Updated pull request %s from commit %s to commit %s, "
574 574 "stored new version %s of this pull request.",
575 575 pull_request.pull_request_id, source_ref_id,
576 576 pull_request.source_ref_parts.commit_id,
577 577 pull_request_version.pull_request_version_id)
578 578 Session().commit()
579 579 self._trigger_pull_request_hook(pull_request, pull_request.author,
580 580 'update')
581 581
582 582 return (pull_request_version, changes)
583 583
584 584 def _create_version_from_snapshot(self, pull_request):
585 585 version = PullRequestVersion()
586 586 version.title = pull_request.title
587 587 version.description = pull_request.description
588 588 version.status = pull_request.status
589 589 version.created_on = pull_request.created_on
590 590 version.updated_on = pull_request.updated_on
591 591 version.user_id = pull_request.user_id
592 592 version.source_repo = pull_request.source_repo
593 593 version.source_ref = pull_request.source_ref
594 594 version.target_repo = pull_request.target_repo
595 595 version.target_ref = pull_request.target_ref
596 596
597 597 version._last_merge_source_rev = pull_request._last_merge_source_rev
598 598 version._last_merge_target_rev = pull_request._last_merge_target_rev
599 599 version._last_merge_status = pull_request._last_merge_status
600 600 version.merge_rev = pull_request.merge_rev
601 601
602 602 version.revisions = pull_request.revisions
603 603 version.pull_request = pull_request
604 604 Session().add(version)
605 605 Session().flush()
606 606
607 607 return version
608 608
609 609 def _generate_update_diffs(self, pull_request, pull_request_version):
610 610 diff_context = (
611 611 self.DIFF_CONTEXT +
612 612 ChangesetCommentsModel.needed_extra_diff_context())
613 613 old_diff = self._get_diff_from_pr_or_version(
614 614 pull_request_version, context=diff_context)
615 615 new_diff = self._get_diff_from_pr_or_version(
616 616 pull_request, context=diff_context)
617 617
618 618 old_diff_data = diffs.DiffProcessor(old_diff)
619 619 old_diff_data.prepare()
620 620 new_diff_data = diffs.DiffProcessor(new_diff)
621 621 new_diff_data.prepare()
622 622
623 623 return old_diff_data, new_diff_data
624 624
625 625 def _link_comments_to_version(self, pull_request_version):
626 626 """
627 627 Link all unlinked comments of this pull request to the given version.
628 628
629 629 :param pull_request_version: The `PullRequestVersion` to which
630 630 the comments shall be linked.
631 631
632 632 """
633 633 pull_request = pull_request_version.pull_request
634 634 comments = ChangesetComment.query().filter(
635 635 # TODO: johbo: Should we query for the repo at all here?
636 636 # Pending decision on how comments of PRs are to be related
637 637 # to either the source repo, the target repo or no repo at all.
638 638 ChangesetComment.repo_id == pull_request.target_repo.repo_id,
639 639 ChangesetComment.pull_request == pull_request,
640 640 ChangesetComment.pull_request_version == None)
641 641
642 642 # TODO: johbo: Find out why this breaks if it is done in a bulk
643 643 # operation.
644 644 for comment in comments:
645 645 comment.pull_request_version_id = (
646 646 pull_request_version.pull_request_version_id)
647 647 Session().add(comment)
648 648
649 649 def _calculate_commit_id_changes(self, old_ids, new_ids):
650 650 added = new_ids.difference(old_ids)
651 651 common = old_ids.intersection(new_ids)
652 652 removed = old_ids.difference(new_ids)
653 653 return ChangeTuple(added, common, removed)
654 654
655 655 def _calculate_file_changes(self, old_diff_data, new_diff_data):
656 656
657 657 old_files = OrderedDict()
658 658 for diff_data in old_diff_data.parsed_diff:
659 659 old_files[diff_data['filename']] = md5_safe(diff_data['raw_diff'])
660 660
661 661 added_files = []
662 662 modified_files = []
663 663 removed_files = []
664 664 for diff_data in new_diff_data.parsed_diff:
665 665 new_filename = diff_data['filename']
666 666 new_hash = md5_safe(diff_data['raw_diff'])
667 667
668 668 old_hash = old_files.get(new_filename)
669 669 if not old_hash:
670 670 # file is not present in old diff, means it's added
671 671 added_files.append(new_filename)
672 672 else:
673 673 if new_hash != old_hash:
674 674 modified_files.append(new_filename)
675 675 # now remove a file from old, since we have seen it already
676 676 del old_files[new_filename]
677 677
678 678 # removed files is when there are present in old, but not in NEW,
679 679 # since we remove old files that are present in new diff, left-overs
680 680 # if any should be the removed files
681 681 removed_files.extend(old_files.keys())
682 682
683 683 return FileChangeTuple(added_files, modified_files, removed_files)
684 684
685 685 def _render_update_message(self, changes, file_changes):
686 686 """
687 687 render the message using DEFAULT_COMMENTS_RENDERER (RST renderer),
688 688 so it's always looking the same disregarding on which default
689 689 renderer system is using.
690 690
691 691 :param changes: changes named tuple
692 692 :param file_changes: file changes named tuple
693 693
694 694 """
695 695 new_status = ChangesetStatus.get_status_lbl(
696 696 ChangesetStatus.STATUS_UNDER_REVIEW)
697 697
698 698 changed_files = (
699 699 file_changes.added + file_changes.modified + file_changes.removed)
700 700
701 701 params = {
702 702 'under_review_label': new_status,
703 703 'added_commits': changes.added,
704 704 'removed_commits': changes.removed,
705 705 'changed_files': changed_files,
706 706 'added_files': file_changes.added,
707 707 'modified_files': file_changes.modified,
708 708 'removed_files': file_changes.removed,
709 709 }
710 710 renderer = RstTemplateRenderer()
711 711 return renderer.render('pull_request_update.mako', **params)
712 712
713 713 def edit(self, pull_request, title, description):
714 714 pull_request = self.__get_pull_request(pull_request)
715 715 if pull_request.is_closed():
716 716 raise ValueError('This pull request is closed')
717 717 if title:
718 718 pull_request.title = title
719 719 pull_request.description = description
720 720 pull_request.updated_on = datetime.datetime.now()
721 721 Session().add(pull_request)
722 722
723 723 def update_reviewers(self, pull_request, reviewer_data):
724 724 """
725 725 Update the reviewers in the pull request
726 726
727 727 :param pull_request: the pr to update
728 728 :param reviewer_data: list of tuples [(user, ['reason1', 'reason2'])]
729 729 """
730 730
731 731 reviewers_reasons = {}
732 732 for user_id, reasons in reviewer_data:
733 733 if isinstance(user_id, (int, basestring)):
734 734 user_id = self._get_user(user_id).user_id
735 735 reviewers_reasons[user_id] = reasons
736 736
737 737 reviewers_ids = set(reviewers_reasons.keys())
738 738 pull_request = self.__get_pull_request(pull_request)
739 739 current_reviewers = PullRequestReviewers.query()\
740 740 .filter(PullRequestReviewers.pull_request ==
741 741 pull_request).all()
742 742 current_reviewers_ids = set([x.user.user_id for x in current_reviewers])
743 743
744 744 ids_to_add = reviewers_ids.difference(current_reviewers_ids)
745 745 ids_to_remove = current_reviewers_ids.difference(reviewers_ids)
746 746
747 747 log.debug("Adding %s reviewers", ids_to_add)
748 748 log.debug("Removing %s reviewers", ids_to_remove)
749 749 changed = False
750 750 for uid in ids_to_add:
751 751 changed = True
752 752 _usr = self._get_user(uid)
753 753 reasons = reviewers_reasons[uid]
754 754 reviewer = PullRequestReviewers(_usr, pull_request, reasons)
755 755 Session().add(reviewer)
756 756
757 757 self.notify_reviewers(pull_request, ids_to_add)
758 758
759 759 for uid in ids_to_remove:
760 760 changed = True
761 761 reviewer = PullRequestReviewers.query()\
762 762 .filter(PullRequestReviewers.user_id == uid,
763 763 PullRequestReviewers.pull_request == pull_request)\
764 764 .scalar()
765 765 if reviewer:
766 766 Session().delete(reviewer)
767 767 if changed:
768 768 pull_request.updated_on = datetime.datetime.now()
769 769 Session().add(pull_request)
770 770
771 771 return ids_to_add, ids_to_remove
772 772
773 773 def get_url(self, pull_request):
774 774 return h.url('pullrequest_show',
775 775 repo_name=safe_str(pull_request.target_repo.repo_name),
776 776 pull_request_id=pull_request.pull_request_id,
777 777 qualified=True)
778 778
779 def get_shadow_clone_url(self, pull_request):
780 return u'{url}/repository'.format(url=self.get_url(pull_request))
781
779 782 def notify_reviewers(self, pull_request, reviewers_ids):
780 783 # notification to reviewers
781 784 if not reviewers_ids:
782 785 return
783 786
784 787 pull_request_obj = pull_request
785 788 # get the current participants of this pull request
786 789 recipients = reviewers_ids
787 790 notification_type = EmailNotificationModel.TYPE_PULL_REQUEST
788 791
789 792 pr_source_repo = pull_request_obj.source_repo
790 793 pr_target_repo = pull_request_obj.target_repo
791 794
792 795 pr_url = h.url(
793 796 'pullrequest_show',
794 797 repo_name=pr_target_repo.repo_name,
795 798 pull_request_id=pull_request_obj.pull_request_id,
796 799 qualified=True,)
797 800
798 801 # set some variables for email notification
799 802 pr_target_repo_url = h.url(
800 803 'summary_home',
801 804 repo_name=pr_target_repo.repo_name,
802 805 qualified=True)
803 806
804 807 pr_source_repo_url = h.url(
805 808 'summary_home',
806 809 repo_name=pr_source_repo.repo_name,
807 810 qualified=True)
808 811
809 812 # pull request specifics
810 813 pull_request_commits = [
811 814 (x.raw_id, x.message)
812 815 for x in map(pr_source_repo.get_commit, pull_request.revisions)]
813 816
814 817 kwargs = {
815 818 'user': pull_request.author,
816 819 'pull_request': pull_request_obj,
817 820 'pull_request_commits': pull_request_commits,
818 821
819 822 'pull_request_target_repo': pr_target_repo,
820 823 'pull_request_target_repo_url': pr_target_repo_url,
821 824
822 825 'pull_request_source_repo': pr_source_repo,
823 826 'pull_request_source_repo_url': pr_source_repo_url,
824 827
825 828 'pull_request_url': pr_url,
826 829 }
827 830
828 831 # pre-generate the subject for notification itself
829 832 (subject,
830 833 _h, _e, # we don't care about those
831 834 body_plaintext) = EmailNotificationModel().render_email(
832 835 notification_type, **kwargs)
833 836
834 837 # create notification objects, and emails
835 838 NotificationModel().create(
836 839 created_by=pull_request.author,
837 840 notification_subject=subject,
838 841 notification_body=body_plaintext,
839 842 notification_type=notification_type,
840 843 recipients=recipients,
841 844 email_kwargs=kwargs,
842 845 )
843 846
844 847 def delete(self, pull_request):
845 848 pull_request = self.__get_pull_request(pull_request)
846 849 self._cleanup_merge_workspace(pull_request)
847 850 Session().delete(pull_request)
848 851
849 852 def close_pull_request(self, pull_request, user):
850 853 pull_request = self.__get_pull_request(pull_request)
851 854 self._cleanup_merge_workspace(pull_request)
852 855 pull_request.status = PullRequest.STATUS_CLOSED
853 856 pull_request.updated_on = datetime.datetime.now()
854 857 Session().add(pull_request)
855 858 self._trigger_pull_request_hook(
856 859 pull_request, pull_request.author, 'close')
857 860 self._log_action('user_closed_pull_request', user, pull_request)
858 861
859 862 def close_pull_request_with_comment(self, pull_request, user, repo,
860 863 message=None):
861 864 status = ChangesetStatus.STATUS_REJECTED
862 865
863 866 if not message:
864 867 message = (
865 868 _('Status change %(transition_icon)s %(status)s') % {
866 869 'transition_icon': '>',
867 870 'status': ChangesetStatus.get_status_lbl(status)})
868 871
869 872 internal_message = _('Closing with') + ' ' + message
870 873
871 874 comm = ChangesetCommentsModel().create(
872 875 text=internal_message,
873 876 repo=repo.repo_id,
874 877 user=user.user_id,
875 878 pull_request=pull_request.pull_request_id,
876 879 f_path=None,
877 880 line_no=None,
878 881 status_change=ChangesetStatus.get_status_lbl(status),
879 882 status_change_type=status,
880 883 closing_pr=True
881 884 )
882 885
883 886 ChangesetStatusModel().set_status(
884 887 repo.repo_id,
885 888 status,
886 889 user.user_id,
887 890 comm,
888 891 pull_request=pull_request.pull_request_id
889 892 )
890 893 Session().flush()
891 894
892 895 PullRequestModel().close_pull_request(
893 896 pull_request.pull_request_id, user)
894 897
895 898 def merge_status(self, pull_request):
896 899 if not self._is_merge_enabled(pull_request):
897 900 return False, _('Server-side pull request merging is disabled.')
898 901 if pull_request.is_closed():
899 902 return False, _('This pull request is closed.')
900 903 merge_possible, msg = self._check_repo_requirements(
901 904 target=pull_request.target_repo, source=pull_request.source_repo)
902 905 if not merge_possible:
903 906 return merge_possible, msg
904 907
905 908 try:
906 909 resp = self._try_merge(pull_request)
907 910 status = resp.possible, self.merge_status_message(
908 911 resp.failure_reason)
909 912 except NotImplementedError:
910 913 status = False, _('Pull request merging is not supported.')
911 914
912 915 return status
913 916
914 917 def _check_repo_requirements(self, target, source):
915 918 """
916 919 Check if `target` and `source` have compatible requirements.
917 920
918 921 Currently this is just checking for largefiles.
919 922 """
920 923 target_has_largefiles = self._has_largefiles(target)
921 924 source_has_largefiles = self._has_largefiles(source)
922 925 merge_possible = True
923 926 message = u''
924 927
925 928 if target_has_largefiles != source_has_largefiles:
926 929 merge_possible = False
927 930 if source_has_largefiles:
928 931 message = _(
929 932 'Target repository large files support is disabled.')
930 933 else:
931 934 message = _(
932 935 'Source repository large files support is disabled.')
933 936
934 937 return merge_possible, message
935 938
936 939 def _has_largefiles(self, repo):
937 940 largefiles_ui = VcsSettingsModel(repo=repo).get_ui_settings(
938 941 'extensions', 'largefiles')
939 942 return largefiles_ui and largefiles_ui[0].active
940 943
941 944 def _try_merge(self, pull_request):
942 945 """
943 946 Try to merge the pull request and return the merge status.
944 947 """
945 948 log.debug(
946 949 "Trying out if the pull request %s can be merged.",
947 950 pull_request.pull_request_id)
948 951 target_vcs = pull_request.target_repo.scm_instance()
949 952 target_ref = self._refresh_reference(
950 953 pull_request.target_ref_parts, target_vcs)
951 954
952 955 target_locked = pull_request.target_repo.locked
953 956 if target_locked and target_locked[0]:
954 957 log.debug("The target repository is locked.")
955 958 merge_state = MergeResponse(
956 959 False, False, None, MergeFailureReason.TARGET_IS_LOCKED)
957 960 elif self._needs_merge_state_refresh(pull_request, target_ref):
958 961 log.debug("Refreshing the merge status of the repository.")
959 962 merge_state = self._refresh_merge_state(
960 963 pull_request, target_vcs, target_ref)
961 964 else:
962 965 possible = pull_request.\
963 966 _last_merge_status == MergeFailureReason.NONE
964 967 merge_state = MergeResponse(
965 968 possible, False, None, pull_request._last_merge_status)
966 969 log.debug("Merge response: %s", merge_state)
967 970 return merge_state
968 971
969 972 def _refresh_reference(self, reference, vcs_repository):
970 973 if reference.type in ('branch', 'book'):
971 974 name_or_id = reference.name
972 975 else:
973 976 name_or_id = reference.commit_id
974 977 refreshed_commit = vcs_repository.get_commit(name_or_id)
975 978 refreshed_reference = Reference(
976 979 reference.type, reference.name, refreshed_commit.raw_id)
977 980 return refreshed_reference
978 981
979 982 def _needs_merge_state_refresh(self, pull_request, target_reference):
980 983 return not(
981 984 pull_request.revisions and
982 985 pull_request.revisions[0] == pull_request._last_merge_source_rev and
983 986 target_reference.commit_id == pull_request._last_merge_target_rev)
984 987
985 988 def _refresh_merge_state(self, pull_request, target_vcs, target_reference):
986 989 workspace_id = self._workspace_id(pull_request)
987 990 source_vcs = pull_request.source_repo.scm_instance()
988 991 use_rebase = self._use_rebase_for_merging(pull_request)
989 992 merge_state = target_vcs.merge(
990 993 target_reference, source_vcs, pull_request.source_ref_parts,
991 994 workspace_id, dry_run=True, use_rebase=use_rebase)
992 995
993 996 # Do not store the response if there was an unknown error.
994 997 if merge_state.failure_reason != MergeFailureReason.UNKNOWN:
995 998 pull_request._last_merge_source_rev = pull_request.\
996 999 source_ref_parts.commit_id
997 1000 pull_request._last_merge_target_rev = target_reference.commit_id
998 1001 pull_request._last_merge_status = (
999 1002 merge_state.failure_reason)
1000 1003 Session().add(pull_request)
1001 1004 Session().flush()
1002 1005
1003 1006 return merge_state
1004 1007
1005 1008 def _workspace_id(self, pull_request):
1006 1009 workspace_id = 'pr-%s' % pull_request.pull_request_id
1007 1010 return workspace_id
1008 1011
1009 1012 def merge_status_message(self, status_code):
1010 1013 """
1011 1014 Return a human friendly error message for the given merge status code.
1012 1015 """
1013 1016 return self.MERGE_STATUS_MESSAGES[status_code]
1014 1017
1015 1018 def generate_repo_data(self, repo, commit_id=None, branch=None,
1016 1019 bookmark=None):
1017 1020 all_refs, selected_ref = \
1018 1021 self._get_repo_pullrequest_sources(
1019 1022 repo.scm_instance(), commit_id=commit_id,
1020 1023 branch=branch, bookmark=bookmark)
1021 1024
1022 1025 refs_select2 = []
1023 1026 for element in all_refs:
1024 1027 children = [{'id': x[0], 'text': x[1]} for x in element[0]]
1025 1028 refs_select2.append({'text': element[1], 'children': children})
1026 1029
1027 1030 return {
1028 1031 'user': {
1029 1032 'user_id': repo.user.user_id,
1030 1033 'username': repo.user.username,
1031 1034 'firstname': repo.user.firstname,
1032 1035 'lastname': repo.user.lastname,
1033 1036 'gravatar_link': h.gravatar_url(repo.user.email, 14),
1034 1037 },
1035 1038 'description': h.chop_at_smart(repo.description, '\n'),
1036 1039 'refs': {
1037 1040 'all_refs': all_refs,
1038 1041 'selected_ref': selected_ref,
1039 1042 'select2_refs': refs_select2
1040 1043 }
1041 1044 }
1042 1045
1043 1046 def generate_pullrequest_title(self, source, source_ref, target):
1044 1047 return u'{source}#{at_ref} to {target}'.format(
1045 1048 source=source,
1046 1049 at_ref=source_ref,
1047 1050 target=target,
1048 1051 )
1049 1052
1050 1053 def _cleanup_merge_workspace(self, pull_request):
1051 1054 # Merging related cleanup
1052 1055 target_scm = pull_request.target_repo.scm_instance()
1053 1056 workspace_id = 'pr-%s' % pull_request.pull_request_id
1054 1057
1055 1058 try:
1056 1059 target_scm.cleanup_merge_workspace(workspace_id)
1057 1060 except NotImplementedError:
1058 1061 pass
1059 1062
1060 1063 def _get_repo_pullrequest_sources(
1061 1064 self, repo, commit_id=None, branch=None, bookmark=None):
1062 1065 """
1063 1066 Return a structure with repo's interesting commits, suitable for
1064 1067 the selectors in pullrequest controller
1065 1068
1066 1069 :param commit_id: a commit that must be in the list somehow
1067 1070 and selected by default
1068 1071 :param branch: a branch that must be in the list and selected
1069 1072 by default - even if closed
1070 1073 :param bookmark: a bookmark that must be in the list and selected
1071 1074 """
1072 1075
1073 1076 commit_id = safe_str(commit_id) if commit_id else None
1074 1077 branch = safe_str(branch) if branch else None
1075 1078 bookmark = safe_str(bookmark) if bookmark else None
1076 1079
1077 1080 selected = None
1078 1081
1079 1082 # order matters: first source that has commit_id in it will be selected
1080 1083 sources = []
1081 1084 sources.append(('book', repo.bookmarks.items(), _('Bookmarks'), bookmark))
1082 1085 sources.append(('branch', repo.branches.items(), _('Branches'), branch))
1083 1086
1084 1087 if commit_id:
1085 1088 ref_commit = (h.short_id(commit_id), commit_id)
1086 1089 sources.append(('rev', [ref_commit], _('Commit IDs'), commit_id))
1087 1090
1088 1091 sources.append(
1089 1092 ('branch', repo.branches_closed.items(), _('Closed Branches'), branch),
1090 1093 )
1091 1094
1092 1095 groups = []
1093 1096 for group_key, ref_list, group_name, match in sources:
1094 1097 group_refs = []
1095 1098 for ref_name, ref_id in ref_list:
1096 1099 ref_key = '%s:%s:%s' % (group_key, ref_name, ref_id)
1097 1100 group_refs.append((ref_key, ref_name))
1098 1101
1099 1102 if not selected:
1100 1103 if set([commit_id, match]) & set([ref_id, ref_name]):
1101 1104 selected = ref_key
1102 1105
1103 1106 if group_refs:
1104 1107 groups.append((group_refs, group_name))
1105 1108
1106 1109 if not selected:
1107 1110 ref = commit_id or branch or bookmark
1108 1111 if ref:
1109 1112 raise CommitDoesNotExistError(
1110 1113 'No commit refs could be found matching: %s' % ref)
1111 1114 elif repo.DEFAULT_BRANCH_NAME in repo.branches:
1112 1115 selected = 'branch:%s:%s' % (
1113 1116 repo.DEFAULT_BRANCH_NAME,
1114 1117 repo.branches[repo.DEFAULT_BRANCH_NAME]
1115 1118 )
1116 1119 elif repo.commit_ids:
1117 1120 rev = repo.commit_ids[0]
1118 1121 selected = 'rev:%s:%s' % (rev, rev)
1119 1122 else:
1120 1123 raise EmptyRepositoryError()
1121 1124 return groups, selected
1122 1125
1123 1126 def get_diff(self, pull_request, context=DIFF_CONTEXT):
1124 1127 pull_request = self.__get_pull_request(pull_request)
1125 1128 return self._get_diff_from_pr_or_version(pull_request, context=context)
1126 1129
1127 1130 def _get_diff_from_pr_or_version(self, pr_or_version, context):
1128 1131 source_repo = pr_or_version.source_repo
1129 1132
1130 1133 # we swap org/other ref since we run a simple diff on one repo
1131 1134 target_ref_id = pr_or_version.target_ref_parts.commit_id
1132 1135 source_ref_id = pr_or_version.source_ref_parts.commit_id
1133 1136 target_commit = source_repo.get_commit(
1134 1137 commit_id=safe_str(target_ref_id))
1135 1138 source_commit = source_repo.get_commit(commit_id=safe_str(source_ref_id))
1136 1139 vcs_repo = source_repo.scm_instance()
1137 1140
1138 1141 # TODO: johbo: In the context of an update, we cannot reach
1139 1142 # the old commit anymore with our normal mechanisms. It needs
1140 1143 # some sort of special support in the vcs layer to avoid this
1141 1144 # workaround.
1142 1145 if (source_commit.raw_id == vcs_repo.EMPTY_COMMIT_ID and
1143 1146 vcs_repo.alias == 'git'):
1144 1147 source_commit.raw_id = safe_str(source_ref_id)
1145 1148
1146 1149 log.debug('calculating diff between '
1147 1150 'source_ref:%s and target_ref:%s for repo `%s`',
1148 1151 target_ref_id, source_ref_id,
1149 1152 safe_unicode(vcs_repo.path))
1150 1153
1151 1154 vcs_diff = vcs_repo.get_diff(
1152 1155 commit1=target_commit, commit2=source_commit, context=context)
1153 1156 return vcs_diff
1154 1157
1155 1158 def _is_merge_enabled(self, pull_request):
1156 1159 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
1157 1160 settings = settings_model.get_general_settings()
1158 1161 return settings.get('rhodecode_pr_merge_enabled', False)
1159 1162
1160 1163 def _use_rebase_for_merging(self, pull_request):
1161 1164 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
1162 1165 settings = settings_model.get_general_settings()
1163 1166 return settings.get('rhodecode_hg_use_rebase_for_merging', False)
1164 1167
1165 1168 def _log_action(self, action, user, pull_request):
1166 1169 action_logger(
1167 1170 user,
1168 1171 '{action}:{pr_id}'.format(
1169 1172 action=action, pr_id=pull_request.pull_request_id),
1170 1173 pull_request.target_repo)
1171 1174
1172 1175
1173 1176 ChangeTuple = namedtuple('ChangeTuple',
1174 1177 ['added', 'common', 'removed'])
1175 1178
1176 1179 FileChangeTuple = namedtuple('FileChangeTuple',
1177 1180 ['added', 'modified', 'removed'])
@@ -1,602 +1,621 b''
1 1 <%inherit file="/base/base.html"/>
2 2
3 3 <%def name="title()">
4 4 ${_('%s Pull Request #%s') % (c.repo_name, c.pull_request.pull_request_id)}
5 5 %if c.rhodecode_name:
6 6 &middot; ${h.branding(c.rhodecode_name)}
7 7 %endif
8 8 </%def>
9 9
10 10 <%def name="breadcrumbs_links()">
11 11 <span id="pr-title">
12 12 ${c.pull_request.title}
13 13 %if c.pull_request.is_closed():
14 14 (${_('Closed')})
15 15 %endif
16 16 </span>
17 17 <div id="pr-title-edit" class="input" style="display: none;">
18 18 ${h.text('pullrequest_title', id_="pr-title-input", class_="large", value=c.pull_request.title)}
19 19 </div>
20 20 </%def>
21 21
22 22 <%def name="menu_bar_nav()">
23 23 ${self.menu_items(active='repositories')}
24 24 </%def>
25 25
26 26 <%def name="menu_bar_subnav()">
27 27 ${self.repo_menu(active='showpullrequest')}
28 28 </%def>
29 29
30 30 <%def name="main()">
31 31 <script type="text/javascript">
32 32 // TODO: marcink switch this to pyroutes
33 33 AJAX_COMMENT_DELETE_URL = "${url('pullrequest_comment_delete',repo_name=c.repo_name,comment_id='__COMMENT_ID__')}";
34 34 templateContext.pull_request_data.pull_request_id = ${c.pull_request.pull_request_id};
35 35 </script>
36 36 <div class="box">
37 37 <div class="title">
38 38 ${self.repo_page_title(c.rhodecode_db_repo)}
39 39 </div>
40 40
41 41 ${self.breadcrumbs()}
42 42
43 43
44 44 <div class="box pr-summary">
45 45 <div class="summary-details block-left">
46 46 <%summary = lambda n:{False:'summary-short'}.get(n)%>
47 47 <div class="pr-details-title">
48 48 ${_('Pull request #%s') % c.pull_request.pull_request_id} ${_('From')} ${h.format_date(c.pull_request.created_on)}
49 49 %if c.allowed_to_update:
50 50 <span id="open_edit_pullrequest" class="block-right action_button">${_('Edit')}</span>
51 51 <span id="close_edit_pullrequest" class="block-right action_button" style="display: none;">${_('Close')}</span>
52 52 %endif
53 53 </div>
54 54
55 55 <div id="summary" class="fields pr-details-content">
56 56 <div class="field">
57 57 <div class="label-summary">
58 58 <label>${_('Origin')}:</label>
59 59 </div>
60 60 <div class="input">
61 61 <div class="pr-origininfo">
62 62 ## branch link is only valid if it is a branch
63 63 <span class="tag">
64 64 %if c.pull_request.source_ref_parts.type == 'branch':
65 65 <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>
66 66 %else:
67 67 ${c.pull_request.source_ref_parts.type}: ${c.pull_request.source_ref_parts.name}
68 68 %endif
69 69 </span>
70 70 <span class="clone-url">
71 71 <a href="${h.url('summary_home', repo_name=c.pull_request.source_repo.repo_name)}">${c.pull_request.source_repo.clone_url()}</a>
72 72 </span>
73 73 </div>
74 74 <div class="pr-pullinfo">
75 75 %if h.is_hg(c.pull_request.source_repo):
76 76 <input type="text" value="hg pull -r ${h.short_id(c.source_ref)} ${c.pull_request.source_repo.clone_url()}" readonly="readonly">
77 77 %elif h.is_git(c.pull_request.source_repo):
78 78 <input type="text" value="git pull ${c.pull_request.source_repo.clone_url()} ${c.pull_request.source_ref_parts.name}" readonly="readonly">
79 79 %endif
80 80 </div>
81 81 </div>
82 82 </div>
83 83 <div class="field">
84 84 <div class="label-summary">
85 85 <label>${_('Target')}:</label>
86 86 </div>
87 87 <div class="input">
88 88 <div class="pr-targetinfo">
89 89 ## branch link is only valid if it is a branch
90 90 <span class="tag">
91 91 %if c.pull_request.target_ref_parts.type == 'branch':
92 92 <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>
93 93 %else:
94 94 ${c.pull_request.target_ref_parts.type}: ${c.pull_request.target_ref_parts.name}
95 95 %endif
96 96 </span>
97 97 <span class="clone-url">
98 98 <a href="${h.url('summary_home', repo_name=c.pull_request.target_repo.repo_name)}">${c.pull_request.target_repo.clone_url()}</a>
99 99 </span>
100 100 </div>
101 101 </div>
102 102 </div>
103
104 ## Clone link of the shadow repository.
105 %if not c.pull_request.is_closed():
106 <div class="field">
107 <div class="label-summary">
108 <label>${_('Shadow')}:</label>
109 </div>
110 <div class="input">
111 <div class="pr-shadowinfo">
112 %if h.is_hg(c.pull_request.target_repo):
113 <input type="text" value="hg clone ${c.shadow_clone_url}" readonly="readonly">
114 %elif h.is_git(c.pull_request.target_repo):
115 <input type="text" value="git clone ${c.shadow_clone_url}" readonly="readonly">
116 %endif
117 </div>
118 </div>
119 </div>
120 %endif
121
103 122 <div class="field">
104 123 <div class="label-summary">
105 124 <label>${_('Review')}:</label>
106 125 </div>
107 126 <div class="input">
108 127 %if c.pull_request_review_status:
109 128 <div class="${'flag_status %s' % c.pull_request_review_status} tooltip pull-left"></div>
110 129 <span class="changeset-status-lbl tooltip">
111 130 %if c.pull_request.is_closed():
112 131 ${_('Closed')},
113 132 %endif
114 133 ${h.commit_status_lbl(c.pull_request_review_status)}
115 134 </span>
116 135 - ${ungettext('calculated based on %s reviewer vote', 'calculated based on %s reviewers votes', len(c.pull_request_reviewers)) % len(c.pull_request_reviewers)}
117 136 %endif
118 137 </div>
119 138 </div>
120 139 <div class="field">
121 140 <div class="pr-description-label label-summary">
122 141 <label>${_('Description')}:</label>
123 142 </div>
124 143 <div id="pr-desc" class="input">
125 144 <div class="pr-description">${h.urlify_commit_message(c.pull_request.description, c.repo_name)}</div>
126 145 </div>
127 146 <div id="pr-desc-edit" class="input textarea editor" style="display: none;">
128 147 <textarea id="pr-description-input" size="30">${c.pull_request.description}</textarea>
129 148 </div>
130 149 </div>
131 150 <div class="field">
132 151 <div class="label-summary">
133 152 <label>${_('Comments')}:</label>
134 153 </div>
135 154 <div class="input">
136 155 <div>
137 156 <div class="comments-number">
138 157 %if c.comments:
139 158 <a href="#comments">${ungettext("%d Pull request comment", "%d Pull request comments", len(c.comments)) % len(c.comments)}</a>,
140 159 %else:
141 160 ${ungettext("%d Pull request comment", "%d Pull request comments", len(c.comments)) % len(c.comments)}
142 161 %endif
143 162 %if c.inline_cnt:
144 163 ## this is replaced with a proper link to first comment via JS linkifyComments() func
145 164 <a href="#inline-comments" id="inline-comments-counter">${ungettext("%d Inline Comment", "%d Inline Comments", c.inline_cnt) % c.inline_cnt}</a>
146 165 %else:
147 166 ${ungettext("%d Inline Comment", "%d Inline Comments", c.inline_cnt) % c.inline_cnt}
148 167 %endif
149 168
150 169 % if c.outdated_cnt:
151 170 ,${ungettext("%d Outdated Comment", "%d Outdated Comments", c.outdated_cnt) % c.outdated_cnt} <span id="show-outdated-comments" class="btn btn-link">${_('(Show)')}</span>
152 171 % endif
153 172 </div>
154 173 </div>
155 174 </div>
156 175 </div>
157 176 <div id="pr-save" class="field" style="display: none;">
158 177 <div class="label-summary"></div>
159 178 <div class="input">
160 179 <span id="edit_pull_request" class="btn btn-small">${_('Save Changes')}</span>
161 180 </div>
162 181 </div>
163 182 </div>
164 183 </div>
165 184 <div>
166 185 ## AUTHOR
167 186 <div class="reviewers-title block-right">
168 187 <div class="pr-details-title">
169 188 ${_('Author')}
170 189 </div>
171 190 </div>
172 191 <div class="block-right pr-details-content reviewers">
173 192 <ul class="group_members">
174 193 <li>
175 194 ${self.gravatar_with_user(c.pull_request.author.email, 16)}
176 195 </li>
177 196 </ul>
178 197 </div>
179 198 ## REVIEWERS
180 199 <div class="reviewers-title block-right">
181 200 <div class="pr-details-title">
182 201 ${_('Pull request reviewers')}
183 202 %if c.allowed_to_update:
184 203 <span id="open_edit_reviewers" class="block-right action_button">${_('Edit')}</span>
185 204 <span id="close_edit_reviewers" class="block-right action_button" style="display: none;">${_('Close')}</span>
186 205 %endif
187 206 </div>
188 207 </div>
189 208 <div id="reviewers" class="block-right pr-details-content reviewers">
190 209 ## members goes here !
191 210 <input type="hidden" name="__start__" value="review_members:sequence">
192 211 <ul id="review_members" class="group_members">
193 212 %for member,reasons,status in c.pull_request_reviewers:
194 213 <li id="reviewer_${member.user_id}">
195 214 <div class="reviewers_member">
196 215 <div class="reviewer_status tooltip" title="${h.tooltip(h.commit_status_lbl(status[0][1].status if status else 'not_reviewed'))}">
197 216 <div class="${'flag_status %s' % (status[0][1].status if status else 'not_reviewed')} pull-left reviewer_member_status"></div>
198 217 </div>
199 218 <div id="reviewer_${member.user_id}_name" class="reviewer_name">
200 219 ${self.gravatar_with_user(member.email, 16)}
201 220 </div>
202 221 <input type="hidden" name="__start__" value="reviewer:mapping">
203 222 <input type="hidden" name="__start__" value="reasons:sequence">
204 223 %for reason in reasons:
205 224 <div class="reviewer_reason">- ${reason}</div>
206 225 <input type="hidden" name="reason" value="${reason}">
207 226
208 227 %endfor
209 228 <input type="hidden" name="__end__" value="reasons:sequence">
210 229 <input id="reviewer_${member.user_id}_input" type="hidden" value="${member.user_id}" name="user_id" />
211 230 <input type="hidden" name="__end__" value="reviewer:mapping">
212 231 %if c.allowed_to_update:
213 232 <div class="reviewer_member_remove action_button" onclick="removeReviewMember(${member.user_id}, true)" style="visibility: hidden;">
214 233 <i class="icon-remove-sign" ></i>
215 234 </div>
216 235 %endif
217 236 </div>
218 237 </li>
219 238 %endfor
220 239 </ul>
221 240 <input type="hidden" name="__end__" value="review_members:sequence">
222 241 %if not c.pull_request.is_closed():
223 242 <div id="add_reviewer_input" class='ac' style="display: none;">
224 243 %if c.allowed_to_update:
225 244 <div class="reviewer_ac">
226 245 ${h.text('user', class_='ac-input', placeholder=_('Add reviewer'))}
227 246 <div id="reviewers_container"></div>
228 247 </div>
229 248 <div>
230 249 <span id="update_pull_request" class="btn btn-small">${_('Save Changes')}</span>
231 250 </div>
232 251 %endif
233 252 </div>
234 253 %endif
235 254 </div>
236 255 </div>
237 256 </div>
238 257 <div class="box">
239 258 ##DIFF
240 259 <div class="table" >
241 260 <div id="changeset_compare_view_content">
242 261 ##CS
243 262 % if c.missing_requirements:
244 263 <div class="box">
245 264 <div class="alert alert-warning">
246 265 <div>
247 266 <strong>${_('Missing requirements:')}</strong>
248 267 ${_('These commits cannot be displayed, because this repository uses the Mercurial largefiles extension, which was not enabled.')}
249 268 </div>
250 269 </div>
251 270 </div>
252 271 % elif c.missing_commits:
253 272 <div class="box">
254 273 <div class="alert alert-warning">
255 274 <div>
256 275 <strong>${_('Missing commits')}:</strong>
257 276 ${_('This pull request cannot be displayed, because one or more commits no longer exist in the source repository.')}
258 277 ${_('Please update this pull request, push the commits back into the source repository, or consider closing this pull request.')}
259 278 </div>
260 279 </div>
261 280 </div>
262 281 % endif
263 282 <div class="compare_view_commits_title">
264 283 % if c.allowed_to_update and not c.pull_request.is_closed():
265 284 <button id="update_commits" class="btn btn-small">${_('Update commits')}</button>
266 285 % endif
267 286 % if len(c.commit_ranges):
268 287 <h2>${ungettext('Compare View: %s commit','Compare View: %s commits', len(c.commit_ranges)) % len(c.commit_ranges)}</h2>
269 288 % endif
270 289 </div>
271 290 % if not c.missing_commits:
272 291 <%include file="/compare/compare_commits.html" />
273 292 ## FILES
274 293 <div class="cs_files_title">
275 294 <span class="cs_files_expand">
276 295 <span id="expand_all_files">${_('Expand All')}</span> | <span id="collapse_all_files">${_('Collapse All')}</span>
277 296 </span>
278 297 <h2>
279 298 ${diff_block.diff_summary_text(len(c.files), c.lines_added, c.lines_deleted, c.limited_diff)}
280 299 </h2>
281 300 </div>
282 301 % endif
283 302 <div class="cs_files">
284 303 %if not c.files and not c.missing_commits:
285 304 <span class="empty_data">${_('No files')}</span>
286 305 %endif
287 306 <table class="compare_view_files">
288 307 <%namespace name="diff_block" file="/changeset/diff_block.html"/>
289 308 %for FID, change, path, stats in c.files:
290 309 <tr class="cs_${change} collapse_file" fid="${FID}">
291 310 <td class="cs_icon_td">
292 311 <span class="collapse_file_icon" fid="${FID}"></span>
293 312 </td>
294 313 <td class="cs_icon_td">
295 314 <div class="flag_status not_reviewed hidden"></div>
296 315 </td>
297 316 <td class="cs_${change}" id="a_${FID}">
298 317 <div class="node">
299 318 <a href="#a_${FID}">
300 319 <i class="icon-file-${change.lower()}"></i>
301 320 ${h.safe_unicode(path)}
302 321 </a>
303 322 </div>
304 323 </td>
305 324 <td>
306 325 <div class="changes pull-right">${h.fancy_file_stats(stats)}</div>
307 326 <div class="comment-bubble pull-right" data-path="${path}">
308 327 <i class="icon-comment"></i>
309 328 </div>
310 329 </td>
311 330 </tr>
312 331 <tr fid="${FID}" id="diff_${FID}" class="diff_links">
313 332 <td></td>
314 333 <td></td>
315 334 <td class="cs_${change}">
316 335 %if c.target_repo.repo_name == c.repo_name:
317 336 ${diff_block.diff_menu(c.repo_name, h.safe_unicode(path), c.target_ref, c.source_ref, change)}
318 337 %else:
319 338 ## this is slightly different case later, since the other repo can have this
320 339 ## file in other state than the origin repo
321 340 ${diff_block.diff_menu(c.target_repo.repo_name, h.safe_unicode(path), c.target_ref, c.source_ref, change)}
322 341 %endif
323 342 </td>
324 343 <td class="td-actions rc-form">
325 344 <div data-comment-id="${FID}" class="btn-link show-inline-comments comments-visible">
326 345 <span class="comments-show">${_('Show comments')}</span>
327 346 <span class="comments-hide">${_('Hide comments')}</span>
328 347 </div>
329 348 </td>
330 349 </tr>
331 350 <tr id="tr_${FID}">
332 351 <td></td>
333 352 <td></td>
334 353 <td class="injected_diff" colspan="2">
335 354 ${diff_block.diff_block_simple([c.changes[FID]])}
336 355 </td>
337 356 </tr>
338 357
339 358 ## Loop through inline comments
340 359 % if c.outdated_comments.get(path,False):
341 360 <tr class="outdated">
342 361 <td></td>
343 362 <td></td>
344 363 <td colspan="2">
345 364 <p>${_('Outdated Inline Comments')}:</p>
346 365 </td>
347 366 </tr>
348 367 <tr class="outdated">
349 368 <td></td>
350 369 <td></td>
351 370 <td colspan="2" class="outdated_comment_block">
352 371 % for line, comments in c.outdated_comments[path].iteritems():
353 372 <div class="inline-comment-placeholder" path="${path}" target_id="${h.safeid(h.safe_unicode(path))}">
354 373 % for co in comments:
355 374 ${comment.comment_block_outdated(co)}
356 375 % endfor
357 376 </div>
358 377 % endfor
359 378 </td>
360 379 </tr>
361 380 % endif
362 381 %endfor
363 382 ## Loop through inline comments for deleted files
364 383 %for path in c.deleted_files:
365 384 <tr class="outdated deleted">
366 385 <td></td>
367 386 <td></td>
368 387 <td>${path}</td>
369 388 </tr>
370 389 <tr class="outdated deleted">
371 390 <td></td>
372 391 <td></td>
373 392 <td>(${_('Removed')})</td>
374 393 </tr>
375 394 % if path in c.outdated_comments:
376 395 <tr class="outdated deleted">
377 396 <td></td>
378 397 <td></td>
379 398 <td colspan="2">
380 399 <p>${_('Outdated Inline Comments')}:</p>
381 400 </td>
382 401 </tr>
383 402 <tr class="outdated">
384 403 <td></td>
385 404 <td></td>
386 405 <td colspan="2" class="outdated_comment_block">
387 406 % for line, comments in c.outdated_comments[path].iteritems():
388 407 <div class="inline-comment-placeholder" path="${path}" target_id="${h.safeid(h.safe_unicode(path))}">
389 408 % for co in comments:
390 409 ${comment.comment_block_outdated(co)}
391 410 % endfor
392 411 </div>
393 412 % endfor
394 413 </td>
395 414 </tr>
396 415 % endif
397 416 %endfor
398 417 </table>
399 418 </div>
400 419 % if c.limited_diff:
401 420 <h5>${_('Commit was too big and was cut off...')} <a href="${h.url.current(fulldiff=1, **request.GET.mixed())}" onclick="return confirm('${_("Showing a huge diff might take some time and resources")}')">${_('Show full diff')}</a></h5>
402 421 % endif
403 422 </div>
404 423 </div>
405 424
406 425 % if c.limited_diff:
407 426 <p>${_('Commit was too big and was cut off...')} <a href="${h.url.current(fulldiff=1, **request.GET.mixed())}" onclick="return confirm('${_("Showing a huge diff might take some time and resources")}')">${_('Show full diff')}</a></p>
408 427 % endif
409 428
410 429 ## template for inline comment form
411 430 <%namespace name="comment" file="/changeset/changeset_file_comment.html"/>
412 431 ${comment.comment_inline_form()}
413 432
414 433 ## render comments and inlines
415 434 ${comment.generate_comments(include_pull_request=True, is_pull_request=True)}
416 435
417 436 % if not c.pull_request.is_closed():
418 437 ## main comment form and it status
419 438 ${comment.comments(h.url('pullrequest_comment', repo_name=c.repo_name,
420 439 pull_request_id=c.pull_request.pull_request_id),
421 440 c.pull_request_review_status,
422 441 is_pull_request=True, change_status=c.allowed_to_change_status)}
423 442 %endif
424 443
425 444 <script type="text/javascript">
426 445 if (location.hash) {
427 446 var result = splitDelimitedHash(location.hash);
428 447 var line = $('html').find(result.loc);
429 448 if (line.length > 0){
430 449 offsetScroll(line, 70);
431 450 }
432 451 }
433 452 $(function(){
434 453 ReviewerAutoComplete('user');
435 454 // custom code mirror
436 455 var codeMirrorInstance = initPullRequestsCodeMirror('#pr-description-input');
437 456
438 457 var PRDetails = {
439 458 editButton: $('#open_edit_pullrequest'),
440 459 closeButton: $('#close_edit_pullrequest'),
441 460 viewFields: $('#pr-desc, #pr-title'),
442 461 editFields: $('#pr-desc-edit, #pr-title-edit, #pr-save'),
443 462
444 463 init: function() {
445 464 var that = this;
446 465 this.editButton.on('click', function(e) { that.edit(); });
447 466 this.closeButton.on('click', function(e) { that.view(); });
448 467 },
449 468
450 469 edit: function(event) {
451 470 this.viewFields.hide();
452 471 this.editButton.hide();
453 472 this.editFields.show();
454 473 codeMirrorInstance.refresh();
455 474 },
456 475
457 476 view: function(event) {
458 477 this.editFields.hide();
459 478 this.closeButton.hide();
460 479 this.viewFields.show();
461 480 }
462 481 };
463 482
464 483 var ReviewersPanel = {
465 484 editButton: $('#open_edit_reviewers'),
466 485 closeButton: $('#close_edit_reviewers'),
467 486 addButton: $('#add_reviewer_input'),
468 487 removeButtons: $('.reviewer_member_remove'),
469 488
470 489 init: function() {
471 490 var that = this;
472 491 this.editButton.on('click', function(e) { that.edit(); });
473 492 this.closeButton.on('click', function(e) { that.close(); });
474 493 },
475 494
476 495 edit: function(event) {
477 496 this.editButton.hide();
478 497 this.closeButton.show();
479 498 this.addButton.show();
480 499 this.removeButtons.css('visibility', 'visible');
481 500 },
482 501
483 502 close: function(event) {
484 503 this.editButton.show();
485 504 this.closeButton.hide();
486 505 this.addButton.hide();
487 506 this.removeButtons.css('visibility', 'hidden');
488 507 }
489 508 };
490 509
491 510 PRDetails.init();
492 511 ReviewersPanel.init();
493 512
494 513 $('#show-outdated-comments').on('click', function(e){
495 514 var button = $(this);
496 515 var outdated = $('.outdated');
497 516 if (button.html() === "(Show)") {
498 517 button.html("(Hide)");
499 518 outdated.show();
500 519 } else {
501 520 button.html("(Show)");
502 521 outdated.hide();
503 522 }
504 523 });
505 524
506 525 $('.show-inline-comments').on('change', function(e){
507 526 var show = 'none';
508 527 var target = e.currentTarget;
509 528 if(target.checked){
510 529 show = ''
511 530 }
512 531 var boxid = $(target).attr('id_for');
513 532 var comments = $('#{0} .inline-comments'.format(boxid));
514 533 var fn_display = function(idx){
515 534 $(this).css('display', show);
516 535 };
517 536 $(comments).each(fn_display);
518 537 var btns = $('#{0} .inline-comments-button'.format(boxid));
519 538 $(btns).each(fn_display);
520 539 });
521 540
522 541 // inject comments into their proper positions
523 542 var file_comments = $('.inline-comment-placeholder');
524 543 %if c.pull_request.is_closed():
525 544 renderInlineComments(file_comments, false);
526 545 %else:
527 546 renderInlineComments(file_comments, true);
528 547 %endif
529 548 var commentTotals = {};
530 549 $.each(file_comments, function(i, comment) {
531 550 var path = $(comment).attr('path');
532 551 var comms = $(comment).children().length;
533 552 if (path in commentTotals) {
534 553 commentTotals[path] += comms;
535 554 } else {
536 555 commentTotals[path] = comms;
537 556 }
538 557 });
539 558 $.each(commentTotals, function(path, total) {
540 559 var elem = $('.comment-bubble[data-path="'+ path +'"]');
541 560 elem.css('visibility', 'visible');
542 561 elem.html(elem.html() + ' ' + total );
543 562 });
544 563
545 564 $('#merge_pull_request_form').submit(function() {
546 565 if (!$('#merge_pull_request').attr('disabled')) {
547 566 $('#merge_pull_request').attr('disabled', 'disabled');
548 567 }
549 568 return true;
550 569 });
551 570
552 571 $('#edit_pull_request').on('click', function(e){
553 572 var title = $('#pr-title-input').val();
554 573 var description = codeMirrorInstance.getValue();
555 574 editPullRequest(
556 575 "${c.repo_name}", "${c.pull_request.pull_request_id}",
557 576 title, description);
558 577 });
559 578
560 579 $('#update_pull_request').on('click', function(e){
561 580 updateReviewers(undefined, "${c.repo_name}", "${c.pull_request.pull_request_id}");
562 581 });
563 582
564 583 $('#update_commits').on('click', function(e){
565 584 var isDisabled = !$(e.currentTarget).attr('disabled');
566 585 $(e.currentTarget).text(_gettext('Updating...'));
567 586 $(e.currentTarget).attr('disabled', 'disabled');
568 587 if(isDisabled){
569 588 updateCommits("${c.repo_name}", "${c.pull_request.pull_request_id}");
570 589 }
571 590
572 591 });
573 592 // fixing issue with caches on firefox
574 593 $('#update_commits').removeAttr("disabled");
575 594
576 595 $('#close_pull_request').on('click', function(e){
577 596 closePullRequest("${c.repo_name}", "${c.pull_request.pull_request_id}");
578 597 });
579 598
580 599 $('.show-inline-comments').on('click', function(e){
581 600 var boxid = $(this).attr('data-comment-id');
582 601 var button = $(this);
583 602
584 603 if(button.hasClass("comments-visible")) {
585 604 $('#{0} .inline-comments'.format(boxid)).each(function(index){
586 605 $(this).hide();
587 606 })
588 607 button.removeClass("comments-visible");
589 608 } else {
590 609 $('#{0} .inline-comments'.format(boxid)).each(function(index){
591 610 $(this).show();
592 611 })
593 612 button.addClass("comments-visible");
594 613 }
595 614 });
596 615 })
597 616 </script>
598 617
599 618 </div>
600 619 </div>
601 620
602 621 </%def>
General Comments 0
You need to be logged in to leave comments. Login now