##// END OF EJS Templates
settings: Read 'rebase-merge' config option and pass it to the VCS insntacen on merge.
Martin Bornhold -
r361:813d7f2f default
parent child Browse files
Show More
@@ -1,1141 +1,1148 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2012-2016 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21
22 22 """
23 23 pull request model for RhodeCode
24 24 """
25 25
26 26 from collections import namedtuple
27 27 import json
28 28 import logging
29 29 import datetime
30 30
31 31 from pylons.i18n.translation import _
32 32 from pylons.i18n.translation import lazy_ugettext
33 33
34 34 import rhodecode
35 35 from rhodecode.lib import helpers as h, hooks_utils, diffs
36 36 from rhodecode.lib.compat import OrderedDict
37 37 from rhodecode.lib.hooks_daemon import prepare_callback_daemon
38 38 from rhodecode.lib.markup_renderer import (
39 39 DEFAULT_COMMENTS_RENDERER, RstTemplateRenderer)
40 40 from rhodecode.lib.utils import action_logger
41 41 from rhodecode.lib.utils2 import safe_unicode, safe_str, md5_safe
42 42 from rhodecode.lib.vcs.backends.base import (
43 43 Reference, MergeResponse, MergeFailureReason)
44 44 from rhodecode.lib.vcs.exceptions import (
45 45 CommitDoesNotExistError, EmptyRepositoryError)
46 46 from rhodecode.model import BaseModel
47 47 from rhodecode.model.changeset_status import ChangesetStatusModel
48 48 from rhodecode.model.comment import ChangesetCommentsModel
49 49 from rhodecode.model.db import (
50 50 PullRequest, PullRequestReviewers, Notification, ChangesetStatus,
51 51 PullRequestVersion, ChangesetComment)
52 52 from rhodecode.model.meta import Session
53 53 from rhodecode.model.notification import NotificationModel, \
54 54 EmailNotificationModel
55 55 from rhodecode.model.scm import ScmModel
56 56 from rhodecode.model.settings import VcsSettingsModel
57 57
58 58
59 59 log = logging.getLogger(__name__)
60 60
61 61
62 62 class PullRequestModel(BaseModel):
63 63
64 64 cls = PullRequest
65 65
66 66 DIFF_CONTEXT = 3
67 67
68 68 MERGE_STATUS_MESSAGES = {
69 69 MergeFailureReason.NONE: lazy_ugettext(
70 70 'This pull request can be automatically merged.'),
71 71 MergeFailureReason.UNKNOWN: lazy_ugettext(
72 72 'This pull request cannot be merged because of an unhandled'
73 73 ' exception.'),
74 74 MergeFailureReason.MERGE_FAILED: lazy_ugettext(
75 75 'This pull request cannot be merged because of conflicts.'),
76 76 MergeFailureReason.PUSH_FAILED: lazy_ugettext(
77 77 'This pull request could not be merged because push to target'
78 78 ' failed.'),
79 79 MergeFailureReason.TARGET_IS_NOT_HEAD: lazy_ugettext(
80 80 'This pull request cannot be merged because the target is not a'
81 81 ' head.'),
82 82 MergeFailureReason.HG_SOURCE_HAS_MORE_BRANCHES: lazy_ugettext(
83 83 'This pull request cannot be merged because the source contains'
84 84 ' more branches than the target.'),
85 85 MergeFailureReason.HG_TARGET_HAS_MULTIPLE_HEADS: lazy_ugettext(
86 86 'This pull request cannot be merged because the target has'
87 87 ' multiple heads.'),
88 88 MergeFailureReason.TARGET_IS_LOCKED: lazy_ugettext(
89 89 'This pull request cannot be merged because the target repository'
90 90 ' is locked.'),
91 91 MergeFailureReason.MISSING_COMMIT: lazy_ugettext(
92 92 'This pull request cannot be merged because the target or the '
93 93 'source reference is missing.'),
94 94 }
95 95
96 96 def __get_pull_request(self, pull_request):
97 97 return self._get_instance(PullRequest, pull_request)
98 98
99 99 def _check_perms(self, perms, pull_request, user, api=False):
100 100 if not api:
101 101 return h.HasRepoPermissionAny(*perms)(
102 102 user=user, repo_name=pull_request.target_repo.repo_name)
103 103 else:
104 104 return h.HasRepoPermissionAnyApi(*perms)(
105 105 user=user, repo_name=pull_request.target_repo.repo_name)
106 106
107 107 def check_user_read(self, pull_request, user, api=False):
108 108 _perms = ('repository.admin', 'repository.write', 'repository.read',)
109 109 return self._check_perms(_perms, pull_request, user, api)
110 110
111 111 def check_user_merge(self, pull_request, user, api=False):
112 112 _perms = ('repository.admin', 'repository.write', 'hg.admin',)
113 113 return self._check_perms(_perms, pull_request, user, api)
114 114
115 115 def check_user_update(self, pull_request, user, api=False):
116 116 owner = user.user_id == pull_request.user_id
117 117 return self.check_user_merge(pull_request, user, api) or owner
118 118
119 119 def check_user_change_status(self, pull_request, user, api=False):
120 120 reviewer = user.user_id in [x.user_id for x in
121 121 pull_request.reviewers]
122 122 return self.check_user_update(pull_request, user, api) or reviewer
123 123
124 124 def get(self, pull_request):
125 125 return self.__get_pull_request(pull_request)
126 126
127 127 def _prepare_get_all_query(self, repo_name, source=False, statuses=None,
128 128 opened_by=None, order_by=None,
129 129 order_dir='desc'):
130 130 repo = self._get_repo(repo_name)
131 131 q = PullRequest.query()
132 132 # source or target
133 133 if source:
134 134 q = q.filter(PullRequest.source_repo == repo)
135 135 else:
136 136 q = q.filter(PullRequest.target_repo == repo)
137 137
138 138 # closed,opened
139 139 if statuses:
140 140 q = q.filter(PullRequest.status.in_(statuses))
141 141
142 142 # opened by filter
143 143 if opened_by:
144 144 q = q.filter(PullRequest.user_id.in_(opened_by))
145 145
146 146 if order_by:
147 147 order_map = {
148 148 'name_raw': PullRequest.pull_request_id,
149 149 'title': PullRequest.title,
150 150 'updated_on_raw': PullRequest.updated_on
151 151 }
152 152 if order_dir == 'asc':
153 153 q = q.order_by(order_map[order_by].asc())
154 154 else:
155 155 q = q.order_by(order_map[order_by].desc())
156 156
157 157 return q
158 158
159 159 def count_all(self, repo_name, source=False, statuses=None,
160 160 opened_by=None):
161 161 """
162 162 Count the number of pull requests for a specific repository.
163 163
164 164 :param repo_name: target or source repo
165 165 :param source: boolean flag to specify if repo_name refers to source
166 166 :param statuses: list of pull request statuses
167 167 :param opened_by: author user of the pull request
168 168 :returns: int number of pull requests
169 169 """
170 170 q = self._prepare_get_all_query(
171 171 repo_name, source=source, statuses=statuses, opened_by=opened_by)
172 172
173 173 return q.count()
174 174
175 175 def get_all(self, repo_name, source=False, statuses=None, opened_by=None,
176 176 offset=0, length=None, order_by=None, order_dir='desc'):
177 177 """
178 178 Get all pull requests for a specific repository.
179 179
180 180 :param repo_name: target or source repo
181 181 :param source: boolean flag to specify if repo_name refers to source
182 182 :param statuses: list of pull request statuses
183 183 :param opened_by: author user of the pull request
184 184 :param offset: pagination offset
185 185 :param length: length of returned list
186 186 :param order_by: order of the returned list
187 187 :param order_dir: 'asc' or 'desc' ordering direction
188 188 :returns: list of pull requests
189 189 """
190 190 q = self._prepare_get_all_query(
191 191 repo_name, source=source, statuses=statuses, opened_by=opened_by,
192 192 order_by=order_by, order_dir=order_dir)
193 193
194 194 if length:
195 195 pull_requests = q.limit(length).offset(offset).all()
196 196 else:
197 197 pull_requests = q.all()
198 198
199 199 return pull_requests
200 200
201 201 def count_awaiting_review(self, repo_name, source=False, statuses=None,
202 202 opened_by=None):
203 203 """
204 204 Count the number of pull requests for a specific repository that are
205 205 awaiting review.
206 206
207 207 :param repo_name: target or source repo
208 208 :param source: boolean flag to specify if repo_name refers to source
209 209 :param statuses: list of pull request statuses
210 210 :param opened_by: author user of the pull request
211 211 :returns: int number of pull requests
212 212 """
213 213 pull_requests = self.get_awaiting_review(
214 214 repo_name, source=source, statuses=statuses, opened_by=opened_by)
215 215
216 216 return len(pull_requests)
217 217
218 218 def get_awaiting_review(self, repo_name, source=False, statuses=None,
219 219 opened_by=None, offset=0, length=None,
220 220 order_by=None, order_dir='desc'):
221 221 """
222 222 Get all pull requests for a specific repository that are awaiting
223 223 review.
224 224
225 225 :param repo_name: target or source repo
226 226 :param source: boolean flag to specify if repo_name refers to source
227 227 :param statuses: list of pull request statuses
228 228 :param opened_by: author user of the pull request
229 229 :param offset: pagination offset
230 230 :param length: length of returned list
231 231 :param order_by: order of the returned list
232 232 :param order_dir: 'asc' or 'desc' ordering direction
233 233 :returns: list of pull requests
234 234 """
235 235 pull_requests = self.get_all(
236 236 repo_name, source=source, statuses=statuses, opened_by=opened_by,
237 237 order_by=order_by, order_dir=order_dir)
238 238
239 239 _filtered_pull_requests = []
240 240 for pr in pull_requests:
241 241 status = pr.calculated_review_status()
242 242 if status in [ChangesetStatus.STATUS_NOT_REVIEWED,
243 243 ChangesetStatus.STATUS_UNDER_REVIEW]:
244 244 _filtered_pull_requests.append(pr)
245 245 if length:
246 246 return _filtered_pull_requests[offset:offset+length]
247 247 else:
248 248 return _filtered_pull_requests
249 249
250 250 def count_awaiting_my_review(self, repo_name, source=False, statuses=None,
251 251 opened_by=None, user_id=None):
252 252 """
253 253 Count the number of pull requests for a specific repository that are
254 254 awaiting review from a specific user.
255 255
256 256 :param repo_name: target or source repo
257 257 :param source: boolean flag to specify if repo_name refers to source
258 258 :param statuses: list of pull request statuses
259 259 :param opened_by: author user of the pull request
260 260 :param user_id: reviewer user of the pull request
261 261 :returns: int number of pull requests
262 262 """
263 263 pull_requests = self.get_awaiting_my_review(
264 264 repo_name, source=source, statuses=statuses, opened_by=opened_by,
265 265 user_id=user_id)
266 266
267 267 return len(pull_requests)
268 268
269 269 def get_awaiting_my_review(self, repo_name, source=False, statuses=None,
270 270 opened_by=None, user_id=None, offset=0,
271 271 length=None, order_by=None, order_dir='desc'):
272 272 """
273 273 Get all pull requests for a specific repository that are awaiting
274 274 review from a specific user.
275 275
276 276 :param repo_name: target or source repo
277 277 :param source: boolean flag to specify if repo_name refers to source
278 278 :param statuses: list of pull request statuses
279 279 :param opened_by: author user of the pull request
280 280 :param user_id: reviewer 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 _my = PullRequestModel().get_not_reviewed(user_id)
292 292 my_participation = []
293 293 for pr in pull_requests:
294 294 if pr in _my:
295 295 my_participation.append(pr)
296 296 _filtered_pull_requests = my_participation
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 get_not_reviewed(self, user_id):
303 303 return [
304 304 x.pull_request for x in PullRequestReviewers.query().filter(
305 305 PullRequestReviewers.user_id == user_id).all()
306 306 ]
307 307
308 308 def get_versions(self, pull_request):
309 309 """
310 310 returns version of pull request sorted by ID descending
311 311 """
312 312 return PullRequestVersion.query()\
313 313 .filter(PullRequestVersion.pull_request == pull_request)\
314 314 .order_by(PullRequestVersion.pull_request_version_id.asc())\
315 315 .all()
316 316
317 317 def create(self, created_by, source_repo, source_ref, target_repo,
318 318 target_ref, revisions, reviewers, title, description=None):
319 319 created_by_user = self._get_user(created_by)
320 320 source_repo = self._get_repo(source_repo)
321 321 target_repo = self._get_repo(target_repo)
322 322
323 323 pull_request = PullRequest()
324 324 pull_request.source_repo = source_repo
325 325 pull_request.source_ref = source_ref
326 326 pull_request.target_repo = target_repo
327 327 pull_request.target_ref = target_ref
328 328 pull_request.revisions = revisions
329 329 pull_request.title = title
330 330 pull_request.description = description
331 331 pull_request.author = created_by_user
332 332
333 333 Session().add(pull_request)
334 334 Session().flush()
335 335
336 336 # members / reviewers
337 337 for user_id in set(reviewers):
338 338 user = self._get_user(user_id)
339 339 reviewer = PullRequestReviewers(user, pull_request)
340 340 Session().add(reviewer)
341 341
342 342 # Set approval status to "Under Review" for all commits which are
343 343 # part of this pull request.
344 344 ChangesetStatusModel().set_status(
345 345 repo=target_repo,
346 346 status=ChangesetStatus.STATUS_UNDER_REVIEW,
347 347 user=created_by_user,
348 348 pull_request=pull_request
349 349 )
350 350
351 351 self.notify_reviewers(pull_request, reviewers)
352 352 self._trigger_pull_request_hook(
353 353 pull_request, created_by_user, 'create')
354 354
355 355 return pull_request
356 356
357 357 def _trigger_pull_request_hook(self, pull_request, user, action):
358 358 pull_request = self.__get_pull_request(pull_request)
359 359 target_scm = pull_request.target_repo.scm_instance()
360 360 if action == 'create':
361 361 trigger_hook = hooks_utils.trigger_log_create_pull_request_hook
362 362 elif action == 'merge':
363 363 trigger_hook = hooks_utils.trigger_log_merge_pull_request_hook
364 364 elif action == 'close':
365 365 trigger_hook = hooks_utils.trigger_log_close_pull_request_hook
366 366 elif action == 'review_status_change':
367 367 trigger_hook = hooks_utils.trigger_log_review_pull_request_hook
368 368 elif action == 'update':
369 369 trigger_hook = hooks_utils.trigger_log_update_pull_request_hook
370 370 else:
371 371 return
372 372
373 373 trigger_hook(
374 374 username=user.username,
375 375 repo_name=pull_request.target_repo.repo_name,
376 376 repo_alias=target_scm.alias,
377 377 pull_request=pull_request)
378 378
379 379 def _get_commit_ids(self, pull_request):
380 380 """
381 381 Return the commit ids of the merged pull request.
382 382
383 383 This method is not dealing correctly yet with the lack of autoupdates
384 384 nor with the implicit target updates.
385 385 For example: if a commit in the source repo is already in the target it
386 386 will be reported anyways.
387 387 """
388 388 merge_rev = pull_request.merge_rev
389 389 if merge_rev is None:
390 390 raise ValueError('This pull request was not merged yet')
391 391
392 392 commit_ids = list(pull_request.revisions)
393 393 if merge_rev not in commit_ids:
394 394 commit_ids.append(merge_rev)
395 395
396 396 return commit_ids
397 397
398 398 def merge(self, pull_request, user, extras):
399 399 log.debug("Merging pull request %s", pull_request.pull_request_id)
400 400 merge_state = self._merge_pull_request(pull_request, user, extras)
401 401 if merge_state.executed:
402 402 log.debug(
403 403 "Merge was successful, updating the pull request comments.")
404 404 self._comment_and_close_pr(pull_request, user, merge_state)
405 405 self._log_action('user_merged_pull_request', user, pull_request)
406 406 else:
407 407 log.warn("Merge failed, not updating the pull request.")
408 408 return merge_state
409 409
410 410 def _merge_pull_request(self, pull_request, user, extras):
411 411 target_vcs = pull_request.target_repo.scm_instance()
412 412 source_vcs = pull_request.source_repo.scm_instance()
413 413 target_ref = self._refresh_reference(
414 414 pull_request.target_ref_parts, target_vcs)
415 415
416 416 message = _(
417 417 'Merge pull request #%(pr_id)s from '
418 418 '%(source_repo)s %(source_ref_name)s\n\n %(pr_title)s') % {
419 419 'pr_id': pull_request.pull_request_id,
420 420 'source_repo': source_vcs.name,
421 421 'source_ref_name': pull_request.source_ref_parts.name,
422 422 'pr_title': pull_request.title
423 423 }
424 424
425 425 workspace_id = self._workspace_id(pull_request)
426 426 protocol = rhodecode.CONFIG.get('vcs.hooks.protocol')
427 427 use_direct_calls = rhodecode.CONFIG.get('vcs.hooks.direct_calls')
428 use_rebase = self._use_rebase_for_merging(pull_request)
428 429
429 430 callback_daemon, extras = prepare_callback_daemon(
430 431 extras, protocol=protocol, use_direct_calls=use_direct_calls)
431 432
432 433 with callback_daemon:
433 434 # TODO: johbo: Implement a clean way to run a config_override
434 435 # for a single call.
435 436 target_vcs.config.set(
436 437 'rhodecode', 'RC_SCM_DATA', json.dumps(extras))
437 438 merge_state = target_vcs.merge(
438 439 target_ref, source_vcs, pull_request.source_ref_parts,
439 440 workspace_id, user_name=user.username,
440 user_email=user.email, message=message)
441 user_email=user.email, message=message, use_rebase=use_rebase)
441 442 return merge_state
442 443
443 444 def _comment_and_close_pr(self, pull_request, user, merge_state):
444 445 pull_request.merge_rev = merge_state.merge_commit_id
445 446 pull_request.updated_on = datetime.datetime.now()
446 447
447 448 ChangesetCommentsModel().create(
448 449 text=unicode(_('Pull request merged and closed')),
449 450 repo=pull_request.target_repo.repo_id,
450 451 user=user.user_id,
451 452 pull_request=pull_request.pull_request_id,
452 453 f_path=None,
453 454 line_no=None,
454 455 closing_pr=True
455 456 )
456 457
457 458 Session().add(pull_request)
458 459 Session().flush()
459 460 # TODO: paris: replace invalidation with less radical solution
460 461 ScmModel().mark_for_invalidation(
461 462 pull_request.target_repo.repo_name)
462 463 self._trigger_pull_request_hook(pull_request, user, 'merge')
463 464
464 465 def has_valid_update_type(self, pull_request):
465 466 source_ref_type = pull_request.source_ref_parts.type
466 467 return source_ref_type in ['book', 'branch', 'tag']
467 468
468 469 def update_commits(self, pull_request):
469 470 """
470 471 Get the updated list of commits for the pull request
471 472 and return the new pull request version and the list
472 473 of commits processed by this update action
473 474 """
474 475
475 476 pull_request = self.__get_pull_request(pull_request)
476 477 source_ref_type = pull_request.source_ref_parts.type
477 478 source_ref_name = pull_request.source_ref_parts.name
478 479 source_ref_id = pull_request.source_ref_parts.commit_id
479 480
480 481 if not self.has_valid_update_type(pull_request):
481 482 log.debug(
482 483 "Skipping update of pull request %s due to ref type: %s",
483 484 pull_request, source_ref_type)
484 485 return (None, None)
485 486
486 487 source_repo = pull_request.source_repo.scm_instance()
487 488 source_commit = source_repo.get_commit(commit_id=source_ref_name)
488 489 if source_ref_id == source_commit.raw_id:
489 490 log.debug("Nothing changed in pull request %s", pull_request)
490 491 return (None, None)
491 492
492 493 # Finally there is a need for an update
493 494 pull_request_version = self._create_version_from_snapshot(pull_request)
494 495 self._link_comments_to_version(pull_request_version)
495 496
496 497 target_ref_type = pull_request.target_ref_parts.type
497 498 target_ref_name = pull_request.target_ref_parts.name
498 499 target_ref_id = pull_request.target_ref_parts.commit_id
499 500 target_repo = pull_request.target_repo.scm_instance()
500 501
501 502 if target_ref_type in ('tag', 'branch', 'book'):
502 503 target_commit = target_repo.get_commit(target_ref_name)
503 504 else:
504 505 target_commit = target_repo.get_commit(target_ref_id)
505 506
506 507 # re-compute commit ids
507 508 old_commit_ids = set(pull_request.revisions)
508 509 pre_load = ["author", "branch", "date", "message"]
509 510 commit_ranges = target_repo.compare(
510 511 target_commit.raw_id, source_commit.raw_id, source_repo, merge=True,
511 512 pre_load=pre_load)
512 513
513 514 ancestor = target_repo.get_common_ancestor(
514 515 target_commit.raw_id, source_commit.raw_id, source_repo)
515 516
516 517 pull_request.source_ref = '%s:%s:%s' % (
517 518 source_ref_type, source_ref_name, source_commit.raw_id)
518 519 pull_request.target_ref = '%s:%s:%s' % (
519 520 target_ref_type, target_ref_name, ancestor)
520 521 pull_request.revisions = [
521 522 commit.raw_id for commit in reversed(commit_ranges)]
522 523 pull_request.updated_on = datetime.datetime.now()
523 524 Session().add(pull_request)
524 525 new_commit_ids = set(pull_request.revisions)
525 526
526 527 changes = self._calculate_commit_id_changes(
527 528 old_commit_ids, new_commit_ids)
528 529
529 530 old_diff_data, new_diff_data = self._generate_update_diffs(
530 531 pull_request, pull_request_version)
531 532
532 533 ChangesetCommentsModel().outdate_comments(
533 534 pull_request, old_diff_data=old_diff_data,
534 535 new_diff_data=new_diff_data)
535 536
536 537 file_changes = self._calculate_file_changes(
537 538 old_diff_data, new_diff_data)
538 539
539 540 # Add an automatic comment to the pull request
540 541 update_comment = ChangesetCommentsModel().create(
541 542 text=self._render_update_message(changes, file_changes),
542 543 repo=pull_request.target_repo,
543 544 user=pull_request.author,
544 545 pull_request=pull_request,
545 546 send_email=False, renderer=DEFAULT_COMMENTS_RENDERER)
546 547
547 548 # Update status to "Under Review" for added commits
548 549 for commit_id in changes.added:
549 550 ChangesetStatusModel().set_status(
550 551 repo=pull_request.source_repo,
551 552 status=ChangesetStatus.STATUS_UNDER_REVIEW,
552 553 comment=update_comment,
553 554 user=pull_request.author,
554 555 pull_request=pull_request,
555 556 revision=commit_id)
556 557
557 558 log.debug(
558 559 'Updated pull request %s, added_ids: %s, common_ids: %s, '
559 560 'removed_ids: %s', pull_request.pull_request_id,
560 561 changes.added, changes.common, changes.removed)
561 562 log.debug('Updated pull request with the following file changes: %s',
562 563 file_changes)
563 564
564 565 log.info(
565 566 "Updated pull request %s from commit %s to commit %s, "
566 567 "stored new version %s of this pull request.",
567 568 pull_request.pull_request_id, source_ref_id,
568 569 pull_request.source_ref_parts.commit_id,
569 570 pull_request_version.pull_request_version_id)
570 571 Session().commit()
571 572 self._trigger_pull_request_hook(pull_request, pull_request.author,
572 573 'update')
573 574 return (pull_request_version, changes)
574 575
575 576 def _create_version_from_snapshot(self, pull_request):
576 577 version = PullRequestVersion()
577 578 version.title = pull_request.title
578 579 version.description = pull_request.description
579 580 version.status = pull_request.status
580 581 version.created_on = pull_request.created_on
581 582 version.updated_on = pull_request.updated_on
582 583 version.user_id = pull_request.user_id
583 584 version.source_repo = pull_request.source_repo
584 585 version.source_ref = pull_request.source_ref
585 586 version.target_repo = pull_request.target_repo
586 587 version.target_ref = pull_request.target_ref
587 588
588 589 version._last_merge_source_rev = pull_request._last_merge_source_rev
589 590 version._last_merge_target_rev = pull_request._last_merge_target_rev
590 591 version._last_merge_status = pull_request._last_merge_status
591 592 version.merge_rev = pull_request.merge_rev
592 593
593 594 version.revisions = pull_request.revisions
594 595 version.pull_request = pull_request
595 596 Session().add(version)
596 597 Session().flush()
597 598
598 599 return version
599 600
600 601 def _generate_update_diffs(self, pull_request, pull_request_version):
601 602 diff_context = (
602 603 self.DIFF_CONTEXT +
603 604 ChangesetCommentsModel.needed_extra_diff_context())
604 605 old_diff = self._get_diff_from_pr_or_version(
605 606 pull_request_version, context=diff_context)
606 607 new_diff = self._get_diff_from_pr_or_version(
607 608 pull_request, context=diff_context)
608 609
609 610 old_diff_data = diffs.DiffProcessor(old_diff)
610 611 old_diff_data.prepare()
611 612 new_diff_data = diffs.DiffProcessor(new_diff)
612 613 new_diff_data.prepare()
613 614
614 615 return old_diff_data, new_diff_data
615 616
616 617 def _link_comments_to_version(self, pull_request_version):
617 618 """
618 619 Link all unlinked comments of this pull request to the given version.
619 620
620 621 :param pull_request_version: The `PullRequestVersion` to which
621 622 the comments shall be linked.
622 623
623 624 """
624 625 pull_request = pull_request_version.pull_request
625 626 comments = ChangesetComment.query().filter(
626 627 # TODO: johbo: Should we query for the repo at all here?
627 628 # Pending decision on how comments of PRs are to be related
628 629 # to either the source repo, the target repo or no repo at all.
629 630 ChangesetComment.repo_id == pull_request.target_repo.repo_id,
630 631 ChangesetComment.pull_request == pull_request,
631 632 ChangesetComment.pull_request_version == None)
632 633
633 634 # TODO: johbo: Find out why this breaks if it is done in a bulk
634 635 # operation.
635 636 for comment in comments:
636 637 comment.pull_request_version_id = (
637 638 pull_request_version.pull_request_version_id)
638 639 Session().add(comment)
639 640
640 641 def _calculate_commit_id_changes(self, old_ids, new_ids):
641 642 added = new_ids.difference(old_ids)
642 643 common = old_ids.intersection(new_ids)
643 644 removed = old_ids.difference(new_ids)
644 645 return ChangeTuple(added, common, removed)
645 646
646 647 def _calculate_file_changes(self, old_diff_data, new_diff_data):
647 648
648 649 old_files = OrderedDict()
649 650 for diff_data in old_diff_data.parsed_diff:
650 651 old_files[diff_data['filename']] = md5_safe(diff_data['raw_diff'])
651 652
652 653 added_files = []
653 654 modified_files = []
654 655 removed_files = []
655 656 for diff_data in new_diff_data.parsed_diff:
656 657 new_filename = diff_data['filename']
657 658 new_hash = md5_safe(diff_data['raw_diff'])
658 659
659 660 old_hash = old_files.get(new_filename)
660 661 if not old_hash:
661 662 # file is not present in old diff, means it's added
662 663 added_files.append(new_filename)
663 664 else:
664 665 if new_hash != old_hash:
665 666 modified_files.append(new_filename)
666 667 # now remove a file from old, since we have seen it already
667 668 del old_files[new_filename]
668 669
669 670 # removed files is when there are present in old, but not in NEW,
670 671 # since we remove old files that are present in new diff, left-overs
671 672 # if any should be the removed files
672 673 removed_files.extend(old_files.keys())
673 674
674 675 return FileChangeTuple(added_files, modified_files, removed_files)
675 676
676 677 def _render_update_message(self, changes, file_changes):
677 678 """
678 679 render the message using DEFAULT_COMMENTS_RENDERER (RST renderer),
679 680 so it's always looking the same disregarding on which default
680 681 renderer system is using.
681 682
682 683 :param changes: changes named tuple
683 684 :param file_changes: file changes named tuple
684 685
685 686 """
686 687 new_status = ChangesetStatus.get_status_lbl(
687 688 ChangesetStatus.STATUS_UNDER_REVIEW)
688 689
689 690 changed_files = (
690 691 file_changes.added + file_changes.modified + file_changes.removed)
691 692
692 693 params = {
693 694 'under_review_label': new_status,
694 695 'added_commits': changes.added,
695 696 'removed_commits': changes.removed,
696 697 'changed_files': changed_files,
697 698 'added_files': file_changes.added,
698 699 'modified_files': file_changes.modified,
699 700 'removed_files': file_changes.removed,
700 701 }
701 702 renderer = RstTemplateRenderer()
702 703 return renderer.render('pull_request_update.mako', **params)
703 704
704 705 def edit(self, pull_request, title, description):
705 706 pull_request = self.__get_pull_request(pull_request)
706 707 if pull_request.is_closed():
707 708 raise ValueError('This pull request is closed')
708 709 if title:
709 710 pull_request.title = title
710 711 pull_request.description = description
711 712 pull_request.updated_on = datetime.datetime.now()
712 713 Session().add(pull_request)
713 714
714 715 def update_reviewers(self, pull_request, reviewers_ids):
715 716 reviewers_ids = set(reviewers_ids)
716 717 pull_request = self.__get_pull_request(pull_request)
717 718 current_reviewers = PullRequestReviewers.query()\
718 719 .filter(PullRequestReviewers.pull_request ==
719 720 pull_request).all()
720 721 current_reviewers_ids = set([x.user.user_id for x in current_reviewers])
721 722
722 723 ids_to_add = reviewers_ids.difference(current_reviewers_ids)
723 724 ids_to_remove = current_reviewers_ids.difference(reviewers_ids)
724 725
725 726 log.debug("Adding %s reviewers", ids_to_add)
726 727 log.debug("Removing %s reviewers", ids_to_remove)
727 728 changed = False
728 729 for uid in ids_to_add:
729 730 changed = True
730 731 _usr = self._get_user(uid)
731 732 reviewer = PullRequestReviewers(_usr, pull_request)
732 733 Session().add(reviewer)
733 734
734 735 self.notify_reviewers(pull_request, ids_to_add)
735 736
736 737 for uid in ids_to_remove:
737 738 changed = True
738 739 reviewer = PullRequestReviewers.query()\
739 740 .filter(PullRequestReviewers.user_id == uid,
740 741 PullRequestReviewers.pull_request == pull_request)\
741 742 .scalar()
742 743 if reviewer:
743 744 Session().delete(reviewer)
744 745 if changed:
745 746 pull_request.updated_on = datetime.datetime.now()
746 747 Session().add(pull_request)
747 748
748 749 return ids_to_add, ids_to_remove
749 750
750 751 def notify_reviewers(self, pull_request, reviewers_ids):
751 752 # notification to reviewers
752 753 if not reviewers_ids:
753 754 return
754 755
755 756 pull_request_obj = pull_request
756 757 # get the current participants of this pull request
757 758 recipients = reviewers_ids
758 759 notification_type = EmailNotificationModel.TYPE_PULL_REQUEST
759 760
760 761 pr_source_repo = pull_request_obj.source_repo
761 762 pr_target_repo = pull_request_obj.target_repo
762 763
763 764 pr_url = h.url(
764 765 'pullrequest_show',
765 766 repo_name=pr_target_repo.repo_name,
766 767 pull_request_id=pull_request_obj.pull_request_id,
767 768 qualified=True,)
768 769
769 770 # set some variables for email notification
770 771 pr_target_repo_url = h.url(
771 772 'summary_home',
772 773 repo_name=pr_target_repo.repo_name,
773 774 qualified=True)
774 775
775 776 pr_source_repo_url = h.url(
776 777 'summary_home',
777 778 repo_name=pr_source_repo.repo_name,
778 779 qualified=True)
779 780
780 781 # pull request specifics
781 782 pull_request_commits = [
782 783 (x.raw_id, x.message)
783 784 for x in map(pr_source_repo.get_commit, pull_request.revisions)]
784 785
785 786 kwargs = {
786 787 'user': pull_request.author,
787 788 'pull_request': pull_request_obj,
788 789 'pull_request_commits': pull_request_commits,
789 790
790 791 'pull_request_target_repo': pr_target_repo,
791 792 'pull_request_target_repo_url': pr_target_repo_url,
792 793
793 794 'pull_request_source_repo': pr_source_repo,
794 795 'pull_request_source_repo_url': pr_source_repo_url,
795 796
796 797 'pull_request_url': pr_url,
797 798 }
798 799
799 800 # pre-generate the subject for notification itself
800 801 (subject,
801 802 _h, _e, # we don't care about those
802 803 body_plaintext) = EmailNotificationModel().render_email(
803 804 notification_type, **kwargs)
804 805
805 806 # create notification objects, and emails
806 807 NotificationModel().create(
807 808 created_by=pull_request.author,
808 809 notification_subject=subject,
809 810 notification_body=body_plaintext,
810 811 notification_type=notification_type,
811 812 recipients=recipients,
812 813 email_kwargs=kwargs,
813 814 )
814 815
815 816 def delete(self, pull_request):
816 817 pull_request = self.__get_pull_request(pull_request)
817 818 self._cleanup_merge_workspace(pull_request)
818 819 Session().delete(pull_request)
819 820
820 821 def close_pull_request(self, pull_request, user):
821 822 pull_request = self.__get_pull_request(pull_request)
822 823 self._cleanup_merge_workspace(pull_request)
823 824 pull_request.status = PullRequest.STATUS_CLOSED
824 825 pull_request.updated_on = datetime.datetime.now()
825 826 Session().add(pull_request)
826 827 self._trigger_pull_request_hook(
827 828 pull_request, pull_request.author, 'close')
828 829 self._log_action('user_closed_pull_request', user, pull_request)
829 830
830 831 def close_pull_request_with_comment(self, pull_request, user, repo,
831 832 message=None):
832 833 status = ChangesetStatus.STATUS_REJECTED
833 834
834 835 if not message:
835 836 message = (
836 837 _('Status change %(transition_icon)s %(status)s') % {
837 838 'transition_icon': '>',
838 839 'status': ChangesetStatus.get_status_lbl(status)})
839 840
840 841 internal_message = _('Closing with') + ' ' + message
841 842
842 843 comm = ChangesetCommentsModel().create(
843 844 text=internal_message,
844 845 repo=repo.repo_id,
845 846 user=user.user_id,
846 847 pull_request=pull_request.pull_request_id,
847 848 f_path=None,
848 849 line_no=None,
849 850 status_change=ChangesetStatus.get_status_lbl(status),
850 851 closing_pr=True
851 852 )
852 853
853 854 ChangesetStatusModel().set_status(
854 855 repo.repo_id,
855 856 status,
856 857 user.user_id,
857 858 comm,
858 859 pull_request=pull_request.pull_request_id
859 860 )
860 861 Session().flush()
861 862
862 863 PullRequestModel().close_pull_request(
863 864 pull_request.pull_request_id, user)
864 865
865 866 def merge_status(self, pull_request):
866 867 if not self._is_merge_enabled(pull_request):
867 868 return False, _('Server-side pull request merging is disabled.')
868 869 if pull_request.is_closed():
869 870 return False, _('This pull request is closed.')
870 871 merge_possible, msg = self._check_repo_requirements(
871 872 target=pull_request.target_repo, source=pull_request.source_repo)
872 873 if not merge_possible:
873 874 return merge_possible, msg
874 875
875 876 try:
876 877 resp = self._try_merge(pull_request)
877 878 status = resp.possible, self.merge_status_message(
878 879 resp.failure_reason)
879 880 except NotImplementedError:
880 881 status = False, _('Pull request merging is not supported.')
881 882
882 883 return status
883 884
884 885 def _check_repo_requirements(self, target, source):
885 886 """
886 887 Check if `target` and `source` have compatible requirements.
887 888
888 889 Currently this is just checking for largefiles.
889 890 """
890 891 target_has_largefiles = self._has_largefiles(target)
891 892 source_has_largefiles = self._has_largefiles(source)
892 893 merge_possible = True
893 894 message = u''
894 895
895 896 if target_has_largefiles != source_has_largefiles:
896 897 merge_possible = False
897 898 if source_has_largefiles:
898 899 message = _(
899 900 'Target repository large files support is disabled.')
900 901 else:
901 902 message = _(
902 903 'Source repository large files support is disabled.')
903 904
904 905 return merge_possible, message
905 906
906 907 def _has_largefiles(self, repo):
907 908 largefiles_ui = VcsSettingsModel(repo=repo).get_ui_settings(
908 909 'extensions', 'largefiles')
909 910 return largefiles_ui and largefiles_ui[0].active
910 911
911 912 def _try_merge(self, pull_request):
912 913 """
913 914 Try to merge the pull request and return the merge status.
914 915 """
915 916 log.debug(
916 917 "Trying out if the pull request %s can be merged.",
917 918 pull_request.pull_request_id)
918 919 target_vcs = pull_request.target_repo.scm_instance()
919 920 target_ref = self._refresh_reference(
920 921 pull_request.target_ref_parts, target_vcs)
921 922
922 923 target_locked = pull_request.target_repo.locked
923 924 if target_locked and target_locked[0]:
924 925 log.debug("The target repository is locked.")
925 926 merge_state = MergeResponse(
926 927 False, False, None, MergeFailureReason.TARGET_IS_LOCKED)
927 928 elif self._needs_merge_state_refresh(pull_request, target_ref):
928 929 log.debug("Refreshing the merge status of the repository.")
929 930 merge_state = self._refresh_merge_state(
930 931 pull_request, target_vcs, target_ref)
931 932 else:
932 933 possible = pull_request.\
933 934 _last_merge_status == MergeFailureReason.NONE
934 935 merge_state = MergeResponse(
935 936 possible, False, None, pull_request._last_merge_status)
936 937 log.debug("Merge response: %s", merge_state)
937 938 return merge_state
938 939
939 940 def _refresh_reference(self, reference, vcs_repository):
940 941 if reference.type in ('branch', 'book'):
941 942 name_or_id = reference.name
942 943 else:
943 944 name_or_id = reference.commit_id
944 945 refreshed_commit = vcs_repository.get_commit(name_or_id)
945 946 refreshed_reference = Reference(
946 947 reference.type, reference.name, refreshed_commit.raw_id)
947 948 return refreshed_reference
948 949
949 950 def _needs_merge_state_refresh(self, pull_request, target_reference):
950 951 return not(
951 952 pull_request.revisions and
952 953 pull_request.revisions[0] == pull_request._last_merge_source_rev and
953 954 target_reference.commit_id == pull_request._last_merge_target_rev)
954 955
955 956 def _refresh_merge_state(self, pull_request, target_vcs, target_reference):
956 957 workspace_id = self._workspace_id(pull_request)
957 958 source_vcs = pull_request.source_repo.scm_instance()
959 use_rebase = self._use_rebase_for_merging(pull_request)
958 960 merge_state = target_vcs.merge(
959 961 target_reference, source_vcs, pull_request.source_ref_parts,
960 workspace_id, dry_run=True)
962 workspace_id, dry_run=True, use_rebase=use_rebase)
961 963
962 964 # Do not store the response if there was an unknown error.
963 965 if merge_state.failure_reason != MergeFailureReason.UNKNOWN:
964 966 pull_request._last_merge_source_rev = pull_request.\
965 967 source_ref_parts.commit_id
966 968 pull_request._last_merge_target_rev = target_reference.commit_id
967 969 pull_request._last_merge_status = (
968 970 merge_state.failure_reason)
969 971 Session().add(pull_request)
970 972 Session().flush()
971 973
972 974 return merge_state
973 975
974 976 def _workspace_id(self, pull_request):
975 977 workspace_id = 'pr-%s' % pull_request.pull_request_id
976 978 return workspace_id
977 979
978 980 def merge_status_message(self, status_code):
979 981 """
980 982 Return a human friendly error message for the given merge status code.
981 983 """
982 984 return self.MERGE_STATUS_MESSAGES[status_code]
983 985
984 986 def generate_repo_data(self, repo, commit_id=None, branch=None,
985 987 bookmark=None):
986 988 all_refs, selected_ref = \
987 989 self._get_repo_pullrequest_sources(
988 990 repo.scm_instance(), commit_id=commit_id,
989 991 branch=branch, bookmark=bookmark)
990 992
991 993 refs_select2 = []
992 994 for element in all_refs:
993 995 children = [{'id': x[0], 'text': x[1]} for x in element[0]]
994 996 refs_select2.append({'text': element[1], 'children': children})
995 997
996 998 return {
997 999 'user': {
998 1000 'user_id': repo.user.user_id,
999 1001 'username': repo.user.username,
1000 1002 'firstname': repo.user.firstname,
1001 1003 'lastname': repo.user.lastname,
1002 1004 'gravatar_link': h.gravatar_url(repo.user.email, 14),
1003 1005 },
1004 1006 'description': h.chop_at_smart(repo.description, '\n'),
1005 1007 'refs': {
1006 1008 'all_refs': all_refs,
1007 1009 'selected_ref': selected_ref,
1008 1010 'select2_refs': refs_select2
1009 1011 }
1010 1012 }
1011 1013
1012 1014 def generate_pullrequest_title(self, source, source_ref, target):
1013 1015 return '{source}#{at_ref} to {target}'.format(
1014 1016 source=source,
1015 1017 at_ref=source_ref,
1016 1018 target=target,
1017 1019 )
1018 1020
1019 1021 def _cleanup_merge_workspace(self, pull_request):
1020 1022 # Merging related cleanup
1021 1023 target_scm = pull_request.target_repo.scm_instance()
1022 1024 workspace_id = 'pr-%s' % pull_request.pull_request_id
1023 1025
1024 1026 try:
1025 1027 target_scm.cleanup_merge_workspace(workspace_id)
1026 1028 except NotImplementedError:
1027 1029 pass
1028 1030
1029 1031 def _get_repo_pullrequest_sources(
1030 1032 self, repo, commit_id=None, branch=None, bookmark=None):
1031 1033 """
1032 1034 Return a structure with repo's interesting commits, suitable for
1033 1035 the selectors in pullrequest controller
1034 1036
1035 1037 :param commit_id: a commit that must be in the list somehow
1036 1038 and selected by default
1037 1039 :param branch: a branch that must be in the list and selected
1038 1040 by default - even if closed
1039 1041 :param bookmark: a bookmark that must be in the list and selected
1040 1042 """
1041 1043
1042 1044 commit_id = safe_str(commit_id) if commit_id else None
1043 1045 branch = safe_str(branch) if branch else None
1044 1046 bookmark = safe_str(bookmark) if bookmark else None
1045 1047
1046 1048 selected = None
1047 1049
1048 1050 # order matters: first source that has commit_id in it will be selected
1049 1051 sources = []
1050 1052 sources.append(('book', repo.bookmarks.items(), _('Bookmarks'), bookmark))
1051 1053 sources.append(('branch', repo.branches.items(), _('Branches'), branch))
1052 1054
1053 1055 if commit_id:
1054 1056 ref_commit = (h.short_id(commit_id), commit_id)
1055 1057 sources.append(('rev', [ref_commit], _('Commit IDs'), commit_id))
1056 1058
1057 1059 sources.append(
1058 1060 ('branch', repo.branches_closed.items(), _('Closed Branches'), branch),
1059 1061 )
1060 1062
1061 1063 groups = []
1062 1064 for group_key, ref_list, group_name, match in sources:
1063 1065 group_refs = []
1064 1066 for ref_name, ref_id in ref_list:
1065 1067 ref_key = '%s:%s:%s' % (group_key, ref_name, ref_id)
1066 1068 group_refs.append((ref_key, ref_name))
1067 1069
1068 1070 if not selected:
1069 1071 if set([commit_id, match]) & set([ref_id, ref_name]):
1070 1072 selected = ref_key
1071 1073
1072 1074 if group_refs:
1073 1075 groups.append((group_refs, group_name))
1074 1076
1075 1077 if not selected:
1076 1078 ref = commit_id or branch or bookmark
1077 1079 if ref:
1078 1080 raise CommitDoesNotExistError(
1079 1081 'No commit refs could be found matching: %s' % ref)
1080 1082 elif repo.DEFAULT_BRANCH_NAME in repo.branches:
1081 1083 selected = 'branch:%s:%s' % (
1082 1084 repo.DEFAULT_BRANCH_NAME,
1083 1085 repo.branches[repo.DEFAULT_BRANCH_NAME]
1084 1086 )
1085 1087 elif repo.commit_ids:
1086 1088 rev = repo.commit_ids[0]
1087 1089 selected = 'rev:%s:%s' % (rev, rev)
1088 1090 else:
1089 1091 raise EmptyRepositoryError()
1090 1092 return groups, selected
1091 1093
1092 1094 def get_diff(self, pull_request, context=DIFF_CONTEXT):
1093 1095 pull_request = self.__get_pull_request(pull_request)
1094 1096 return self._get_diff_from_pr_or_version(pull_request, context=context)
1095 1097
1096 1098 def _get_diff_from_pr_or_version(self, pr_or_version, context):
1097 1099 source_repo = pr_or_version.source_repo
1098 1100
1099 1101 # we swap org/other ref since we run a simple diff on one repo
1100 1102 target_ref_id = pr_or_version.target_ref_parts.commit_id
1101 1103 source_ref_id = pr_or_version.source_ref_parts.commit_id
1102 1104 target_commit = source_repo.get_commit(
1103 1105 commit_id=safe_str(target_ref_id))
1104 1106 source_commit = source_repo.get_commit(commit_id=safe_str(source_ref_id))
1105 1107 vcs_repo = source_repo.scm_instance()
1106 1108
1107 1109 # TODO: johbo: In the context of an update, we cannot reach
1108 1110 # the old commit anymore with our normal mechanisms. It needs
1109 1111 # some sort of special support in the vcs layer to avoid this
1110 1112 # workaround.
1111 1113 if (source_commit.raw_id == vcs_repo.EMPTY_COMMIT_ID and
1112 1114 vcs_repo.alias == 'git'):
1113 1115 source_commit.raw_id = safe_str(source_ref_id)
1114 1116
1115 1117 log.debug('calculating diff between '
1116 1118 'source_ref:%s and target_ref:%s for repo `%s`',
1117 1119 target_ref_id, source_ref_id,
1118 1120 safe_unicode(vcs_repo.path))
1119 1121
1120 1122 vcs_diff = vcs_repo.get_diff(
1121 1123 commit1=target_commit, commit2=source_commit, context=context)
1122 1124 return vcs_diff
1123 1125
1124 1126 def _is_merge_enabled(self, pull_request):
1125 1127 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
1126 1128 settings = settings_model.get_general_settings()
1127 1129 return settings.get('rhodecode_pr_merge_enabled', False)
1128 1130
1131 def _use_rebase_for_merging(self, pull_request):
1132 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
1133 settings = settings_model.get_general_settings()
1134 return settings.get('rhodecode_hg_use_rebase_for_merging', False)
1135
1129 1136 def _log_action(self, action, user, pull_request):
1130 1137 action_logger(
1131 1138 user,
1132 1139 '{action}:{pr_id}'.format(
1133 1140 action=action, pr_id=pull_request.pull_request_id),
1134 1141 pull_request.target_repo)
1135 1142
1136 1143
1137 1144 ChangeTuple = namedtuple('ChangeTuple',
1138 1145 ['added', 'common', 'removed'])
1139 1146
1140 1147 FileChangeTuple = namedtuple('FileChangeTuple',
1141 1148 ['added', 'modified', 'removed'])
General Comments 0
You need to be logged in to leave comments. Login now