##// END OF EJS Templates
pullrequests: select a ref if one exists matching the commit id
dan -
r6:a06379f2 default
parent child Browse files
Show More
@@ -1,1128 +1,1130 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 910 target_vcs = pull_request.target_repo.scm_instance()
911 911 target_ref = self._refresh_reference(
912 912 pull_request.target_ref_parts, target_vcs)
913 913
914 914 target_locked = pull_request.target_repo.locked
915 915 if target_locked and target_locked[0]:
916 916 merge_state = MergeResponse(
917 917 False, False, None, MergeFailureReason.TARGET_IS_LOCKED)
918 918 elif self._needs_merge_state_refresh(pull_request, target_ref):
919 919 merge_state = self._refresh_merge_state(
920 920 pull_request, target_vcs, target_ref)
921 921 else:
922 922 possible = pull_request.\
923 923 _last_merge_status == MergeFailureReason.NONE
924 924 merge_state = MergeResponse(
925 925 possible, False, None, pull_request._last_merge_status)
926 926 return merge_state
927 927
928 928 def _refresh_reference(self, reference, vcs_repository):
929 929 if reference.type in ('branch', 'book'):
930 930 name_or_id = reference.name
931 931 else:
932 932 name_or_id = reference.commit_id
933 933 refreshed_commit = vcs_repository.get_commit(name_or_id)
934 934 refreshed_reference = Reference(
935 935 reference.type, reference.name, refreshed_commit.raw_id)
936 936 return refreshed_reference
937 937
938 938 def _needs_merge_state_refresh(self, pull_request, target_reference):
939 939 return not(
940 940 pull_request.revisions and
941 941 pull_request.revisions[0] == pull_request._last_merge_source_rev and
942 942 target_reference.commit_id == pull_request._last_merge_target_rev)
943 943
944 944 def _refresh_merge_state(self, pull_request, target_vcs, target_reference):
945 945 workspace_id = self._workspace_id(pull_request)
946 946 source_vcs = pull_request.source_repo.scm_instance()
947 947 merge_state = target_vcs.merge(
948 948 target_reference, source_vcs, pull_request.source_ref_parts,
949 949 workspace_id, dry_run=True)
950 950
951 951 # Do not store the response if there was an unknown error.
952 952 if merge_state.failure_reason != MergeFailureReason.UNKNOWN:
953 953 pull_request._last_merge_source_rev = pull_request.\
954 954 source_ref_parts.commit_id
955 955 pull_request._last_merge_target_rev = target_reference.commit_id
956 956 pull_request._last_merge_status = (
957 957 merge_state.failure_reason)
958 958 Session().add(pull_request)
959 959 Session().flush()
960 960
961 961 return merge_state
962 962
963 963 def _workspace_id(self, pull_request):
964 964 workspace_id = 'pr-%s' % pull_request.pull_request_id
965 965 return workspace_id
966 966
967 967 def merge_status_message(self, status_code):
968 968 """
969 969 Return a human friendly error message for the given merge status code.
970 970 """
971 971 return self.MERGE_STATUS_MESSAGES[status_code]
972 972
973 973 def generate_repo_data(self, repo, commit_id=None, branch=None,
974 974 bookmark=None):
975 975 all_refs, selected_ref = \
976 976 self._get_repo_pullrequest_sources(
977 977 repo.scm_instance(), commit_id=commit_id,
978 978 branch=branch, bookmark=bookmark)
979 979
980 980 refs_select2 = []
981 981 for element in all_refs:
982 982 children = [{'id': x[0], 'text': x[1]} for x in element[0]]
983 983 refs_select2.append({'text': element[1], 'children': children})
984 984
985 985 return {
986 986 'user': {
987 987 'user_id': repo.user.user_id,
988 988 'username': repo.user.username,
989 989 'firstname': repo.user.firstname,
990 990 'lastname': repo.user.lastname,
991 991 'gravatar_link': h.gravatar_url(repo.user.email, 14),
992 992 },
993 993 'description': h.chop_at_smart(repo.description, '\n'),
994 994 'refs': {
995 995 'all_refs': all_refs,
996 996 'selected_ref': selected_ref,
997 997 'select2_refs': refs_select2
998 998 }
999 999 }
1000 1000
1001 1001 def generate_pullrequest_title(self, source, source_ref, target):
1002 1002 return '{source}#{at_ref} to {target}'.format(
1003 1003 source=source,
1004 1004 at_ref=source_ref,
1005 1005 target=target,
1006 1006 )
1007 1007
1008 1008 def _cleanup_merge_workspace(self, pull_request):
1009 1009 # Merging related cleanup
1010 1010 target_scm = pull_request.target_repo.scm_instance()
1011 1011 workspace_id = 'pr-%s' % pull_request.pull_request_id
1012 1012
1013 1013 try:
1014 1014 target_scm.cleanup_merge_workspace(workspace_id)
1015 1015 except NotImplementedError:
1016 1016 pass
1017 1017
1018 1018 def _get_repo_pullrequest_sources(
1019 1019 self, repo, commit_id=None, branch=None, bookmark=None):
1020 1020 """
1021 1021 Return a structure with repo's interesting commits, suitable for
1022 1022 the selectors in pullrequest controller
1023 1023
1024 1024 :param commit_id: a commit that must be in the list somehow
1025 1025 and selected by default
1026 1026 :param branch: a branch that must be in the list and selected
1027 1027 by default - even if closed
1028 1028 :param bookmark: a bookmark that must be in the list and selected
1029 1029 """
1030 1030
1031 1031 commit_id = safe_str(commit_id) if commit_id else None
1032 1032 branch = safe_str(branch) if branch else None
1033 1033 bookmark = safe_str(bookmark) if bookmark else None
1034 1034
1035 1035 selected = None
1036 1036
1037 1037 # order matters: first source that has commit_id in it will be selected
1038 1038 sources = []
1039 1039 sources.append(('book', repo.bookmarks.items(), _('Bookmarks'), bookmark))
1040 1040 sources.append(('branch', repo.branches.items(), _('Branches'), branch))
1041 1041
1042 1042 if commit_id:
1043 1043 ref_commit = (h.short_id(commit_id), commit_id)
1044 1044 sources.append(('rev', [ref_commit], _('Commit IDs'), commit_id))
1045 1045
1046 1046 sources.append(
1047 1047 ('branch', repo.branches_closed.items(), _('Closed Branches'), branch),
1048 1048 )
1049 1049
1050 1050 groups = []
1051 1051 for group_key, ref_list, group_name, match in sources:
1052 1052 group_refs = []
1053 1053 for ref_name, ref_id in ref_list:
1054 1054 ref_key = '%s:%s:%s' % (group_key, ref_name, ref_id)
1055 1055 group_refs.append((ref_key, ref_name))
1056 1056
1057 if not selected and match in (ref_id, ref_name):
1058 selected = ref_key
1057 if not selected:
1058 if set([commit_id, match]) & set([ref_id, ref_name]):
1059 selected = ref_key
1060
1059 1061 if group_refs:
1060 1062 groups.append((group_refs, group_name))
1061 1063
1062 1064 if not selected:
1063 1065 ref = commit_id or branch or bookmark
1064 1066 if ref:
1065 1067 raise CommitDoesNotExistError(
1066 1068 'No commit refs could be found matching: %s' % ref)
1067 1069 elif repo.DEFAULT_BRANCH_NAME in repo.branches:
1068 1070 selected = 'branch:%s:%s' % (
1069 1071 repo.DEFAULT_BRANCH_NAME,
1070 1072 repo.branches[repo.DEFAULT_BRANCH_NAME]
1071 1073 )
1072 1074 elif repo.commit_ids:
1073 1075 rev = repo.commit_ids[0]
1074 1076 selected = 'rev:%s:%s' % (rev, rev)
1075 1077 else:
1076 1078 raise EmptyRepositoryError()
1077 1079 return groups, selected
1078 1080
1079 1081 def get_diff(self, pull_request, context=DIFF_CONTEXT):
1080 1082 pull_request = self.__get_pull_request(pull_request)
1081 1083 return self._get_diff_from_pr_or_version(pull_request, context=context)
1082 1084
1083 1085 def _get_diff_from_pr_or_version(self, pr_or_version, context):
1084 1086 source_repo = pr_or_version.source_repo
1085 1087
1086 1088 # we swap org/other ref since we run a simple diff on one repo
1087 1089 target_ref_id = pr_or_version.target_ref_parts.commit_id
1088 1090 source_ref_id = pr_or_version.source_ref_parts.commit_id
1089 1091 target_commit = source_repo.get_commit(
1090 1092 commit_id=safe_str(target_ref_id))
1091 1093 source_commit = source_repo.get_commit(commit_id=safe_str(source_ref_id))
1092 1094 vcs_repo = source_repo.scm_instance()
1093 1095
1094 1096 # TODO: johbo: In the context of an update, we cannot reach
1095 1097 # the old commit anymore with our normal mechanisms. It needs
1096 1098 # some sort of special support in the vcs layer to avoid this
1097 1099 # workaround.
1098 1100 if (source_commit.raw_id == vcs_repo.EMPTY_COMMIT_ID and
1099 1101 vcs_repo.alias == 'git'):
1100 1102 source_commit.raw_id = safe_str(source_ref_id)
1101 1103
1102 1104 log.debug('calculating diff between '
1103 1105 'source_ref:%s and target_ref:%s for repo `%s`',
1104 1106 target_ref_id, source_ref_id,
1105 1107 safe_unicode(vcs_repo.path))
1106 1108
1107 1109 vcs_diff = vcs_repo.get_diff(
1108 1110 commit1=target_commit, commit2=source_commit, context=context)
1109 1111 return vcs_diff
1110 1112
1111 1113 def _is_merge_enabled(self, pull_request):
1112 1114 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
1113 1115 settings = settings_model.get_general_settings()
1114 1116 return settings.get('rhodecode_pr_merge_enabled', False)
1115 1117
1116 1118 def _log_action(self, action, user, pull_request):
1117 1119 action_logger(
1118 1120 user,
1119 1121 '{action}:{pr_id}'.format(
1120 1122 action=action, pr_id=pull_request.pull_request_id),
1121 1123 pull_request.target_repo)
1122 1124
1123 1125
1124 1126 ChangeTuple = namedtuple('ChangeTuple',
1125 1127 ['added', 'common', 'removed'])
1126 1128
1127 1129 FileChangeTuple = namedtuple('FileChangeTuple',
1128 1130 ['added', 'modified', 'removed'])
General Comments 0
You need to be logged in to leave comments. Login now