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