##// END OF EJS Templates
pull-requests: don't select first commit in case we don't get default branch. Loading...
marcink -
r2474:829818ee default
parent child Browse files
Show More
@@ -1,1654 +1,1654 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
27 27 import json
28 28 import logging
29 29 import datetime
30 30 import urllib
31 31 import collections
32 32
33 33 from pyramid.threadlocal import get_current_request
34 34
35 35 from rhodecode import events
36 36 from rhodecode.translation import lazy_ugettext#, _
37 37 from rhodecode.lib import helpers as h, hooks_utils, diffs
38 38 from rhodecode.lib import audit_logger
39 39 from rhodecode.lib.compat import OrderedDict
40 40 from rhodecode.lib.hooks_daemon import prepare_callback_daemon
41 41 from rhodecode.lib.markup_renderer import (
42 42 DEFAULT_COMMENTS_RENDERER, RstTemplateRenderer)
43 43 from rhodecode.lib.utils2 import safe_unicode, safe_str, md5_safe
44 44 from rhodecode.lib.vcs.backends.base import (
45 45 Reference, MergeResponse, MergeFailureReason, UpdateFailureReason)
46 46 from rhodecode.lib.vcs.conf import settings as vcs_settings
47 47 from rhodecode.lib.vcs.exceptions import (
48 48 CommitDoesNotExistError, EmptyRepositoryError)
49 49 from rhodecode.model import BaseModel
50 50 from rhodecode.model.changeset_status import ChangesetStatusModel
51 51 from rhodecode.model.comment import CommentsModel
52 52 from rhodecode.model.db import (
53 53 or_, PullRequest, PullRequestReviewers, ChangesetStatus,
54 54 PullRequestVersion, ChangesetComment, Repository)
55 55 from rhodecode.model.meta import Session
56 56 from rhodecode.model.notification import NotificationModel, \
57 57 EmailNotificationModel
58 58 from rhodecode.model.scm import ScmModel
59 59 from rhodecode.model.settings import VcsSettingsModel
60 60
61 61
62 62 log = logging.getLogger(__name__)
63 63
64 64
65 65 # Data structure to hold the response data when updating commits during a pull
66 66 # request update.
67 67 UpdateResponse = collections.namedtuple('UpdateResponse', [
68 68 'executed', 'reason', 'new', 'old', 'changes',
69 69 'source_changed', 'target_changed'])
70 70
71 71
72 72 class PullRequestModel(BaseModel):
73 73
74 74 cls = PullRequest
75 75
76 76 DIFF_CONTEXT = 3
77 77
78 78 MERGE_STATUS_MESSAGES = {
79 79 MergeFailureReason.NONE: lazy_ugettext(
80 80 'This pull request can be automatically merged.'),
81 81 MergeFailureReason.UNKNOWN: lazy_ugettext(
82 82 'This pull request cannot be merged because of an unhandled'
83 83 ' exception.'),
84 84 MergeFailureReason.MERGE_FAILED: lazy_ugettext(
85 85 'This pull request cannot be merged because of merge conflicts.'),
86 86 MergeFailureReason.PUSH_FAILED: lazy_ugettext(
87 87 'This pull request could not be merged because push to target'
88 88 ' failed.'),
89 89 MergeFailureReason.TARGET_IS_NOT_HEAD: lazy_ugettext(
90 90 'This pull request cannot be merged because the target is not a'
91 91 ' head.'),
92 92 MergeFailureReason.HG_SOURCE_HAS_MORE_BRANCHES: lazy_ugettext(
93 93 'This pull request cannot be merged because the source contains'
94 94 ' more branches than the target.'),
95 95 MergeFailureReason.HG_TARGET_HAS_MULTIPLE_HEADS: lazy_ugettext(
96 96 'This pull request cannot be merged because the target has'
97 97 ' multiple heads.'),
98 98 MergeFailureReason.TARGET_IS_LOCKED: lazy_ugettext(
99 99 'This pull request cannot be merged because the target repository'
100 100 ' is locked.'),
101 101 MergeFailureReason._DEPRECATED_MISSING_COMMIT: lazy_ugettext(
102 102 'This pull request cannot be merged because the target or the '
103 103 'source reference is missing.'),
104 104 MergeFailureReason.MISSING_TARGET_REF: lazy_ugettext(
105 105 'This pull request cannot be merged because the target '
106 106 'reference is missing.'),
107 107 MergeFailureReason.MISSING_SOURCE_REF: lazy_ugettext(
108 108 'This pull request cannot be merged because the source '
109 109 'reference is missing.'),
110 110 MergeFailureReason.SUBREPO_MERGE_FAILED: lazy_ugettext(
111 111 'This pull request cannot be merged because of conflicts related '
112 112 'to sub repositories.'),
113 113 }
114 114
115 115 UPDATE_STATUS_MESSAGES = {
116 116 UpdateFailureReason.NONE: lazy_ugettext(
117 117 'Pull request update successful.'),
118 118 UpdateFailureReason.UNKNOWN: lazy_ugettext(
119 119 'Pull request update failed because of an unknown error.'),
120 120 UpdateFailureReason.NO_CHANGE: lazy_ugettext(
121 121 'No update needed because the source and target have not changed.'),
122 122 UpdateFailureReason.WRONG_REF_TYPE: lazy_ugettext(
123 123 'Pull request cannot be updated because the reference type is '
124 124 'not supported for an update. Only Branch, Tag or Bookmark is allowed.'),
125 125 UpdateFailureReason.MISSING_TARGET_REF: lazy_ugettext(
126 126 'This pull request cannot be updated because the target '
127 127 'reference is missing.'),
128 128 UpdateFailureReason.MISSING_SOURCE_REF: lazy_ugettext(
129 129 'This pull request cannot be updated because the source '
130 130 'reference is missing.'),
131 131 }
132 132
133 133 def __get_pull_request(self, pull_request):
134 134 return self._get_instance((
135 135 PullRequest, PullRequestVersion), pull_request)
136 136
137 137 def _check_perms(self, perms, pull_request, user, api=False):
138 138 if not api:
139 139 return h.HasRepoPermissionAny(*perms)(
140 140 user=user, repo_name=pull_request.target_repo.repo_name)
141 141 else:
142 142 return h.HasRepoPermissionAnyApi(*perms)(
143 143 user=user, repo_name=pull_request.target_repo.repo_name)
144 144
145 145 def check_user_read(self, pull_request, user, api=False):
146 146 _perms = ('repository.admin', 'repository.write', 'repository.read',)
147 147 return self._check_perms(_perms, pull_request, user, api)
148 148
149 149 def check_user_merge(self, pull_request, user, api=False):
150 150 _perms = ('repository.admin', 'repository.write', 'hg.admin',)
151 151 return self._check_perms(_perms, pull_request, user, api)
152 152
153 153 def check_user_update(self, pull_request, user, api=False):
154 154 owner = user.user_id == pull_request.user_id
155 155 return self.check_user_merge(pull_request, user, api) or owner
156 156
157 157 def check_user_delete(self, pull_request, user):
158 158 owner = user.user_id == pull_request.user_id
159 159 _perms = ('repository.admin',)
160 160 return self._check_perms(_perms, pull_request, user) or owner
161 161
162 162 def check_user_change_status(self, pull_request, user, api=False):
163 163 reviewer = user.user_id in [x.user_id for x in
164 164 pull_request.reviewers]
165 165 return self.check_user_update(pull_request, user, api) or reviewer
166 166
167 167 def check_user_comment(self, pull_request, user):
168 168 owner = user.user_id == pull_request.user_id
169 169 return self.check_user_read(pull_request, user) or owner
170 170
171 171 def get(self, pull_request):
172 172 return self.__get_pull_request(pull_request)
173 173
174 174 def _prepare_get_all_query(self, repo_name, source=False, statuses=None,
175 175 opened_by=None, order_by=None,
176 176 order_dir='desc'):
177 177 repo = None
178 178 if repo_name:
179 179 repo = self._get_repo(repo_name)
180 180
181 181 q = PullRequest.query()
182 182
183 183 # source or target
184 184 if repo and source:
185 185 q = q.filter(PullRequest.source_repo == repo)
186 186 elif repo:
187 187 q = q.filter(PullRequest.target_repo == repo)
188 188
189 189 # closed,opened
190 190 if statuses:
191 191 q = q.filter(PullRequest.status.in_(statuses))
192 192
193 193 # opened by filter
194 194 if opened_by:
195 195 q = q.filter(PullRequest.user_id.in_(opened_by))
196 196
197 197 if order_by:
198 198 order_map = {
199 199 'name_raw': PullRequest.pull_request_id,
200 200 'title': PullRequest.title,
201 201 'updated_on_raw': PullRequest.updated_on,
202 202 'target_repo': PullRequest.target_repo_id
203 203 }
204 204 if order_dir == 'asc':
205 205 q = q.order_by(order_map[order_by].asc())
206 206 else:
207 207 q = q.order_by(order_map[order_by].desc())
208 208
209 209 return q
210 210
211 211 def count_all(self, repo_name, source=False, statuses=None,
212 212 opened_by=None):
213 213 """
214 214 Count the number of pull requests for a specific repository.
215 215
216 216 :param repo_name: target or source repo
217 217 :param source: boolean flag to specify if repo_name refers to source
218 218 :param statuses: list of pull request statuses
219 219 :param opened_by: author user of the pull request
220 220 :returns: int number of pull requests
221 221 """
222 222 q = self._prepare_get_all_query(
223 223 repo_name, source=source, statuses=statuses, opened_by=opened_by)
224 224
225 225 return q.count()
226 226
227 227 def get_all(self, repo_name, source=False, statuses=None, opened_by=None,
228 228 offset=0, length=None, order_by=None, order_dir='desc'):
229 229 """
230 230 Get all pull requests for a specific repository.
231 231
232 232 :param repo_name: target or source repo
233 233 :param source: boolean flag to specify if repo_name refers to source
234 234 :param statuses: list of pull request statuses
235 235 :param opened_by: author user of the pull request
236 236 :param offset: pagination offset
237 237 :param length: length of returned list
238 238 :param order_by: order of the returned list
239 239 :param order_dir: 'asc' or 'desc' ordering direction
240 240 :returns: list of pull requests
241 241 """
242 242 q = self._prepare_get_all_query(
243 243 repo_name, source=source, statuses=statuses, opened_by=opened_by,
244 244 order_by=order_by, order_dir=order_dir)
245 245
246 246 if length:
247 247 pull_requests = q.limit(length).offset(offset).all()
248 248 else:
249 249 pull_requests = q.all()
250 250
251 251 return pull_requests
252 252
253 253 def count_awaiting_review(self, repo_name, source=False, statuses=None,
254 254 opened_by=None):
255 255 """
256 256 Count the number of pull requests for a specific repository that are
257 257 awaiting review.
258 258
259 259 :param repo_name: target or source repo
260 260 :param source: boolean flag to specify if repo_name refers to source
261 261 :param statuses: list of pull request statuses
262 262 :param opened_by: author user of the pull request
263 263 :returns: int number of pull requests
264 264 """
265 265 pull_requests = self.get_awaiting_review(
266 266 repo_name, source=source, statuses=statuses, opened_by=opened_by)
267 267
268 268 return len(pull_requests)
269 269
270 270 def get_awaiting_review(self, repo_name, source=False, statuses=None,
271 271 opened_by=None, offset=0, length=None,
272 272 order_by=None, order_dir='desc'):
273 273 """
274 274 Get all pull requests for a specific repository that are awaiting
275 275 review.
276 276
277 277 :param repo_name: target or source repo
278 278 :param source: boolean flag to specify if repo_name refers to source
279 279 :param statuses: list of pull request statuses
280 280 :param opened_by: author user of the pull request
281 281 :param offset: pagination offset
282 282 :param length: length of returned list
283 283 :param order_by: order of the returned list
284 284 :param order_dir: 'asc' or 'desc' ordering direction
285 285 :returns: list of pull requests
286 286 """
287 287 pull_requests = self.get_all(
288 288 repo_name, source=source, statuses=statuses, opened_by=opened_by,
289 289 order_by=order_by, order_dir=order_dir)
290 290
291 291 _filtered_pull_requests = []
292 292 for pr in pull_requests:
293 293 status = pr.calculated_review_status()
294 294 if status in [ChangesetStatus.STATUS_NOT_REVIEWED,
295 295 ChangesetStatus.STATUS_UNDER_REVIEW]:
296 296 _filtered_pull_requests.append(pr)
297 297 if length:
298 298 return _filtered_pull_requests[offset:offset+length]
299 299 else:
300 300 return _filtered_pull_requests
301 301
302 302 def count_awaiting_my_review(self, repo_name, source=False, statuses=None,
303 303 opened_by=None, user_id=None):
304 304 """
305 305 Count the number of pull requests for a specific repository that are
306 306 awaiting review from a specific user.
307 307
308 308 :param repo_name: target or source repo
309 309 :param source: boolean flag to specify if repo_name refers to source
310 310 :param statuses: list of pull request statuses
311 311 :param opened_by: author user of the pull request
312 312 :param user_id: reviewer user of the pull request
313 313 :returns: int number of pull requests
314 314 """
315 315 pull_requests = self.get_awaiting_my_review(
316 316 repo_name, source=source, statuses=statuses, opened_by=opened_by,
317 317 user_id=user_id)
318 318
319 319 return len(pull_requests)
320 320
321 321 def get_awaiting_my_review(self, repo_name, source=False, statuses=None,
322 322 opened_by=None, user_id=None, offset=0,
323 323 length=None, order_by=None, order_dir='desc'):
324 324 """
325 325 Get all pull requests for a specific repository that are awaiting
326 326 review from a specific user.
327 327
328 328 :param repo_name: target or source repo
329 329 :param source: boolean flag to specify if repo_name refers to source
330 330 :param statuses: list of pull request statuses
331 331 :param opened_by: author user of the pull request
332 332 :param user_id: reviewer user of the pull request
333 333 :param offset: pagination offset
334 334 :param length: length of returned list
335 335 :param order_by: order of the returned list
336 336 :param order_dir: 'asc' or 'desc' ordering direction
337 337 :returns: list of pull requests
338 338 """
339 339 pull_requests = self.get_all(
340 340 repo_name, source=source, statuses=statuses, opened_by=opened_by,
341 341 order_by=order_by, order_dir=order_dir)
342 342
343 343 _my = PullRequestModel().get_not_reviewed(user_id)
344 344 my_participation = []
345 345 for pr in pull_requests:
346 346 if pr in _my:
347 347 my_participation.append(pr)
348 348 _filtered_pull_requests = my_participation
349 349 if length:
350 350 return _filtered_pull_requests[offset:offset+length]
351 351 else:
352 352 return _filtered_pull_requests
353 353
354 354 def get_not_reviewed(self, user_id):
355 355 return [
356 356 x.pull_request for x in PullRequestReviewers.query().filter(
357 357 PullRequestReviewers.user_id == user_id).all()
358 358 ]
359 359
360 360 def _prepare_participating_query(self, user_id=None, statuses=None,
361 361 order_by=None, order_dir='desc'):
362 362 q = PullRequest.query()
363 363 if user_id:
364 364 reviewers_subquery = Session().query(
365 365 PullRequestReviewers.pull_request_id).filter(
366 366 PullRequestReviewers.user_id == user_id).subquery()
367 367 user_filter = or_(
368 368 PullRequest.user_id == user_id,
369 369 PullRequest.pull_request_id.in_(reviewers_subquery)
370 370 )
371 371 q = PullRequest.query().filter(user_filter)
372 372
373 373 # closed,opened
374 374 if statuses:
375 375 q = q.filter(PullRequest.status.in_(statuses))
376 376
377 377 if order_by:
378 378 order_map = {
379 379 'name_raw': PullRequest.pull_request_id,
380 380 'title': PullRequest.title,
381 381 'updated_on_raw': PullRequest.updated_on,
382 382 'target_repo': PullRequest.target_repo_id
383 383 }
384 384 if order_dir == 'asc':
385 385 q = q.order_by(order_map[order_by].asc())
386 386 else:
387 387 q = q.order_by(order_map[order_by].desc())
388 388
389 389 return q
390 390
391 391 def count_im_participating_in(self, user_id=None, statuses=None):
392 392 q = self._prepare_participating_query(user_id, statuses=statuses)
393 393 return q.count()
394 394
395 395 def get_im_participating_in(
396 396 self, user_id=None, statuses=None, offset=0,
397 397 length=None, order_by=None, order_dir='desc'):
398 398 """
399 399 Get all Pull requests that i'm participating in, or i have opened
400 400 """
401 401
402 402 q = self._prepare_participating_query(
403 403 user_id, statuses=statuses, order_by=order_by,
404 404 order_dir=order_dir)
405 405
406 406 if length:
407 407 pull_requests = q.limit(length).offset(offset).all()
408 408 else:
409 409 pull_requests = q.all()
410 410
411 411 return pull_requests
412 412
413 413 def get_versions(self, pull_request):
414 414 """
415 415 returns version of pull request sorted by ID descending
416 416 """
417 417 return PullRequestVersion.query()\
418 418 .filter(PullRequestVersion.pull_request == pull_request)\
419 419 .order_by(PullRequestVersion.pull_request_version_id.asc())\
420 420 .all()
421 421
422 422 def get_pr_version(self, pull_request_id, version=None):
423 423 at_version = None
424 424
425 425 if version and version == 'latest':
426 426 pull_request_ver = PullRequest.get(pull_request_id)
427 427 pull_request_obj = pull_request_ver
428 428 _org_pull_request_obj = pull_request_obj
429 429 at_version = 'latest'
430 430 elif version:
431 431 pull_request_ver = PullRequestVersion.get_or_404(version)
432 432 pull_request_obj = pull_request_ver
433 433 _org_pull_request_obj = pull_request_ver.pull_request
434 434 at_version = pull_request_ver.pull_request_version_id
435 435 else:
436 436 _org_pull_request_obj = pull_request_obj = PullRequest.get_or_404(
437 437 pull_request_id)
438 438
439 439 pull_request_display_obj = PullRequest.get_pr_display_object(
440 440 pull_request_obj, _org_pull_request_obj)
441 441
442 442 return _org_pull_request_obj, pull_request_obj, \
443 443 pull_request_display_obj, at_version
444 444
445 445 def create(self, created_by, source_repo, source_ref, target_repo,
446 446 target_ref, revisions, reviewers, title, description=None,
447 447 reviewer_data=None, translator=None):
448 448 translator = translator or get_current_request().translate
449 449
450 450 created_by_user = self._get_user(created_by)
451 451 source_repo = self._get_repo(source_repo)
452 452 target_repo = self._get_repo(target_repo)
453 453
454 454 pull_request = PullRequest()
455 455 pull_request.source_repo = source_repo
456 456 pull_request.source_ref = source_ref
457 457 pull_request.target_repo = target_repo
458 458 pull_request.target_ref = target_ref
459 459 pull_request.revisions = revisions
460 460 pull_request.title = title
461 461 pull_request.description = description
462 462 pull_request.author = created_by_user
463 463 pull_request.reviewer_data = reviewer_data
464 464
465 465 Session().add(pull_request)
466 466 Session().flush()
467 467
468 468 reviewer_ids = set()
469 469 # members / reviewers
470 470 for reviewer_object in reviewers:
471 471 user_id, reasons, mandatory = reviewer_object
472 472 user = self._get_user(user_id)
473 473
474 474 # skip duplicates
475 475 if user.user_id in reviewer_ids:
476 476 continue
477 477
478 478 reviewer_ids.add(user.user_id)
479 479
480 480 reviewer = PullRequestReviewers()
481 481 reviewer.user = user
482 482 reviewer.pull_request = pull_request
483 483 reviewer.reasons = reasons
484 484 reviewer.mandatory = mandatory
485 485 Session().add(reviewer)
486 486
487 487 # Set approval status to "Under Review" for all commits which are
488 488 # part of this pull request.
489 489 ChangesetStatusModel().set_status(
490 490 repo=target_repo,
491 491 status=ChangesetStatus.STATUS_UNDER_REVIEW,
492 492 user=created_by_user,
493 493 pull_request=pull_request
494 494 )
495 495
496 496 MergeCheck.validate(
497 497 pull_request, user=created_by_user, translator=translator)
498 498
499 499 self.notify_reviewers(pull_request, reviewer_ids)
500 500 self._trigger_pull_request_hook(
501 501 pull_request, created_by_user, 'create')
502 502
503 503 creation_data = pull_request.get_api_data(with_merge_state=False)
504 504 self._log_audit_action(
505 505 'repo.pull_request.create', {'data': creation_data},
506 506 created_by_user, pull_request)
507 507
508 508 return pull_request
509 509
510 510 def _trigger_pull_request_hook(self, pull_request, user, action):
511 511 pull_request = self.__get_pull_request(pull_request)
512 512 target_scm = pull_request.target_repo.scm_instance()
513 513 if action == 'create':
514 514 trigger_hook = hooks_utils.trigger_log_create_pull_request_hook
515 515 elif action == 'merge':
516 516 trigger_hook = hooks_utils.trigger_log_merge_pull_request_hook
517 517 elif action == 'close':
518 518 trigger_hook = hooks_utils.trigger_log_close_pull_request_hook
519 519 elif action == 'review_status_change':
520 520 trigger_hook = hooks_utils.trigger_log_review_pull_request_hook
521 521 elif action == 'update':
522 522 trigger_hook = hooks_utils.trigger_log_update_pull_request_hook
523 523 else:
524 524 return
525 525
526 526 trigger_hook(
527 527 username=user.username,
528 528 repo_name=pull_request.target_repo.repo_name,
529 529 repo_alias=target_scm.alias,
530 530 pull_request=pull_request)
531 531
532 532 def _get_commit_ids(self, pull_request):
533 533 """
534 534 Return the commit ids of the merged pull request.
535 535
536 536 This method is not dealing correctly yet with the lack of autoupdates
537 537 nor with the implicit target updates.
538 538 For example: if a commit in the source repo is already in the target it
539 539 will be reported anyways.
540 540 """
541 541 merge_rev = pull_request.merge_rev
542 542 if merge_rev is None:
543 543 raise ValueError('This pull request was not merged yet')
544 544
545 545 commit_ids = list(pull_request.revisions)
546 546 if merge_rev not in commit_ids:
547 547 commit_ids.append(merge_rev)
548 548
549 549 return commit_ids
550 550
551 551 def merge(self, pull_request, user, extras):
552 552 log.debug("Merging pull request %s", pull_request.pull_request_id)
553 553 merge_state = self._merge_pull_request(pull_request, user, extras)
554 554 if merge_state.executed:
555 555 log.debug(
556 556 "Merge was successful, updating the pull request comments.")
557 557 self._comment_and_close_pr(pull_request, user, merge_state)
558 558
559 559 self._log_audit_action(
560 560 'repo.pull_request.merge',
561 561 {'merge_state': merge_state.__dict__},
562 562 user, pull_request)
563 563
564 564 else:
565 565 log.warn("Merge failed, not updating the pull request.")
566 566 return merge_state
567 567
568 568 def _merge_pull_request(self, pull_request, user, extras, merge_msg=None):
569 569 target_vcs = pull_request.target_repo.scm_instance()
570 570 source_vcs = pull_request.source_repo.scm_instance()
571 571 target_ref = self._refresh_reference(
572 572 pull_request.target_ref_parts, target_vcs)
573 573
574 574 message = merge_msg or (
575 575 'Merge pull request #%(pr_id)s from '
576 576 '%(source_repo)s %(source_ref_name)s\n\n %(pr_title)s') % {
577 577 'pr_id': pull_request.pull_request_id,
578 578 'source_repo': source_vcs.name,
579 579 'source_ref_name': pull_request.source_ref_parts.name,
580 580 'pr_title': pull_request.title
581 581 }
582 582
583 583 workspace_id = self._workspace_id(pull_request)
584 584 use_rebase = self._use_rebase_for_merging(pull_request)
585 585 close_branch = self._close_branch_before_merging(pull_request)
586 586
587 587 callback_daemon, extras = prepare_callback_daemon(
588 588 extras, protocol=vcs_settings.HOOKS_PROTOCOL,
589 589 use_direct_calls=vcs_settings.HOOKS_DIRECT_CALLS)
590 590
591 591 with callback_daemon:
592 592 # TODO: johbo: Implement a clean way to run a config_override
593 593 # for a single call.
594 594 target_vcs.config.set(
595 595 'rhodecode', 'RC_SCM_DATA', json.dumps(extras))
596 596 merge_state = target_vcs.merge(
597 597 target_ref, source_vcs, pull_request.source_ref_parts,
598 598 workspace_id, user_name=user.username,
599 599 user_email=user.email, message=message, use_rebase=use_rebase,
600 600 close_branch=close_branch)
601 601 return merge_state
602 602
603 603 def _comment_and_close_pr(self, pull_request, user, merge_state, close_msg=None):
604 604 pull_request.merge_rev = merge_state.merge_ref.commit_id
605 605 pull_request.updated_on = datetime.datetime.now()
606 606 close_msg = close_msg or 'Pull request merged and closed'
607 607
608 608 CommentsModel().create(
609 609 text=safe_unicode(close_msg),
610 610 repo=pull_request.target_repo.repo_id,
611 611 user=user.user_id,
612 612 pull_request=pull_request.pull_request_id,
613 613 f_path=None,
614 614 line_no=None,
615 615 closing_pr=True
616 616 )
617 617
618 618 Session().add(pull_request)
619 619 Session().flush()
620 620 # TODO: paris: replace invalidation with less radical solution
621 621 ScmModel().mark_for_invalidation(
622 622 pull_request.target_repo.repo_name)
623 623 self._trigger_pull_request_hook(pull_request, user, 'merge')
624 624
625 625 def has_valid_update_type(self, pull_request):
626 626 source_ref_type = pull_request.source_ref_parts.type
627 627 return source_ref_type in ['book', 'branch', 'tag']
628 628
629 629 def update_commits(self, pull_request):
630 630 """
631 631 Get the updated list of commits for the pull request
632 632 and return the new pull request version and the list
633 633 of commits processed by this update action
634 634 """
635 635 pull_request = self.__get_pull_request(pull_request)
636 636 source_ref_type = pull_request.source_ref_parts.type
637 637 source_ref_name = pull_request.source_ref_parts.name
638 638 source_ref_id = pull_request.source_ref_parts.commit_id
639 639
640 640 target_ref_type = pull_request.target_ref_parts.type
641 641 target_ref_name = pull_request.target_ref_parts.name
642 642 target_ref_id = pull_request.target_ref_parts.commit_id
643 643
644 644 if not self.has_valid_update_type(pull_request):
645 645 log.debug(
646 646 "Skipping update of pull request %s due to ref type: %s",
647 647 pull_request, source_ref_type)
648 648 return UpdateResponse(
649 649 executed=False,
650 650 reason=UpdateFailureReason.WRONG_REF_TYPE,
651 651 old=pull_request, new=None, changes=None,
652 652 source_changed=False, target_changed=False)
653 653
654 654 # source repo
655 655 source_repo = pull_request.source_repo.scm_instance()
656 656 try:
657 657 source_commit = source_repo.get_commit(commit_id=source_ref_name)
658 658 except CommitDoesNotExistError:
659 659 return UpdateResponse(
660 660 executed=False,
661 661 reason=UpdateFailureReason.MISSING_SOURCE_REF,
662 662 old=pull_request, new=None, changes=None,
663 663 source_changed=False, target_changed=False)
664 664
665 665 source_changed = source_ref_id != source_commit.raw_id
666 666
667 667 # target repo
668 668 target_repo = pull_request.target_repo.scm_instance()
669 669 try:
670 670 target_commit = target_repo.get_commit(commit_id=target_ref_name)
671 671 except CommitDoesNotExistError:
672 672 return UpdateResponse(
673 673 executed=False,
674 674 reason=UpdateFailureReason.MISSING_TARGET_REF,
675 675 old=pull_request, new=None, changes=None,
676 676 source_changed=False, target_changed=False)
677 677 target_changed = target_ref_id != target_commit.raw_id
678 678
679 679 if not (source_changed or target_changed):
680 680 log.debug("Nothing changed in pull request %s", pull_request)
681 681 return UpdateResponse(
682 682 executed=False,
683 683 reason=UpdateFailureReason.NO_CHANGE,
684 684 old=pull_request, new=None, changes=None,
685 685 source_changed=target_changed, target_changed=source_changed)
686 686
687 687 change_in_found = 'target repo' if target_changed else 'source repo'
688 688 log.debug('Updating pull request because of change in %s detected',
689 689 change_in_found)
690 690
691 691 # Finally there is a need for an update, in case of source change
692 692 # we create a new version, else just an update
693 693 if source_changed:
694 694 pull_request_version = self._create_version_from_snapshot(pull_request)
695 695 self._link_comments_to_version(pull_request_version)
696 696 else:
697 697 try:
698 698 ver = pull_request.versions[-1]
699 699 except IndexError:
700 700 ver = None
701 701
702 702 pull_request.pull_request_version_id = \
703 703 ver.pull_request_version_id if ver else None
704 704 pull_request_version = pull_request
705 705
706 706 try:
707 707 if target_ref_type in ('tag', 'branch', 'book'):
708 708 target_commit = target_repo.get_commit(target_ref_name)
709 709 else:
710 710 target_commit = target_repo.get_commit(target_ref_id)
711 711 except CommitDoesNotExistError:
712 712 return UpdateResponse(
713 713 executed=False,
714 714 reason=UpdateFailureReason.MISSING_TARGET_REF,
715 715 old=pull_request, new=None, changes=None,
716 716 source_changed=source_changed, target_changed=target_changed)
717 717
718 718 # re-compute commit ids
719 719 old_commit_ids = pull_request.revisions
720 720 pre_load = ["author", "branch", "date", "message"]
721 721 commit_ranges = target_repo.compare(
722 722 target_commit.raw_id, source_commit.raw_id, source_repo, merge=True,
723 723 pre_load=pre_load)
724 724
725 725 ancestor = target_repo.get_common_ancestor(
726 726 target_commit.raw_id, source_commit.raw_id, source_repo)
727 727
728 728 pull_request.source_ref = '%s:%s:%s' % (
729 729 source_ref_type, source_ref_name, source_commit.raw_id)
730 730 pull_request.target_ref = '%s:%s:%s' % (
731 731 target_ref_type, target_ref_name, ancestor)
732 732
733 733 pull_request.revisions = [
734 734 commit.raw_id for commit in reversed(commit_ranges)]
735 735 pull_request.updated_on = datetime.datetime.now()
736 736 Session().add(pull_request)
737 737 new_commit_ids = pull_request.revisions
738 738
739 739 old_diff_data, new_diff_data = self._generate_update_diffs(
740 740 pull_request, pull_request_version)
741 741
742 742 # calculate commit and file changes
743 743 changes = self._calculate_commit_id_changes(
744 744 old_commit_ids, new_commit_ids)
745 745 file_changes = self._calculate_file_changes(
746 746 old_diff_data, new_diff_data)
747 747
748 748 # set comments as outdated if DIFFS changed
749 749 CommentsModel().outdate_comments(
750 750 pull_request, old_diff_data=old_diff_data,
751 751 new_diff_data=new_diff_data)
752 752
753 753 commit_changes = (changes.added or changes.removed)
754 754 file_node_changes = (
755 755 file_changes.added or file_changes.modified or file_changes.removed)
756 756 pr_has_changes = commit_changes or file_node_changes
757 757
758 758 # Add an automatic comment to the pull request, in case
759 759 # anything has changed
760 760 if pr_has_changes:
761 761 update_comment = CommentsModel().create(
762 762 text=self._render_update_message(changes, file_changes),
763 763 repo=pull_request.target_repo,
764 764 user=pull_request.author,
765 765 pull_request=pull_request,
766 766 send_email=False, renderer=DEFAULT_COMMENTS_RENDERER)
767 767
768 768 # Update status to "Under Review" for added commits
769 769 for commit_id in changes.added:
770 770 ChangesetStatusModel().set_status(
771 771 repo=pull_request.source_repo,
772 772 status=ChangesetStatus.STATUS_UNDER_REVIEW,
773 773 comment=update_comment,
774 774 user=pull_request.author,
775 775 pull_request=pull_request,
776 776 revision=commit_id)
777 777
778 778 log.debug(
779 779 'Updated pull request %s, added_ids: %s, common_ids: %s, '
780 780 'removed_ids: %s', pull_request.pull_request_id,
781 781 changes.added, changes.common, changes.removed)
782 782 log.debug(
783 783 'Updated pull request with the following file changes: %s',
784 784 file_changes)
785 785
786 786 log.info(
787 787 "Updated pull request %s from commit %s to commit %s, "
788 788 "stored new version %s of this pull request.",
789 789 pull_request.pull_request_id, source_ref_id,
790 790 pull_request.source_ref_parts.commit_id,
791 791 pull_request_version.pull_request_version_id)
792 792 Session().commit()
793 793 self._trigger_pull_request_hook(
794 794 pull_request, pull_request.author, 'update')
795 795
796 796 return UpdateResponse(
797 797 executed=True, reason=UpdateFailureReason.NONE,
798 798 old=pull_request, new=pull_request_version, changes=changes,
799 799 source_changed=source_changed, target_changed=target_changed)
800 800
801 801 def _create_version_from_snapshot(self, pull_request):
802 802 version = PullRequestVersion()
803 803 version.title = pull_request.title
804 804 version.description = pull_request.description
805 805 version.status = pull_request.status
806 806 version.created_on = datetime.datetime.now()
807 807 version.updated_on = pull_request.updated_on
808 808 version.user_id = pull_request.user_id
809 809 version.source_repo = pull_request.source_repo
810 810 version.source_ref = pull_request.source_ref
811 811 version.target_repo = pull_request.target_repo
812 812 version.target_ref = pull_request.target_ref
813 813
814 814 version._last_merge_source_rev = pull_request._last_merge_source_rev
815 815 version._last_merge_target_rev = pull_request._last_merge_target_rev
816 816 version.last_merge_status = pull_request.last_merge_status
817 817 version.shadow_merge_ref = pull_request.shadow_merge_ref
818 818 version.merge_rev = pull_request.merge_rev
819 819 version.reviewer_data = pull_request.reviewer_data
820 820
821 821 version.revisions = pull_request.revisions
822 822 version.pull_request = pull_request
823 823 Session().add(version)
824 824 Session().flush()
825 825
826 826 return version
827 827
828 828 def _generate_update_diffs(self, pull_request, pull_request_version):
829 829
830 830 diff_context = (
831 831 self.DIFF_CONTEXT +
832 832 CommentsModel.needed_extra_diff_context())
833 833
834 834 source_repo = pull_request_version.source_repo
835 835 source_ref_id = pull_request_version.source_ref_parts.commit_id
836 836 target_ref_id = pull_request_version.target_ref_parts.commit_id
837 837 old_diff = self._get_diff_from_pr_or_version(
838 838 source_repo, source_ref_id, target_ref_id, context=diff_context)
839 839
840 840 source_repo = pull_request.source_repo
841 841 source_ref_id = pull_request.source_ref_parts.commit_id
842 842 target_ref_id = pull_request.target_ref_parts.commit_id
843 843
844 844 new_diff = self._get_diff_from_pr_or_version(
845 845 source_repo, source_ref_id, target_ref_id, context=diff_context)
846 846
847 847 old_diff_data = diffs.DiffProcessor(old_diff)
848 848 old_diff_data.prepare()
849 849 new_diff_data = diffs.DiffProcessor(new_diff)
850 850 new_diff_data.prepare()
851 851
852 852 return old_diff_data, new_diff_data
853 853
854 854 def _link_comments_to_version(self, pull_request_version):
855 855 """
856 856 Link all unlinked comments of this pull request to the given version.
857 857
858 858 :param pull_request_version: The `PullRequestVersion` to which
859 859 the comments shall be linked.
860 860
861 861 """
862 862 pull_request = pull_request_version.pull_request
863 863 comments = ChangesetComment.query()\
864 864 .filter(
865 865 # TODO: johbo: Should we query for the repo at all here?
866 866 # Pending decision on how comments of PRs are to be related
867 867 # to either the source repo, the target repo or no repo at all.
868 868 ChangesetComment.repo_id == pull_request.target_repo.repo_id,
869 869 ChangesetComment.pull_request == pull_request,
870 870 ChangesetComment.pull_request_version == None)\
871 871 .order_by(ChangesetComment.comment_id.asc())
872 872
873 873 # TODO: johbo: Find out why this breaks if it is done in a bulk
874 874 # operation.
875 875 for comment in comments:
876 876 comment.pull_request_version_id = (
877 877 pull_request_version.pull_request_version_id)
878 878 Session().add(comment)
879 879
880 880 def _calculate_commit_id_changes(self, old_ids, new_ids):
881 881 added = [x for x in new_ids if x not in old_ids]
882 882 common = [x for x in new_ids if x in old_ids]
883 883 removed = [x for x in old_ids if x not in new_ids]
884 884 total = new_ids
885 885 return ChangeTuple(added, common, removed, total)
886 886
887 887 def _calculate_file_changes(self, old_diff_data, new_diff_data):
888 888
889 889 old_files = OrderedDict()
890 890 for diff_data in old_diff_data.parsed_diff:
891 891 old_files[diff_data['filename']] = md5_safe(diff_data['raw_diff'])
892 892
893 893 added_files = []
894 894 modified_files = []
895 895 removed_files = []
896 896 for diff_data in new_diff_data.parsed_diff:
897 897 new_filename = diff_data['filename']
898 898 new_hash = md5_safe(diff_data['raw_diff'])
899 899
900 900 old_hash = old_files.get(new_filename)
901 901 if not old_hash:
902 902 # file is not present in old diff, means it's added
903 903 added_files.append(new_filename)
904 904 else:
905 905 if new_hash != old_hash:
906 906 modified_files.append(new_filename)
907 907 # now remove a file from old, since we have seen it already
908 908 del old_files[new_filename]
909 909
910 910 # removed files is when there are present in old, but not in NEW,
911 911 # since we remove old files that are present in new diff, left-overs
912 912 # if any should be the removed files
913 913 removed_files.extend(old_files.keys())
914 914
915 915 return FileChangeTuple(added_files, modified_files, removed_files)
916 916
917 917 def _render_update_message(self, changes, file_changes):
918 918 """
919 919 render the message using DEFAULT_COMMENTS_RENDERER (RST renderer),
920 920 so it's always looking the same disregarding on which default
921 921 renderer system is using.
922 922
923 923 :param changes: changes named tuple
924 924 :param file_changes: file changes named tuple
925 925
926 926 """
927 927 new_status = ChangesetStatus.get_status_lbl(
928 928 ChangesetStatus.STATUS_UNDER_REVIEW)
929 929
930 930 changed_files = (
931 931 file_changes.added + file_changes.modified + file_changes.removed)
932 932
933 933 params = {
934 934 'under_review_label': new_status,
935 935 'added_commits': changes.added,
936 936 'removed_commits': changes.removed,
937 937 'changed_files': changed_files,
938 938 'added_files': file_changes.added,
939 939 'modified_files': file_changes.modified,
940 940 'removed_files': file_changes.removed,
941 941 }
942 942 renderer = RstTemplateRenderer()
943 943 return renderer.render('pull_request_update.mako', **params)
944 944
945 945 def edit(self, pull_request, title, description, user):
946 946 pull_request = self.__get_pull_request(pull_request)
947 947 old_data = pull_request.get_api_data(with_merge_state=False)
948 948 if pull_request.is_closed():
949 949 raise ValueError('This pull request is closed')
950 950 if title:
951 951 pull_request.title = title
952 952 pull_request.description = description
953 953 pull_request.updated_on = datetime.datetime.now()
954 954 Session().add(pull_request)
955 955 self._log_audit_action(
956 956 'repo.pull_request.edit', {'old_data': old_data},
957 957 user, pull_request)
958 958
959 959 def update_reviewers(self, pull_request, reviewer_data, user):
960 960 """
961 961 Update the reviewers in the pull request
962 962
963 963 :param pull_request: the pr to update
964 964 :param reviewer_data: list of tuples
965 965 [(user, ['reason1', 'reason2'], mandatory_flag)]
966 966 """
967 967 pull_request = self.__get_pull_request(pull_request)
968 968 if pull_request.is_closed():
969 969 raise ValueError('This pull request is closed')
970 970
971 971 reviewers = {}
972 972 for user_id, reasons, mandatory in reviewer_data:
973 973 if isinstance(user_id, (int, basestring)):
974 974 user_id = self._get_user(user_id).user_id
975 975 reviewers[user_id] = {
976 976 'reasons': reasons, 'mandatory': mandatory}
977 977
978 978 reviewers_ids = set(reviewers.keys())
979 979 current_reviewers = PullRequestReviewers.query()\
980 980 .filter(PullRequestReviewers.pull_request ==
981 981 pull_request).all()
982 982 current_reviewers_ids = set([x.user.user_id for x in current_reviewers])
983 983
984 984 ids_to_add = reviewers_ids.difference(current_reviewers_ids)
985 985 ids_to_remove = current_reviewers_ids.difference(reviewers_ids)
986 986
987 987 log.debug("Adding %s reviewers", ids_to_add)
988 988 log.debug("Removing %s reviewers", ids_to_remove)
989 989 changed = False
990 990 for uid in ids_to_add:
991 991 changed = True
992 992 _usr = self._get_user(uid)
993 993 reviewer = PullRequestReviewers()
994 994 reviewer.user = _usr
995 995 reviewer.pull_request = pull_request
996 996 reviewer.reasons = reviewers[uid]['reasons']
997 997 # NOTE(marcink): mandatory shouldn't be changed now
998 998 # reviewer.mandatory = reviewers[uid]['reasons']
999 999 Session().add(reviewer)
1000 1000 self._log_audit_action(
1001 1001 'repo.pull_request.reviewer.add', {'data': reviewer.get_dict()},
1002 1002 user, pull_request)
1003 1003
1004 1004 for uid in ids_to_remove:
1005 1005 changed = True
1006 1006 reviewers = PullRequestReviewers.query()\
1007 1007 .filter(PullRequestReviewers.user_id == uid,
1008 1008 PullRequestReviewers.pull_request == pull_request)\
1009 1009 .all()
1010 1010 # use .all() in case we accidentally added the same person twice
1011 1011 # this CAN happen due to the lack of DB checks
1012 1012 for obj in reviewers:
1013 1013 old_data = obj.get_dict()
1014 1014 Session().delete(obj)
1015 1015 self._log_audit_action(
1016 1016 'repo.pull_request.reviewer.delete',
1017 1017 {'old_data': old_data}, user, pull_request)
1018 1018
1019 1019 if changed:
1020 1020 pull_request.updated_on = datetime.datetime.now()
1021 1021 Session().add(pull_request)
1022 1022
1023 1023 self.notify_reviewers(pull_request, ids_to_add)
1024 1024 return ids_to_add, ids_to_remove
1025 1025
1026 1026 def get_url(self, pull_request, request=None, permalink=False):
1027 1027 if not request:
1028 1028 request = get_current_request()
1029 1029
1030 1030 if permalink:
1031 1031 return request.route_url(
1032 1032 'pull_requests_global',
1033 1033 pull_request_id=pull_request.pull_request_id,)
1034 1034 else:
1035 1035 return request.route_url('pullrequest_show',
1036 1036 repo_name=safe_str(pull_request.target_repo.repo_name),
1037 1037 pull_request_id=pull_request.pull_request_id,)
1038 1038
1039 1039 def get_shadow_clone_url(self, pull_request):
1040 1040 """
1041 1041 Returns qualified url pointing to the shadow repository. If this pull
1042 1042 request is closed there is no shadow repository and ``None`` will be
1043 1043 returned.
1044 1044 """
1045 1045 if pull_request.is_closed():
1046 1046 return None
1047 1047 else:
1048 1048 pr_url = urllib.unquote(self.get_url(pull_request))
1049 1049 return safe_unicode('{pr_url}/repository'.format(pr_url=pr_url))
1050 1050
1051 1051 def notify_reviewers(self, pull_request, reviewers_ids):
1052 1052 # notification to reviewers
1053 1053 if not reviewers_ids:
1054 1054 return
1055 1055
1056 1056 pull_request_obj = pull_request
1057 1057 # get the current participants of this pull request
1058 1058 recipients = reviewers_ids
1059 1059 notification_type = EmailNotificationModel.TYPE_PULL_REQUEST
1060 1060
1061 1061 pr_source_repo = pull_request_obj.source_repo
1062 1062 pr_target_repo = pull_request_obj.target_repo
1063 1063
1064 1064 pr_url = h.route_url('pullrequest_show',
1065 1065 repo_name=pr_target_repo.repo_name,
1066 1066 pull_request_id=pull_request_obj.pull_request_id,)
1067 1067
1068 1068 # set some variables for email notification
1069 1069 pr_target_repo_url = h.route_url(
1070 1070 'repo_summary', repo_name=pr_target_repo.repo_name)
1071 1071
1072 1072 pr_source_repo_url = h.route_url(
1073 1073 'repo_summary', repo_name=pr_source_repo.repo_name)
1074 1074
1075 1075 # pull request specifics
1076 1076 pull_request_commits = [
1077 1077 (x.raw_id, x.message)
1078 1078 for x in map(pr_source_repo.get_commit, pull_request.revisions)]
1079 1079
1080 1080 kwargs = {
1081 1081 'user': pull_request.author,
1082 1082 'pull_request': pull_request_obj,
1083 1083 'pull_request_commits': pull_request_commits,
1084 1084
1085 1085 'pull_request_target_repo': pr_target_repo,
1086 1086 'pull_request_target_repo_url': pr_target_repo_url,
1087 1087
1088 1088 'pull_request_source_repo': pr_source_repo,
1089 1089 'pull_request_source_repo_url': pr_source_repo_url,
1090 1090
1091 1091 'pull_request_url': pr_url,
1092 1092 }
1093 1093
1094 1094 # pre-generate the subject for notification itself
1095 1095 (subject,
1096 1096 _h, _e, # we don't care about those
1097 1097 body_plaintext) = EmailNotificationModel().render_email(
1098 1098 notification_type, **kwargs)
1099 1099
1100 1100 # create notification objects, and emails
1101 1101 NotificationModel().create(
1102 1102 created_by=pull_request.author,
1103 1103 notification_subject=subject,
1104 1104 notification_body=body_plaintext,
1105 1105 notification_type=notification_type,
1106 1106 recipients=recipients,
1107 1107 email_kwargs=kwargs,
1108 1108 )
1109 1109
1110 1110 def delete(self, pull_request, user):
1111 1111 pull_request = self.__get_pull_request(pull_request)
1112 1112 old_data = pull_request.get_api_data(with_merge_state=False)
1113 1113 self._cleanup_merge_workspace(pull_request)
1114 1114 self._log_audit_action(
1115 1115 'repo.pull_request.delete', {'old_data': old_data},
1116 1116 user, pull_request)
1117 1117 Session().delete(pull_request)
1118 1118
1119 1119 def close_pull_request(self, pull_request, user):
1120 1120 pull_request = self.__get_pull_request(pull_request)
1121 1121 self._cleanup_merge_workspace(pull_request)
1122 1122 pull_request.status = PullRequest.STATUS_CLOSED
1123 1123 pull_request.updated_on = datetime.datetime.now()
1124 1124 Session().add(pull_request)
1125 1125 self._trigger_pull_request_hook(
1126 1126 pull_request, pull_request.author, 'close')
1127 1127
1128 1128 pr_data = pull_request.get_api_data(with_merge_state=False)
1129 1129 self._log_audit_action(
1130 1130 'repo.pull_request.close', {'data': pr_data}, user, pull_request)
1131 1131
1132 1132 def close_pull_request_with_comment(
1133 1133 self, pull_request, user, repo, message=None):
1134 1134
1135 1135 pull_request_review_status = pull_request.calculated_review_status()
1136 1136
1137 1137 if pull_request_review_status == ChangesetStatus.STATUS_APPROVED:
1138 1138 # approved only if we have voting consent
1139 1139 status = ChangesetStatus.STATUS_APPROVED
1140 1140 else:
1141 1141 status = ChangesetStatus.STATUS_REJECTED
1142 1142 status_lbl = ChangesetStatus.get_status_lbl(status)
1143 1143
1144 1144 default_message = (
1145 1145 'Closing with status change {transition_icon} {status}.'
1146 1146 ).format(transition_icon='>', status=status_lbl)
1147 1147 text = message or default_message
1148 1148
1149 1149 # create a comment, and link it to new status
1150 1150 comment = CommentsModel().create(
1151 1151 text=text,
1152 1152 repo=repo.repo_id,
1153 1153 user=user.user_id,
1154 1154 pull_request=pull_request.pull_request_id,
1155 1155 status_change=status_lbl,
1156 1156 status_change_type=status,
1157 1157 closing_pr=True
1158 1158 )
1159 1159
1160 1160 # calculate old status before we change it
1161 1161 old_calculated_status = pull_request.calculated_review_status()
1162 1162 ChangesetStatusModel().set_status(
1163 1163 repo.repo_id,
1164 1164 status,
1165 1165 user.user_id,
1166 1166 comment=comment,
1167 1167 pull_request=pull_request.pull_request_id
1168 1168 )
1169 1169
1170 1170 Session().flush()
1171 1171 events.trigger(events.PullRequestCommentEvent(pull_request, comment))
1172 1172 # we now calculate the status of pull request again, and based on that
1173 1173 # calculation trigger status change. This might happen in cases
1174 1174 # that non-reviewer admin closes a pr, which means his vote doesn't
1175 1175 # change the status, while if he's a reviewer this might change it.
1176 1176 calculated_status = pull_request.calculated_review_status()
1177 1177 if old_calculated_status != calculated_status:
1178 1178 self._trigger_pull_request_hook(
1179 1179 pull_request, user, 'review_status_change')
1180 1180
1181 1181 # finally close the PR
1182 1182 PullRequestModel().close_pull_request(
1183 1183 pull_request.pull_request_id, user)
1184 1184
1185 1185 return comment, status
1186 1186
1187 1187 def merge_status(self, pull_request, translator=None):
1188 1188 _ = translator or get_current_request().translate
1189 1189
1190 1190 if not self._is_merge_enabled(pull_request):
1191 1191 return False, _('Server-side pull request merging is disabled.')
1192 1192 if pull_request.is_closed():
1193 1193 return False, _('This pull request is closed.')
1194 1194 merge_possible, msg = self._check_repo_requirements(
1195 1195 target=pull_request.target_repo, source=pull_request.source_repo,
1196 1196 translator=_)
1197 1197 if not merge_possible:
1198 1198 return merge_possible, msg
1199 1199
1200 1200 try:
1201 1201 resp = self._try_merge(pull_request)
1202 1202 log.debug("Merge response: %s", resp)
1203 1203 status = resp.possible, self.merge_status_message(
1204 1204 resp.failure_reason)
1205 1205 except NotImplementedError:
1206 1206 status = False, _('Pull request merging is not supported.')
1207 1207
1208 1208 return status
1209 1209
1210 1210 def _check_repo_requirements(self, target, source, translator):
1211 1211 """
1212 1212 Check if `target` and `source` have compatible requirements.
1213 1213
1214 1214 Currently this is just checking for largefiles.
1215 1215 """
1216 1216 _ = translator
1217 1217 target_has_largefiles = self._has_largefiles(target)
1218 1218 source_has_largefiles = self._has_largefiles(source)
1219 1219 merge_possible = True
1220 1220 message = u''
1221 1221
1222 1222 if target_has_largefiles != source_has_largefiles:
1223 1223 merge_possible = False
1224 1224 if source_has_largefiles:
1225 1225 message = _(
1226 1226 'Target repository large files support is disabled.')
1227 1227 else:
1228 1228 message = _(
1229 1229 'Source repository large files support is disabled.')
1230 1230
1231 1231 return merge_possible, message
1232 1232
1233 1233 def _has_largefiles(self, repo):
1234 1234 largefiles_ui = VcsSettingsModel(repo=repo).get_ui_settings(
1235 1235 'extensions', 'largefiles')
1236 1236 return largefiles_ui and largefiles_ui[0].active
1237 1237
1238 1238 def _try_merge(self, pull_request):
1239 1239 """
1240 1240 Try to merge the pull request and return the merge status.
1241 1241 """
1242 1242 log.debug(
1243 1243 "Trying out if the pull request %s can be merged.",
1244 1244 pull_request.pull_request_id)
1245 1245 target_vcs = pull_request.target_repo.scm_instance()
1246 1246
1247 1247 # Refresh the target reference.
1248 1248 try:
1249 1249 target_ref = self._refresh_reference(
1250 1250 pull_request.target_ref_parts, target_vcs)
1251 1251 except CommitDoesNotExistError:
1252 1252 merge_state = MergeResponse(
1253 1253 False, False, None, MergeFailureReason.MISSING_TARGET_REF)
1254 1254 return merge_state
1255 1255
1256 1256 target_locked = pull_request.target_repo.locked
1257 1257 if target_locked and target_locked[0]:
1258 1258 log.debug("The target repository is locked.")
1259 1259 merge_state = MergeResponse(
1260 1260 False, False, None, MergeFailureReason.TARGET_IS_LOCKED)
1261 1261 elif self._needs_merge_state_refresh(pull_request, target_ref):
1262 1262 log.debug("Refreshing the merge status of the repository.")
1263 1263 merge_state = self._refresh_merge_state(
1264 1264 pull_request, target_vcs, target_ref)
1265 1265 else:
1266 1266 possible = pull_request.\
1267 1267 last_merge_status == MergeFailureReason.NONE
1268 1268 merge_state = MergeResponse(
1269 1269 possible, False, None, pull_request.last_merge_status)
1270 1270
1271 1271 return merge_state
1272 1272
1273 1273 def _refresh_reference(self, reference, vcs_repository):
1274 1274 if reference.type in ('branch', 'book'):
1275 1275 name_or_id = reference.name
1276 1276 else:
1277 1277 name_or_id = reference.commit_id
1278 1278 refreshed_commit = vcs_repository.get_commit(name_or_id)
1279 1279 refreshed_reference = Reference(
1280 1280 reference.type, reference.name, refreshed_commit.raw_id)
1281 1281 return refreshed_reference
1282 1282
1283 1283 def _needs_merge_state_refresh(self, pull_request, target_reference):
1284 1284 return not(
1285 1285 pull_request.revisions and
1286 1286 pull_request.revisions[0] == pull_request._last_merge_source_rev and
1287 1287 target_reference.commit_id == pull_request._last_merge_target_rev)
1288 1288
1289 1289 def _refresh_merge_state(self, pull_request, target_vcs, target_reference):
1290 1290 workspace_id = self._workspace_id(pull_request)
1291 1291 source_vcs = pull_request.source_repo.scm_instance()
1292 1292 use_rebase = self._use_rebase_for_merging(pull_request)
1293 1293 close_branch = self._close_branch_before_merging(pull_request)
1294 1294 merge_state = target_vcs.merge(
1295 1295 target_reference, source_vcs, pull_request.source_ref_parts,
1296 1296 workspace_id, dry_run=True, use_rebase=use_rebase,
1297 1297 close_branch=close_branch)
1298 1298
1299 1299 # Do not store the response if there was an unknown error.
1300 1300 if merge_state.failure_reason != MergeFailureReason.UNKNOWN:
1301 1301 pull_request._last_merge_source_rev = \
1302 1302 pull_request.source_ref_parts.commit_id
1303 1303 pull_request._last_merge_target_rev = target_reference.commit_id
1304 1304 pull_request.last_merge_status = merge_state.failure_reason
1305 1305 pull_request.shadow_merge_ref = merge_state.merge_ref
1306 1306 Session().add(pull_request)
1307 1307 Session().commit()
1308 1308
1309 1309 return merge_state
1310 1310
1311 1311 def _workspace_id(self, pull_request):
1312 1312 workspace_id = 'pr-%s' % pull_request.pull_request_id
1313 1313 return workspace_id
1314 1314
1315 1315 def merge_status_message(self, status_code):
1316 1316 """
1317 1317 Return a human friendly error message for the given merge status code.
1318 1318 """
1319 1319 return self.MERGE_STATUS_MESSAGES[status_code]
1320 1320
1321 1321 def generate_repo_data(self, repo, commit_id=None, branch=None,
1322 1322 bookmark=None, translator=None):
1323 1323 from rhodecode.model.repo import RepoModel
1324 1324
1325 1325 all_refs, selected_ref = \
1326 1326 self._get_repo_pullrequest_sources(
1327 1327 repo.scm_instance(), commit_id=commit_id,
1328 1328 branch=branch, bookmark=bookmark, translator=translator)
1329 1329
1330 1330 refs_select2 = []
1331 1331 for element in all_refs:
1332 1332 children = [{'id': x[0], 'text': x[1]} for x in element[0]]
1333 1333 refs_select2.append({'text': element[1], 'children': children})
1334 1334
1335 1335 return {
1336 1336 'user': {
1337 1337 'user_id': repo.user.user_id,
1338 1338 'username': repo.user.username,
1339 1339 'firstname': repo.user.first_name,
1340 1340 'lastname': repo.user.last_name,
1341 1341 'gravatar_link': h.gravatar_url(repo.user.email, 14),
1342 1342 },
1343 1343 'name': repo.repo_name,
1344 1344 'link': RepoModel().get_url(repo),
1345 1345 'description': h.chop_at_smart(repo.description_safe, '\n'),
1346 1346 'refs': {
1347 1347 'all_refs': all_refs,
1348 1348 'selected_ref': selected_ref,
1349 1349 'select2_refs': refs_select2
1350 1350 }
1351 1351 }
1352 1352
1353 1353 def generate_pullrequest_title(self, source, source_ref, target):
1354 1354 return u'{source}#{at_ref} to {target}'.format(
1355 1355 source=source,
1356 1356 at_ref=source_ref,
1357 1357 target=target,
1358 1358 )
1359 1359
1360 1360 def _cleanup_merge_workspace(self, pull_request):
1361 1361 # Merging related cleanup
1362 1362 target_scm = pull_request.target_repo.scm_instance()
1363 1363 workspace_id = 'pr-%s' % pull_request.pull_request_id
1364 1364
1365 1365 try:
1366 1366 target_scm.cleanup_merge_workspace(workspace_id)
1367 1367 except NotImplementedError:
1368 1368 pass
1369 1369
1370 1370 def _get_repo_pullrequest_sources(
1371 1371 self, repo, commit_id=None, branch=None, bookmark=None,
1372 1372 translator=None):
1373 1373 """
1374 1374 Return a structure with repo's interesting commits, suitable for
1375 1375 the selectors in pullrequest controller
1376 1376
1377 1377 :param commit_id: a commit that must be in the list somehow
1378 1378 and selected by default
1379 1379 :param branch: a branch that must be in the list and selected
1380 1380 by default - even if closed
1381 1381 :param bookmark: a bookmark that must be in the list and selected
1382 1382 """
1383 1383 _ = translator or get_current_request().translate
1384 1384
1385 1385 commit_id = safe_str(commit_id) if commit_id else None
1386 1386 branch = safe_str(branch) if branch else None
1387 1387 bookmark = safe_str(bookmark) if bookmark else None
1388 1388
1389 1389 selected = None
1390 1390
1391 1391 # order matters: first source that has commit_id in it will be selected
1392 1392 sources = []
1393 1393 sources.append(('book', repo.bookmarks.items(), _('Bookmarks'), bookmark))
1394 1394 sources.append(('branch', repo.branches.items(), _('Branches'), branch))
1395 1395
1396 1396 if commit_id:
1397 1397 ref_commit = (h.short_id(commit_id), commit_id)
1398 1398 sources.append(('rev', [ref_commit], _('Commit IDs'), commit_id))
1399 1399
1400 1400 sources.append(
1401 1401 ('branch', repo.branches_closed.items(), _('Closed Branches'), branch),
1402 1402 )
1403 1403
1404 1404 groups = []
1405 1405 for group_key, ref_list, group_name, match in sources:
1406 1406 group_refs = []
1407 1407 for ref_name, ref_id in ref_list:
1408 1408 ref_key = '%s:%s:%s' % (group_key, ref_name, ref_id)
1409 1409 group_refs.append((ref_key, ref_name))
1410 1410
1411 1411 if not selected:
1412 1412 if set([commit_id, match]) & set([ref_id, ref_name]):
1413 1413 selected = ref_key
1414 1414
1415 1415 if group_refs:
1416 1416 groups.append((group_refs, group_name))
1417 1417
1418 1418 if not selected:
1419 1419 ref = commit_id or branch or bookmark
1420 1420 if ref:
1421 1421 raise CommitDoesNotExistError(
1422 1422 'No commit refs could be found matching: %s' % ref)
1423 1423 elif repo.DEFAULT_BRANCH_NAME in repo.branches:
1424 1424 selected = 'branch:%s:%s' % (
1425 1425 repo.DEFAULT_BRANCH_NAME,
1426 1426 repo.branches[repo.DEFAULT_BRANCH_NAME]
1427 1427 )
1428 1428 elif repo.commit_ids:
1429 rev = repo.commit_ids[0]
1430 selected = 'rev:%s:%s' % (rev, rev)
1429 # make the user select in this case
1430 selected = None
1431 1431 else:
1432 1432 raise EmptyRepositoryError()
1433 1433 return groups, selected
1434 1434
1435 1435 def get_diff(self, source_repo, source_ref_id, target_ref_id, context=DIFF_CONTEXT):
1436 1436 return self._get_diff_from_pr_or_version(
1437 1437 source_repo, source_ref_id, target_ref_id, context=context)
1438 1438
1439 1439 def _get_diff_from_pr_or_version(
1440 1440 self, source_repo, source_ref_id, target_ref_id, context):
1441 1441 target_commit = source_repo.get_commit(
1442 1442 commit_id=safe_str(target_ref_id))
1443 1443 source_commit = source_repo.get_commit(
1444 1444 commit_id=safe_str(source_ref_id))
1445 1445 if isinstance(source_repo, Repository):
1446 1446 vcs_repo = source_repo.scm_instance()
1447 1447 else:
1448 1448 vcs_repo = source_repo
1449 1449
1450 1450 # TODO: johbo: In the context of an update, we cannot reach
1451 1451 # the old commit anymore with our normal mechanisms. It needs
1452 1452 # some sort of special support in the vcs layer to avoid this
1453 1453 # workaround.
1454 1454 if (source_commit.raw_id == vcs_repo.EMPTY_COMMIT_ID and
1455 1455 vcs_repo.alias == 'git'):
1456 1456 source_commit.raw_id = safe_str(source_ref_id)
1457 1457
1458 1458 log.debug('calculating diff between '
1459 1459 'source_ref:%s and target_ref:%s for repo `%s`',
1460 1460 target_ref_id, source_ref_id,
1461 1461 safe_unicode(vcs_repo.path))
1462 1462
1463 1463 vcs_diff = vcs_repo.get_diff(
1464 1464 commit1=target_commit, commit2=source_commit, context=context)
1465 1465 return vcs_diff
1466 1466
1467 1467 def _is_merge_enabled(self, pull_request):
1468 1468 return self._get_general_setting(
1469 1469 pull_request, 'rhodecode_pr_merge_enabled')
1470 1470
1471 1471 def _use_rebase_for_merging(self, pull_request):
1472 1472 repo_type = pull_request.target_repo.repo_type
1473 1473 if repo_type == 'hg':
1474 1474 return self._get_general_setting(
1475 1475 pull_request, 'rhodecode_hg_use_rebase_for_merging')
1476 1476 elif repo_type == 'git':
1477 1477 return self._get_general_setting(
1478 1478 pull_request, 'rhodecode_git_use_rebase_for_merging')
1479 1479
1480 1480 return False
1481 1481
1482 1482 def _close_branch_before_merging(self, pull_request):
1483 1483 repo_type = pull_request.target_repo.repo_type
1484 1484 if repo_type == 'hg':
1485 1485 return self._get_general_setting(
1486 1486 pull_request, 'rhodecode_hg_close_branch_before_merging')
1487 1487 elif repo_type == 'git':
1488 1488 return self._get_general_setting(
1489 1489 pull_request, 'rhodecode_git_close_branch_before_merging')
1490 1490
1491 1491 return False
1492 1492
1493 1493 def _get_general_setting(self, pull_request, settings_key, default=False):
1494 1494 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
1495 1495 settings = settings_model.get_general_settings()
1496 1496 return settings.get(settings_key, default)
1497 1497
1498 1498 def _log_audit_action(self, action, action_data, user, pull_request):
1499 1499 audit_logger.store(
1500 1500 action=action,
1501 1501 action_data=action_data,
1502 1502 user=user,
1503 1503 repo=pull_request.target_repo)
1504 1504
1505 1505 def get_reviewer_functions(self):
1506 1506 """
1507 1507 Fetches functions for validation and fetching default reviewers.
1508 1508 If available we use the EE package, else we fallback to CE
1509 1509 package functions
1510 1510 """
1511 1511 try:
1512 1512 from rc_reviewers.utils import get_default_reviewers_data
1513 1513 from rc_reviewers.utils import validate_default_reviewers
1514 1514 except ImportError:
1515 1515 from rhodecode.apps.repository.utils import \
1516 1516 get_default_reviewers_data
1517 1517 from rhodecode.apps.repository.utils import \
1518 1518 validate_default_reviewers
1519 1519
1520 1520 return get_default_reviewers_data, validate_default_reviewers
1521 1521
1522 1522
1523 1523 class MergeCheck(object):
1524 1524 """
1525 1525 Perform Merge Checks and returns a check object which stores information
1526 1526 about merge errors, and merge conditions
1527 1527 """
1528 1528 TODO_CHECK = 'todo'
1529 1529 PERM_CHECK = 'perm'
1530 1530 REVIEW_CHECK = 'review'
1531 1531 MERGE_CHECK = 'merge'
1532 1532
1533 1533 def __init__(self):
1534 1534 self.review_status = None
1535 1535 self.merge_possible = None
1536 1536 self.merge_msg = ''
1537 1537 self.failed = None
1538 1538 self.errors = []
1539 1539 self.error_details = OrderedDict()
1540 1540
1541 1541 def push_error(self, error_type, message, error_key, details):
1542 1542 self.failed = True
1543 1543 self.errors.append([error_type, message])
1544 1544 self.error_details[error_key] = dict(
1545 1545 details=details,
1546 1546 error_type=error_type,
1547 1547 message=message
1548 1548 )
1549 1549
1550 1550 @classmethod
1551 1551 def validate(cls, pull_request, user, translator, fail_early=False):
1552 1552 _ = translator
1553 1553 merge_check = cls()
1554 1554
1555 1555 # permissions to merge
1556 1556 user_allowed_to_merge = PullRequestModel().check_user_merge(
1557 1557 pull_request, user)
1558 1558 if not user_allowed_to_merge:
1559 1559 log.debug("MergeCheck: cannot merge, approval is pending.")
1560 1560
1561 1561 msg = _('User `{}` not allowed to perform merge.').format(user.username)
1562 1562 merge_check.push_error('error', msg, cls.PERM_CHECK, user.username)
1563 1563 if fail_early:
1564 1564 return merge_check
1565 1565
1566 1566 # review status, must be always present
1567 1567 review_status = pull_request.calculated_review_status()
1568 1568 merge_check.review_status = review_status
1569 1569
1570 1570 status_approved = review_status == ChangesetStatus.STATUS_APPROVED
1571 1571 if not status_approved:
1572 1572 log.debug("MergeCheck: cannot merge, approval is pending.")
1573 1573
1574 1574 msg = _('Pull request reviewer approval is pending.')
1575 1575
1576 1576 merge_check.push_error(
1577 1577 'warning', msg, cls.REVIEW_CHECK, review_status)
1578 1578
1579 1579 if fail_early:
1580 1580 return merge_check
1581 1581
1582 1582 # left over TODOs
1583 1583 todos = CommentsModel().get_unresolved_todos(pull_request)
1584 1584 if todos:
1585 1585 log.debug("MergeCheck: cannot merge, {} "
1586 1586 "unresolved todos left.".format(len(todos)))
1587 1587
1588 1588 if len(todos) == 1:
1589 1589 msg = _('Cannot merge, {} TODO still not resolved.').format(
1590 1590 len(todos))
1591 1591 else:
1592 1592 msg = _('Cannot merge, {} TODOs still not resolved.').format(
1593 1593 len(todos))
1594 1594
1595 1595 merge_check.push_error('warning', msg, cls.TODO_CHECK, todos)
1596 1596
1597 1597 if fail_early:
1598 1598 return merge_check
1599 1599
1600 1600 # merge possible
1601 1601 merge_status, msg = PullRequestModel().merge_status(
1602 1602 pull_request, translator=translator)
1603 1603 merge_check.merge_possible = merge_status
1604 1604 merge_check.merge_msg = msg
1605 1605 if not merge_status:
1606 1606 log.debug(
1607 1607 "MergeCheck: cannot merge, pull request merge not possible.")
1608 1608 merge_check.push_error('warning', msg, cls.MERGE_CHECK, None)
1609 1609
1610 1610 if fail_early:
1611 1611 return merge_check
1612 1612
1613 1613 log.debug('MergeCheck: is failed: %s', merge_check.failed)
1614 1614 return merge_check
1615 1615
1616 1616 @classmethod
1617 1617 def get_merge_conditions(cls, pull_request, translator):
1618 1618 _ = translator
1619 1619 merge_details = {}
1620 1620
1621 1621 model = PullRequestModel()
1622 1622 use_rebase = model._use_rebase_for_merging(pull_request)
1623 1623
1624 1624 if use_rebase:
1625 1625 merge_details['merge_strategy'] = dict(
1626 1626 details={},
1627 1627 message=_('Merge strategy: rebase')
1628 1628 )
1629 1629 else:
1630 1630 merge_details['merge_strategy'] = dict(
1631 1631 details={},
1632 1632 message=_('Merge strategy: explicit merge commit')
1633 1633 )
1634 1634
1635 1635 close_branch = model._close_branch_before_merging(pull_request)
1636 1636 if close_branch:
1637 1637 repo_type = pull_request.target_repo.repo_type
1638 1638 if repo_type == 'hg':
1639 1639 close_msg = _('Source branch will be closed after merge.')
1640 1640 elif repo_type == 'git':
1641 1641 close_msg = _('Source branch will be deleted after merge.')
1642 1642
1643 1643 merge_details['close_branch'] = dict(
1644 1644 details={},
1645 1645 message=close_msg
1646 1646 )
1647 1647
1648 1648 return merge_details
1649 1649
1650 1650 ChangeTuple = collections.namedtuple(
1651 1651 'ChangeTuple', ['added', 'common', 'removed', 'total'])
1652 1652
1653 1653 FileChangeTuple = collections.namedtuple(
1654 1654 'FileChangeTuple', ['added', 'modified', 'removed'])
General Comments 0
You need to be logged in to leave comments. Login now