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