##// END OF EJS Templates
pull_request: Increase debug logging around merge.
johbo -
r149:14cb409d default
parent child Browse files
Show More
@@ -1,1136 +1,1141 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 log.debug("Merging pull request %s", pull_request.pull_request_id)
399 400 merge_state = self._merge_pull_request(pull_request, user, extras)
400 401 if merge_state.executed:
402 log.debug(
403 "Merge was successful, updating the pull request comments.")
401 404 self._comment_and_close_pr(pull_request, user, merge_state)
402 405 self._log_action('user_merged_pull_request', user, pull_request)
406 else:
407 log.warn("Merge failed, not updating the pull request.")
403 408 return merge_state
404 409
405 410 def _merge_pull_request(self, pull_request, user, extras):
406 411 target_vcs = pull_request.target_repo.scm_instance()
407 412 source_vcs = pull_request.source_repo.scm_instance()
408 413 target_ref = self._refresh_reference(
409 414 pull_request.target_ref_parts, target_vcs)
410 415
411 416 message = _(
412 417 'Merge pull request #%(pr_id)s from '
413 418 '%(source_repo)s %(source_ref_name)s\n\n %(pr_title)s') % {
414 419 'pr_id': pull_request.pull_request_id,
415 420 'source_repo': source_vcs.name,
416 421 'source_ref_name': pull_request.source_ref_parts.name,
417 422 'pr_title': pull_request.title
418 423 }
419 424
420 425 workspace_id = self._workspace_id(pull_request)
421 426 protocol = rhodecode.CONFIG.get('vcs.hooks.protocol')
422 427 use_direct_calls = rhodecode.CONFIG.get('vcs.hooks.direct_calls')
423 428
424 429 callback_daemon, extras = prepare_callback_daemon(
425 430 extras, protocol=protocol, use_direct_calls=use_direct_calls)
426 431
427 432 with callback_daemon:
428 433 # TODO: johbo: Implement a clean way to run a config_override
429 434 # for a single call.
430 435 target_vcs.config.set(
431 436 'rhodecode', 'RC_SCM_DATA', json.dumps(extras))
432 437 merge_state = target_vcs.merge(
433 438 target_ref, source_vcs, pull_request.source_ref_parts,
434 439 workspace_id, user_name=user.username,
435 440 user_email=user.email, message=message)
436 441 return merge_state
437 442
438 443 def _comment_and_close_pr(self, pull_request, user, merge_state):
439 444 pull_request.merge_rev = merge_state.merge_commit_id
440 445 pull_request.updated_on = datetime.datetime.now()
441 446
442 447 ChangesetCommentsModel().create(
443 448 text=unicode(_('Pull request merged and closed')),
444 449 repo=pull_request.target_repo.repo_id,
445 450 user=user.user_id,
446 451 pull_request=pull_request.pull_request_id,
447 452 f_path=None,
448 453 line_no=None,
449 454 closing_pr=True
450 455 )
451 456
452 457 Session().add(pull_request)
453 458 Session().flush()
454 459 # TODO: paris: replace invalidation with less radical solution
455 460 ScmModel().mark_for_invalidation(
456 461 pull_request.target_repo.repo_name)
457 462 self._trigger_pull_request_hook(pull_request, user, 'merge')
458 463
459 464 def has_valid_update_type(self, pull_request):
460 465 source_ref_type = pull_request.source_ref_parts.type
461 466 return source_ref_type in ['book', 'branch', 'tag']
462 467
463 468 def update_commits(self, pull_request):
464 469 """
465 470 Get the updated list of commits for the pull request
466 471 and return the new pull request version and the list
467 472 of commits processed by this update action
468 473 """
469 474
470 475 pull_request = self.__get_pull_request(pull_request)
471 476 source_ref_type = pull_request.source_ref_parts.type
472 477 source_ref_name = pull_request.source_ref_parts.name
473 478 source_ref_id = pull_request.source_ref_parts.commit_id
474 479
475 480 if not self.has_valid_update_type(pull_request):
476 481 log.debug(
477 482 "Skipping update of pull request %s due to ref type: %s",
478 483 pull_request, source_ref_type)
479 484 return (None, None)
480 485
481 486 source_repo = pull_request.source_repo.scm_instance()
482 487 source_commit = source_repo.get_commit(commit_id=source_ref_name)
483 488 if source_ref_id == source_commit.raw_id:
484 489 log.debug("Nothing changed in pull request %s", pull_request)
485 490 return (None, None)
486 491
487 492 # Finally there is a need for an update
488 493 pull_request_version = self._create_version_from_snapshot(pull_request)
489 494 self._link_comments_to_version(pull_request_version)
490 495
491 496 target_ref_type = pull_request.target_ref_parts.type
492 497 target_ref_name = pull_request.target_ref_parts.name
493 498 target_ref_id = pull_request.target_ref_parts.commit_id
494 499 target_repo = pull_request.target_repo.scm_instance()
495 500
496 501 if target_ref_type in ('tag', 'branch', 'book'):
497 502 target_commit = target_repo.get_commit(target_ref_name)
498 503 else:
499 504 target_commit = target_repo.get_commit(target_ref_id)
500 505
501 506 # re-compute commit ids
502 507 old_commit_ids = set(pull_request.revisions)
503 508 pre_load = ["author", "branch", "date", "message"]
504 509 commit_ranges = target_repo.compare(
505 510 target_commit.raw_id, source_commit.raw_id, source_repo, merge=True,
506 511 pre_load=pre_load)
507 512
508 513 ancestor = target_repo.get_common_ancestor(
509 514 target_commit.raw_id, source_commit.raw_id, source_repo)
510 515
511 516 pull_request.source_ref = '%s:%s:%s' % (
512 517 source_ref_type, source_ref_name, source_commit.raw_id)
513 518 pull_request.target_ref = '%s:%s:%s' % (
514 519 target_ref_type, target_ref_name, ancestor)
515 520 pull_request.revisions = [
516 521 commit.raw_id for commit in reversed(commit_ranges)]
517 522 pull_request.updated_on = datetime.datetime.now()
518 523 Session().add(pull_request)
519 524 new_commit_ids = set(pull_request.revisions)
520 525
521 526 changes = self._calculate_commit_id_changes(
522 527 old_commit_ids, new_commit_ids)
523 528
524 529 old_diff_data, new_diff_data = self._generate_update_diffs(
525 530 pull_request, pull_request_version)
526 531
527 532 ChangesetCommentsModel().outdate_comments(
528 533 pull_request, old_diff_data=old_diff_data,
529 534 new_diff_data=new_diff_data)
530 535
531 536 file_changes = self._calculate_file_changes(
532 537 old_diff_data, new_diff_data)
533 538
534 539 # Add an automatic comment to the pull request
535 540 update_comment = ChangesetCommentsModel().create(
536 541 text=self._render_update_message(changes, file_changes),
537 542 repo=pull_request.target_repo,
538 543 user=pull_request.author,
539 544 pull_request=pull_request,
540 545 send_email=False, renderer=DEFAULT_COMMENTS_RENDERER)
541 546
542 547 # Update status to "Under Review" for added commits
543 548 for commit_id in changes.added:
544 549 ChangesetStatusModel().set_status(
545 550 repo=pull_request.source_repo,
546 551 status=ChangesetStatus.STATUS_UNDER_REVIEW,
547 552 comment=update_comment,
548 553 user=pull_request.author,
549 554 pull_request=pull_request,
550 555 revision=commit_id)
551 556
552 557 log.debug(
553 558 'Updated pull request %s, added_ids: %s, common_ids: %s, '
554 559 'removed_ids: %s', pull_request.pull_request_id,
555 560 changes.added, changes.common, changes.removed)
556 561 log.debug('Updated pull request with the following file changes: %s',
557 562 file_changes)
558 563
559 564 log.info(
560 565 "Updated pull request %s from commit %s to commit %s, "
561 566 "stored new version %s of this pull request.",
562 567 pull_request.pull_request_id, source_ref_id,
563 568 pull_request.source_ref_parts.commit_id,
564 569 pull_request_version.pull_request_version_id)
565 570 Session().commit()
566 571 self._trigger_pull_request_hook(pull_request, pull_request.author,
567 572 'update')
568 573 return (pull_request_version, changes)
569 574
570 575 def _create_version_from_snapshot(self, pull_request):
571 576 version = PullRequestVersion()
572 577 version.title = pull_request.title
573 578 version.description = pull_request.description
574 579 version.status = pull_request.status
575 580 version.created_on = pull_request.created_on
576 581 version.updated_on = pull_request.updated_on
577 582 version.user_id = pull_request.user_id
578 583 version.source_repo = pull_request.source_repo
579 584 version.source_ref = pull_request.source_ref
580 585 version.target_repo = pull_request.target_repo
581 586 version.target_ref = pull_request.target_ref
582 587
583 588 version._last_merge_source_rev = pull_request._last_merge_source_rev
584 589 version._last_merge_target_rev = pull_request._last_merge_target_rev
585 590 version._last_merge_status = pull_request._last_merge_status
586 591 version.merge_rev = pull_request.merge_rev
587 592
588 593 version.revisions = pull_request.revisions
589 594 version.pull_request = pull_request
590 595 Session().add(version)
591 596 Session().flush()
592 597
593 598 return version
594 599
595 600 def _generate_update_diffs(self, pull_request, pull_request_version):
596 601 diff_context = (
597 602 self.DIFF_CONTEXT +
598 603 ChangesetCommentsModel.needed_extra_diff_context())
599 604 old_diff = self._get_diff_from_pr_or_version(
600 605 pull_request_version, context=diff_context)
601 606 new_diff = self._get_diff_from_pr_or_version(
602 607 pull_request, context=diff_context)
603 608
604 609 old_diff_data = diffs.DiffProcessor(old_diff)
605 610 old_diff_data.prepare()
606 611 new_diff_data = diffs.DiffProcessor(new_diff)
607 612 new_diff_data.prepare()
608 613
609 614 return old_diff_data, new_diff_data
610 615
611 616 def _link_comments_to_version(self, pull_request_version):
612 617 """
613 618 Link all unlinked comments of this pull request to the given version.
614 619
615 620 :param pull_request_version: The `PullRequestVersion` to which
616 621 the comments shall be linked.
617 622
618 623 """
619 624 pull_request = pull_request_version.pull_request
620 625 comments = ChangesetComment.query().filter(
621 626 # TODO: johbo: Should we query for the repo at all here?
622 627 # Pending decision on how comments of PRs are to be related
623 628 # to either the source repo, the target repo or no repo at all.
624 629 ChangesetComment.repo_id == pull_request.target_repo.repo_id,
625 630 ChangesetComment.pull_request == pull_request,
626 631 ChangesetComment.pull_request_version == None)
627 632
628 633 # TODO: johbo: Find out why this breaks if it is done in a bulk
629 634 # operation.
630 635 for comment in comments:
631 636 comment.pull_request_version_id = (
632 637 pull_request_version.pull_request_version_id)
633 638 Session().add(comment)
634 639
635 640 def _calculate_commit_id_changes(self, old_ids, new_ids):
636 641 added = new_ids.difference(old_ids)
637 642 common = old_ids.intersection(new_ids)
638 643 removed = old_ids.difference(new_ids)
639 644 return ChangeTuple(added, common, removed)
640 645
641 646 def _calculate_file_changes(self, old_diff_data, new_diff_data):
642 647
643 648 old_files = OrderedDict()
644 649 for diff_data in old_diff_data.parsed_diff:
645 650 old_files[diff_data['filename']] = md5_safe(diff_data['raw_diff'])
646 651
647 652 added_files = []
648 653 modified_files = []
649 654 removed_files = []
650 655 for diff_data in new_diff_data.parsed_diff:
651 656 new_filename = diff_data['filename']
652 657 new_hash = md5_safe(diff_data['raw_diff'])
653 658
654 659 old_hash = old_files.get(new_filename)
655 660 if not old_hash:
656 661 # file is not present in old diff, means it's added
657 662 added_files.append(new_filename)
658 663 else:
659 664 if new_hash != old_hash:
660 665 modified_files.append(new_filename)
661 666 # now remove a file from old, since we have seen it already
662 667 del old_files[new_filename]
663 668
664 669 # removed files is when there are present in old, but not in NEW,
665 670 # since we remove old files that are present in new diff, left-overs
666 671 # if any should be the removed files
667 672 removed_files.extend(old_files.keys())
668 673
669 674 return FileChangeTuple(added_files, modified_files, removed_files)
670 675
671 676 def _render_update_message(self, changes, file_changes):
672 677 """
673 678 render the message using DEFAULT_COMMENTS_RENDERER (RST renderer),
674 679 so it's always looking the same disregarding on which default
675 680 renderer system is using.
676 681
677 682 :param changes: changes named tuple
678 683 :param file_changes: file changes named tuple
679 684
680 685 """
681 686 new_status = ChangesetStatus.get_status_lbl(
682 687 ChangesetStatus.STATUS_UNDER_REVIEW)
683 688
684 689 changed_files = (
685 690 file_changes.added + file_changes.modified + file_changes.removed)
686 691
687 692 params = {
688 693 'under_review_label': new_status,
689 694 'added_commits': changes.added,
690 695 'removed_commits': changes.removed,
691 696 'changed_files': changed_files,
692 697 'added_files': file_changes.added,
693 698 'modified_files': file_changes.modified,
694 699 'removed_files': file_changes.removed,
695 700 }
696 701 renderer = RstTemplateRenderer()
697 702 return renderer.render('pull_request_update.mako', **params)
698 703
699 704 def edit(self, pull_request, title, description):
700 705 pull_request = self.__get_pull_request(pull_request)
701 706 if pull_request.is_closed():
702 707 raise ValueError('This pull request is closed')
703 708 if title:
704 709 pull_request.title = title
705 710 pull_request.description = description
706 711 pull_request.updated_on = datetime.datetime.now()
707 712 Session().add(pull_request)
708 713
709 714 def update_reviewers(self, pull_request, reviewers_ids):
710 715 reviewers_ids = set(reviewers_ids)
711 716 pull_request = self.__get_pull_request(pull_request)
712 717 current_reviewers = PullRequestReviewers.query()\
713 718 .filter(PullRequestReviewers.pull_request ==
714 719 pull_request).all()
715 720 current_reviewers_ids = set([x.user.user_id for x in current_reviewers])
716 721
717 722 ids_to_add = reviewers_ids.difference(current_reviewers_ids)
718 723 ids_to_remove = current_reviewers_ids.difference(reviewers_ids)
719 724
720 725 log.debug("Adding %s reviewers", ids_to_add)
721 726 log.debug("Removing %s reviewers", ids_to_remove)
722 727 changed = False
723 728 for uid in ids_to_add:
724 729 changed = True
725 730 _usr = self._get_user(uid)
726 731 reviewer = PullRequestReviewers(_usr, pull_request)
727 732 Session().add(reviewer)
728 733
729 734 self.notify_reviewers(pull_request, ids_to_add)
730 735
731 736 for uid in ids_to_remove:
732 737 changed = True
733 738 reviewer = PullRequestReviewers.query()\
734 739 .filter(PullRequestReviewers.user_id == uid,
735 740 PullRequestReviewers.pull_request == pull_request)\
736 741 .scalar()
737 742 if reviewer:
738 743 Session().delete(reviewer)
739 744 if changed:
740 745 pull_request.updated_on = datetime.datetime.now()
741 746 Session().add(pull_request)
742 747
743 748 return ids_to_add, ids_to_remove
744 749
745 750 def notify_reviewers(self, pull_request, reviewers_ids):
746 751 # notification to reviewers
747 752 if not reviewers_ids:
748 753 return
749 754
750 755 pull_request_obj = pull_request
751 756 # get the current participants of this pull request
752 757 recipients = reviewers_ids
753 758 notification_type = EmailNotificationModel.TYPE_PULL_REQUEST
754 759
755 760 pr_source_repo = pull_request_obj.source_repo
756 761 pr_target_repo = pull_request_obj.target_repo
757 762
758 763 pr_url = h.url(
759 764 'pullrequest_show',
760 765 repo_name=pr_target_repo.repo_name,
761 766 pull_request_id=pull_request_obj.pull_request_id,
762 767 qualified=True,)
763 768
764 769 # set some variables for email notification
765 770 pr_target_repo_url = h.url(
766 771 'summary_home',
767 772 repo_name=pr_target_repo.repo_name,
768 773 qualified=True)
769 774
770 775 pr_source_repo_url = h.url(
771 776 'summary_home',
772 777 repo_name=pr_source_repo.repo_name,
773 778 qualified=True)
774 779
775 780 # pull request specifics
776 781 pull_request_commits = [
777 782 (x.raw_id, x.message)
778 783 for x in map(pr_source_repo.get_commit, pull_request.revisions)]
779 784
780 785 kwargs = {
781 786 'user': pull_request.author,
782 787 'pull_request': pull_request_obj,
783 788 'pull_request_commits': pull_request_commits,
784 789
785 790 'pull_request_target_repo': pr_target_repo,
786 791 'pull_request_target_repo_url': pr_target_repo_url,
787 792
788 793 'pull_request_source_repo': pr_source_repo,
789 794 'pull_request_source_repo_url': pr_source_repo_url,
790 795
791 796 'pull_request_url': pr_url,
792 797 }
793 798
794 799 # pre-generate the subject for notification itself
795 800 (subject,
796 801 _h, _e, # we don't care about those
797 802 body_plaintext) = EmailNotificationModel().render_email(
798 803 notification_type, **kwargs)
799 804
800 805 # create notification objects, and emails
801 806 NotificationModel().create(
802 807 created_by=pull_request.author,
803 808 notification_subject=subject,
804 809 notification_body=body_plaintext,
805 810 notification_type=notification_type,
806 811 recipients=recipients,
807 812 email_kwargs=kwargs,
808 813 )
809 814
810 815 def delete(self, pull_request):
811 816 pull_request = self.__get_pull_request(pull_request)
812 817 self._cleanup_merge_workspace(pull_request)
813 818 Session().delete(pull_request)
814 819
815 820 def close_pull_request(self, pull_request, user):
816 821 pull_request = self.__get_pull_request(pull_request)
817 822 self._cleanup_merge_workspace(pull_request)
818 823 pull_request.status = PullRequest.STATUS_CLOSED
819 824 pull_request.updated_on = datetime.datetime.now()
820 825 Session().add(pull_request)
821 826 self._trigger_pull_request_hook(
822 827 pull_request, pull_request.author, 'close')
823 828 self._log_action('user_closed_pull_request', user, pull_request)
824 829
825 830 def close_pull_request_with_comment(self, pull_request, user, repo,
826 831 message=None):
827 832 status = ChangesetStatus.STATUS_REJECTED
828 833
829 834 if not message:
830 835 message = (
831 836 _('Status change %(transition_icon)s %(status)s') % {
832 837 'transition_icon': '>',
833 838 'status': ChangesetStatus.get_status_lbl(status)})
834 839
835 840 internal_message = _('Closing with') + ' ' + message
836 841
837 842 comm = ChangesetCommentsModel().create(
838 843 text=internal_message,
839 844 repo=repo.repo_id,
840 845 user=user.user_id,
841 846 pull_request=pull_request.pull_request_id,
842 847 f_path=None,
843 848 line_no=None,
844 849 status_change=ChangesetStatus.get_status_lbl(status),
845 850 closing_pr=True
846 851 )
847 852
848 853 ChangesetStatusModel().set_status(
849 854 repo.repo_id,
850 855 status,
851 856 user.user_id,
852 857 comm,
853 858 pull_request=pull_request.pull_request_id
854 859 )
855 860 Session().flush()
856 861
857 862 PullRequestModel().close_pull_request(
858 863 pull_request.pull_request_id, user)
859 864
860 865 def merge_status(self, pull_request):
861 866 if not self._is_merge_enabled(pull_request):
862 867 return False, _('Server-side pull request merging is disabled.')
863 868 if pull_request.is_closed():
864 869 return False, _('This pull request is closed.')
865 870 merge_possible, msg = self._check_repo_requirements(
866 871 target=pull_request.target_repo, source=pull_request.source_repo)
867 872 if not merge_possible:
868 873 return merge_possible, msg
869 874
870 875 try:
871 876 resp = self._try_merge(pull_request)
872 877 status = resp.possible, self.merge_status_message(
873 878 resp.failure_reason)
874 879 except NotImplementedError:
875 880 status = False, _('Pull request merging is not supported.')
876 881
877 882 return status
878 883
879 884 def _check_repo_requirements(self, target, source):
880 885 """
881 886 Check if `target` and `source` have compatible requirements.
882 887
883 888 Currently this is just checking for largefiles.
884 889 """
885 890 target_has_largefiles = self._has_largefiles(target)
886 891 source_has_largefiles = self._has_largefiles(source)
887 892 merge_possible = True
888 893 message = u''
889 894
890 895 if target_has_largefiles != source_has_largefiles:
891 896 merge_possible = False
892 897 if source_has_largefiles:
893 898 message = _(
894 899 'Target repository large files support is disabled.')
895 900 else:
896 901 message = _(
897 902 'Source repository large files support is disabled.')
898 903
899 904 return merge_possible, message
900 905
901 906 def _has_largefiles(self, repo):
902 907 largefiles_ui = VcsSettingsModel(repo=repo).get_ui_settings(
903 908 'extensions', 'largefiles')
904 909 return largefiles_ui and largefiles_ui[0].active
905 910
906 911 def _try_merge(self, pull_request):
907 912 """
908 913 Try to merge the pull request and return the merge status.
909 914 """
910 915 log.debug(
911 916 "Trying out if the pull request %s can be merged.",
912 917 pull_request.pull_request_id)
913 918 target_vcs = pull_request.target_repo.scm_instance()
914 919 target_ref = self._refresh_reference(
915 920 pull_request.target_ref_parts, target_vcs)
916 921
917 922 target_locked = pull_request.target_repo.locked
918 923 if target_locked and target_locked[0]:
919 924 log.debug("The target repository is locked.")
920 925 merge_state = MergeResponse(
921 926 False, False, None, MergeFailureReason.TARGET_IS_LOCKED)
922 927 elif self._needs_merge_state_refresh(pull_request, target_ref):
923 928 log.debug("Refreshing the merge status of the repository.")
924 929 merge_state = self._refresh_merge_state(
925 930 pull_request, target_vcs, target_ref)
926 931 else:
927 932 possible = pull_request.\
928 933 _last_merge_status == MergeFailureReason.NONE
929 934 merge_state = MergeResponse(
930 935 possible, False, None, pull_request._last_merge_status)
931 936 log.debug("Merge response: %s", merge_state)
932 937 return merge_state
933 938
934 939 def _refresh_reference(self, reference, vcs_repository):
935 940 if reference.type in ('branch', 'book'):
936 941 name_or_id = reference.name
937 942 else:
938 943 name_or_id = reference.commit_id
939 944 refreshed_commit = vcs_repository.get_commit(name_or_id)
940 945 refreshed_reference = Reference(
941 946 reference.type, reference.name, refreshed_commit.raw_id)
942 947 return refreshed_reference
943 948
944 949 def _needs_merge_state_refresh(self, pull_request, target_reference):
945 950 return not(
946 951 pull_request.revisions and
947 952 pull_request.revisions[0] == pull_request._last_merge_source_rev and
948 953 target_reference.commit_id == pull_request._last_merge_target_rev)
949 954
950 955 def _refresh_merge_state(self, pull_request, target_vcs, target_reference):
951 956 workspace_id = self._workspace_id(pull_request)
952 957 source_vcs = pull_request.source_repo.scm_instance()
953 958 merge_state = target_vcs.merge(
954 959 target_reference, source_vcs, pull_request.source_ref_parts,
955 960 workspace_id, dry_run=True)
956 961
957 962 # Do not store the response if there was an unknown error.
958 963 if merge_state.failure_reason != MergeFailureReason.UNKNOWN:
959 964 pull_request._last_merge_source_rev = pull_request.\
960 965 source_ref_parts.commit_id
961 966 pull_request._last_merge_target_rev = target_reference.commit_id
962 967 pull_request._last_merge_status = (
963 968 merge_state.failure_reason)
964 969 Session().add(pull_request)
965 970 Session().flush()
966 971
967 972 return merge_state
968 973
969 974 def _workspace_id(self, pull_request):
970 975 workspace_id = 'pr-%s' % pull_request.pull_request_id
971 976 return workspace_id
972 977
973 978 def merge_status_message(self, status_code):
974 979 """
975 980 Return a human friendly error message for the given merge status code.
976 981 """
977 982 return self.MERGE_STATUS_MESSAGES[status_code]
978 983
979 984 def generate_repo_data(self, repo, commit_id=None, branch=None,
980 985 bookmark=None):
981 986 all_refs, selected_ref = \
982 987 self._get_repo_pullrequest_sources(
983 988 repo.scm_instance(), commit_id=commit_id,
984 989 branch=branch, bookmark=bookmark)
985 990
986 991 refs_select2 = []
987 992 for element in all_refs:
988 993 children = [{'id': x[0], 'text': x[1]} for x in element[0]]
989 994 refs_select2.append({'text': element[1], 'children': children})
990 995
991 996 return {
992 997 'user': {
993 998 'user_id': repo.user.user_id,
994 999 'username': repo.user.username,
995 1000 'firstname': repo.user.firstname,
996 1001 'lastname': repo.user.lastname,
997 1002 'gravatar_link': h.gravatar_url(repo.user.email, 14),
998 1003 },
999 1004 'description': h.chop_at_smart(repo.description, '\n'),
1000 1005 'refs': {
1001 1006 'all_refs': all_refs,
1002 1007 'selected_ref': selected_ref,
1003 1008 'select2_refs': refs_select2
1004 1009 }
1005 1010 }
1006 1011
1007 1012 def generate_pullrequest_title(self, source, source_ref, target):
1008 1013 return '{source}#{at_ref} to {target}'.format(
1009 1014 source=source,
1010 1015 at_ref=source_ref,
1011 1016 target=target,
1012 1017 )
1013 1018
1014 1019 def _cleanup_merge_workspace(self, pull_request):
1015 1020 # Merging related cleanup
1016 1021 target_scm = pull_request.target_repo.scm_instance()
1017 1022 workspace_id = 'pr-%s' % pull_request.pull_request_id
1018 1023
1019 1024 try:
1020 1025 target_scm.cleanup_merge_workspace(workspace_id)
1021 1026 except NotImplementedError:
1022 1027 pass
1023 1028
1024 1029 def _get_repo_pullrequest_sources(
1025 1030 self, repo, commit_id=None, branch=None, bookmark=None):
1026 1031 """
1027 1032 Return a structure with repo's interesting commits, suitable for
1028 1033 the selectors in pullrequest controller
1029 1034
1030 1035 :param commit_id: a commit that must be in the list somehow
1031 1036 and selected by default
1032 1037 :param branch: a branch that must be in the list and selected
1033 1038 by default - even if closed
1034 1039 :param bookmark: a bookmark that must be in the list and selected
1035 1040 """
1036 1041
1037 1042 commit_id = safe_str(commit_id) if commit_id else None
1038 1043 branch = safe_str(branch) if branch else None
1039 1044 bookmark = safe_str(bookmark) if bookmark else None
1040 1045
1041 1046 selected = None
1042 1047
1043 1048 # order matters: first source that has commit_id in it will be selected
1044 1049 sources = []
1045 1050 sources.append(('book', repo.bookmarks.items(), _('Bookmarks'), bookmark))
1046 1051 sources.append(('branch', repo.branches.items(), _('Branches'), branch))
1047 1052
1048 1053 if commit_id:
1049 1054 ref_commit = (h.short_id(commit_id), commit_id)
1050 1055 sources.append(('rev', [ref_commit], _('Commit IDs'), commit_id))
1051 1056
1052 1057 sources.append(
1053 1058 ('branch', repo.branches_closed.items(), _('Closed Branches'), branch),
1054 1059 )
1055 1060
1056 1061 groups = []
1057 1062 for group_key, ref_list, group_name, match in sources:
1058 1063 group_refs = []
1059 1064 for ref_name, ref_id in ref_list:
1060 1065 ref_key = '%s:%s:%s' % (group_key, ref_name, ref_id)
1061 1066 group_refs.append((ref_key, ref_name))
1062 1067
1063 1068 if not selected:
1064 1069 if set([commit_id, match]) & set([ref_id, ref_name]):
1065 1070 selected = ref_key
1066 1071
1067 1072 if group_refs:
1068 1073 groups.append((group_refs, group_name))
1069 1074
1070 1075 if not selected:
1071 1076 ref = commit_id or branch or bookmark
1072 1077 if ref:
1073 1078 raise CommitDoesNotExistError(
1074 1079 'No commit refs could be found matching: %s' % ref)
1075 1080 elif repo.DEFAULT_BRANCH_NAME in repo.branches:
1076 1081 selected = 'branch:%s:%s' % (
1077 1082 repo.DEFAULT_BRANCH_NAME,
1078 1083 repo.branches[repo.DEFAULT_BRANCH_NAME]
1079 1084 )
1080 1085 elif repo.commit_ids:
1081 1086 rev = repo.commit_ids[0]
1082 1087 selected = 'rev:%s:%s' % (rev, rev)
1083 1088 else:
1084 1089 raise EmptyRepositoryError()
1085 1090 return groups, selected
1086 1091
1087 1092 def get_diff(self, pull_request, context=DIFF_CONTEXT):
1088 1093 pull_request = self.__get_pull_request(pull_request)
1089 1094 return self._get_diff_from_pr_or_version(pull_request, context=context)
1090 1095
1091 1096 def _get_diff_from_pr_or_version(self, pr_or_version, context):
1092 1097 source_repo = pr_or_version.source_repo
1093 1098
1094 1099 # we swap org/other ref since we run a simple diff on one repo
1095 1100 target_ref_id = pr_or_version.target_ref_parts.commit_id
1096 1101 source_ref_id = pr_or_version.source_ref_parts.commit_id
1097 1102 target_commit = source_repo.get_commit(
1098 1103 commit_id=safe_str(target_ref_id))
1099 1104 source_commit = source_repo.get_commit(commit_id=safe_str(source_ref_id))
1100 1105 vcs_repo = source_repo.scm_instance()
1101 1106
1102 1107 # TODO: johbo: In the context of an update, we cannot reach
1103 1108 # the old commit anymore with our normal mechanisms. It needs
1104 1109 # some sort of special support in the vcs layer to avoid this
1105 1110 # workaround.
1106 1111 if (source_commit.raw_id == vcs_repo.EMPTY_COMMIT_ID and
1107 1112 vcs_repo.alias == 'git'):
1108 1113 source_commit.raw_id = safe_str(source_ref_id)
1109 1114
1110 1115 log.debug('calculating diff between '
1111 1116 'source_ref:%s and target_ref:%s for repo `%s`',
1112 1117 target_ref_id, source_ref_id,
1113 1118 safe_unicode(vcs_repo.path))
1114 1119
1115 1120 vcs_diff = vcs_repo.get_diff(
1116 1121 commit1=target_commit, commit2=source_commit, context=context)
1117 1122 return vcs_diff
1118 1123
1119 1124 def _is_merge_enabled(self, pull_request):
1120 1125 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
1121 1126 settings = settings_model.get_general_settings()
1122 1127 return settings.get('rhodecode_pr_merge_enabled', False)
1123 1128
1124 1129 def _log_action(self, action, user, pull_request):
1125 1130 action_logger(
1126 1131 user,
1127 1132 '{action}:{pr_id}'.format(
1128 1133 action=action, pr_id=pull_request.pull_request_id),
1129 1134 pull_request.target_repo)
1130 1135
1131 1136
1132 1137 ChangeTuple = namedtuple('ChangeTuple',
1133 1138 ['added', 'common', 'removed'])
1134 1139
1135 1140 FileChangeTuple = namedtuple('FileChangeTuple',
1136 1141 ['added', 'modified', 'removed'])
General Comments 0
You need to be logged in to leave comments. Login now