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