##// END OF EJS Templates
pull-requests: fix problem with long DB transaction and row-locking....
marcink -
r2785:d0af9af8 default
parent child Browse files
Show More
@@ -1,1687 +1,1694 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2012-2018 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, RepoReviewRule)
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, rules = 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
486 486 # NOTE(marcink): pick only first rule for now
487 487 rule_id = rules[0] if rules else None
488 488 rule = RepoReviewRule.get(rule_id) if rule_id else None
489 489 if rule:
490 490 review_group = rule.user_group_vote_rule()
491 491 if review_group:
492 492 # NOTE(marcink):
493 493 # again, can be that user is member of more,
494 494 # but we pick the first same, as default reviewers algo
495 495 review_group = review_group[0]
496 496
497 497 rule_data = {
498 498 'rule_name':
499 499 rule.review_rule_name,
500 500 'rule_user_group_entry_id':
501 501 review_group.repo_review_rule_users_group_id,
502 502 'rule_user_group_name':
503 503 review_group.users_group.users_group_name,
504 504 'rule_user_group_members':
505 505 [x.user.username for x in review_group.users_group.members],
506 506 }
507 507 # e.g {'vote_rule': -1, 'mandatory': True}
508 508 rule_data.update(review_group.rule_data())
509 509
510 510 reviewer.rule_data = rule_data
511 511
512 512 Session().add(reviewer)
513 Session().flush()
513 514
514 515 # Set approval status to "Under Review" for all commits which are
515 516 # part of this pull request.
516 517 ChangesetStatusModel().set_status(
517 518 repo=target_repo,
518 519 status=ChangesetStatus.STATUS_UNDER_REVIEW,
519 520 user=created_by_user,
520 521 pull_request=pull_request
521 522 )
523 # we commit early at this point. This has to do with a fact
524 # that before queries do some row-locking. And because of that
525 # we need to commit and finish transation before below validate call
526 # that for large repos could be long resulting in long row locks
527 Session().commit()
522 528
529 # prepare workspace, and run initial merge simulation
523 530 MergeCheck.validate(
524 531 pull_request, user=created_by_user, translator=translator)
525 532
526 533 self.notify_reviewers(pull_request, reviewer_ids)
527 534 self._trigger_pull_request_hook(
528 535 pull_request, created_by_user, 'create')
529 536
530 537 creation_data = pull_request.get_api_data(with_merge_state=False)
531 538 self._log_audit_action(
532 539 'repo.pull_request.create', {'data': creation_data},
533 540 created_by_user, pull_request)
534 541
535 542 return pull_request
536 543
537 544 def _trigger_pull_request_hook(self, pull_request, user, action):
538 545 pull_request = self.__get_pull_request(pull_request)
539 546 target_scm = pull_request.target_repo.scm_instance()
540 547 if action == 'create':
541 548 trigger_hook = hooks_utils.trigger_log_create_pull_request_hook
542 549 elif action == 'merge':
543 550 trigger_hook = hooks_utils.trigger_log_merge_pull_request_hook
544 551 elif action == 'close':
545 552 trigger_hook = hooks_utils.trigger_log_close_pull_request_hook
546 553 elif action == 'review_status_change':
547 554 trigger_hook = hooks_utils.trigger_log_review_pull_request_hook
548 555 elif action == 'update':
549 556 trigger_hook = hooks_utils.trigger_log_update_pull_request_hook
550 557 else:
551 558 return
552 559
553 560 trigger_hook(
554 561 username=user.username,
555 562 repo_name=pull_request.target_repo.repo_name,
556 563 repo_alias=target_scm.alias,
557 564 pull_request=pull_request)
558 565
559 566 def _get_commit_ids(self, pull_request):
560 567 """
561 568 Return the commit ids of the merged pull request.
562 569
563 570 This method is not dealing correctly yet with the lack of autoupdates
564 571 nor with the implicit target updates.
565 572 For example: if a commit in the source repo is already in the target it
566 573 will be reported anyways.
567 574 """
568 575 merge_rev = pull_request.merge_rev
569 576 if merge_rev is None:
570 577 raise ValueError('This pull request was not merged yet')
571 578
572 579 commit_ids = list(pull_request.revisions)
573 580 if merge_rev not in commit_ids:
574 581 commit_ids.append(merge_rev)
575 582
576 583 return commit_ids
577 584
578 585 def merge(self, pull_request, user, extras):
579 586 log.debug("Merging pull request %s", pull_request.pull_request_id)
580 587 merge_state = self._merge_pull_request(pull_request, user, extras)
581 588 if merge_state.executed:
582 589 log.debug(
583 590 "Merge was successful, updating the pull request comments.")
584 591 self._comment_and_close_pr(pull_request, user, merge_state)
585 592
586 593 self._log_audit_action(
587 594 'repo.pull_request.merge',
588 595 {'merge_state': merge_state.__dict__},
589 596 user, pull_request)
590 597
591 598 else:
592 599 log.warn("Merge failed, not updating the pull request.")
593 600 return merge_state
594 601
595 602 def _merge_pull_request(self, pull_request, user, extras, merge_msg=None):
596 603 target_vcs = pull_request.target_repo.scm_instance()
597 604 source_vcs = pull_request.source_repo.scm_instance()
598 605 target_ref = self._refresh_reference(
599 606 pull_request.target_ref_parts, target_vcs)
600 607
601 608 message = merge_msg or (
602 609 'Merge pull request #%(pr_id)s from '
603 610 '%(source_repo)s %(source_ref_name)s\n\n %(pr_title)s') % {
604 611 'pr_id': pull_request.pull_request_id,
605 612 'source_repo': source_vcs.name,
606 613 'source_ref_name': pull_request.source_ref_parts.name,
607 614 'pr_title': pull_request.title
608 615 }
609 616
610 617 workspace_id = self._workspace_id(pull_request)
611 618 use_rebase = self._use_rebase_for_merging(pull_request)
612 619 close_branch = self._close_branch_before_merging(pull_request)
613 620
614 621 callback_daemon, extras = prepare_callback_daemon(
615 622 extras, protocol=vcs_settings.HOOKS_PROTOCOL,
616 623 use_direct_calls=vcs_settings.HOOKS_DIRECT_CALLS)
617 624
618 625 with callback_daemon:
619 626 # TODO: johbo: Implement a clean way to run a config_override
620 627 # for a single call.
621 628 target_vcs.config.set(
622 629 'rhodecode', 'RC_SCM_DATA', json.dumps(extras))
623 630 merge_state = target_vcs.merge(
624 631 target_ref, source_vcs, pull_request.source_ref_parts,
625 632 workspace_id, user_name=user.username,
626 633 user_email=user.email, message=message, use_rebase=use_rebase,
627 634 close_branch=close_branch)
628 635 return merge_state
629 636
630 637 def _comment_and_close_pr(self, pull_request, user, merge_state, close_msg=None):
631 638 pull_request.merge_rev = merge_state.merge_ref.commit_id
632 639 pull_request.updated_on = datetime.datetime.now()
633 640 close_msg = close_msg or 'Pull request merged and closed'
634 641
635 642 CommentsModel().create(
636 643 text=safe_unicode(close_msg),
637 644 repo=pull_request.target_repo.repo_id,
638 645 user=user.user_id,
639 646 pull_request=pull_request.pull_request_id,
640 647 f_path=None,
641 648 line_no=None,
642 649 closing_pr=True
643 650 )
644 651
645 652 Session().add(pull_request)
646 653 Session().flush()
647 654 # TODO: paris: replace invalidation with less radical solution
648 655 ScmModel().mark_for_invalidation(
649 656 pull_request.target_repo.repo_name)
650 657 self._trigger_pull_request_hook(pull_request, user, 'merge')
651 658
652 659 def has_valid_update_type(self, pull_request):
653 660 source_ref_type = pull_request.source_ref_parts.type
654 661 return source_ref_type in ['book', 'branch', 'tag']
655 662
656 663 def update_commits(self, pull_request):
657 664 """
658 665 Get the updated list of commits for the pull request
659 666 and return the new pull request version and the list
660 667 of commits processed by this update action
661 668 """
662 669 pull_request = self.__get_pull_request(pull_request)
663 670 source_ref_type = pull_request.source_ref_parts.type
664 671 source_ref_name = pull_request.source_ref_parts.name
665 672 source_ref_id = pull_request.source_ref_parts.commit_id
666 673
667 674 target_ref_type = pull_request.target_ref_parts.type
668 675 target_ref_name = pull_request.target_ref_parts.name
669 676 target_ref_id = pull_request.target_ref_parts.commit_id
670 677
671 678 if not self.has_valid_update_type(pull_request):
672 679 log.debug(
673 680 "Skipping update of pull request %s due to ref type: %s",
674 681 pull_request, source_ref_type)
675 682 return UpdateResponse(
676 683 executed=False,
677 684 reason=UpdateFailureReason.WRONG_REF_TYPE,
678 685 old=pull_request, new=None, changes=None,
679 686 source_changed=False, target_changed=False)
680 687
681 688 # source repo
682 689 source_repo = pull_request.source_repo.scm_instance()
683 690 try:
684 691 source_commit = source_repo.get_commit(commit_id=source_ref_name)
685 692 except CommitDoesNotExistError:
686 693 return UpdateResponse(
687 694 executed=False,
688 695 reason=UpdateFailureReason.MISSING_SOURCE_REF,
689 696 old=pull_request, new=None, changes=None,
690 697 source_changed=False, target_changed=False)
691 698
692 699 source_changed = source_ref_id != source_commit.raw_id
693 700
694 701 # target repo
695 702 target_repo = pull_request.target_repo.scm_instance()
696 703 try:
697 704 target_commit = target_repo.get_commit(commit_id=target_ref_name)
698 705 except CommitDoesNotExistError:
699 706 return UpdateResponse(
700 707 executed=False,
701 708 reason=UpdateFailureReason.MISSING_TARGET_REF,
702 709 old=pull_request, new=None, changes=None,
703 710 source_changed=False, target_changed=False)
704 711 target_changed = target_ref_id != target_commit.raw_id
705 712
706 713 if not (source_changed or target_changed):
707 714 log.debug("Nothing changed in pull request %s", pull_request)
708 715 return UpdateResponse(
709 716 executed=False,
710 717 reason=UpdateFailureReason.NO_CHANGE,
711 718 old=pull_request, new=None, changes=None,
712 719 source_changed=target_changed, target_changed=source_changed)
713 720
714 721 change_in_found = 'target repo' if target_changed else 'source repo'
715 722 log.debug('Updating pull request because of change in %s detected',
716 723 change_in_found)
717 724
718 725 # Finally there is a need for an update, in case of source change
719 726 # we create a new version, else just an update
720 727 if source_changed:
721 728 pull_request_version = self._create_version_from_snapshot(pull_request)
722 729 self._link_comments_to_version(pull_request_version)
723 730 else:
724 731 try:
725 732 ver = pull_request.versions[-1]
726 733 except IndexError:
727 734 ver = None
728 735
729 736 pull_request.pull_request_version_id = \
730 737 ver.pull_request_version_id if ver else None
731 738 pull_request_version = pull_request
732 739
733 740 try:
734 741 if target_ref_type in ('tag', 'branch', 'book'):
735 742 target_commit = target_repo.get_commit(target_ref_name)
736 743 else:
737 744 target_commit = target_repo.get_commit(target_ref_id)
738 745 except CommitDoesNotExistError:
739 746 return UpdateResponse(
740 747 executed=False,
741 748 reason=UpdateFailureReason.MISSING_TARGET_REF,
742 749 old=pull_request, new=None, changes=None,
743 750 source_changed=source_changed, target_changed=target_changed)
744 751
745 752 # re-compute commit ids
746 753 old_commit_ids = pull_request.revisions
747 754 pre_load = ["author", "branch", "date", "message"]
748 755 commit_ranges = target_repo.compare(
749 756 target_commit.raw_id, source_commit.raw_id, source_repo, merge=True,
750 757 pre_load=pre_load)
751 758
752 759 ancestor = target_repo.get_common_ancestor(
753 760 target_commit.raw_id, source_commit.raw_id, source_repo)
754 761
755 762 pull_request.source_ref = '%s:%s:%s' % (
756 763 source_ref_type, source_ref_name, source_commit.raw_id)
757 764 pull_request.target_ref = '%s:%s:%s' % (
758 765 target_ref_type, target_ref_name, ancestor)
759 766
760 767 pull_request.revisions = [
761 768 commit.raw_id for commit in reversed(commit_ranges)]
762 769 pull_request.updated_on = datetime.datetime.now()
763 770 Session().add(pull_request)
764 771 new_commit_ids = pull_request.revisions
765 772
766 773 old_diff_data, new_diff_data = self._generate_update_diffs(
767 774 pull_request, pull_request_version)
768 775
769 776 # calculate commit and file changes
770 777 changes = self._calculate_commit_id_changes(
771 778 old_commit_ids, new_commit_ids)
772 779 file_changes = self._calculate_file_changes(
773 780 old_diff_data, new_diff_data)
774 781
775 782 # set comments as outdated if DIFFS changed
776 783 CommentsModel().outdate_comments(
777 784 pull_request, old_diff_data=old_diff_data,
778 785 new_diff_data=new_diff_data)
779 786
780 787 commit_changes = (changes.added or changes.removed)
781 788 file_node_changes = (
782 789 file_changes.added or file_changes.modified or file_changes.removed)
783 790 pr_has_changes = commit_changes or file_node_changes
784 791
785 792 # Add an automatic comment to the pull request, in case
786 793 # anything has changed
787 794 if pr_has_changes:
788 795 update_comment = CommentsModel().create(
789 796 text=self._render_update_message(changes, file_changes),
790 797 repo=pull_request.target_repo,
791 798 user=pull_request.author,
792 799 pull_request=pull_request,
793 800 send_email=False, renderer=DEFAULT_COMMENTS_RENDERER)
794 801
795 802 # Update status to "Under Review" for added commits
796 803 for commit_id in changes.added:
797 804 ChangesetStatusModel().set_status(
798 805 repo=pull_request.source_repo,
799 806 status=ChangesetStatus.STATUS_UNDER_REVIEW,
800 807 comment=update_comment,
801 808 user=pull_request.author,
802 809 pull_request=pull_request,
803 810 revision=commit_id)
804 811
805 812 log.debug(
806 813 'Updated pull request %s, added_ids: %s, common_ids: %s, '
807 814 'removed_ids: %s', pull_request.pull_request_id,
808 815 changes.added, changes.common, changes.removed)
809 816 log.debug(
810 817 'Updated pull request with the following file changes: %s',
811 818 file_changes)
812 819
813 820 log.info(
814 821 "Updated pull request %s from commit %s to commit %s, "
815 822 "stored new version %s of this pull request.",
816 823 pull_request.pull_request_id, source_ref_id,
817 824 pull_request.source_ref_parts.commit_id,
818 825 pull_request_version.pull_request_version_id)
819 826 Session().commit()
820 827 self._trigger_pull_request_hook(
821 828 pull_request, pull_request.author, 'update')
822 829
823 830 return UpdateResponse(
824 831 executed=True, reason=UpdateFailureReason.NONE,
825 832 old=pull_request, new=pull_request_version, changes=changes,
826 833 source_changed=source_changed, target_changed=target_changed)
827 834
828 835 def _create_version_from_snapshot(self, pull_request):
829 836 version = PullRequestVersion()
830 837 version.title = pull_request.title
831 838 version.description = pull_request.description
832 839 version.status = pull_request.status
833 840 version.created_on = datetime.datetime.now()
834 841 version.updated_on = pull_request.updated_on
835 842 version.user_id = pull_request.user_id
836 843 version.source_repo = pull_request.source_repo
837 844 version.source_ref = pull_request.source_ref
838 845 version.target_repo = pull_request.target_repo
839 846 version.target_ref = pull_request.target_ref
840 847
841 848 version._last_merge_source_rev = pull_request._last_merge_source_rev
842 849 version._last_merge_target_rev = pull_request._last_merge_target_rev
843 850 version.last_merge_status = pull_request.last_merge_status
844 851 version.shadow_merge_ref = pull_request.shadow_merge_ref
845 852 version.merge_rev = pull_request.merge_rev
846 853 version.reviewer_data = pull_request.reviewer_data
847 854
848 855 version.revisions = pull_request.revisions
849 856 version.pull_request = pull_request
850 857 Session().add(version)
851 858 Session().flush()
852 859
853 860 return version
854 861
855 862 def _generate_update_diffs(self, pull_request, pull_request_version):
856 863
857 864 diff_context = (
858 865 self.DIFF_CONTEXT +
859 866 CommentsModel.needed_extra_diff_context())
860 867
861 868 source_repo = pull_request_version.source_repo
862 869 source_ref_id = pull_request_version.source_ref_parts.commit_id
863 870 target_ref_id = pull_request_version.target_ref_parts.commit_id
864 871 old_diff = self._get_diff_from_pr_or_version(
865 872 source_repo, source_ref_id, target_ref_id, context=diff_context)
866 873
867 874 source_repo = pull_request.source_repo
868 875 source_ref_id = pull_request.source_ref_parts.commit_id
869 876 target_ref_id = pull_request.target_ref_parts.commit_id
870 877
871 878 new_diff = self._get_diff_from_pr_or_version(
872 879 source_repo, source_ref_id, target_ref_id, context=diff_context)
873 880
874 881 old_diff_data = diffs.DiffProcessor(old_diff)
875 882 old_diff_data.prepare()
876 883 new_diff_data = diffs.DiffProcessor(new_diff)
877 884 new_diff_data.prepare()
878 885
879 886 return old_diff_data, new_diff_data
880 887
881 888 def _link_comments_to_version(self, pull_request_version):
882 889 """
883 890 Link all unlinked comments of this pull request to the given version.
884 891
885 892 :param pull_request_version: The `PullRequestVersion` to which
886 893 the comments shall be linked.
887 894
888 895 """
889 896 pull_request = pull_request_version.pull_request
890 897 comments = ChangesetComment.query()\
891 898 .filter(
892 899 # TODO: johbo: Should we query for the repo at all here?
893 900 # Pending decision on how comments of PRs are to be related
894 901 # to either the source repo, the target repo or no repo at all.
895 902 ChangesetComment.repo_id == pull_request.target_repo.repo_id,
896 903 ChangesetComment.pull_request == pull_request,
897 904 ChangesetComment.pull_request_version == None)\
898 905 .order_by(ChangesetComment.comment_id.asc())
899 906
900 907 # TODO: johbo: Find out why this breaks if it is done in a bulk
901 908 # operation.
902 909 for comment in comments:
903 910 comment.pull_request_version_id = (
904 911 pull_request_version.pull_request_version_id)
905 912 Session().add(comment)
906 913
907 914 def _calculate_commit_id_changes(self, old_ids, new_ids):
908 915 added = [x for x in new_ids if x not in old_ids]
909 916 common = [x for x in new_ids if x in old_ids]
910 917 removed = [x for x in old_ids if x not in new_ids]
911 918 total = new_ids
912 919 return ChangeTuple(added, common, removed, total)
913 920
914 921 def _calculate_file_changes(self, old_diff_data, new_diff_data):
915 922
916 923 old_files = OrderedDict()
917 924 for diff_data in old_diff_data.parsed_diff:
918 925 old_files[diff_data['filename']] = md5_safe(diff_data['raw_diff'])
919 926
920 927 added_files = []
921 928 modified_files = []
922 929 removed_files = []
923 930 for diff_data in new_diff_data.parsed_diff:
924 931 new_filename = diff_data['filename']
925 932 new_hash = md5_safe(diff_data['raw_diff'])
926 933
927 934 old_hash = old_files.get(new_filename)
928 935 if not old_hash:
929 936 # file is not present in old diff, means it's added
930 937 added_files.append(new_filename)
931 938 else:
932 939 if new_hash != old_hash:
933 940 modified_files.append(new_filename)
934 941 # now remove a file from old, since we have seen it already
935 942 del old_files[new_filename]
936 943
937 944 # removed files is when there are present in old, but not in NEW,
938 945 # since we remove old files that are present in new diff, left-overs
939 946 # if any should be the removed files
940 947 removed_files.extend(old_files.keys())
941 948
942 949 return FileChangeTuple(added_files, modified_files, removed_files)
943 950
944 951 def _render_update_message(self, changes, file_changes):
945 952 """
946 953 render the message using DEFAULT_COMMENTS_RENDERER (RST renderer),
947 954 so it's always looking the same disregarding on which default
948 955 renderer system is using.
949 956
950 957 :param changes: changes named tuple
951 958 :param file_changes: file changes named tuple
952 959
953 960 """
954 961 new_status = ChangesetStatus.get_status_lbl(
955 962 ChangesetStatus.STATUS_UNDER_REVIEW)
956 963
957 964 changed_files = (
958 965 file_changes.added + file_changes.modified + file_changes.removed)
959 966
960 967 params = {
961 968 'under_review_label': new_status,
962 969 'added_commits': changes.added,
963 970 'removed_commits': changes.removed,
964 971 'changed_files': changed_files,
965 972 'added_files': file_changes.added,
966 973 'modified_files': file_changes.modified,
967 974 'removed_files': file_changes.removed,
968 975 }
969 976 renderer = RstTemplateRenderer()
970 977 return renderer.render('pull_request_update.mako', **params)
971 978
972 979 def edit(self, pull_request, title, description, user):
973 980 pull_request = self.__get_pull_request(pull_request)
974 981 old_data = pull_request.get_api_data(with_merge_state=False)
975 982 if pull_request.is_closed():
976 983 raise ValueError('This pull request is closed')
977 984 if title:
978 985 pull_request.title = title
979 986 pull_request.description = description
980 987 pull_request.updated_on = datetime.datetime.now()
981 988 Session().add(pull_request)
982 989 self._log_audit_action(
983 990 'repo.pull_request.edit', {'old_data': old_data},
984 991 user, pull_request)
985 992
986 993 def update_reviewers(self, pull_request, reviewer_data, user):
987 994 """
988 995 Update the reviewers in the pull request
989 996
990 997 :param pull_request: the pr to update
991 998 :param reviewer_data: list of tuples
992 999 [(user, ['reason1', 'reason2'], mandatory_flag, [rules])]
993 1000 """
994 1001 pull_request = self.__get_pull_request(pull_request)
995 1002 if pull_request.is_closed():
996 1003 raise ValueError('This pull request is closed')
997 1004
998 1005 reviewers = {}
999 1006 for user_id, reasons, mandatory, rules in reviewer_data:
1000 1007 if isinstance(user_id, (int, basestring)):
1001 1008 user_id = self._get_user(user_id).user_id
1002 1009 reviewers[user_id] = {
1003 1010 'reasons': reasons, 'mandatory': mandatory}
1004 1011
1005 1012 reviewers_ids = set(reviewers.keys())
1006 1013 current_reviewers = PullRequestReviewers.query()\
1007 1014 .filter(PullRequestReviewers.pull_request ==
1008 1015 pull_request).all()
1009 1016 current_reviewers_ids = set([x.user.user_id for x in current_reviewers])
1010 1017
1011 1018 ids_to_add = reviewers_ids.difference(current_reviewers_ids)
1012 1019 ids_to_remove = current_reviewers_ids.difference(reviewers_ids)
1013 1020
1014 1021 log.debug("Adding %s reviewers", ids_to_add)
1015 1022 log.debug("Removing %s reviewers", ids_to_remove)
1016 1023 changed = False
1017 1024 for uid in ids_to_add:
1018 1025 changed = True
1019 1026 _usr = self._get_user(uid)
1020 1027 reviewer = PullRequestReviewers()
1021 1028 reviewer.user = _usr
1022 1029 reviewer.pull_request = pull_request
1023 1030 reviewer.reasons = reviewers[uid]['reasons']
1024 1031 # NOTE(marcink): mandatory shouldn't be changed now
1025 1032 # reviewer.mandatory = reviewers[uid]['reasons']
1026 1033 Session().add(reviewer)
1027 1034 self._log_audit_action(
1028 1035 'repo.pull_request.reviewer.add', {'data': reviewer.get_dict()},
1029 1036 user, pull_request)
1030 1037
1031 1038 for uid in ids_to_remove:
1032 1039 changed = True
1033 1040 reviewers = PullRequestReviewers.query()\
1034 1041 .filter(PullRequestReviewers.user_id == uid,
1035 1042 PullRequestReviewers.pull_request == pull_request)\
1036 1043 .all()
1037 1044 # use .all() in case we accidentally added the same person twice
1038 1045 # this CAN happen due to the lack of DB checks
1039 1046 for obj in reviewers:
1040 1047 old_data = obj.get_dict()
1041 1048 Session().delete(obj)
1042 1049 self._log_audit_action(
1043 1050 'repo.pull_request.reviewer.delete',
1044 1051 {'old_data': old_data}, user, pull_request)
1045 1052
1046 1053 if changed:
1047 1054 pull_request.updated_on = datetime.datetime.now()
1048 1055 Session().add(pull_request)
1049 1056
1050 1057 self.notify_reviewers(pull_request, ids_to_add)
1051 1058 return ids_to_add, ids_to_remove
1052 1059
1053 1060 def get_url(self, pull_request, request=None, permalink=False):
1054 1061 if not request:
1055 1062 request = get_current_request()
1056 1063
1057 1064 if permalink:
1058 1065 return request.route_url(
1059 1066 'pull_requests_global',
1060 1067 pull_request_id=pull_request.pull_request_id,)
1061 1068 else:
1062 1069 return request.route_url('pullrequest_show',
1063 1070 repo_name=safe_str(pull_request.target_repo.repo_name),
1064 1071 pull_request_id=pull_request.pull_request_id,)
1065 1072
1066 1073 def get_shadow_clone_url(self, pull_request, request=None):
1067 1074 """
1068 1075 Returns qualified url pointing to the shadow repository. If this pull
1069 1076 request is closed there is no shadow repository and ``None`` will be
1070 1077 returned.
1071 1078 """
1072 1079 if pull_request.is_closed():
1073 1080 return None
1074 1081 else:
1075 1082 pr_url = urllib.unquote(self.get_url(pull_request, request=request))
1076 1083 return safe_unicode('{pr_url}/repository'.format(pr_url=pr_url))
1077 1084
1078 1085 def notify_reviewers(self, pull_request, reviewers_ids):
1079 1086 # notification to reviewers
1080 1087 if not reviewers_ids:
1081 1088 return
1082 1089
1083 1090 pull_request_obj = pull_request
1084 1091 # get the current participants of this pull request
1085 1092 recipients = reviewers_ids
1086 1093 notification_type = EmailNotificationModel.TYPE_PULL_REQUEST
1087 1094
1088 1095 pr_source_repo = pull_request_obj.source_repo
1089 1096 pr_target_repo = pull_request_obj.target_repo
1090 1097
1091 1098 pr_url = h.route_url('pullrequest_show',
1092 1099 repo_name=pr_target_repo.repo_name,
1093 1100 pull_request_id=pull_request_obj.pull_request_id,)
1094 1101
1095 1102 # set some variables for email notification
1096 1103 pr_target_repo_url = h.route_url(
1097 1104 'repo_summary', repo_name=pr_target_repo.repo_name)
1098 1105
1099 1106 pr_source_repo_url = h.route_url(
1100 1107 'repo_summary', repo_name=pr_source_repo.repo_name)
1101 1108
1102 1109 # pull request specifics
1103 1110 pull_request_commits = [
1104 1111 (x.raw_id, x.message)
1105 1112 for x in map(pr_source_repo.get_commit, pull_request.revisions)]
1106 1113
1107 1114 kwargs = {
1108 1115 'user': pull_request.author,
1109 1116 'pull_request': pull_request_obj,
1110 1117 'pull_request_commits': pull_request_commits,
1111 1118
1112 1119 'pull_request_target_repo': pr_target_repo,
1113 1120 'pull_request_target_repo_url': pr_target_repo_url,
1114 1121
1115 1122 'pull_request_source_repo': pr_source_repo,
1116 1123 'pull_request_source_repo_url': pr_source_repo_url,
1117 1124
1118 1125 'pull_request_url': pr_url,
1119 1126 }
1120 1127
1121 1128 # pre-generate the subject for notification itself
1122 1129 (subject,
1123 1130 _h, _e, # we don't care about those
1124 1131 body_plaintext) = EmailNotificationModel().render_email(
1125 1132 notification_type, **kwargs)
1126 1133
1127 1134 # create notification objects, and emails
1128 1135 NotificationModel().create(
1129 1136 created_by=pull_request.author,
1130 1137 notification_subject=subject,
1131 1138 notification_body=body_plaintext,
1132 1139 notification_type=notification_type,
1133 1140 recipients=recipients,
1134 1141 email_kwargs=kwargs,
1135 1142 )
1136 1143
1137 1144 def delete(self, pull_request, user):
1138 1145 pull_request = self.__get_pull_request(pull_request)
1139 1146 old_data = pull_request.get_api_data(with_merge_state=False)
1140 1147 self._cleanup_merge_workspace(pull_request)
1141 1148 self._log_audit_action(
1142 1149 'repo.pull_request.delete', {'old_data': old_data},
1143 1150 user, pull_request)
1144 1151 Session().delete(pull_request)
1145 1152
1146 1153 def close_pull_request(self, pull_request, user):
1147 1154 pull_request = self.__get_pull_request(pull_request)
1148 1155 self._cleanup_merge_workspace(pull_request)
1149 1156 pull_request.status = PullRequest.STATUS_CLOSED
1150 1157 pull_request.updated_on = datetime.datetime.now()
1151 1158 Session().add(pull_request)
1152 1159 self._trigger_pull_request_hook(
1153 1160 pull_request, pull_request.author, 'close')
1154 1161
1155 1162 pr_data = pull_request.get_api_data(with_merge_state=False)
1156 1163 self._log_audit_action(
1157 1164 'repo.pull_request.close', {'data': pr_data}, user, pull_request)
1158 1165
1159 1166 def close_pull_request_with_comment(
1160 1167 self, pull_request, user, repo, message=None):
1161 1168
1162 1169 pull_request_review_status = pull_request.calculated_review_status()
1163 1170
1164 1171 if pull_request_review_status == ChangesetStatus.STATUS_APPROVED:
1165 1172 # approved only if we have voting consent
1166 1173 status = ChangesetStatus.STATUS_APPROVED
1167 1174 else:
1168 1175 status = ChangesetStatus.STATUS_REJECTED
1169 1176 status_lbl = ChangesetStatus.get_status_lbl(status)
1170 1177
1171 1178 default_message = (
1172 1179 'Closing with status change {transition_icon} {status}.'
1173 1180 ).format(transition_icon='>', status=status_lbl)
1174 1181 text = message or default_message
1175 1182
1176 1183 # create a comment, and link it to new status
1177 1184 comment = CommentsModel().create(
1178 1185 text=text,
1179 1186 repo=repo.repo_id,
1180 1187 user=user.user_id,
1181 1188 pull_request=pull_request.pull_request_id,
1182 1189 status_change=status_lbl,
1183 1190 status_change_type=status,
1184 1191 closing_pr=True
1185 1192 )
1186 1193
1187 1194 # calculate old status before we change it
1188 1195 old_calculated_status = pull_request.calculated_review_status()
1189 1196 ChangesetStatusModel().set_status(
1190 1197 repo.repo_id,
1191 1198 status,
1192 1199 user.user_id,
1193 1200 comment=comment,
1194 1201 pull_request=pull_request.pull_request_id
1195 1202 )
1196 1203
1197 1204 Session().flush()
1198 1205 events.trigger(events.PullRequestCommentEvent(pull_request, comment))
1199 1206 # we now calculate the status of pull request again, and based on that
1200 1207 # calculation trigger status change. This might happen in cases
1201 1208 # that non-reviewer admin closes a pr, which means his vote doesn't
1202 1209 # change the status, while if he's a reviewer this might change it.
1203 1210 calculated_status = pull_request.calculated_review_status()
1204 1211 if old_calculated_status != calculated_status:
1205 1212 self._trigger_pull_request_hook(
1206 1213 pull_request, user, 'review_status_change')
1207 1214
1208 1215 # finally close the PR
1209 1216 PullRequestModel().close_pull_request(
1210 1217 pull_request.pull_request_id, user)
1211 1218
1212 1219 return comment, status
1213 1220
1214 1221 def merge_status(self, pull_request, translator=None,
1215 1222 force_shadow_repo_refresh=False):
1216 1223 _ = translator or get_current_request().translate
1217 1224
1218 1225 if not self._is_merge_enabled(pull_request):
1219 1226 return False, _('Server-side pull request merging is disabled.')
1220 1227 if pull_request.is_closed():
1221 1228 return False, _('This pull request is closed.')
1222 1229 merge_possible, msg = self._check_repo_requirements(
1223 1230 target=pull_request.target_repo, source=pull_request.source_repo,
1224 1231 translator=_)
1225 1232 if not merge_possible:
1226 1233 return merge_possible, msg
1227 1234
1228 1235 try:
1229 1236 resp = self._try_merge(
1230 1237 pull_request,
1231 1238 force_shadow_repo_refresh=force_shadow_repo_refresh)
1232 1239 log.debug("Merge response: %s", resp)
1233 1240 status = resp.possible, self.merge_status_message(
1234 1241 resp.failure_reason)
1235 1242 except NotImplementedError:
1236 1243 status = False, _('Pull request merging is not supported.')
1237 1244
1238 1245 return status
1239 1246
1240 1247 def _check_repo_requirements(self, target, source, translator):
1241 1248 """
1242 1249 Check if `target` and `source` have compatible requirements.
1243 1250
1244 1251 Currently this is just checking for largefiles.
1245 1252 """
1246 1253 _ = translator
1247 1254 target_has_largefiles = self._has_largefiles(target)
1248 1255 source_has_largefiles = self._has_largefiles(source)
1249 1256 merge_possible = True
1250 1257 message = u''
1251 1258
1252 1259 if target_has_largefiles != source_has_largefiles:
1253 1260 merge_possible = False
1254 1261 if source_has_largefiles:
1255 1262 message = _(
1256 1263 'Target repository large files support is disabled.')
1257 1264 else:
1258 1265 message = _(
1259 1266 'Source repository large files support is disabled.')
1260 1267
1261 1268 return merge_possible, message
1262 1269
1263 1270 def _has_largefiles(self, repo):
1264 1271 largefiles_ui = VcsSettingsModel(repo=repo).get_ui_settings(
1265 1272 'extensions', 'largefiles')
1266 1273 return largefiles_ui and largefiles_ui[0].active
1267 1274
1268 1275 def _try_merge(self, pull_request, force_shadow_repo_refresh=False):
1269 1276 """
1270 1277 Try to merge the pull request and return the merge status.
1271 1278 """
1272 1279 log.debug(
1273 1280 "Trying out if the pull request %s can be merged. Force_refresh=%s",
1274 1281 pull_request.pull_request_id, force_shadow_repo_refresh)
1275 1282 target_vcs = pull_request.target_repo.scm_instance()
1276 1283
1277 1284 # Refresh the target reference.
1278 1285 try:
1279 1286 target_ref = self._refresh_reference(
1280 1287 pull_request.target_ref_parts, target_vcs)
1281 1288 except CommitDoesNotExistError:
1282 1289 merge_state = MergeResponse(
1283 1290 False, False, None, MergeFailureReason.MISSING_TARGET_REF)
1284 1291 return merge_state
1285 1292
1286 1293 target_locked = pull_request.target_repo.locked
1287 1294 if target_locked and target_locked[0]:
1288 1295 log.debug("The target repository is locked.")
1289 1296 merge_state = MergeResponse(
1290 1297 False, False, None, MergeFailureReason.TARGET_IS_LOCKED)
1291 1298 elif force_shadow_repo_refresh or self._needs_merge_state_refresh(
1292 1299 pull_request, target_ref):
1293 1300 log.debug("Refreshing the merge status of the repository.")
1294 1301 merge_state = self._refresh_merge_state(
1295 1302 pull_request, target_vcs, target_ref)
1296 1303 else:
1297 1304 possible = pull_request.\
1298 1305 last_merge_status == MergeFailureReason.NONE
1299 1306 merge_state = MergeResponse(
1300 1307 possible, False, None, pull_request.last_merge_status)
1301 1308
1302 1309 return merge_state
1303 1310
1304 1311 def _refresh_reference(self, reference, vcs_repository):
1305 1312 if reference.type in ('branch', 'book'):
1306 1313 name_or_id = reference.name
1307 1314 else:
1308 1315 name_or_id = reference.commit_id
1309 1316 refreshed_commit = vcs_repository.get_commit(name_or_id)
1310 1317 refreshed_reference = Reference(
1311 1318 reference.type, reference.name, refreshed_commit.raw_id)
1312 1319 return refreshed_reference
1313 1320
1314 1321 def _needs_merge_state_refresh(self, pull_request, target_reference):
1315 1322 return not(
1316 1323 pull_request.revisions and
1317 1324 pull_request.revisions[0] == pull_request._last_merge_source_rev and
1318 1325 target_reference.commit_id == pull_request._last_merge_target_rev)
1319 1326
1320 1327 def _refresh_merge_state(self, pull_request, target_vcs, target_reference):
1321 1328 workspace_id = self._workspace_id(pull_request)
1322 1329 source_vcs = pull_request.source_repo.scm_instance()
1323 1330 use_rebase = self._use_rebase_for_merging(pull_request)
1324 1331 close_branch = self._close_branch_before_merging(pull_request)
1325 1332 merge_state = target_vcs.merge(
1326 1333 target_reference, source_vcs, pull_request.source_ref_parts,
1327 1334 workspace_id, dry_run=True, use_rebase=use_rebase,
1328 1335 close_branch=close_branch)
1329 1336
1330 1337 # Do not store the response if there was an unknown error.
1331 1338 if merge_state.failure_reason != MergeFailureReason.UNKNOWN:
1332 1339 pull_request._last_merge_source_rev = \
1333 1340 pull_request.source_ref_parts.commit_id
1334 1341 pull_request._last_merge_target_rev = target_reference.commit_id
1335 1342 pull_request.last_merge_status = merge_state.failure_reason
1336 1343 pull_request.shadow_merge_ref = merge_state.merge_ref
1337 1344 Session().add(pull_request)
1338 1345 Session().commit()
1339 1346
1340 1347 return merge_state
1341 1348
1342 1349 def _workspace_id(self, pull_request):
1343 1350 workspace_id = 'pr-%s' % pull_request.pull_request_id
1344 1351 return workspace_id
1345 1352
1346 1353 def merge_status_message(self, status_code):
1347 1354 """
1348 1355 Return a human friendly error message for the given merge status code.
1349 1356 """
1350 1357 return self.MERGE_STATUS_MESSAGES[status_code]
1351 1358
1352 1359 def generate_repo_data(self, repo, commit_id=None, branch=None,
1353 1360 bookmark=None, translator=None):
1354 1361 from rhodecode.model.repo import RepoModel
1355 1362
1356 1363 all_refs, selected_ref = \
1357 1364 self._get_repo_pullrequest_sources(
1358 1365 repo.scm_instance(), commit_id=commit_id,
1359 1366 branch=branch, bookmark=bookmark, translator=translator)
1360 1367
1361 1368 refs_select2 = []
1362 1369 for element in all_refs:
1363 1370 children = [{'id': x[0], 'text': x[1]} for x in element[0]]
1364 1371 refs_select2.append({'text': element[1], 'children': children})
1365 1372
1366 1373 return {
1367 1374 'user': {
1368 1375 'user_id': repo.user.user_id,
1369 1376 'username': repo.user.username,
1370 1377 'firstname': repo.user.first_name,
1371 1378 'lastname': repo.user.last_name,
1372 1379 'gravatar_link': h.gravatar_url(repo.user.email, 14),
1373 1380 },
1374 1381 'name': repo.repo_name,
1375 1382 'link': RepoModel().get_url(repo),
1376 1383 'description': h.chop_at_smart(repo.description_safe, '\n'),
1377 1384 'refs': {
1378 1385 'all_refs': all_refs,
1379 1386 'selected_ref': selected_ref,
1380 1387 'select2_refs': refs_select2
1381 1388 }
1382 1389 }
1383 1390
1384 1391 def generate_pullrequest_title(self, source, source_ref, target):
1385 1392 return u'{source}#{at_ref} to {target}'.format(
1386 1393 source=source,
1387 1394 at_ref=source_ref,
1388 1395 target=target,
1389 1396 )
1390 1397
1391 1398 def _cleanup_merge_workspace(self, pull_request):
1392 1399 # Merging related cleanup
1393 1400 target_scm = pull_request.target_repo.scm_instance()
1394 1401 workspace_id = 'pr-%s' % pull_request.pull_request_id
1395 1402
1396 1403 try:
1397 1404 target_scm.cleanup_merge_workspace(workspace_id)
1398 1405 except NotImplementedError:
1399 1406 pass
1400 1407
1401 1408 def _get_repo_pullrequest_sources(
1402 1409 self, repo, commit_id=None, branch=None, bookmark=None,
1403 1410 translator=None):
1404 1411 """
1405 1412 Return a structure with repo's interesting commits, suitable for
1406 1413 the selectors in pullrequest controller
1407 1414
1408 1415 :param commit_id: a commit that must be in the list somehow
1409 1416 and selected by default
1410 1417 :param branch: a branch that must be in the list and selected
1411 1418 by default - even if closed
1412 1419 :param bookmark: a bookmark that must be in the list and selected
1413 1420 """
1414 1421 _ = translator or get_current_request().translate
1415 1422
1416 1423 commit_id = safe_str(commit_id) if commit_id else None
1417 1424 branch = safe_str(branch) if branch else None
1418 1425 bookmark = safe_str(bookmark) if bookmark else None
1419 1426
1420 1427 selected = None
1421 1428
1422 1429 # order matters: first source that has commit_id in it will be selected
1423 1430 sources = []
1424 1431 sources.append(('book', repo.bookmarks.items(), _('Bookmarks'), bookmark))
1425 1432 sources.append(('branch', repo.branches.items(), _('Branches'), branch))
1426 1433
1427 1434 if commit_id:
1428 1435 ref_commit = (h.short_id(commit_id), commit_id)
1429 1436 sources.append(('rev', [ref_commit], _('Commit IDs'), commit_id))
1430 1437
1431 1438 sources.append(
1432 1439 ('branch', repo.branches_closed.items(), _('Closed Branches'), branch),
1433 1440 )
1434 1441
1435 1442 groups = []
1436 1443 for group_key, ref_list, group_name, match in sources:
1437 1444 group_refs = []
1438 1445 for ref_name, ref_id in ref_list:
1439 1446 ref_key = '%s:%s:%s' % (group_key, ref_name, ref_id)
1440 1447 group_refs.append((ref_key, ref_name))
1441 1448
1442 1449 if not selected:
1443 1450 if set([commit_id, match]) & set([ref_id, ref_name]):
1444 1451 selected = ref_key
1445 1452
1446 1453 if group_refs:
1447 1454 groups.append((group_refs, group_name))
1448 1455
1449 1456 if not selected:
1450 1457 ref = commit_id or branch or bookmark
1451 1458 if ref:
1452 1459 raise CommitDoesNotExistError(
1453 1460 'No commit refs could be found matching: %s' % ref)
1454 1461 elif repo.DEFAULT_BRANCH_NAME in repo.branches:
1455 1462 selected = 'branch:%s:%s' % (
1456 1463 repo.DEFAULT_BRANCH_NAME,
1457 1464 repo.branches[repo.DEFAULT_BRANCH_NAME]
1458 1465 )
1459 1466 elif repo.commit_ids:
1460 1467 # make the user select in this case
1461 1468 selected = None
1462 1469 else:
1463 1470 raise EmptyRepositoryError()
1464 1471 return groups, selected
1465 1472
1466 1473 def get_diff(self, source_repo, source_ref_id, target_ref_id, context=DIFF_CONTEXT):
1467 1474 return self._get_diff_from_pr_or_version(
1468 1475 source_repo, source_ref_id, target_ref_id, context=context)
1469 1476
1470 1477 def _get_diff_from_pr_or_version(
1471 1478 self, source_repo, source_ref_id, target_ref_id, context):
1472 1479 target_commit = source_repo.get_commit(
1473 1480 commit_id=safe_str(target_ref_id))
1474 1481 source_commit = source_repo.get_commit(
1475 1482 commit_id=safe_str(source_ref_id))
1476 1483 if isinstance(source_repo, Repository):
1477 1484 vcs_repo = source_repo.scm_instance()
1478 1485 else:
1479 1486 vcs_repo = source_repo
1480 1487
1481 1488 # TODO: johbo: In the context of an update, we cannot reach
1482 1489 # the old commit anymore with our normal mechanisms. It needs
1483 1490 # some sort of special support in the vcs layer to avoid this
1484 1491 # workaround.
1485 1492 if (source_commit.raw_id == vcs_repo.EMPTY_COMMIT_ID and
1486 1493 vcs_repo.alias == 'git'):
1487 1494 source_commit.raw_id = safe_str(source_ref_id)
1488 1495
1489 1496 log.debug('calculating diff between '
1490 1497 'source_ref:%s and target_ref:%s for repo `%s`',
1491 1498 target_ref_id, source_ref_id,
1492 1499 safe_unicode(vcs_repo.path))
1493 1500
1494 1501 vcs_diff = vcs_repo.get_diff(
1495 1502 commit1=target_commit, commit2=source_commit, context=context)
1496 1503 return vcs_diff
1497 1504
1498 1505 def _is_merge_enabled(self, pull_request):
1499 1506 return self._get_general_setting(
1500 1507 pull_request, 'rhodecode_pr_merge_enabled')
1501 1508
1502 1509 def _use_rebase_for_merging(self, pull_request):
1503 1510 repo_type = pull_request.target_repo.repo_type
1504 1511 if repo_type == 'hg':
1505 1512 return self._get_general_setting(
1506 1513 pull_request, 'rhodecode_hg_use_rebase_for_merging')
1507 1514 elif repo_type == 'git':
1508 1515 return self._get_general_setting(
1509 1516 pull_request, 'rhodecode_git_use_rebase_for_merging')
1510 1517
1511 1518 return False
1512 1519
1513 1520 def _close_branch_before_merging(self, pull_request):
1514 1521 repo_type = pull_request.target_repo.repo_type
1515 1522 if repo_type == 'hg':
1516 1523 return self._get_general_setting(
1517 1524 pull_request, 'rhodecode_hg_close_branch_before_merging')
1518 1525 elif repo_type == 'git':
1519 1526 return self._get_general_setting(
1520 1527 pull_request, 'rhodecode_git_close_branch_before_merging')
1521 1528
1522 1529 return False
1523 1530
1524 1531 def _get_general_setting(self, pull_request, settings_key, default=False):
1525 1532 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
1526 1533 settings = settings_model.get_general_settings()
1527 1534 return settings.get(settings_key, default)
1528 1535
1529 1536 def _log_audit_action(self, action, action_data, user, pull_request):
1530 1537 audit_logger.store(
1531 1538 action=action,
1532 1539 action_data=action_data,
1533 1540 user=user,
1534 1541 repo=pull_request.target_repo)
1535 1542
1536 1543 def get_reviewer_functions(self):
1537 1544 """
1538 1545 Fetches functions for validation and fetching default reviewers.
1539 1546 If available we use the EE package, else we fallback to CE
1540 1547 package functions
1541 1548 """
1542 1549 try:
1543 1550 from rc_reviewers.utils import get_default_reviewers_data
1544 1551 from rc_reviewers.utils import validate_default_reviewers
1545 1552 except ImportError:
1546 1553 from rhodecode.apps.repository.utils import \
1547 1554 get_default_reviewers_data
1548 1555 from rhodecode.apps.repository.utils import \
1549 1556 validate_default_reviewers
1550 1557
1551 1558 return get_default_reviewers_data, validate_default_reviewers
1552 1559
1553 1560
1554 1561 class MergeCheck(object):
1555 1562 """
1556 1563 Perform Merge Checks and returns a check object which stores information
1557 1564 about merge errors, and merge conditions
1558 1565 """
1559 1566 TODO_CHECK = 'todo'
1560 1567 PERM_CHECK = 'perm'
1561 1568 REVIEW_CHECK = 'review'
1562 1569 MERGE_CHECK = 'merge'
1563 1570
1564 1571 def __init__(self):
1565 1572 self.review_status = None
1566 1573 self.merge_possible = None
1567 1574 self.merge_msg = ''
1568 1575 self.failed = None
1569 1576 self.errors = []
1570 1577 self.error_details = OrderedDict()
1571 1578
1572 1579 def push_error(self, error_type, message, error_key, details):
1573 1580 self.failed = True
1574 1581 self.errors.append([error_type, message])
1575 1582 self.error_details[error_key] = dict(
1576 1583 details=details,
1577 1584 error_type=error_type,
1578 1585 message=message
1579 1586 )
1580 1587
1581 1588 @classmethod
1582 1589 def validate(cls, pull_request, user, translator, fail_early=False,
1583 1590 force_shadow_repo_refresh=False):
1584 1591 _ = translator
1585 1592 merge_check = cls()
1586 1593
1587 1594 # permissions to merge
1588 1595 user_allowed_to_merge = PullRequestModel().check_user_merge(
1589 1596 pull_request, user)
1590 1597 if not user_allowed_to_merge:
1591 1598 log.debug("MergeCheck: cannot merge, approval is pending.")
1592 1599
1593 1600 msg = _('User `{}` not allowed to perform merge.').format(user.username)
1594 1601 merge_check.push_error('error', msg, cls.PERM_CHECK, user.username)
1595 1602 if fail_early:
1596 1603 return merge_check
1597 1604
1598 1605 # review status, must be always present
1599 1606 review_status = pull_request.calculated_review_status()
1600 1607 merge_check.review_status = review_status
1601 1608
1602 1609 status_approved = review_status == ChangesetStatus.STATUS_APPROVED
1603 1610 if not status_approved:
1604 1611 log.debug("MergeCheck: cannot merge, approval is pending.")
1605 1612
1606 1613 msg = _('Pull request reviewer approval is pending.')
1607 1614
1608 1615 merge_check.push_error(
1609 1616 'warning', msg, cls.REVIEW_CHECK, review_status)
1610 1617
1611 1618 if fail_early:
1612 1619 return merge_check
1613 1620
1614 1621 # left over TODOs
1615 1622 todos = CommentsModel().get_unresolved_todos(pull_request)
1616 1623 if todos:
1617 1624 log.debug("MergeCheck: cannot merge, {} "
1618 1625 "unresolved todos left.".format(len(todos)))
1619 1626
1620 1627 if len(todos) == 1:
1621 1628 msg = _('Cannot merge, {} TODO still not resolved.').format(
1622 1629 len(todos))
1623 1630 else:
1624 1631 msg = _('Cannot merge, {} TODOs still not resolved.').format(
1625 1632 len(todos))
1626 1633
1627 1634 merge_check.push_error('warning', msg, cls.TODO_CHECK, todos)
1628 1635
1629 1636 if fail_early:
1630 1637 return merge_check
1631 1638
1632 # merge possible
1639 # merge possible, here is the filesystem simulation + shadow repo
1633 1640 merge_status, msg = PullRequestModel().merge_status(
1634 1641 pull_request, translator=translator,
1635 1642 force_shadow_repo_refresh=force_shadow_repo_refresh)
1636 1643 merge_check.merge_possible = merge_status
1637 1644 merge_check.merge_msg = msg
1638 1645 if not merge_status:
1639 1646 log.debug(
1640 1647 "MergeCheck: cannot merge, pull request merge not possible.")
1641 1648 merge_check.push_error('warning', msg, cls.MERGE_CHECK, None)
1642 1649
1643 1650 if fail_early:
1644 1651 return merge_check
1645 1652
1646 1653 log.debug('MergeCheck: is failed: %s', merge_check.failed)
1647 1654 return merge_check
1648 1655
1649 1656 @classmethod
1650 1657 def get_merge_conditions(cls, pull_request, translator):
1651 1658 _ = translator
1652 1659 merge_details = {}
1653 1660
1654 1661 model = PullRequestModel()
1655 1662 use_rebase = model._use_rebase_for_merging(pull_request)
1656 1663
1657 1664 if use_rebase:
1658 1665 merge_details['merge_strategy'] = dict(
1659 1666 details={},
1660 1667 message=_('Merge strategy: rebase')
1661 1668 )
1662 1669 else:
1663 1670 merge_details['merge_strategy'] = dict(
1664 1671 details={},
1665 1672 message=_('Merge strategy: explicit merge commit')
1666 1673 )
1667 1674
1668 1675 close_branch = model._close_branch_before_merging(pull_request)
1669 1676 if close_branch:
1670 1677 repo_type = pull_request.target_repo.repo_type
1671 1678 if repo_type == 'hg':
1672 1679 close_msg = _('Source branch will be closed after merge.')
1673 1680 elif repo_type == 'git':
1674 1681 close_msg = _('Source branch will be deleted after merge.')
1675 1682
1676 1683 merge_details['close_branch'] = dict(
1677 1684 details={},
1678 1685 message=close_msg
1679 1686 )
1680 1687
1681 1688 return merge_details
1682 1689
1683 1690 ChangeTuple = collections.namedtuple(
1684 1691 'ChangeTuple', ['added', 'common', 'removed', 'total'])
1685 1692
1686 1693 FileChangeTuple = collections.namedtuple(
1687 1694 'FileChangeTuple', ['added', 'modified', 'removed'])
General Comments 0
You need to be logged in to leave comments. Login now