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