##// END OF EJS Templates
pull-requests: change the update commits logic to handle target changes better....
marcink -
r1601:a639657c default
parent child Browse files
Show More
@@ -1,1071 +1,1083 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2012-2017 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 """
22 22 pull requests controller for rhodecode for initializing pull requests
23 23 """
24 24 import types
25 25
26 26 import peppercorn
27 27 import formencode
28 28 import logging
29 29 import collections
30 30
31 31 from webob.exc import HTTPNotFound, HTTPForbidden, HTTPBadRequest
32 32 from pylons import request, tmpl_context as c, url
33 33 from pylons.controllers.util import redirect
34 34 from pylons.i18n.translation import _
35 35 from pyramid.threadlocal import get_current_registry
36 36 from sqlalchemy.sql import func
37 37 from sqlalchemy.sql.expression import or_
38 38
39 39 from rhodecode import events
40 40 from rhodecode.lib import auth, diffs, helpers as h, codeblocks
41 41 from rhodecode.lib.ext_json import json
42 42 from rhodecode.lib.base import (
43 43 BaseRepoController, render, vcs_operation_context)
44 44 from rhodecode.lib.auth import (
45 45 LoginRequired, HasRepoPermissionAnyDecorator, NotAnonymous,
46 46 HasAcceptedRepoType, XHRRequired)
47 47 from rhodecode.lib.channelstream import channelstream_request
48 48 from rhodecode.lib.utils import jsonify
49 49 from rhodecode.lib.utils2 import (
50 50 safe_int, safe_str, str2bool, safe_unicode)
51 51 from rhodecode.lib.vcs.backends.base import (
52 52 EmptyCommit, UpdateFailureReason, EmptyRepository)
53 53 from rhodecode.lib.vcs.exceptions import (
54 54 EmptyRepositoryError, CommitDoesNotExistError, RepositoryRequirementError,
55 55 NodeDoesNotExistError)
56 56
57 57 from rhodecode.model.changeset_status import ChangesetStatusModel
58 58 from rhodecode.model.comment import CommentsModel
59 59 from rhodecode.model.db import (PullRequest, ChangesetStatus, ChangesetComment,
60 60 Repository, PullRequestVersion)
61 61 from rhodecode.model.forms import PullRequestForm
62 62 from rhodecode.model.meta import Session
63 63 from rhodecode.model.pull_request import PullRequestModel, MergeCheck
64 64
65 65 log = logging.getLogger(__name__)
66 66
67 67
68 68 class PullrequestsController(BaseRepoController):
69 69
70 70 def __before__(self):
71 71 super(PullrequestsController, self).__before__()
72 72 c.REVIEW_STATUS_APPROVED = ChangesetStatus.STATUS_APPROVED
73 73 c.REVIEW_STATUS_REJECTED = ChangesetStatus.STATUS_REJECTED
74 74
75 75 def _extract_ordering(self, request):
76 76 column_index = safe_int(request.GET.get('order[0][column]'))
77 77 order_dir = request.GET.get('order[0][dir]', 'desc')
78 78 order_by = request.GET.get(
79 79 'columns[%s][data][sort]' % column_index, 'name_raw')
80 80 return order_by, order_dir
81 81
82 82 @LoginRequired()
83 83 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
84 84 'repository.admin')
85 85 @HasAcceptedRepoType('git', 'hg')
86 86 def show_all(self, repo_name):
87 87 # filter types
88 88 c.active = 'open'
89 89 c.source = str2bool(request.GET.get('source'))
90 90 c.closed = str2bool(request.GET.get('closed'))
91 91 c.my = str2bool(request.GET.get('my'))
92 92 c.awaiting_review = str2bool(request.GET.get('awaiting_review'))
93 93 c.awaiting_my_review = str2bool(request.GET.get('awaiting_my_review'))
94 94 c.repo_name = repo_name
95 95
96 96 opened_by = None
97 97 if c.my:
98 98 c.active = 'my'
99 99 opened_by = [c.rhodecode_user.user_id]
100 100
101 101 statuses = [PullRequest.STATUS_NEW, PullRequest.STATUS_OPEN]
102 102 if c.closed:
103 103 c.active = 'closed'
104 104 statuses = [PullRequest.STATUS_CLOSED]
105 105
106 106 if c.awaiting_review and not c.source:
107 107 c.active = 'awaiting'
108 108 if c.source and not c.awaiting_review:
109 109 c.active = 'source'
110 110 if c.awaiting_my_review:
111 111 c.active = 'awaiting_my'
112 112
113 113 data = self._get_pull_requests_list(
114 114 repo_name=repo_name, opened_by=opened_by, statuses=statuses)
115 115 if not request.is_xhr:
116 116 c.data = json.dumps(data['data'])
117 117 c.records_total = data['recordsTotal']
118 118 return render('/pullrequests/pullrequests.mako')
119 119 else:
120 120 return json.dumps(data)
121 121
122 122 def _get_pull_requests_list(self, repo_name, opened_by, statuses):
123 123 # pagination
124 124 start = safe_int(request.GET.get('start'), 0)
125 125 length = safe_int(request.GET.get('length'), c.visual.dashboard_items)
126 126 order_by, order_dir = self._extract_ordering(request)
127 127
128 128 if c.awaiting_review:
129 129 pull_requests = PullRequestModel().get_awaiting_review(
130 130 repo_name, source=c.source, opened_by=opened_by,
131 131 statuses=statuses, offset=start, length=length,
132 132 order_by=order_by, order_dir=order_dir)
133 133 pull_requests_total_count = PullRequestModel(
134 134 ).count_awaiting_review(
135 135 repo_name, source=c.source, statuses=statuses,
136 136 opened_by=opened_by)
137 137 elif c.awaiting_my_review:
138 138 pull_requests = PullRequestModel().get_awaiting_my_review(
139 139 repo_name, source=c.source, opened_by=opened_by,
140 140 user_id=c.rhodecode_user.user_id, statuses=statuses,
141 141 offset=start, length=length, order_by=order_by,
142 142 order_dir=order_dir)
143 143 pull_requests_total_count = PullRequestModel(
144 144 ).count_awaiting_my_review(
145 145 repo_name, source=c.source, user_id=c.rhodecode_user.user_id,
146 146 statuses=statuses, opened_by=opened_by)
147 147 else:
148 148 pull_requests = PullRequestModel().get_all(
149 149 repo_name, source=c.source, opened_by=opened_by,
150 150 statuses=statuses, offset=start, length=length,
151 151 order_by=order_by, order_dir=order_dir)
152 152 pull_requests_total_count = PullRequestModel().count_all(
153 153 repo_name, source=c.source, statuses=statuses,
154 154 opened_by=opened_by)
155 155
156 156 from rhodecode.lib.utils import PartialRenderer
157 157 _render = PartialRenderer('data_table/_dt_elements.mako')
158 158 data = []
159 159 for pr in pull_requests:
160 160 comments = CommentsModel().get_all_comments(
161 161 c.rhodecode_db_repo.repo_id, pull_request=pr)
162 162
163 163 data.append({
164 164 'name': _render('pullrequest_name',
165 165 pr.pull_request_id, pr.target_repo.repo_name),
166 166 'name_raw': pr.pull_request_id,
167 167 'status': _render('pullrequest_status',
168 168 pr.calculated_review_status()),
169 169 'title': _render(
170 170 'pullrequest_title', pr.title, pr.description),
171 171 'description': h.escape(pr.description),
172 172 'updated_on': _render('pullrequest_updated_on',
173 173 h.datetime_to_time(pr.updated_on)),
174 174 'updated_on_raw': h.datetime_to_time(pr.updated_on),
175 175 'created_on': _render('pullrequest_updated_on',
176 176 h.datetime_to_time(pr.created_on)),
177 177 'created_on_raw': h.datetime_to_time(pr.created_on),
178 178 'author': _render('pullrequest_author',
179 179 pr.author.full_contact, ),
180 180 'author_raw': pr.author.full_name,
181 181 'comments': _render('pullrequest_comments', len(comments)),
182 182 'comments_raw': len(comments),
183 183 'closed': pr.is_closed(),
184 184 })
185 185 # json used to render the grid
186 186 data = ({
187 187 'data': data,
188 188 'recordsTotal': pull_requests_total_count,
189 189 'recordsFiltered': pull_requests_total_count,
190 190 })
191 191 return data
192 192
193 193 @LoginRequired()
194 194 @NotAnonymous()
195 195 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
196 196 'repository.admin')
197 197 @HasAcceptedRepoType('git', 'hg')
198 198 def index(self):
199 199 source_repo = c.rhodecode_db_repo
200 200
201 201 try:
202 202 source_repo.scm_instance().get_commit()
203 203 except EmptyRepositoryError:
204 204 h.flash(h.literal(_('There are no commits yet')),
205 205 category='warning')
206 206 redirect(url('summary_home', repo_name=source_repo.repo_name))
207 207
208 208 commit_id = request.GET.get('commit')
209 209 branch_ref = request.GET.get('branch')
210 210 bookmark_ref = request.GET.get('bookmark')
211 211
212 212 try:
213 213 source_repo_data = PullRequestModel().generate_repo_data(
214 214 source_repo, commit_id=commit_id,
215 215 branch=branch_ref, bookmark=bookmark_ref)
216 216 except CommitDoesNotExistError as e:
217 217 log.exception(e)
218 218 h.flash(_('Commit does not exist'), 'error')
219 219 redirect(url('pullrequest_home', repo_name=source_repo.repo_name))
220 220
221 221 default_target_repo = source_repo
222 222
223 223 if source_repo.parent:
224 224 parent_vcs_obj = source_repo.parent.scm_instance()
225 225 if parent_vcs_obj and not parent_vcs_obj.is_empty():
226 226 # change default if we have a parent repo
227 227 default_target_repo = source_repo.parent
228 228
229 229 target_repo_data = PullRequestModel().generate_repo_data(
230 230 default_target_repo)
231 231
232 232 selected_source_ref = source_repo_data['refs']['selected_ref']
233 233
234 234 title_source_ref = selected_source_ref.split(':', 2)[1]
235 235 c.default_title = PullRequestModel().generate_pullrequest_title(
236 236 source=source_repo.repo_name,
237 237 source_ref=title_source_ref,
238 238 target=default_target_repo.repo_name
239 239 )
240 240
241 241 c.default_repo_data = {
242 242 'source_repo_name': source_repo.repo_name,
243 243 'source_refs_json': json.dumps(source_repo_data),
244 244 'target_repo_name': default_target_repo.repo_name,
245 245 'target_refs_json': json.dumps(target_repo_data),
246 246 }
247 247 c.default_source_ref = selected_source_ref
248 248
249 249 return render('/pullrequests/pullrequest.mako')
250 250
251 251 @LoginRequired()
252 252 @NotAnonymous()
253 253 @XHRRequired()
254 254 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
255 255 'repository.admin')
256 256 @jsonify
257 257 def get_repo_refs(self, repo_name, target_repo_name):
258 258 repo = Repository.get_by_repo_name(target_repo_name)
259 259 if not repo:
260 260 raise HTTPNotFound
261 261 return PullRequestModel().generate_repo_data(repo)
262 262
263 263 @LoginRequired()
264 264 @NotAnonymous()
265 265 @XHRRequired()
266 266 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
267 267 'repository.admin')
268 268 @jsonify
269 269 def get_repo_destinations(self, repo_name):
270 270 repo = Repository.get_by_repo_name(repo_name)
271 271 if not repo:
272 272 raise HTTPNotFound
273 273 filter_query = request.GET.get('query')
274 274
275 275 query = Repository.query() \
276 276 .order_by(func.length(Repository.repo_name)) \
277 277 .filter(or_(
278 278 Repository.repo_name == repo.repo_name,
279 279 Repository.fork_id == repo.repo_id))
280 280
281 281 if filter_query:
282 282 ilike_expression = u'%{}%'.format(safe_unicode(filter_query))
283 283 query = query.filter(
284 284 Repository.repo_name.ilike(ilike_expression))
285 285
286 286 add_parent = False
287 287 if repo.parent:
288 288 if filter_query in repo.parent.repo_name:
289 289 parent_vcs_obj = repo.parent.scm_instance()
290 290 if parent_vcs_obj and not parent_vcs_obj.is_empty():
291 291 add_parent = True
292 292
293 293 limit = 20 - 1 if add_parent else 20
294 294 all_repos = query.limit(limit).all()
295 295 if add_parent:
296 296 all_repos += [repo.parent]
297 297
298 298 repos = []
299 299 for obj in self.scm_model.get_repos(all_repos):
300 300 repos.append({
301 301 'id': obj['name'],
302 302 'text': obj['name'],
303 303 'type': 'repo',
304 304 'obj': obj['dbrepo']
305 305 })
306 306
307 307 data = {
308 308 'more': False,
309 309 'results': [{
310 310 'text': _('Repositories'),
311 311 'children': repos
312 312 }] if repos else []
313 313 }
314 314 return data
315 315
316 316 @LoginRequired()
317 317 @NotAnonymous()
318 318 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
319 319 'repository.admin')
320 320 @HasAcceptedRepoType('git', 'hg')
321 321 @auth.CSRFRequired()
322 322 def create(self, repo_name):
323 323 repo = Repository.get_by_repo_name(repo_name)
324 324 if not repo:
325 325 raise HTTPNotFound
326 326
327 327 controls = peppercorn.parse(request.POST.items())
328 328
329 329 try:
330 330 _form = PullRequestForm(repo.repo_id)().to_python(controls)
331 331 except formencode.Invalid as errors:
332 332 if errors.error_dict.get('revisions'):
333 333 msg = 'Revisions: %s' % errors.error_dict['revisions']
334 334 elif errors.error_dict.get('pullrequest_title'):
335 335 msg = _('Pull request requires a title with min. 3 chars')
336 336 else:
337 337 msg = _('Error creating pull request: {}').format(errors)
338 338 log.exception(msg)
339 339 h.flash(msg, 'error')
340 340
341 341 # would rather just go back to form ...
342 342 return redirect(url('pullrequest_home', repo_name=repo_name))
343 343
344 344 source_repo = _form['source_repo']
345 345 source_ref = _form['source_ref']
346 346 target_repo = _form['target_repo']
347 347 target_ref = _form['target_ref']
348 348 commit_ids = _form['revisions'][::-1]
349 349 reviewers = [
350 350 (r['user_id'], r['reasons']) for r in _form['review_members']]
351 351
352 352 # find the ancestor for this pr
353 353 source_db_repo = Repository.get_by_repo_name(_form['source_repo'])
354 354 target_db_repo = Repository.get_by_repo_name(_form['target_repo'])
355 355
356 356 source_scm = source_db_repo.scm_instance()
357 357 target_scm = target_db_repo.scm_instance()
358 358
359 359 source_commit = source_scm.get_commit(source_ref.split(':')[-1])
360 360 target_commit = target_scm.get_commit(target_ref.split(':')[-1])
361 361
362 362 ancestor = source_scm.get_common_ancestor(
363 363 source_commit.raw_id, target_commit.raw_id, target_scm)
364 364
365 365 target_ref_type, target_ref_name, __ = _form['target_ref'].split(':')
366 366 target_ref = ':'.join((target_ref_type, target_ref_name, ancestor))
367 367
368 368 pullrequest_title = _form['pullrequest_title']
369 369 title_source_ref = source_ref.split(':', 2)[1]
370 370 if not pullrequest_title:
371 371 pullrequest_title = PullRequestModel().generate_pullrequest_title(
372 372 source=source_repo,
373 373 source_ref=title_source_ref,
374 374 target=target_repo
375 375 )
376 376
377 377 description = _form['pullrequest_desc']
378 378 try:
379 379 pull_request = PullRequestModel().create(
380 380 c.rhodecode_user.user_id, source_repo, source_ref, target_repo,
381 381 target_ref, commit_ids, reviewers, pullrequest_title,
382 382 description
383 383 )
384 384 Session().commit()
385 385 h.flash(_('Successfully opened new pull request'),
386 386 category='success')
387 387 except Exception as e:
388 388 msg = _('Error occurred during sending pull request')
389 389 log.exception(msg)
390 390 h.flash(msg, category='error')
391 391 return redirect(url('pullrequest_home', repo_name=repo_name))
392 392
393 393 return redirect(url('pullrequest_show', repo_name=target_repo,
394 394 pull_request_id=pull_request.pull_request_id))
395 395
396 396 @LoginRequired()
397 397 @NotAnonymous()
398 398 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
399 399 'repository.admin')
400 400 @auth.CSRFRequired()
401 401 @jsonify
402 402 def update(self, repo_name, pull_request_id):
403 403 pull_request_id = safe_int(pull_request_id)
404 404 pull_request = PullRequest.get_or_404(pull_request_id)
405 405 # only owner or admin can update it
406 406 allowed_to_update = PullRequestModel().check_user_update(
407 407 pull_request, c.rhodecode_user)
408 408 if allowed_to_update:
409 409 controls = peppercorn.parse(request.POST.items())
410 410
411 411 if 'review_members' in controls:
412 412 self._update_reviewers(
413 413 pull_request_id, controls['review_members'])
414 414 elif str2bool(request.POST.get('update_commits', 'false')):
415 415 self._update_commits(pull_request)
416 416 elif str2bool(request.POST.get('close_pull_request', 'false')):
417 417 self._reject_close(pull_request)
418 418 elif str2bool(request.POST.get('edit_pull_request', 'false')):
419 419 self._edit_pull_request(pull_request)
420 420 else:
421 421 raise HTTPBadRequest()
422 422 return True
423 423 raise HTTPForbidden()
424 424
425 425 def _edit_pull_request(self, pull_request):
426 426 try:
427 427 PullRequestModel().edit(
428 428 pull_request, request.POST.get('title'),
429 429 request.POST.get('description'))
430 430 except ValueError:
431 431 msg = _(u'Cannot update closed pull requests.')
432 432 h.flash(msg, category='error')
433 433 return
434 434 else:
435 435 Session().commit()
436 436
437 437 msg = _(u'Pull request title & description updated.')
438 438 h.flash(msg, category='success')
439 439 return
440 440
441 441 def _update_commits(self, pull_request):
442 442 resp = PullRequestModel().update_commits(pull_request)
443 443
444 444 if resp.executed:
445
446 if resp.target_changed and resp.source_changed:
447 changed = 'target and source repositories'
448 elif resp.target_changed and not resp.source_changed:
449 changed = 'target repository'
450 elif not resp.target_changed and resp.source_changed:
451 changed = 'source repository'
452 else:
453 changed = 'nothing'
454
445 455 msg = _(
446 456 u'Pull request updated to "{source_commit_id}" with '
447 u'{count_added} added, {count_removed} removed commits.')
457 u'{count_added} added, {count_removed} removed commits. '
458 u'Source of changes: {change_source}')
448 459 msg = msg.format(
449 460 source_commit_id=pull_request.source_ref_parts.commit_id,
450 461 count_added=len(resp.changes.added),
451 count_removed=len(resp.changes.removed))
462 count_removed=len(resp.changes.removed),
463 change_source=changed)
452 464 h.flash(msg, category='success')
453 465
454 466 registry = get_current_registry()
455 467 rhodecode_plugins = getattr(registry, 'rhodecode_plugins', {})
456 468 channelstream_config = rhodecode_plugins.get('channelstream', {})
457 469 if channelstream_config.get('enabled'):
458 470 message = msg + (
459 471 ' - <a onclick="window.location.reload()">'
460 472 '<strong>{}</strong></a>'.format(_('Reload page')))
461 473 channel = '/repo${}$/pr/{}'.format(
462 474 pull_request.target_repo.repo_name,
463 475 pull_request.pull_request_id
464 476 )
465 477 payload = {
466 478 'type': 'message',
467 479 'user': 'system',
468 480 'exclude_users': [request.user.username],
469 481 'channel': channel,
470 482 'message': {
471 483 'message': message,
472 484 'level': 'success',
473 485 'topic': '/notifications'
474 486 }
475 487 }
476 488 channelstream_request(
477 489 channelstream_config, [payload], '/message',
478 490 raise_exc=False)
479 491 else:
480 492 msg = PullRequestModel.UPDATE_STATUS_MESSAGES[resp.reason]
481 493 warning_reasons = [
482 494 UpdateFailureReason.NO_CHANGE,
483 495 UpdateFailureReason.WRONG_REF_TPYE,
484 496 ]
485 497 category = 'warning' if resp.reason in warning_reasons else 'error'
486 498 h.flash(msg, category=category)
487 499
488 500 @auth.CSRFRequired()
489 501 @LoginRequired()
490 502 @NotAnonymous()
491 503 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
492 504 'repository.admin')
493 505 def merge(self, repo_name, pull_request_id):
494 506 """
495 507 POST /{repo_name}/pull-request/{pull_request_id}
496 508
497 509 Merge will perform a server-side merge of the specified
498 510 pull request, if the pull request is approved and mergeable.
499 511 After successful merging, the pull request is automatically
500 512 closed, with a relevant comment.
501 513 """
502 514 pull_request_id = safe_int(pull_request_id)
503 515 pull_request = PullRequest.get_or_404(pull_request_id)
504 516 user = c.rhodecode_user
505 517
506 518 check = MergeCheck.validate(pull_request, user)
507 519 merge_possible = not check.failed
508 520
509 521 for err_type, error_msg in check.errors:
510 522 h.flash(error_msg, category=err_type)
511 523
512 524 if merge_possible:
513 525 log.debug("Pre-conditions checked, trying to merge.")
514 526 extras = vcs_operation_context(
515 527 request.environ, repo_name=pull_request.target_repo.repo_name,
516 528 username=user.username, action='push',
517 529 scm=pull_request.target_repo.repo_type)
518 530 self._merge_pull_request(pull_request, user, extras)
519 531
520 532 return redirect(url(
521 533 'pullrequest_show',
522 534 repo_name=pull_request.target_repo.repo_name,
523 535 pull_request_id=pull_request.pull_request_id))
524 536
525 537 def _merge_pull_request(self, pull_request, user, extras):
526 538 merge_resp = PullRequestModel().merge(
527 539 pull_request, user, extras=extras)
528 540
529 541 if merge_resp.executed:
530 542 log.debug("The merge was successful, closing the pull request.")
531 543 PullRequestModel().close_pull_request(
532 544 pull_request.pull_request_id, user)
533 545 Session().commit()
534 546 msg = _('Pull request was successfully merged and closed.')
535 547 h.flash(msg, category='success')
536 548 else:
537 549 log.debug(
538 550 "The merge was not successful. Merge response: %s",
539 551 merge_resp)
540 552 msg = PullRequestModel().merge_status_message(
541 553 merge_resp.failure_reason)
542 554 h.flash(msg, category='error')
543 555
544 556 def _update_reviewers(self, pull_request_id, review_members):
545 557 reviewers = [
546 558 (int(r['user_id']), r['reasons']) for r in review_members]
547 559 PullRequestModel().update_reviewers(pull_request_id, reviewers)
548 560 Session().commit()
549 561
550 562 def _reject_close(self, pull_request):
551 563 if pull_request.is_closed():
552 564 raise HTTPForbidden()
553 565
554 566 PullRequestModel().close_pull_request_with_comment(
555 567 pull_request, c.rhodecode_user, c.rhodecode_db_repo)
556 568 Session().commit()
557 569
558 570 @LoginRequired()
559 571 @NotAnonymous()
560 572 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
561 573 'repository.admin')
562 574 @auth.CSRFRequired()
563 575 @jsonify
564 576 def delete(self, repo_name, pull_request_id):
565 577 pull_request_id = safe_int(pull_request_id)
566 578 pull_request = PullRequest.get_or_404(pull_request_id)
567 579 # only owner can delete it !
568 580 if pull_request.author.user_id == c.rhodecode_user.user_id:
569 581 PullRequestModel().delete(pull_request)
570 582 Session().commit()
571 583 h.flash(_('Successfully deleted pull request'),
572 584 category='success')
573 585 return redirect(url('my_account_pullrequests'))
574 586 raise HTTPForbidden()
575 587
576 588 def _get_pr_version(self, pull_request_id, version=None):
577 589 pull_request_id = safe_int(pull_request_id)
578 590 at_version = None
579 591
580 592 if version and version == 'latest':
581 593 pull_request_ver = PullRequest.get(pull_request_id)
582 594 pull_request_obj = pull_request_ver
583 595 _org_pull_request_obj = pull_request_obj
584 596 at_version = 'latest'
585 597 elif version:
586 598 pull_request_ver = PullRequestVersion.get_or_404(version)
587 599 pull_request_obj = pull_request_ver
588 600 _org_pull_request_obj = pull_request_ver.pull_request
589 601 at_version = pull_request_ver.pull_request_version_id
590 602 else:
591 603 _org_pull_request_obj = pull_request_obj = PullRequest.get_or_404(pull_request_id)
592 604
593 605 pull_request_display_obj = PullRequest.get_pr_display_object(
594 606 pull_request_obj, _org_pull_request_obj)
595 607
596 608 return _org_pull_request_obj, pull_request_obj, \
597 609 pull_request_display_obj, at_version
598 610
599 611 def _get_diffset(
600 612 self, source_repo, source_ref_id, target_ref_id, target_commit,
601 613 source_commit, diff_limit, file_limit, display_inline_comments):
602 614 vcs_diff = PullRequestModel().get_diff(
603 615 source_repo, source_ref_id, target_ref_id)
604 616
605 617 diff_processor = diffs.DiffProcessor(
606 618 vcs_diff, format='newdiff', diff_limit=diff_limit,
607 619 file_limit=file_limit, show_full_diff=c.fulldiff)
608 620
609 621 _parsed = diff_processor.prepare()
610 622
611 623 def _node_getter(commit):
612 624 def get_node(fname):
613 625 try:
614 626 return commit.get_node(fname)
615 627 except NodeDoesNotExistError:
616 628 return None
617 629
618 630 return get_node
619 631
620 632 diffset = codeblocks.DiffSet(
621 633 repo_name=c.repo_name,
622 634 source_repo_name=c.source_repo.repo_name,
623 635 source_node_getter=_node_getter(target_commit),
624 636 target_node_getter=_node_getter(source_commit),
625 637 comments=display_inline_comments
626 638 )
627 639 diffset = diffset.render_patchset(
628 640 _parsed, target_commit.raw_id, source_commit.raw_id)
629 641
630 642 return diffset
631 643
632 644 @LoginRequired()
633 645 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
634 646 'repository.admin')
635 647 def show(self, repo_name, pull_request_id):
636 648 pull_request_id = safe_int(pull_request_id)
637 649 version = request.GET.get('version')
638 650 from_version = request.GET.get('from_version') or version
639 651 merge_checks = request.GET.get('merge_checks')
640 652 c.fulldiff = str2bool(request.GET.get('fulldiff'))
641 653
642 654 (pull_request_latest,
643 655 pull_request_at_ver,
644 656 pull_request_display_obj,
645 657 at_version) = self._get_pr_version(
646 658 pull_request_id, version=version)
647 659 pr_closed = pull_request_latest.is_closed()
648 660
649 661 if pr_closed and (version or from_version):
650 662 # not allow to browse versions
651 663 return redirect(h.url('pullrequest_show', repo_name=repo_name,
652 664 pull_request_id=pull_request_id))
653 665
654 666 versions = pull_request_display_obj.versions()
655 667
656 668 c.at_version = at_version
657 669 c.at_version_num = (at_version
658 670 if at_version and at_version != 'latest'
659 671 else None)
660 672 c.at_version_pos = ChangesetComment.get_index_from_version(
661 673 c.at_version_num, versions)
662 674
663 675 (prev_pull_request_latest,
664 676 prev_pull_request_at_ver,
665 677 prev_pull_request_display_obj,
666 678 prev_at_version) = self._get_pr_version(
667 679 pull_request_id, version=from_version)
668 680
669 681 c.from_version = prev_at_version
670 682 c.from_version_num = (prev_at_version
671 683 if prev_at_version and prev_at_version != 'latest'
672 684 else None)
673 685 c.from_version_pos = ChangesetComment.get_index_from_version(
674 686 c.from_version_num, versions)
675 687
676 688 # define if we're in COMPARE mode or VIEW at version mode
677 689 compare = at_version != prev_at_version
678 690
679 691 # pull_requests repo_name we opened it against
680 692 # ie. target_repo must match
681 693 if repo_name != pull_request_at_ver.target_repo.repo_name:
682 694 raise HTTPNotFound
683 695
684 696 c.shadow_clone_url = PullRequestModel().get_shadow_clone_url(
685 697 pull_request_at_ver)
686 698
687 699 c.pull_request = pull_request_display_obj
688 700 c.pull_request_latest = pull_request_latest
689 701
690 702 if compare or (at_version and not at_version == 'latest'):
691 703 c.allowed_to_change_status = False
692 704 c.allowed_to_update = False
693 705 c.allowed_to_merge = False
694 706 c.allowed_to_delete = False
695 707 c.allowed_to_comment = False
696 708 c.allowed_to_close = False
697 709 else:
698 710 c.allowed_to_change_status = PullRequestModel(). \
699 711 check_user_change_status(pull_request_at_ver, c.rhodecode_user) \
700 712 and not pr_closed
701 713
702 714 c.allowed_to_update = PullRequestModel().check_user_update(
703 715 pull_request_latest, c.rhodecode_user) and not pr_closed
704 716 c.allowed_to_merge = PullRequestModel().check_user_merge(
705 717 pull_request_latest, c.rhodecode_user) and not pr_closed
706 718 c.allowed_to_delete = PullRequestModel().check_user_delete(
707 719 pull_request_latest, c.rhodecode_user) and not pr_closed
708 720 c.allowed_to_comment = not pr_closed
709 721 c.allowed_to_close = c.allowed_to_change_status and not pr_closed
710 722
711 723 # check merge capabilities
712 724 _merge_check = MergeCheck.validate(
713 725 pull_request_latest, user=c.rhodecode_user)
714 726 c.pr_merge_errors = _merge_check.error_details
715 727 c.pr_merge_possible = not _merge_check.failed
716 728 c.pr_merge_message = _merge_check.merge_msg
717 729
718 730 c.pull_request_review_status = _merge_check.review_status
719 731 if merge_checks:
720 732 return render('/pullrequests/pullrequest_merge_checks.mako')
721 733
722 734 comments_model = CommentsModel()
723 735
724 736 # reviewers and statuses
725 737 c.pull_request_reviewers = pull_request_at_ver.reviewers_statuses()
726 738 allowed_reviewers = [x[0].user_id for x in c.pull_request_reviewers]
727 739
728 740 # GENERAL COMMENTS with versions #
729 741 q = comments_model._all_general_comments_of_pull_request(pull_request_latest)
730 742 q = q.order_by(ChangesetComment.comment_id.asc())
731 743 general_comments = q.order_by(ChangesetComment.pull_request_version_id.asc())
732 744
733 745 # pick comments we want to render at current version
734 746 c.comment_versions = comments_model.aggregate_comments(
735 747 general_comments, versions, c.at_version_num)
736 748 c.comments = c.comment_versions[c.at_version_num]['until']
737 749
738 750 # INLINE COMMENTS with versions #
739 751 q = comments_model._all_inline_comments_of_pull_request(pull_request_latest)
740 752 q = q.order_by(ChangesetComment.comment_id.asc())
741 753 inline_comments = q.order_by(ChangesetComment.pull_request_version_id.asc())
742 754 c.inline_versions = comments_model.aggregate_comments(
743 755 inline_comments, versions, c.at_version_num, inline=True)
744 756
745 757 # inject latest version
746 758 latest_ver = PullRequest.get_pr_display_object(
747 759 pull_request_latest, pull_request_latest)
748 760
749 761 c.versions = versions + [latest_ver]
750 762
751 763 # if we use version, then do not show later comments
752 764 # than current version
753 765 display_inline_comments = collections.defaultdict(
754 766 lambda: collections.defaultdict(list))
755 767 for co in inline_comments:
756 768 if c.at_version_num:
757 769 # pick comments that are at least UPTO given version, so we
758 770 # don't render comments for higher version
759 771 should_render = co.pull_request_version_id and \
760 772 co.pull_request_version_id <= c.at_version_num
761 773 else:
762 774 # showing all, for 'latest'
763 775 should_render = True
764 776
765 777 if should_render:
766 778 display_inline_comments[co.f_path][co.line_no].append(co)
767 779
768 780 # load diff data into template context, if we use compare mode then
769 781 # diff is calculated based on changes between versions of PR
770 782
771 783 source_repo = pull_request_at_ver.source_repo
772 784 source_ref_id = pull_request_at_ver.source_ref_parts.commit_id
773 785
774 786 target_repo = pull_request_at_ver.target_repo
775 787 target_ref_id = pull_request_at_ver.target_ref_parts.commit_id
776 788
777 789 if compare:
778 790 # in compare switch the diff base to latest commit from prev version
779 791 target_ref_id = prev_pull_request_display_obj.revisions[0]
780 792
781 793 # despite opening commits for bookmarks/branches/tags, we always
782 794 # convert this to rev to prevent changes after bookmark or branch change
783 795 c.source_ref_type = 'rev'
784 796 c.source_ref = source_ref_id
785 797
786 798 c.target_ref_type = 'rev'
787 799 c.target_ref = target_ref_id
788 800
789 801 c.source_repo = source_repo
790 802 c.target_repo = target_repo
791 803
792 804 # diff_limit is the old behavior, will cut off the whole diff
793 805 # if the limit is applied otherwise will just hide the
794 806 # big files from the front-end
795 807 diff_limit = self.cut_off_limit_diff
796 808 file_limit = self.cut_off_limit_file
797 809
798 810 c.commit_ranges = []
799 811 source_commit = EmptyCommit()
800 812 target_commit = EmptyCommit()
801 813 c.missing_requirements = False
802 814
803 815 source_scm = source_repo.scm_instance()
804 816 target_scm = target_repo.scm_instance()
805 817
806 818 # try first shadow repo, fallback to regular repo
807 819 try:
808 820 commits_source_repo = pull_request_latest.get_shadow_repo()
809 821 except Exception:
810 822 log.debug('Failed to get shadow repo', exc_info=True)
811 823 commits_source_repo = source_scm
812 824
813 825 c.commits_source_repo = commits_source_repo
814 826 commit_cache = {}
815 827 try:
816 828 pre_load = ["author", "branch", "date", "message"]
817 829 show_revs = pull_request_at_ver.revisions
818 830 for rev in show_revs:
819 831 comm = commits_source_repo.get_commit(
820 832 commit_id=rev, pre_load=pre_load)
821 833 c.commit_ranges.append(comm)
822 834 commit_cache[comm.raw_id] = comm
823 835
824 836 target_commit = commits_source_repo.get_commit(
825 837 commit_id=safe_str(target_ref_id))
826 838 source_commit = commits_source_repo.get_commit(
827 839 commit_id=safe_str(source_ref_id))
828 840 except CommitDoesNotExistError:
829 841 pass
830 842 except RepositoryRequirementError:
831 843 log.warning(
832 844 'Failed to get all required data from repo', exc_info=True)
833 845 c.missing_requirements = True
834 846
835 847 c.ancestor = None # set it to None, to hide it from PR view
836 848
837 849 try:
838 850 ancestor_id = source_scm.get_common_ancestor(
839 851 source_commit.raw_id, target_commit.raw_id, target_scm)
840 852 c.ancestor_commit = source_scm.get_commit(ancestor_id)
841 853 except Exception:
842 854 c.ancestor_commit = None
843 855
844 856 c.statuses = source_repo.statuses(
845 857 [x.raw_id for x in c.commit_ranges])
846 858
847 859 # auto collapse if we have more than limit
848 860 collapse_limit = diffs.DiffProcessor._collapse_commits_over
849 861 c.collapse_all_commits = len(c.commit_ranges) > collapse_limit
850 862 c.compare_mode = compare
851 863
852 864 c.missing_commits = False
853 865 if (c.missing_requirements or isinstance(source_commit, EmptyCommit)
854 866 or source_commit == target_commit):
855 867
856 868 c.missing_commits = True
857 869 else:
858 870
859 871 c.diffset = self._get_diffset(
860 872 commits_source_repo, source_ref_id, target_ref_id,
861 873 target_commit, source_commit,
862 874 diff_limit, file_limit, display_inline_comments)
863 875
864 876 c.limited_diff = c.diffset.limited_diff
865 877
866 878 # calculate removed files that are bound to comments
867 879 comment_deleted_files = [
868 880 fname for fname in display_inline_comments
869 881 if fname not in c.diffset.file_stats]
870 882
871 883 c.deleted_files_comments = collections.defaultdict(dict)
872 884 for fname, per_line_comments in display_inline_comments.items():
873 885 if fname in comment_deleted_files:
874 886 c.deleted_files_comments[fname]['stats'] = 0
875 887 c.deleted_files_comments[fname]['comments'] = list()
876 888 for lno, comments in per_line_comments.items():
877 889 c.deleted_files_comments[fname]['comments'].extend(
878 890 comments)
879 891
880 892 # this is a hack to properly display links, when creating PR, the
881 893 # compare view and others uses different notation, and
882 894 # compare_commits.mako renders links based on the target_repo.
883 895 # We need to swap that here to generate it properly on the html side
884 896 c.target_repo = c.source_repo
885 897
886 898 c.commit_statuses = ChangesetStatus.STATUSES
887 899
888 900 c.show_version_changes = not pr_closed
889 901 if c.show_version_changes:
890 902 cur_obj = pull_request_at_ver
891 903 prev_obj = prev_pull_request_at_ver
892 904
893 905 old_commit_ids = prev_obj.revisions
894 906 new_commit_ids = cur_obj.revisions
895 907 commit_changes = PullRequestModel()._calculate_commit_id_changes(
896 908 old_commit_ids, new_commit_ids)
897 909 c.commit_changes_summary = commit_changes
898 910
899 911 # calculate the diff for commits between versions
900 912 c.commit_changes = []
901 913 mark = lambda cs, fw: list(
902 914 h.itertools.izip_longest([], cs, fillvalue=fw))
903 915 for c_type, raw_id in mark(commit_changes.added, 'a') \
904 916 + mark(commit_changes.removed, 'r') \
905 917 + mark(commit_changes.common, 'c'):
906 918
907 919 if raw_id in commit_cache:
908 920 commit = commit_cache[raw_id]
909 921 else:
910 922 try:
911 923 commit = commits_source_repo.get_commit(raw_id)
912 924 except CommitDoesNotExistError:
913 925 # in case we fail extracting still use "dummy" commit
914 926 # for display in commit diff
915 927 commit = h.AttributeDict(
916 928 {'raw_id': raw_id,
917 929 'message': 'EMPTY or MISSING COMMIT'})
918 930 c.commit_changes.append([c_type, commit])
919 931
920 932 # current user review statuses for each version
921 933 c.review_versions = {}
922 934 if c.rhodecode_user.user_id in allowed_reviewers:
923 935 for co in general_comments:
924 936 if co.author.user_id == c.rhodecode_user.user_id:
925 937 # each comment has a status change
926 938 status = co.status_change
927 939 if status:
928 940 _ver_pr = status[0].comment.pull_request_version_id
929 941 c.review_versions[_ver_pr] = status[0]
930 942
931 943 return render('/pullrequests/pullrequest_show.mako')
932 944
933 945 @LoginRequired()
934 946 @NotAnonymous()
935 947 @HasRepoPermissionAnyDecorator(
936 948 'repository.read', 'repository.write', 'repository.admin')
937 949 @auth.CSRFRequired()
938 950 @jsonify
939 951 def comment(self, repo_name, pull_request_id):
940 952 pull_request_id = safe_int(pull_request_id)
941 953 pull_request = PullRequest.get_or_404(pull_request_id)
942 954 if pull_request.is_closed():
943 955 raise HTTPForbidden()
944 956
945 957 status = request.POST.get('changeset_status', None)
946 958 text = request.POST.get('text')
947 959 comment_type = request.POST.get('comment_type')
948 960 resolves_comment_id = request.POST.get('resolves_comment_id', None)
949 961 close_pull_request = request.POST.get('close_pull_request')
950 962
951 963 close_pr = False
952 964 if close_pull_request:
953 965 close_pr = True
954 966 pull_request_review_status = pull_request.calculated_review_status()
955 967 if pull_request_review_status == ChangesetStatus.STATUS_APPROVED:
956 968 # approved only if we have voting consent
957 969 status = ChangesetStatus.STATUS_APPROVED
958 970 else:
959 971 status = ChangesetStatus.STATUS_REJECTED
960 972
961 973 allowed_to_change_status = PullRequestModel().check_user_change_status(
962 974 pull_request, c.rhodecode_user)
963 975
964 976 if status and allowed_to_change_status:
965 977 message = (_('Status change %(transition_icon)s %(status)s')
966 978 % {'transition_icon': '>',
967 979 'status': ChangesetStatus.get_status_lbl(status)})
968 980 if close_pr:
969 981 message = _('Closing with') + ' ' + message
970 982 text = text or message
971 983 comm = CommentsModel().create(
972 984 text=text,
973 985 repo=c.rhodecode_db_repo.repo_id,
974 986 user=c.rhodecode_user.user_id,
975 987 pull_request=pull_request_id,
976 988 f_path=request.POST.get('f_path'),
977 989 line_no=request.POST.get('line'),
978 990 status_change=(ChangesetStatus.get_status_lbl(status)
979 991 if status and allowed_to_change_status else None),
980 992 status_change_type=(status
981 993 if status and allowed_to_change_status else None),
982 994 closing_pr=close_pr,
983 995 comment_type=comment_type,
984 996 resolves_comment_id=resolves_comment_id
985 997 )
986 998
987 999 if allowed_to_change_status:
988 1000 old_calculated_status = pull_request.calculated_review_status()
989 1001 # get status if set !
990 1002 if status:
991 1003 ChangesetStatusModel().set_status(
992 1004 c.rhodecode_db_repo.repo_id,
993 1005 status,
994 1006 c.rhodecode_user.user_id,
995 1007 comm,
996 1008 pull_request=pull_request_id
997 1009 )
998 1010
999 1011 Session().flush()
1000 1012 events.trigger(events.PullRequestCommentEvent(pull_request, comm))
1001 1013 # we now calculate the status of pull request, and based on that
1002 1014 # calculation we set the commits status
1003 1015 calculated_status = pull_request.calculated_review_status()
1004 1016 if old_calculated_status != calculated_status:
1005 1017 PullRequestModel()._trigger_pull_request_hook(
1006 1018 pull_request, c.rhodecode_user, 'review_status_change')
1007 1019
1008 1020 calculated_status_lbl = ChangesetStatus.get_status_lbl(
1009 1021 calculated_status)
1010 1022
1011 1023 if close_pr:
1012 1024 status_completed = (
1013 1025 calculated_status in [ChangesetStatus.STATUS_APPROVED,
1014 1026 ChangesetStatus.STATUS_REJECTED])
1015 1027 if close_pull_request or status_completed:
1016 1028 PullRequestModel().close_pull_request(
1017 1029 pull_request_id, c.rhodecode_user)
1018 1030 else:
1019 1031 h.flash(_('Closing pull request on other statuses than '
1020 1032 'rejected or approved is forbidden. '
1021 1033 'Calculated status from all reviewers '
1022 1034 'is currently: %s') % calculated_status_lbl,
1023 1035 category='warning')
1024 1036
1025 1037 Session().commit()
1026 1038
1027 1039 if not request.is_xhr:
1028 1040 return redirect(h.url('pullrequest_show', repo_name=repo_name,
1029 1041 pull_request_id=pull_request_id))
1030 1042
1031 1043 data = {
1032 1044 'target_id': h.safeid(h.safe_unicode(request.POST.get('f_path'))),
1033 1045 }
1034 1046 if comm:
1035 1047 c.co = comm
1036 1048 c.inline_comment = True if comm.line_no else False
1037 1049 data.update(comm.get_dict())
1038 1050 data.update({'rendered_text':
1039 1051 render('changeset/changeset_comment_block.mako')})
1040 1052
1041 1053 return data
1042 1054
1043 1055 @LoginRequired()
1044 1056 @NotAnonymous()
1045 1057 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
1046 1058 'repository.admin')
1047 1059 @auth.CSRFRequired()
1048 1060 @jsonify
1049 1061 def delete_comment(self, repo_name, comment_id):
1050 1062 return self._delete_comment(comment_id)
1051 1063
1052 1064 def _delete_comment(self, comment_id):
1053 1065 comment_id = safe_int(comment_id)
1054 1066 co = ChangesetComment.get_or_404(comment_id)
1055 1067 if co.pull_request.is_closed():
1056 1068 # don't allow deleting comments on closed pull request
1057 1069 raise HTTPForbidden()
1058 1070
1059 1071 is_owner = co.author.user_id == c.rhodecode_user.user_id
1060 1072 is_repo_admin = h.HasRepoPermissionAny('repository.admin')(c.repo_name)
1061 1073 if h.HasPermissionAny('hg.admin')() or is_repo_admin or is_owner:
1062 1074 old_calculated_status = co.pull_request.calculated_review_status()
1063 1075 CommentsModel().delete(comment=co)
1064 1076 Session().commit()
1065 1077 calculated_status = co.pull_request.calculated_review_status()
1066 1078 if old_calculated_status != calculated_status:
1067 1079 PullRequestModel()._trigger_pull_request_hook(
1068 1080 co.pull_request, c.rhodecode_user, 'review_status_change')
1069 1081 return True
1070 1082 else:
1071 1083 raise HTTPForbidden()
@@ -1,1454 +1,1469 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2012-2017 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21
22 22 """
23 23 pull request model for RhodeCode
24 24 """
25 25
26 26 from collections import namedtuple
27 27 import json
28 28 import logging
29 29 import datetime
30 30 import urllib
31 31
32 32 from pylons.i18n.translation import _
33 33 from pylons.i18n.translation import lazy_ugettext
34 34 from sqlalchemy import or_
35 35
36 36 from rhodecode.lib import helpers as h, hooks_utils, diffs
37 37 from rhodecode.lib.compat import OrderedDict
38 38 from rhodecode.lib.hooks_daemon import prepare_callback_daemon
39 39 from rhodecode.lib.markup_renderer import (
40 40 DEFAULT_COMMENTS_RENDERER, RstTemplateRenderer)
41 41 from rhodecode.lib.utils import action_logger
42 42 from rhodecode.lib.utils2 import safe_unicode, safe_str, md5_safe
43 43 from rhodecode.lib.vcs.backends.base import (
44 44 Reference, MergeResponse, MergeFailureReason, UpdateFailureReason)
45 45 from rhodecode.lib.vcs.conf import settings as vcs_settings
46 46 from rhodecode.lib.vcs.exceptions import (
47 47 CommitDoesNotExistError, EmptyRepositoryError)
48 48 from rhodecode.model import BaseModel
49 49 from rhodecode.model.changeset_status import ChangesetStatusModel
50 50 from rhodecode.model.comment import CommentsModel
51 51 from rhodecode.model.db import (
52 52 PullRequest, PullRequestReviewers, ChangesetStatus,
53 53 PullRequestVersion, ChangesetComment, Repository)
54 54 from rhodecode.model.meta import Session
55 55 from rhodecode.model.notification import NotificationModel, \
56 56 EmailNotificationModel
57 57 from rhodecode.model.scm import ScmModel
58 58 from rhodecode.model.settings import VcsSettingsModel
59 59
60 60
61 61 log = logging.getLogger(__name__)
62 62
63 63
64 64 # Data structure to hold the response data when updating commits during a pull
65 65 # request update.
66 UpdateResponse = namedtuple(
67 'UpdateResponse', 'executed, reason, new, old, changes')
66 UpdateResponse = namedtuple('UpdateResponse', [
67 'executed', 'reason', 'new', 'old', 'changes',
68 'source_changed', 'target_changed'])
68 69
69 70
70 71 class PullRequestModel(BaseModel):
71 72
72 73 cls = PullRequest
73 74
74 75 DIFF_CONTEXT = 3
75 76
76 77 MERGE_STATUS_MESSAGES = {
77 78 MergeFailureReason.NONE: lazy_ugettext(
78 79 'This pull request can be automatically merged.'),
79 80 MergeFailureReason.UNKNOWN: lazy_ugettext(
80 81 'This pull request cannot be merged because of an unhandled'
81 82 ' exception.'),
82 83 MergeFailureReason.MERGE_FAILED: lazy_ugettext(
83 84 'This pull request cannot be merged because of merge conflicts.'),
84 85 MergeFailureReason.PUSH_FAILED: lazy_ugettext(
85 86 'This pull request could not be merged because push to target'
86 87 ' failed.'),
87 88 MergeFailureReason.TARGET_IS_NOT_HEAD: lazy_ugettext(
88 89 'This pull request cannot be merged because the target is not a'
89 90 ' head.'),
90 91 MergeFailureReason.HG_SOURCE_HAS_MORE_BRANCHES: lazy_ugettext(
91 92 'This pull request cannot be merged because the source contains'
92 93 ' more branches than the target.'),
93 94 MergeFailureReason.HG_TARGET_HAS_MULTIPLE_HEADS: lazy_ugettext(
94 95 'This pull request cannot be merged because the target has'
95 96 ' multiple heads.'),
96 97 MergeFailureReason.TARGET_IS_LOCKED: lazy_ugettext(
97 98 'This pull request cannot be merged because the target repository'
98 99 ' is locked.'),
99 100 MergeFailureReason._DEPRECATED_MISSING_COMMIT: lazy_ugettext(
100 101 'This pull request cannot be merged because the target or the '
101 102 'source reference is missing.'),
102 103 MergeFailureReason.MISSING_TARGET_REF: lazy_ugettext(
103 104 'This pull request cannot be merged because the target '
104 105 'reference is missing.'),
105 106 MergeFailureReason.MISSING_SOURCE_REF: lazy_ugettext(
106 107 'This pull request cannot be merged because the source '
107 108 'reference is missing.'),
108 109 MergeFailureReason.SUBREPO_MERGE_FAILED: lazy_ugettext(
109 110 'This pull request cannot be merged because of conflicts related '
110 111 'to sub repositories.'),
111 112 }
112 113
113 114 UPDATE_STATUS_MESSAGES = {
114 115 UpdateFailureReason.NONE: lazy_ugettext(
115 116 'Pull request update successful.'),
116 117 UpdateFailureReason.UNKNOWN: lazy_ugettext(
117 118 'Pull request update failed because of an unknown error.'),
118 119 UpdateFailureReason.NO_CHANGE: lazy_ugettext(
119 'No update needed because the source reference is already '
120 'up to date.'),
120 'No update needed because the source and target have not changed.'),
121 121 UpdateFailureReason.WRONG_REF_TPYE: lazy_ugettext(
122 122 'Pull request cannot be updated because the reference type is '
123 123 'not supported for an update.'),
124 124 UpdateFailureReason.MISSING_TARGET_REF: lazy_ugettext(
125 125 'This pull request cannot be updated because the target '
126 126 'reference is missing.'),
127 127 UpdateFailureReason.MISSING_SOURCE_REF: lazy_ugettext(
128 128 'This pull request cannot be updated because the source '
129 129 'reference is missing.'),
130 130 }
131 131
132 132 def __get_pull_request(self, pull_request):
133 133 return self._get_instance((
134 134 PullRequest, PullRequestVersion), pull_request)
135 135
136 136 def _check_perms(self, perms, pull_request, user, api=False):
137 137 if not api:
138 138 return h.HasRepoPermissionAny(*perms)(
139 139 user=user, repo_name=pull_request.target_repo.repo_name)
140 140 else:
141 141 return h.HasRepoPermissionAnyApi(*perms)(
142 142 user=user, repo_name=pull_request.target_repo.repo_name)
143 143
144 144 def check_user_read(self, pull_request, user, api=False):
145 145 _perms = ('repository.admin', 'repository.write', 'repository.read',)
146 146 return self._check_perms(_perms, pull_request, user, api)
147 147
148 148 def check_user_merge(self, pull_request, user, api=False):
149 149 _perms = ('repository.admin', 'repository.write', 'hg.admin',)
150 150 return self._check_perms(_perms, pull_request, user, api)
151 151
152 152 def check_user_update(self, pull_request, user, api=False):
153 153 owner = user.user_id == pull_request.user_id
154 154 return self.check_user_merge(pull_request, user, api) or owner
155 155
156 156 def check_user_delete(self, pull_request, user):
157 157 owner = user.user_id == pull_request.user_id
158 158 _perms = ('repository.admin',)
159 159 return self._check_perms(_perms, pull_request, user) or owner
160 160
161 161 def check_user_change_status(self, pull_request, user, api=False):
162 162 reviewer = user.user_id in [x.user_id for x in
163 163 pull_request.reviewers]
164 164 return self.check_user_update(pull_request, user, api) or reviewer
165 165
166 166 def get(self, pull_request):
167 167 return self.__get_pull_request(pull_request)
168 168
169 169 def _prepare_get_all_query(self, repo_name, source=False, statuses=None,
170 170 opened_by=None, order_by=None,
171 171 order_dir='desc'):
172 172 repo = None
173 173 if repo_name:
174 174 repo = self._get_repo(repo_name)
175 175
176 176 q = PullRequest.query()
177 177
178 178 # source or target
179 179 if repo and source:
180 180 q = q.filter(PullRequest.source_repo == repo)
181 181 elif repo:
182 182 q = q.filter(PullRequest.target_repo == repo)
183 183
184 184 # closed,opened
185 185 if statuses:
186 186 q = q.filter(PullRequest.status.in_(statuses))
187 187
188 188 # opened by filter
189 189 if opened_by:
190 190 q = q.filter(PullRequest.user_id.in_(opened_by))
191 191
192 192 if order_by:
193 193 order_map = {
194 194 'name_raw': PullRequest.pull_request_id,
195 195 'title': PullRequest.title,
196 196 'updated_on_raw': PullRequest.updated_on,
197 197 'target_repo': PullRequest.target_repo_id
198 198 }
199 199 if order_dir == 'asc':
200 200 q = q.order_by(order_map[order_by].asc())
201 201 else:
202 202 q = q.order_by(order_map[order_by].desc())
203 203
204 204 return q
205 205
206 206 def count_all(self, repo_name, source=False, statuses=None,
207 207 opened_by=None):
208 208 """
209 209 Count the number of pull requests for a specific repository.
210 210
211 211 :param repo_name: target or source repo
212 212 :param source: boolean flag to specify if repo_name refers to source
213 213 :param statuses: list of pull request statuses
214 214 :param opened_by: author user of the pull request
215 215 :returns: int number of pull requests
216 216 """
217 217 q = self._prepare_get_all_query(
218 218 repo_name, source=source, statuses=statuses, opened_by=opened_by)
219 219
220 220 return q.count()
221 221
222 222 def get_all(self, repo_name, source=False, statuses=None, opened_by=None,
223 223 offset=0, length=None, order_by=None, order_dir='desc'):
224 224 """
225 225 Get all pull requests for a specific repository.
226 226
227 227 :param repo_name: target or source repo
228 228 :param source: boolean flag to specify if repo_name refers to source
229 229 :param statuses: list of pull request statuses
230 230 :param opened_by: author user of the pull request
231 231 :param offset: pagination offset
232 232 :param length: length of returned list
233 233 :param order_by: order of the returned list
234 234 :param order_dir: 'asc' or 'desc' ordering direction
235 235 :returns: list of pull requests
236 236 """
237 237 q = self._prepare_get_all_query(
238 238 repo_name, source=source, statuses=statuses, opened_by=opened_by,
239 239 order_by=order_by, order_dir=order_dir)
240 240
241 241 if length:
242 242 pull_requests = q.limit(length).offset(offset).all()
243 243 else:
244 244 pull_requests = q.all()
245 245
246 246 return pull_requests
247 247
248 248 def count_awaiting_review(self, repo_name, source=False, statuses=None,
249 249 opened_by=None):
250 250 """
251 251 Count the number of pull requests for a specific repository that are
252 252 awaiting review.
253 253
254 254 :param repo_name: target or source repo
255 255 :param source: boolean flag to specify if repo_name refers to source
256 256 :param statuses: list of pull request statuses
257 257 :param opened_by: author user of the pull request
258 258 :returns: int number of pull requests
259 259 """
260 260 pull_requests = self.get_awaiting_review(
261 261 repo_name, source=source, statuses=statuses, opened_by=opened_by)
262 262
263 263 return len(pull_requests)
264 264
265 265 def get_awaiting_review(self, repo_name, source=False, statuses=None,
266 266 opened_by=None, offset=0, length=None,
267 267 order_by=None, order_dir='desc'):
268 268 """
269 269 Get all pull requests for a specific repository that are awaiting
270 270 review.
271 271
272 272 :param repo_name: target or source repo
273 273 :param source: boolean flag to specify if repo_name refers to source
274 274 :param statuses: list of pull request statuses
275 275 :param opened_by: author user of the pull request
276 276 :param offset: pagination offset
277 277 :param length: length of returned list
278 278 :param order_by: order of the returned list
279 279 :param order_dir: 'asc' or 'desc' ordering direction
280 280 :returns: list of pull requests
281 281 """
282 282 pull_requests = self.get_all(
283 283 repo_name, source=source, statuses=statuses, opened_by=opened_by,
284 284 order_by=order_by, order_dir=order_dir)
285 285
286 286 _filtered_pull_requests = []
287 287 for pr in pull_requests:
288 288 status = pr.calculated_review_status()
289 289 if status in [ChangesetStatus.STATUS_NOT_REVIEWED,
290 290 ChangesetStatus.STATUS_UNDER_REVIEW]:
291 291 _filtered_pull_requests.append(pr)
292 292 if length:
293 293 return _filtered_pull_requests[offset:offset+length]
294 294 else:
295 295 return _filtered_pull_requests
296 296
297 297 def count_awaiting_my_review(self, repo_name, source=False, statuses=None,
298 298 opened_by=None, user_id=None):
299 299 """
300 300 Count the number of pull requests for a specific repository that are
301 301 awaiting review from a specific user.
302 302
303 303 :param repo_name: target or source repo
304 304 :param source: boolean flag to specify if repo_name refers to source
305 305 :param statuses: list of pull request statuses
306 306 :param opened_by: author user of the pull request
307 307 :param user_id: reviewer user of the pull request
308 308 :returns: int number of pull requests
309 309 """
310 310 pull_requests = self.get_awaiting_my_review(
311 311 repo_name, source=source, statuses=statuses, opened_by=opened_by,
312 312 user_id=user_id)
313 313
314 314 return len(pull_requests)
315 315
316 316 def get_awaiting_my_review(self, repo_name, source=False, statuses=None,
317 317 opened_by=None, user_id=None, offset=0,
318 318 length=None, order_by=None, order_dir='desc'):
319 319 """
320 320 Get all pull requests for a specific repository that are awaiting
321 321 review from a specific user.
322 322
323 323 :param repo_name: target or source repo
324 324 :param source: boolean flag to specify if repo_name refers to source
325 325 :param statuses: list of pull request statuses
326 326 :param opened_by: author user of the pull request
327 327 :param user_id: reviewer user of the pull request
328 328 :param offset: pagination offset
329 329 :param length: length of returned list
330 330 :param order_by: order of the returned list
331 331 :param order_dir: 'asc' or 'desc' ordering direction
332 332 :returns: list of pull requests
333 333 """
334 334 pull_requests = self.get_all(
335 335 repo_name, source=source, statuses=statuses, opened_by=opened_by,
336 336 order_by=order_by, order_dir=order_dir)
337 337
338 338 _my = PullRequestModel().get_not_reviewed(user_id)
339 339 my_participation = []
340 340 for pr in pull_requests:
341 341 if pr in _my:
342 342 my_participation.append(pr)
343 343 _filtered_pull_requests = my_participation
344 344 if length:
345 345 return _filtered_pull_requests[offset:offset+length]
346 346 else:
347 347 return _filtered_pull_requests
348 348
349 349 def get_not_reviewed(self, user_id):
350 350 return [
351 351 x.pull_request for x in PullRequestReviewers.query().filter(
352 352 PullRequestReviewers.user_id == user_id).all()
353 353 ]
354 354
355 355 def _prepare_participating_query(self, user_id=None, statuses=None,
356 356 order_by=None, order_dir='desc'):
357 357 q = PullRequest.query()
358 358 if user_id:
359 359 reviewers_subquery = Session().query(
360 360 PullRequestReviewers.pull_request_id).filter(
361 361 PullRequestReviewers.user_id == user_id).subquery()
362 362 user_filter= or_(
363 363 PullRequest.user_id == user_id,
364 364 PullRequest.pull_request_id.in_(reviewers_subquery)
365 365 )
366 366 q = PullRequest.query().filter(user_filter)
367 367
368 368 # closed,opened
369 369 if statuses:
370 370 q = q.filter(PullRequest.status.in_(statuses))
371 371
372 372 if order_by:
373 373 order_map = {
374 374 'name_raw': PullRequest.pull_request_id,
375 375 'title': PullRequest.title,
376 376 'updated_on_raw': PullRequest.updated_on,
377 377 'target_repo': PullRequest.target_repo_id
378 378 }
379 379 if order_dir == 'asc':
380 380 q = q.order_by(order_map[order_by].asc())
381 381 else:
382 382 q = q.order_by(order_map[order_by].desc())
383 383
384 384 return q
385 385
386 386 def count_im_participating_in(self, user_id=None, statuses=None):
387 387 q = self._prepare_participating_query(user_id, statuses=statuses)
388 388 return q.count()
389 389
390 390 def get_im_participating_in(
391 391 self, user_id=None, statuses=None, offset=0,
392 392 length=None, order_by=None, order_dir='desc'):
393 393 """
394 394 Get all Pull requests that i'm participating in, or i have opened
395 395 """
396 396
397 397 q = self._prepare_participating_query(
398 398 user_id, statuses=statuses, order_by=order_by,
399 399 order_dir=order_dir)
400 400
401 401 if length:
402 402 pull_requests = q.limit(length).offset(offset).all()
403 403 else:
404 404 pull_requests = q.all()
405 405
406 406 return pull_requests
407 407
408 408 def get_versions(self, pull_request):
409 409 """
410 410 returns version of pull request sorted by ID descending
411 411 """
412 412 return PullRequestVersion.query()\
413 413 .filter(PullRequestVersion.pull_request == pull_request)\
414 414 .order_by(PullRequestVersion.pull_request_version_id.asc())\
415 415 .all()
416 416
417 417 def create(self, created_by, source_repo, source_ref, target_repo,
418 418 target_ref, revisions, reviewers, title, description=None):
419 419 created_by_user = self._get_user(created_by)
420 420 source_repo = self._get_repo(source_repo)
421 421 target_repo = self._get_repo(target_repo)
422 422
423 423 pull_request = PullRequest()
424 424 pull_request.source_repo = source_repo
425 425 pull_request.source_ref = source_ref
426 426 pull_request.target_repo = target_repo
427 427 pull_request.target_ref = target_ref
428 428 pull_request.revisions = revisions
429 429 pull_request.title = title
430 430 pull_request.description = description
431 431 pull_request.author = created_by_user
432 432
433 433 Session().add(pull_request)
434 434 Session().flush()
435 435
436 436 reviewer_ids = set()
437 437 # members / reviewers
438 438 for reviewer_object in reviewers:
439 439 if isinstance(reviewer_object, tuple):
440 440 user_id, reasons = reviewer_object
441 441 else:
442 442 user_id, reasons = reviewer_object, []
443 443
444 444 user = self._get_user(user_id)
445 445 reviewer_ids.add(user.user_id)
446 446
447 447 reviewer = PullRequestReviewers(user, pull_request, reasons)
448 448 Session().add(reviewer)
449 449
450 450 # Set approval status to "Under Review" for all commits which are
451 451 # part of this pull request.
452 452 ChangesetStatusModel().set_status(
453 453 repo=target_repo,
454 454 status=ChangesetStatus.STATUS_UNDER_REVIEW,
455 455 user=created_by_user,
456 456 pull_request=pull_request
457 457 )
458 458
459 459 self.notify_reviewers(pull_request, reviewer_ids)
460 460 self._trigger_pull_request_hook(
461 461 pull_request, created_by_user, 'create')
462 462
463 463 return pull_request
464 464
465 465 def _trigger_pull_request_hook(self, pull_request, user, action):
466 466 pull_request = self.__get_pull_request(pull_request)
467 467 target_scm = pull_request.target_repo.scm_instance()
468 468 if action == 'create':
469 469 trigger_hook = hooks_utils.trigger_log_create_pull_request_hook
470 470 elif action == 'merge':
471 471 trigger_hook = hooks_utils.trigger_log_merge_pull_request_hook
472 472 elif action == 'close':
473 473 trigger_hook = hooks_utils.trigger_log_close_pull_request_hook
474 474 elif action == 'review_status_change':
475 475 trigger_hook = hooks_utils.trigger_log_review_pull_request_hook
476 476 elif action == 'update':
477 477 trigger_hook = hooks_utils.trigger_log_update_pull_request_hook
478 478 else:
479 479 return
480 480
481 481 trigger_hook(
482 482 username=user.username,
483 483 repo_name=pull_request.target_repo.repo_name,
484 484 repo_alias=target_scm.alias,
485 485 pull_request=pull_request)
486 486
487 487 def _get_commit_ids(self, pull_request):
488 488 """
489 489 Return the commit ids of the merged pull request.
490 490
491 491 This method is not dealing correctly yet with the lack of autoupdates
492 492 nor with the implicit target updates.
493 493 For example: if a commit in the source repo is already in the target it
494 494 will be reported anyways.
495 495 """
496 496 merge_rev = pull_request.merge_rev
497 497 if merge_rev is None:
498 498 raise ValueError('This pull request was not merged yet')
499 499
500 500 commit_ids = list(pull_request.revisions)
501 501 if merge_rev not in commit_ids:
502 502 commit_ids.append(merge_rev)
503 503
504 504 return commit_ids
505 505
506 506 def merge(self, pull_request, user, extras):
507 507 log.debug("Merging pull request %s", pull_request.pull_request_id)
508 508 merge_state = self._merge_pull_request(pull_request, user, extras)
509 509 if merge_state.executed:
510 510 log.debug(
511 511 "Merge was successful, updating the pull request comments.")
512 512 self._comment_and_close_pr(pull_request, user, merge_state)
513 513 self._log_action('user_merged_pull_request', user, pull_request)
514 514 else:
515 515 log.warn("Merge failed, not updating the pull request.")
516 516 return merge_state
517 517
518 518 def _merge_pull_request(self, pull_request, user, extras):
519 519 target_vcs = pull_request.target_repo.scm_instance()
520 520 source_vcs = pull_request.source_repo.scm_instance()
521 521 target_ref = self._refresh_reference(
522 522 pull_request.target_ref_parts, target_vcs)
523 523
524 524 message = _(
525 525 'Merge pull request #%(pr_id)s from '
526 526 '%(source_repo)s %(source_ref_name)s\n\n %(pr_title)s') % {
527 527 'pr_id': pull_request.pull_request_id,
528 528 'source_repo': source_vcs.name,
529 529 'source_ref_name': pull_request.source_ref_parts.name,
530 530 'pr_title': pull_request.title
531 531 }
532 532
533 533 workspace_id = self._workspace_id(pull_request)
534 534 use_rebase = self._use_rebase_for_merging(pull_request)
535 535
536 536 callback_daemon, extras = prepare_callback_daemon(
537 537 extras, protocol=vcs_settings.HOOKS_PROTOCOL,
538 538 use_direct_calls=vcs_settings.HOOKS_DIRECT_CALLS)
539 539
540 540 with callback_daemon:
541 541 # TODO: johbo: Implement a clean way to run a config_override
542 542 # for a single call.
543 543 target_vcs.config.set(
544 544 'rhodecode', 'RC_SCM_DATA', json.dumps(extras))
545 545 merge_state = target_vcs.merge(
546 546 target_ref, source_vcs, pull_request.source_ref_parts,
547 547 workspace_id, user_name=user.username,
548 548 user_email=user.email, message=message, use_rebase=use_rebase)
549 549 return merge_state
550 550
551 551 def _comment_and_close_pr(self, pull_request, user, merge_state):
552 552 pull_request.merge_rev = merge_state.merge_ref.commit_id
553 553 pull_request.updated_on = datetime.datetime.now()
554 554
555 555 CommentsModel().create(
556 556 text=unicode(_('Pull request merged and closed')),
557 557 repo=pull_request.target_repo.repo_id,
558 558 user=user.user_id,
559 559 pull_request=pull_request.pull_request_id,
560 560 f_path=None,
561 561 line_no=None,
562 562 closing_pr=True
563 563 )
564 564
565 565 Session().add(pull_request)
566 566 Session().flush()
567 567 # TODO: paris: replace invalidation with less radical solution
568 568 ScmModel().mark_for_invalidation(
569 569 pull_request.target_repo.repo_name)
570 570 self._trigger_pull_request_hook(pull_request, user, 'merge')
571 571
572 572 def has_valid_update_type(self, pull_request):
573 573 source_ref_type = pull_request.source_ref_parts.type
574 574 return source_ref_type in ['book', 'branch', 'tag']
575 575
576 576 def update_commits(self, pull_request):
577 577 """
578 578 Get the updated list of commits for the pull request
579 579 and return the new pull request version and the list
580 580 of commits processed by this update action
581 581 """
582 582 pull_request = self.__get_pull_request(pull_request)
583 583 source_ref_type = pull_request.source_ref_parts.type
584 584 source_ref_name = pull_request.source_ref_parts.name
585 585 source_ref_id = pull_request.source_ref_parts.commit_id
586 586
587 587 target_ref_type = pull_request.target_ref_parts.type
588 588 target_ref_name = pull_request.target_ref_parts.name
589 589 target_ref_id = pull_request.target_ref_parts.commit_id
590 590
591 591 if not self.has_valid_update_type(pull_request):
592 592 log.debug(
593 593 "Skipping update of pull request %s due to ref type: %s",
594 594 pull_request, source_ref_type)
595 595 return UpdateResponse(
596 596 executed=False,
597 597 reason=UpdateFailureReason.WRONG_REF_TPYE,
598 old=pull_request, new=None, changes=None)
598 old=pull_request, new=None, changes=None,
599 source_changed=False, target_changed=False)
599 600
600 601 # source repo
601 602 source_repo = pull_request.source_repo.scm_instance()
602 603 try:
603 604 source_commit = source_repo.get_commit(commit_id=source_ref_name)
604 605 except CommitDoesNotExistError:
605 606 return UpdateResponse(
606 607 executed=False,
607 608 reason=UpdateFailureReason.MISSING_SOURCE_REF,
608 old=pull_request, new=None, changes=None)
609 old=pull_request, new=None, changes=None,
610 source_changed=False, target_changed=False)
609 611
610 612 source_changed = source_ref_id != source_commit.raw_id
611 613
612 614 # target repo
613 615 target_repo = pull_request.target_repo.scm_instance()
614 616 try:
615 617 target_commit = target_repo.get_commit(commit_id=target_ref_name)
616 618 except CommitDoesNotExistError:
617 619 return UpdateResponse(
618 620 executed=False,
619 621 reason=UpdateFailureReason.MISSING_TARGET_REF,
620 old=pull_request, new=None, changes=None)
622 old=pull_request, new=None, changes=None,
623 source_changed=False, target_changed=False)
621 624 target_changed = target_ref_id != target_commit.raw_id
622 625
623 626 if not (source_changed or target_changed):
624 627 log.debug("Nothing changed in pull request %s", pull_request)
625 628 return UpdateResponse(
626 629 executed=False,
627 630 reason=UpdateFailureReason.NO_CHANGE,
628 old=pull_request, new=None, changes=None)
631 old=pull_request, new=None, changes=None,
632 source_changed=target_changed, target_changed=source_changed)
629 633
630 634 change_in_found = 'target repo' if target_changed else 'source repo'
631 635 log.debug('Updating pull request because of change in %s detected',
632 636 change_in_found)
633 637
634 638 # Finally there is a need for an update, in case of source change
635 639 # we create a new version, else just an update
636 640 if source_changed:
637 641 pull_request_version = self._create_version_from_snapshot(pull_request)
638 642 self._link_comments_to_version(pull_request_version)
639 643 else:
640 644 try:
641 645 ver = pull_request.versions[-1]
642 646 except IndexError:
643 647 ver = None
644 648
645 649 pull_request.pull_request_version_id = \
646 650 ver.pull_request_version_id if ver else None
647 651 pull_request_version = pull_request
648 652
649 653 try:
650 654 if target_ref_type in ('tag', 'branch', 'book'):
651 655 target_commit = target_repo.get_commit(target_ref_name)
652 656 else:
653 657 target_commit = target_repo.get_commit(target_ref_id)
654 658 except CommitDoesNotExistError:
655 659 return UpdateResponse(
656 660 executed=False,
657 661 reason=UpdateFailureReason.MISSING_TARGET_REF,
658 old=pull_request, new=None, changes=None)
662 old=pull_request, new=None, changes=None,
663 source_changed=source_changed, target_changed=target_changed)
659 664
660 665 # re-compute commit ids
661 666 old_commit_ids = pull_request.revisions
662 667 pre_load = ["author", "branch", "date", "message"]
663 668 commit_ranges = target_repo.compare(
664 669 target_commit.raw_id, source_commit.raw_id, source_repo, merge=True,
665 670 pre_load=pre_load)
666 671
667 672 ancestor = target_repo.get_common_ancestor(
668 673 target_commit.raw_id, source_commit.raw_id, source_repo)
669 674
670 675 pull_request.source_ref = '%s:%s:%s' % (
671 676 source_ref_type, source_ref_name, source_commit.raw_id)
672 677 pull_request.target_ref = '%s:%s:%s' % (
673 678 target_ref_type, target_ref_name, ancestor)
674 679
675 680 pull_request.revisions = [
676 681 commit.raw_id for commit in reversed(commit_ranges)]
677 682 pull_request.updated_on = datetime.datetime.now()
678 683 Session().add(pull_request)
679 684 new_commit_ids = pull_request.revisions
680 685
681 changes = self._calculate_commit_id_changes(
682 old_commit_ids, new_commit_ids)
683
684 686 old_diff_data, new_diff_data = self._generate_update_diffs(
685 687 pull_request, pull_request_version)
686 688
689 # calculate commit and file changes
690 changes = self._calculate_commit_id_changes(
691 old_commit_ids, new_commit_ids)
692 file_changes = self._calculate_file_changes(
693 old_diff_data, new_diff_data)
694
695 # set comments as outdated if DIFFS changed
687 696 CommentsModel().outdate_comments(
688 697 pull_request, old_diff_data=old_diff_data,
689 698 new_diff_data=new_diff_data)
690 699
691 file_changes = self._calculate_file_changes(
692 old_diff_data, new_diff_data)
700 commit_changes = (changes.added or changes.removed)
701 file_node_changes = (
702 file_changes.added or file_changes.modified or file_changes.removed)
703 pr_has_changes = commit_changes or file_node_changes
693 704
694 # Add an automatic comment to the pull request
695 update_comment = CommentsModel().create(
696 text=self._render_update_message(changes, file_changes),
697 repo=pull_request.target_repo,
698 user=pull_request.author,
699 pull_request=pull_request,
700 send_email=False, renderer=DEFAULT_COMMENTS_RENDERER)
701
702 # Update status to "Under Review" for added commits
703 for commit_id in changes.added:
704 ChangesetStatusModel().set_status(
705 repo=pull_request.source_repo,
706 status=ChangesetStatus.STATUS_UNDER_REVIEW,
707 comment=update_comment,
705 # Add an automatic comment to the pull request, in case
706 # anything has changed
707 if pr_has_changes:
708 update_comment = CommentsModel().create(
709 text=self._render_update_message(changes, file_changes),
710 repo=pull_request.target_repo,
708 711 user=pull_request.author,
709 712 pull_request=pull_request,
710 revision=commit_id)
713 send_email=False, renderer=DEFAULT_COMMENTS_RENDERER)
714
715 # Update status to "Under Review" for added commits
716 for commit_id in changes.added:
717 ChangesetStatusModel().set_status(
718 repo=pull_request.source_repo,
719 status=ChangesetStatus.STATUS_UNDER_REVIEW,
720 comment=update_comment,
721 user=pull_request.author,
722 pull_request=pull_request,
723 revision=commit_id)
711 724
712 725 log.debug(
713 726 'Updated pull request %s, added_ids: %s, common_ids: %s, '
714 727 'removed_ids: %s', pull_request.pull_request_id,
715 728 changes.added, changes.common, changes.removed)
716 log.debug('Updated pull request with the following file changes: %s',
717 file_changes)
729 log.debug(
730 'Updated pull request with the following file changes: %s',
731 file_changes)
718 732
719 733 log.info(
720 734 "Updated pull request %s from commit %s to commit %s, "
721 735 "stored new version %s of this pull request.",
722 736 pull_request.pull_request_id, source_ref_id,
723 737 pull_request.source_ref_parts.commit_id,
724 738 pull_request_version.pull_request_version_id)
725 739 Session().commit()
726 self._trigger_pull_request_hook(pull_request, pull_request.author,
727 'update')
740 self._trigger_pull_request_hook(
741 pull_request, pull_request.author, 'update')
728 742
729 743 return UpdateResponse(
730 744 executed=True, reason=UpdateFailureReason.NONE,
731 old=pull_request, new=pull_request_version, changes=changes)
745 old=pull_request, new=pull_request_version, changes=changes,
746 source_changed=source_changed, target_changed=target_changed)
732 747
733 748 def _create_version_from_snapshot(self, pull_request):
734 749 version = PullRequestVersion()
735 750 version.title = pull_request.title
736 751 version.description = pull_request.description
737 752 version.status = pull_request.status
738 753 version.created_on = datetime.datetime.now()
739 754 version.updated_on = pull_request.updated_on
740 755 version.user_id = pull_request.user_id
741 756 version.source_repo = pull_request.source_repo
742 757 version.source_ref = pull_request.source_ref
743 758 version.target_repo = pull_request.target_repo
744 759 version.target_ref = pull_request.target_ref
745 760
746 761 version._last_merge_source_rev = pull_request._last_merge_source_rev
747 762 version._last_merge_target_rev = pull_request._last_merge_target_rev
748 763 version._last_merge_status = pull_request._last_merge_status
749 764 version.shadow_merge_ref = pull_request.shadow_merge_ref
750 765 version.merge_rev = pull_request.merge_rev
751 766
752 767 version.revisions = pull_request.revisions
753 768 version.pull_request = pull_request
754 769 Session().add(version)
755 770 Session().flush()
756 771
757 772 return version
758 773
759 774 def _generate_update_diffs(self, pull_request, pull_request_version):
760 775
761 776 diff_context = (
762 777 self.DIFF_CONTEXT +
763 778 CommentsModel.needed_extra_diff_context())
764 779
765 780 source_repo = pull_request_version.source_repo
766 781 source_ref_id = pull_request_version.source_ref_parts.commit_id
767 782 target_ref_id = pull_request_version.target_ref_parts.commit_id
768 783 old_diff = self._get_diff_from_pr_or_version(
769 784 source_repo, source_ref_id, target_ref_id, context=diff_context)
770 785
771 786 source_repo = pull_request.source_repo
772 787 source_ref_id = pull_request.source_ref_parts.commit_id
773 788 target_ref_id = pull_request.target_ref_parts.commit_id
774 789
775 790 new_diff = self._get_diff_from_pr_or_version(
776 791 source_repo, source_ref_id, target_ref_id, context=diff_context)
777 792
778 793 old_diff_data = diffs.DiffProcessor(old_diff)
779 794 old_diff_data.prepare()
780 795 new_diff_data = diffs.DiffProcessor(new_diff)
781 796 new_diff_data.prepare()
782 797
783 798 return old_diff_data, new_diff_data
784 799
785 800 def _link_comments_to_version(self, pull_request_version):
786 801 """
787 802 Link all unlinked comments of this pull request to the given version.
788 803
789 804 :param pull_request_version: The `PullRequestVersion` to which
790 805 the comments shall be linked.
791 806
792 807 """
793 808 pull_request = pull_request_version.pull_request
794 809 comments = ChangesetComment.query().filter(
795 810 # TODO: johbo: Should we query for the repo at all here?
796 811 # Pending decision on how comments of PRs are to be related
797 812 # to either the source repo, the target repo or no repo at all.
798 813 ChangesetComment.repo_id == pull_request.target_repo.repo_id,
799 814 ChangesetComment.pull_request == pull_request,
800 815 ChangesetComment.pull_request_version == None)
801 816
802 817 # TODO: johbo: Find out why this breaks if it is done in a bulk
803 818 # operation.
804 819 for comment in comments:
805 820 comment.pull_request_version_id = (
806 821 pull_request_version.pull_request_version_id)
807 822 Session().add(comment)
808 823
809 824 def _calculate_commit_id_changes(self, old_ids, new_ids):
810 825 added = [x for x in new_ids if x not in old_ids]
811 826 common = [x for x in new_ids if x in old_ids]
812 827 removed = [x for x in old_ids if x not in new_ids]
813 828 total = new_ids
814 829 return ChangeTuple(added, common, removed, total)
815 830
816 831 def _calculate_file_changes(self, old_diff_data, new_diff_data):
817 832
818 833 old_files = OrderedDict()
819 834 for diff_data in old_diff_data.parsed_diff:
820 835 old_files[diff_data['filename']] = md5_safe(diff_data['raw_diff'])
821 836
822 837 added_files = []
823 838 modified_files = []
824 839 removed_files = []
825 840 for diff_data in new_diff_data.parsed_diff:
826 841 new_filename = diff_data['filename']
827 842 new_hash = md5_safe(diff_data['raw_diff'])
828 843
829 844 old_hash = old_files.get(new_filename)
830 845 if not old_hash:
831 846 # file is not present in old diff, means it's added
832 847 added_files.append(new_filename)
833 848 else:
834 849 if new_hash != old_hash:
835 850 modified_files.append(new_filename)
836 851 # now remove a file from old, since we have seen it already
837 852 del old_files[new_filename]
838 853
839 854 # removed files is when there are present in old, but not in NEW,
840 855 # since we remove old files that are present in new diff, left-overs
841 856 # if any should be the removed files
842 857 removed_files.extend(old_files.keys())
843 858
844 859 return FileChangeTuple(added_files, modified_files, removed_files)
845 860
846 861 def _render_update_message(self, changes, file_changes):
847 862 """
848 863 render the message using DEFAULT_COMMENTS_RENDERER (RST renderer),
849 864 so it's always looking the same disregarding on which default
850 865 renderer system is using.
851 866
852 867 :param changes: changes named tuple
853 868 :param file_changes: file changes named tuple
854 869
855 870 """
856 871 new_status = ChangesetStatus.get_status_lbl(
857 872 ChangesetStatus.STATUS_UNDER_REVIEW)
858 873
859 874 changed_files = (
860 875 file_changes.added + file_changes.modified + file_changes.removed)
861 876
862 877 params = {
863 878 'under_review_label': new_status,
864 879 'added_commits': changes.added,
865 880 'removed_commits': changes.removed,
866 881 'changed_files': changed_files,
867 882 'added_files': file_changes.added,
868 883 'modified_files': file_changes.modified,
869 884 'removed_files': file_changes.removed,
870 885 }
871 886 renderer = RstTemplateRenderer()
872 887 return renderer.render('pull_request_update.mako', **params)
873 888
874 889 def edit(self, pull_request, title, description):
875 890 pull_request = self.__get_pull_request(pull_request)
876 891 if pull_request.is_closed():
877 892 raise ValueError('This pull request is closed')
878 893 if title:
879 894 pull_request.title = title
880 895 pull_request.description = description
881 896 pull_request.updated_on = datetime.datetime.now()
882 897 Session().add(pull_request)
883 898
884 899 def update_reviewers(self, pull_request, reviewer_data):
885 900 """
886 901 Update the reviewers in the pull request
887 902
888 903 :param pull_request: the pr to update
889 904 :param reviewer_data: list of tuples [(user, ['reason1', 'reason2'])]
890 905 """
891 906
892 907 reviewers_reasons = {}
893 908 for user_id, reasons in reviewer_data:
894 909 if isinstance(user_id, (int, basestring)):
895 910 user_id = self._get_user(user_id).user_id
896 911 reviewers_reasons[user_id] = reasons
897 912
898 913 reviewers_ids = set(reviewers_reasons.keys())
899 914 pull_request = self.__get_pull_request(pull_request)
900 915 current_reviewers = PullRequestReviewers.query()\
901 916 .filter(PullRequestReviewers.pull_request ==
902 917 pull_request).all()
903 918 current_reviewers_ids = set([x.user.user_id for x in current_reviewers])
904 919
905 920 ids_to_add = reviewers_ids.difference(current_reviewers_ids)
906 921 ids_to_remove = current_reviewers_ids.difference(reviewers_ids)
907 922
908 923 log.debug("Adding %s reviewers", ids_to_add)
909 924 log.debug("Removing %s reviewers", ids_to_remove)
910 925 changed = False
911 926 for uid in ids_to_add:
912 927 changed = True
913 928 _usr = self._get_user(uid)
914 929 reasons = reviewers_reasons[uid]
915 930 reviewer = PullRequestReviewers(_usr, pull_request, reasons)
916 931 Session().add(reviewer)
917 932
918 933 for uid in ids_to_remove:
919 934 changed = True
920 935 reviewers = PullRequestReviewers.query()\
921 936 .filter(PullRequestReviewers.user_id == uid,
922 937 PullRequestReviewers.pull_request == pull_request)\
923 938 .all()
924 939 # use .all() in case we accidentally added the same person twice
925 940 # this CAN happen due to the lack of DB checks
926 941 for obj in reviewers:
927 942 Session().delete(obj)
928 943
929 944 if changed:
930 945 pull_request.updated_on = datetime.datetime.now()
931 946 Session().add(pull_request)
932 947
933 948 self.notify_reviewers(pull_request, ids_to_add)
934 949 return ids_to_add, ids_to_remove
935 950
936 951 def get_url(self, pull_request):
937 952 return h.url('pullrequest_show',
938 953 repo_name=safe_str(pull_request.target_repo.repo_name),
939 954 pull_request_id=pull_request.pull_request_id,
940 955 qualified=True)
941 956
942 957 def get_shadow_clone_url(self, pull_request):
943 958 """
944 959 Returns qualified url pointing to the shadow repository. If this pull
945 960 request is closed there is no shadow repository and ``None`` will be
946 961 returned.
947 962 """
948 963 if pull_request.is_closed():
949 964 return None
950 965 else:
951 966 pr_url = urllib.unquote(self.get_url(pull_request))
952 967 return safe_unicode('{pr_url}/repository'.format(pr_url=pr_url))
953 968
954 969 def notify_reviewers(self, pull_request, reviewers_ids):
955 970 # notification to reviewers
956 971 if not reviewers_ids:
957 972 return
958 973
959 974 pull_request_obj = pull_request
960 975 # get the current participants of this pull request
961 976 recipients = reviewers_ids
962 977 notification_type = EmailNotificationModel.TYPE_PULL_REQUEST
963 978
964 979 pr_source_repo = pull_request_obj.source_repo
965 980 pr_target_repo = pull_request_obj.target_repo
966 981
967 982 pr_url = h.url(
968 983 'pullrequest_show',
969 984 repo_name=pr_target_repo.repo_name,
970 985 pull_request_id=pull_request_obj.pull_request_id,
971 986 qualified=True,)
972 987
973 988 # set some variables for email notification
974 989 pr_target_repo_url = h.url(
975 990 'summary_home',
976 991 repo_name=pr_target_repo.repo_name,
977 992 qualified=True)
978 993
979 994 pr_source_repo_url = h.url(
980 995 'summary_home',
981 996 repo_name=pr_source_repo.repo_name,
982 997 qualified=True)
983 998
984 999 # pull request specifics
985 1000 pull_request_commits = [
986 1001 (x.raw_id, x.message)
987 1002 for x in map(pr_source_repo.get_commit, pull_request.revisions)]
988 1003
989 1004 kwargs = {
990 1005 'user': pull_request.author,
991 1006 'pull_request': pull_request_obj,
992 1007 'pull_request_commits': pull_request_commits,
993 1008
994 1009 'pull_request_target_repo': pr_target_repo,
995 1010 'pull_request_target_repo_url': pr_target_repo_url,
996 1011
997 1012 'pull_request_source_repo': pr_source_repo,
998 1013 'pull_request_source_repo_url': pr_source_repo_url,
999 1014
1000 1015 'pull_request_url': pr_url,
1001 1016 }
1002 1017
1003 1018 # pre-generate the subject for notification itself
1004 1019 (subject,
1005 1020 _h, _e, # we don't care about those
1006 1021 body_plaintext) = EmailNotificationModel().render_email(
1007 1022 notification_type, **kwargs)
1008 1023
1009 1024 # create notification objects, and emails
1010 1025 NotificationModel().create(
1011 1026 created_by=pull_request.author,
1012 1027 notification_subject=subject,
1013 1028 notification_body=body_plaintext,
1014 1029 notification_type=notification_type,
1015 1030 recipients=recipients,
1016 1031 email_kwargs=kwargs,
1017 1032 )
1018 1033
1019 1034 def delete(self, pull_request):
1020 1035 pull_request = self.__get_pull_request(pull_request)
1021 1036 self._cleanup_merge_workspace(pull_request)
1022 1037 Session().delete(pull_request)
1023 1038
1024 1039 def close_pull_request(self, pull_request, user):
1025 1040 pull_request = self.__get_pull_request(pull_request)
1026 1041 self._cleanup_merge_workspace(pull_request)
1027 1042 pull_request.status = PullRequest.STATUS_CLOSED
1028 1043 pull_request.updated_on = datetime.datetime.now()
1029 1044 Session().add(pull_request)
1030 1045 self._trigger_pull_request_hook(
1031 1046 pull_request, pull_request.author, 'close')
1032 1047 self._log_action('user_closed_pull_request', user, pull_request)
1033 1048
1034 1049 def close_pull_request_with_comment(self, pull_request, user, repo,
1035 1050 message=None):
1036 1051 status = ChangesetStatus.STATUS_REJECTED
1037 1052
1038 1053 if not message:
1039 1054 message = (
1040 1055 _('Status change %(transition_icon)s %(status)s') % {
1041 1056 'transition_icon': '>',
1042 1057 'status': ChangesetStatus.get_status_lbl(status)})
1043 1058
1044 1059 internal_message = _('Closing with') + ' ' + message
1045 1060
1046 1061 comm = CommentsModel().create(
1047 1062 text=internal_message,
1048 1063 repo=repo.repo_id,
1049 1064 user=user.user_id,
1050 1065 pull_request=pull_request.pull_request_id,
1051 1066 f_path=None,
1052 1067 line_no=None,
1053 1068 status_change=ChangesetStatus.get_status_lbl(status),
1054 1069 status_change_type=status,
1055 1070 closing_pr=True
1056 1071 )
1057 1072
1058 1073 ChangesetStatusModel().set_status(
1059 1074 repo.repo_id,
1060 1075 status,
1061 1076 user.user_id,
1062 1077 comm,
1063 1078 pull_request=pull_request.pull_request_id
1064 1079 )
1065 1080 Session().flush()
1066 1081
1067 1082 PullRequestModel().close_pull_request(
1068 1083 pull_request.pull_request_id, user)
1069 1084
1070 1085 def merge_status(self, pull_request):
1071 1086 if not self._is_merge_enabled(pull_request):
1072 1087 return False, _('Server-side pull request merging is disabled.')
1073 1088 if pull_request.is_closed():
1074 1089 return False, _('This pull request is closed.')
1075 1090 merge_possible, msg = self._check_repo_requirements(
1076 1091 target=pull_request.target_repo, source=pull_request.source_repo)
1077 1092 if not merge_possible:
1078 1093 return merge_possible, msg
1079 1094
1080 1095 try:
1081 1096 resp = self._try_merge(pull_request)
1082 1097 log.debug("Merge response: %s", resp)
1083 1098 status = resp.possible, self.merge_status_message(
1084 1099 resp.failure_reason)
1085 1100 except NotImplementedError:
1086 1101 status = False, _('Pull request merging is not supported.')
1087 1102
1088 1103 return status
1089 1104
1090 1105 def _check_repo_requirements(self, target, source):
1091 1106 """
1092 1107 Check if `target` and `source` have compatible requirements.
1093 1108
1094 1109 Currently this is just checking for largefiles.
1095 1110 """
1096 1111 target_has_largefiles = self._has_largefiles(target)
1097 1112 source_has_largefiles = self._has_largefiles(source)
1098 1113 merge_possible = True
1099 1114 message = u''
1100 1115
1101 1116 if target_has_largefiles != source_has_largefiles:
1102 1117 merge_possible = False
1103 1118 if source_has_largefiles:
1104 1119 message = _(
1105 1120 'Target repository large files support is disabled.')
1106 1121 else:
1107 1122 message = _(
1108 1123 'Source repository large files support is disabled.')
1109 1124
1110 1125 return merge_possible, message
1111 1126
1112 1127 def _has_largefiles(self, repo):
1113 1128 largefiles_ui = VcsSettingsModel(repo=repo).get_ui_settings(
1114 1129 'extensions', 'largefiles')
1115 1130 return largefiles_ui and largefiles_ui[0].active
1116 1131
1117 1132 def _try_merge(self, pull_request):
1118 1133 """
1119 1134 Try to merge the pull request and return the merge status.
1120 1135 """
1121 1136 log.debug(
1122 1137 "Trying out if the pull request %s can be merged.",
1123 1138 pull_request.pull_request_id)
1124 1139 target_vcs = pull_request.target_repo.scm_instance()
1125 1140
1126 1141 # Refresh the target reference.
1127 1142 try:
1128 1143 target_ref = self._refresh_reference(
1129 1144 pull_request.target_ref_parts, target_vcs)
1130 1145 except CommitDoesNotExistError:
1131 1146 merge_state = MergeResponse(
1132 1147 False, False, None, MergeFailureReason.MISSING_TARGET_REF)
1133 1148 return merge_state
1134 1149
1135 1150 target_locked = pull_request.target_repo.locked
1136 1151 if target_locked and target_locked[0]:
1137 1152 log.debug("The target repository is locked.")
1138 1153 merge_state = MergeResponse(
1139 1154 False, False, None, MergeFailureReason.TARGET_IS_LOCKED)
1140 1155 elif self._needs_merge_state_refresh(pull_request, target_ref):
1141 1156 log.debug("Refreshing the merge status of the repository.")
1142 1157 merge_state = self._refresh_merge_state(
1143 1158 pull_request, target_vcs, target_ref)
1144 1159 else:
1145 1160 possible = pull_request.\
1146 1161 _last_merge_status == MergeFailureReason.NONE
1147 1162 merge_state = MergeResponse(
1148 1163 possible, False, None, pull_request._last_merge_status)
1149 1164
1150 1165 return merge_state
1151 1166
1152 1167 def _refresh_reference(self, reference, vcs_repository):
1153 1168 if reference.type in ('branch', 'book'):
1154 1169 name_or_id = reference.name
1155 1170 else:
1156 1171 name_or_id = reference.commit_id
1157 1172 refreshed_commit = vcs_repository.get_commit(name_or_id)
1158 1173 refreshed_reference = Reference(
1159 1174 reference.type, reference.name, refreshed_commit.raw_id)
1160 1175 return refreshed_reference
1161 1176
1162 1177 def _needs_merge_state_refresh(self, pull_request, target_reference):
1163 1178 return not(
1164 1179 pull_request.revisions and
1165 1180 pull_request.revisions[0] == pull_request._last_merge_source_rev and
1166 1181 target_reference.commit_id == pull_request._last_merge_target_rev)
1167 1182
1168 1183 def _refresh_merge_state(self, pull_request, target_vcs, target_reference):
1169 1184 workspace_id = self._workspace_id(pull_request)
1170 1185 source_vcs = pull_request.source_repo.scm_instance()
1171 1186 use_rebase = self._use_rebase_for_merging(pull_request)
1172 1187 merge_state = target_vcs.merge(
1173 1188 target_reference, source_vcs, pull_request.source_ref_parts,
1174 1189 workspace_id, dry_run=True, use_rebase=use_rebase)
1175 1190
1176 1191 # Do not store the response if there was an unknown error.
1177 1192 if merge_state.failure_reason != MergeFailureReason.UNKNOWN:
1178 1193 pull_request._last_merge_source_rev = \
1179 1194 pull_request.source_ref_parts.commit_id
1180 1195 pull_request._last_merge_target_rev = target_reference.commit_id
1181 1196 pull_request._last_merge_status = merge_state.failure_reason
1182 1197 pull_request.shadow_merge_ref = merge_state.merge_ref
1183 1198 Session().add(pull_request)
1184 1199 Session().commit()
1185 1200
1186 1201 return merge_state
1187 1202
1188 1203 def _workspace_id(self, pull_request):
1189 1204 workspace_id = 'pr-%s' % pull_request.pull_request_id
1190 1205 return workspace_id
1191 1206
1192 1207 def merge_status_message(self, status_code):
1193 1208 """
1194 1209 Return a human friendly error message for the given merge status code.
1195 1210 """
1196 1211 return self.MERGE_STATUS_MESSAGES[status_code]
1197 1212
1198 1213 def generate_repo_data(self, repo, commit_id=None, branch=None,
1199 1214 bookmark=None):
1200 1215 all_refs, selected_ref = \
1201 1216 self._get_repo_pullrequest_sources(
1202 1217 repo.scm_instance(), commit_id=commit_id,
1203 1218 branch=branch, bookmark=bookmark)
1204 1219
1205 1220 refs_select2 = []
1206 1221 for element in all_refs:
1207 1222 children = [{'id': x[0], 'text': x[1]} for x in element[0]]
1208 1223 refs_select2.append({'text': element[1], 'children': children})
1209 1224
1210 1225 return {
1211 1226 'user': {
1212 1227 'user_id': repo.user.user_id,
1213 1228 'username': repo.user.username,
1214 1229 'firstname': repo.user.firstname,
1215 1230 'lastname': repo.user.lastname,
1216 1231 'gravatar_link': h.gravatar_url(repo.user.email, 14),
1217 1232 },
1218 1233 'description': h.chop_at_smart(repo.description, '\n'),
1219 1234 'refs': {
1220 1235 'all_refs': all_refs,
1221 1236 'selected_ref': selected_ref,
1222 1237 'select2_refs': refs_select2
1223 1238 }
1224 1239 }
1225 1240
1226 1241 def generate_pullrequest_title(self, source, source_ref, target):
1227 1242 return u'{source}#{at_ref} to {target}'.format(
1228 1243 source=source,
1229 1244 at_ref=source_ref,
1230 1245 target=target,
1231 1246 )
1232 1247
1233 1248 def _cleanup_merge_workspace(self, pull_request):
1234 1249 # Merging related cleanup
1235 1250 target_scm = pull_request.target_repo.scm_instance()
1236 1251 workspace_id = 'pr-%s' % pull_request.pull_request_id
1237 1252
1238 1253 try:
1239 1254 target_scm.cleanup_merge_workspace(workspace_id)
1240 1255 except NotImplementedError:
1241 1256 pass
1242 1257
1243 1258 def _get_repo_pullrequest_sources(
1244 1259 self, repo, commit_id=None, branch=None, bookmark=None):
1245 1260 """
1246 1261 Return a structure with repo's interesting commits, suitable for
1247 1262 the selectors in pullrequest controller
1248 1263
1249 1264 :param commit_id: a commit that must be in the list somehow
1250 1265 and selected by default
1251 1266 :param branch: a branch that must be in the list and selected
1252 1267 by default - even if closed
1253 1268 :param bookmark: a bookmark that must be in the list and selected
1254 1269 """
1255 1270
1256 1271 commit_id = safe_str(commit_id) if commit_id else None
1257 1272 branch = safe_str(branch) if branch else None
1258 1273 bookmark = safe_str(bookmark) if bookmark else None
1259 1274
1260 1275 selected = None
1261 1276
1262 1277 # order matters: first source that has commit_id in it will be selected
1263 1278 sources = []
1264 1279 sources.append(('book', repo.bookmarks.items(), _('Bookmarks'), bookmark))
1265 1280 sources.append(('branch', repo.branches.items(), _('Branches'), branch))
1266 1281
1267 1282 if commit_id:
1268 1283 ref_commit = (h.short_id(commit_id), commit_id)
1269 1284 sources.append(('rev', [ref_commit], _('Commit IDs'), commit_id))
1270 1285
1271 1286 sources.append(
1272 1287 ('branch', repo.branches_closed.items(), _('Closed Branches'), branch),
1273 1288 )
1274 1289
1275 1290 groups = []
1276 1291 for group_key, ref_list, group_name, match in sources:
1277 1292 group_refs = []
1278 1293 for ref_name, ref_id in ref_list:
1279 1294 ref_key = '%s:%s:%s' % (group_key, ref_name, ref_id)
1280 1295 group_refs.append((ref_key, ref_name))
1281 1296
1282 1297 if not selected:
1283 1298 if set([commit_id, match]) & set([ref_id, ref_name]):
1284 1299 selected = ref_key
1285 1300
1286 1301 if group_refs:
1287 1302 groups.append((group_refs, group_name))
1288 1303
1289 1304 if not selected:
1290 1305 ref = commit_id or branch or bookmark
1291 1306 if ref:
1292 1307 raise CommitDoesNotExistError(
1293 1308 'No commit refs could be found matching: %s' % ref)
1294 1309 elif repo.DEFAULT_BRANCH_NAME in repo.branches:
1295 1310 selected = 'branch:%s:%s' % (
1296 1311 repo.DEFAULT_BRANCH_NAME,
1297 1312 repo.branches[repo.DEFAULT_BRANCH_NAME]
1298 1313 )
1299 1314 elif repo.commit_ids:
1300 1315 rev = repo.commit_ids[0]
1301 1316 selected = 'rev:%s:%s' % (rev, rev)
1302 1317 else:
1303 1318 raise EmptyRepositoryError()
1304 1319 return groups, selected
1305 1320
1306 1321 def get_diff(self, source_repo, source_ref_id, target_ref_id, context=DIFF_CONTEXT):
1307 1322 return self._get_diff_from_pr_or_version(
1308 1323 source_repo, source_ref_id, target_ref_id, context=context)
1309 1324
1310 1325 def _get_diff_from_pr_or_version(
1311 1326 self, source_repo, source_ref_id, target_ref_id, context):
1312 1327 target_commit = source_repo.get_commit(
1313 1328 commit_id=safe_str(target_ref_id))
1314 1329 source_commit = source_repo.get_commit(
1315 1330 commit_id=safe_str(source_ref_id))
1316 1331 if isinstance(source_repo, Repository):
1317 1332 vcs_repo = source_repo.scm_instance()
1318 1333 else:
1319 1334 vcs_repo = source_repo
1320 1335
1321 1336 # TODO: johbo: In the context of an update, we cannot reach
1322 1337 # the old commit anymore with our normal mechanisms. It needs
1323 1338 # some sort of special support in the vcs layer to avoid this
1324 1339 # workaround.
1325 1340 if (source_commit.raw_id == vcs_repo.EMPTY_COMMIT_ID and
1326 1341 vcs_repo.alias == 'git'):
1327 1342 source_commit.raw_id = safe_str(source_ref_id)
1328 1343
1329 1344 log.debug('calculating diff between '
1330 1345 'source_ref:%s and target_ref:%s for repo `%s`',
1331 1346 target_ref_id, source_ref_id,
1332 1347 safe_unicode(vcs_repo.path))
1333 1348
1334 1349 vcs_diff = vcs_repo.get_diff(
1335 1350 commit1=target_commit, commit2=source_commit, context=context)
1336 1351 return vcs_diff
1337 1352
1338 1353 def _is_merge_enabled(self, pull_request):
1339 1354 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
1340 1355 settings = settings_model.get_general_settings()
1341 1356 return settings.get('rhodecode_pr_merge_enabled', False)
1342 1357
1343 1358 def _use_rebase_for_merging(self, pull_request):
1344 1359 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
1345 1360 settings = settings_model.get_general_settings()
1346 1361 return settings.get('rhodecode_hg_use_rebase_for_merging', False)
1347 1362
1348 1363 def _log_action(self, action, user, pull_request):
1349 1364 action_logger(
1350 1365 user,
1351 1366 '{action}:{pr_id}'.format(
1352 1367 action=action, pr_id=pull_request.pull_request_id),
1353 1368 pull_request.target_repo)
1354 1369
1355 1370
1356 1371 class MergeCheck(object):
1357 1372 """
1358 1373 Perform Merge Checks and returns a check object which stores information
1359 1374 about merge errors, and merge conditions
1360 1375 """
1361 1376 TODO_CHECK = 'todo'
1362 1377 PERM_CHECK = 'perm'
1363 1378 REVIEW_CHECK = 'review'
1364 1379 MERGE_CHECK = 'merge'
1365 1380
1366 1381 def __init__(self):
1367 1382 self.review_status = None
1368 1383 self.merge_possible = None
1369 1384 self.merge_msg = ''
1370 1385 self.failed = None
1371 1386 self.errors = []
1372 1387 self.error_details = OrderedDict()
1373 1388
1374 1389 def push_error(self, error_type, message, error_key, details):
1375 1390 self.failed = True
1376 1391 self.errors.append([error_type, message])
1377 1392 self.error_details[error_key] = dict(
1378 1393 details=details,
1379 1394 error_type=error_type,
1380 1395 message=message
1381 1396 )
1382 1397
1383 1398 @classmethod
1384 1399 def validate(cls, pull_request, user, fail_early=False, translator=None):
1385 1400 # if migrated to pyramid...
1386 1401 # _ = lambda: translator or _ # use passed in translator if any
1387 1402
1388 1403 merge_check = cls()
1389 1404
1390 1405 # permissions to merge
1391 1406 user_allowed_to_merge = PullRequestModel().check_user_merge(
1392 1407 pull_request, user)
1393 1408 if not user_allowed_to_merge:
1394 1409 log.debug("MergeCheck: cannot merge, approval is pending.")
1395 1410
1396 1411 msg = _('User `{}` not allowed to perform merge.').format(user.username)
1397 1412 merge_check.push_error('error', msg, cls.PERM_CHECK, user.username)
1398 1413 if fail_early:
1399 1414 return merge_check
1400 1415
1401 1416 # review status, must be always present
1402 1417 review_status = pull_request.calculated_review_status()
1403 1418 merge_check.review_status = review_status
1404 1419
1405 1420 status_approved = review_status == ChangesetStatus.STATUS_APPROVED
1406 1421 if not status_approved:
1407 1422 log.debug("MergeCheck: cannot merge, approval is pending.")
1408 1423
1409 1424 msg = _('Pull request reviewer approval is pending.')
1410 1425
1411 1426 merge_check.push_error(
1412 1427 'warning', msg, cls.REVIEW_CHECK, review_status)
1413 1428
1414 1429 if fail_early:
1415 1430 return merge_check
1416 1431
1417 1432 # left over TODOs
1418 1433 todos = CommentsModel().get_unresolved_todos(pull_request)
1419 1434 if todos:
1420 1435 log.debug("MergeCheck: cannot merge, {} "
1421 1436 "unresolved todos left.".format(len(todos)))
1422 1437
1423 1438 if len(todos) == 1:
1424 1439 msg = _('Cannot merge, {} TODO still not resolved.').format(
1425 1440 len(todos))
1426 1441 else:
1427 1442 msg = _('Cannot merge, {} TODOs still not resolved.').format(
1428 1443 len(todos))
1429 1444
1430 1445 merge_check.push_error('warning', msg, cls.TODO_CHECK, todos)
1431 1446
1432 1447 if fail_early:
1433 1448 return merge_check
1434 1449
1435 1450 # merge possible
1436 1451 merge_status, msg = PullRequestModel().merge_status(pull_request)
1437 1452 merge_check.merge_possible = merge_status
1438 1453 merge_check.merge_msg = msg
1439 1454 if not merge_status:
1440 1455 log.debug(
1441 1456 "MergeCheck: cannot merge, pull request merge not possible.")
1442 1457 merge_check.push_error('warning', msg, cls.MERGE_CHECK, None)
1443 1458
1444 1459 if fail_early:
1445 1460 return merge_check
1446 1461
1447 1462 return merge_check
1448 1463
1449 1464
1450 1465 ChangeTuple = namedtuple('ChangeTuple',
1451 1466 ['added', 'common', 'removed', 'total'])
1452 1467
1453 1468 FileChangeTuple = namedtuple('FileChangeTuple',
1454 1469 ['added', 'modified', 'removed'])
General Comments 0
You need to be logged in to leave comments. Login now