##// END OF EJS Templates
pull-requests: make sure to skip duplicates of reviewers via PR create method.
dan -
r1793:f248a72c default
parent child Browse files
Show More
@@ -1,1524 +1,1528 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2012-2017 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 import urllib
31 31
32 32 from pylons.i18n.translation import _
33 33 from pylons.i18n.translation import lazy_ugettext
34 34 from pyramid.threadlocal import get_current_request
35 35 from sqlalchemy import or_
36 36
37 37 from rhodecode import events
38 38 from rhodecode.lib import helpers as h, hooks_utils, diffs
39 39 from rhodecode.lib.compat import OrderedDict
40 40 from rhodecode.lib.hooks_daemon import prepare_callback_daemon
41 41 from rhodecode.lib.markup_renderer import (
42 42 DEFAULT_COMMENTS_RENDERER, RstTemplateRenderer)
43 43 from rhodecode.lib.utils import action_logger
44 44 from rhodecode.lib.utils2 import safe_unicode, safe_str, md5_safe
45 45 from rhodecode.lib.vcs.backends.base import (
46 46 Reference, MergeResponse, MergeFailureReason, UpdateFailureReason)
47 47 from rhodecode.lib.vcs.conf import settings as vcs_settings
48 48 from rhodecode.lib.vcs.exceptions import (
49 49 CommitDoesNotExistError, EmptyRepositoryError)
50 50 from rhodecode.model import BaseModel
51 51 from rhodecode.model.changeset_status import ChangesetStatusModel
52 52 from rhodecode.model.comment import CommentsModel
53 53 from rhodecode.model.db import (
54 54 PullRequest, PullRequestReviewers, ChangesetStatus,
55 55 PullRequestVersion, ChangesetComment, Repository)
56 56 from rhodecode.model.meta import Session
57 57 from rhodecode.model.notification import NotificationModel, \
58 58 EmailNotificationModel
59 59 from rhodecode.model.scm import ScmModel
60 60 from rhodecode.model.settings import VcsSettingsModel
61 61
62 62
63 63 log = logging.getLogger(__name__)
64 64
65 65
66 66 # Data structure to hold the response data when updating commits during a pull
67 67 # request update.
68 68 UpdateResponse = namedtuple('UpdateResponse', [
69 69 'executed', 'reason', 'new', 'old', 'changes',
70 70 'source_changed', 'target_changed'])
71 71
72 72
73 73 class PullRequestModel(BaseModel):
74 74
75 75 cls = PullRequest
76 76
77 77 DIFF_CONTEXT = 3
78 78
79 79 MERGE_STATUS_MESSAGES = {
80 80 MergeFailureReason.NONE: lazy_ugettext(
81 81 'This pull request can be automatically merged.'),
82 82 MergeFailureReason.UNKNOWN: lazy_ugettext(
83 83 'This pull request cannot be merged because of an unhandled'
84 84 ' exception.'),
85 85 MergeFailureReason.MERGE_FAILED: lazy_ugettext(
86 86 'This pull request cannot be merged because of merge conflicts.'),
87 87 MergeFailureReason.PUSH_FAILED: lazy_ugettext(
88 88 'This pull request could not be merged because push to target'
89 89 ' failed.'),
90 90 MergeFailureReason.TARGET_IS_NOT_HEAD: lazy_ugettext(
91 91 'This pull request cannot be merged because the target is not a'
92 92 ' head.'),
93 93 MergeFailureReason.HG_SOURCE_HAS_MORE_BRANCHES: lazy_ugettext(
94 94 'This pull request cannot be merged because the source contains'
95 95 ' more branches than the target.'),
96 96 MergeFailureReason.HG_TARGET_HAS_MULTIPLE_HEADS: lazy_ugettext(
97 97 'This pull request cannot be merged because the target has'
98 98 ' multiple heads.'),
99 99 MergeFailureReason.TARGET_IS_LOCKED: lazy_ugettext(
100 100 'This pull request cannot be merged because the target repository'
101 101 ' is locked.'),
102 102 MergeFailureReason._DEPRECATED_MISSING_COMMIT: lazy_ugettext(
103 103 'This pull request cannot be merged because the target or the '
104 104 'source reference is missing.'),
105 105 MergeFailureReason.MISSING_TARGET_REF: lazy_ugettext(
106 106 'This pull request cannot be merged because the target '
107 107 'reference is missing.'),
108 108 MergeFailureReason.MISSING_SOURCE_REF: lazy_ugettext(
109 109 'This pull request cannot be merged because the source '
110 110 'reference is missing.'),
111 111 MergeFailureReason.SUBREPO_MERGE_FAILED: lazy_ugettext(
112 112 'This pull request cannot be merged because of conflicts related '
113 113 'to sub repositories.'),
114 114 }
115 115
116 116 UPDATE_STATUS_MESSAGES = {
117 117 UpdateFailureReason.NONE: lazy_ugettext(
118 118 'Pull request update successful.'),
119 119 UpdateFailureReason.UNKNOWN: lazy_ugettext(
120 120 'Pull request update failed because of an unknown error.'),
121 121 UpdateFailureReason.NO_CHANGE: lazy_ugettext(
122 122 'No update needed because the source and target have not changed.'),
123 123 UpdateFailureReason.WRONG_REF_TYPE: lazy_ugettext(
124 124 'Pull request cannot be updated because the reference type is '
125 125 'not supported for an update. Only Branch, Tag or Bookmark is allowed.'),
126 126 UpdateFailureReason.MISSING_TARGET_REF: lazy_ugettext(
127 127 'This pull request cannot be updated because the target '
128 128 'reference is missing.'),
129 129 UpdateFailureReason.MISSING_SOURCE_REF: lazy_ugettext(
130 130 'This pull request cannot be updated because the source '
131 131 'reference is missing.'),
132 132 }
133 133
134 134 def __get_pull_request(self, pull_request):
135 135 return self._get_instance((
136 136 PullRequest, PullRequestVersion), pull_request)
137 137
138 138 def _check_perms(self, perms, pull_request, user, api=False):
139 139 if not api:
140 140 return h.HasRepoPermissionAny(*perms)(
141 141 user=user, repo_name=pull_request.target_repo.repo_name)
142 142 else:
143 143 return h.HasRepoPermissionAnyApi(*perms)(
144 144 user=user, repo_name=pull_request.target_repo.repo_name)
145 145
146 146 def check_user_read(self, pull_request, user, api=False):
147 147 _perms = ('repository.admin', 'repository.write', 'repository.read',)
148 148 return self._check_perms(_perms, pull_request, user, api)
149 149
150 150 def check_user_merge(self, pull_request, user, api=False):
151 151 _perms = ('repository.admin', 'repository.write', 'hg.admin',)
152 152 return self._check_perms(_perms, pull_request, user, api)
153 153
154 154 def check_user_update(self, pull_request, user, api=False):
155 155 owner = user.user_id == pull_request.user_id
156 156 return self.check_user_merge(pull_request, user, api) or owner
157 157
158 158 def check_user_delete(self, pull_request, user):
159 159 owner = user.user_id == pull_request.user_id
160 160 _perms = ('repository.admin',)
161 161 return self._check_perms(_perms, pull_request, user) or owner
162 162
163 163 def check_user_change_status(self, pull_request, user, api=False):
164 164 reviewer = user.user_id in [x.user_id for x in
165 165 pull_request.reviewers]
166 166 return self.check_user_update(pull_request, user, api) or reviewer
167 167
168 168 def get(self, pull_request):
169 169 return self.__get_pull_request(pull_request)
170 170
171 171 def _prepare_get_all_query(self, repo_name, source=False, statuses=None,
172 172 opened_by=None, order_by=None,
173 173 order_dir='desc'):
174 174 repo = None
175 175 if repo_name:
176 176 repo = self._get_repo(repo_name)
177 177
178 178 q = PullRequest.query()
179 179
180 180 # source or target
181 181 if repo and source:
182 182 q = q.filter(PullRequest.source_repo == repo)
183 183 elif repo:
184 184 q = q.filter(PullRequest.target_repo == repo)
185 185
186 186 # closed,opened
187 187 if statuses:
188 188 q = q.filter(PullRequest.status.in_(statuses))
189 189
190 190 # opened by filter
191 191 if opened_by:
192 192 q = q.filter(PullRequest.user_id.in_(opened_by))
193 193
194 194 if order_by:
195 195 order_map = {
196 196 'name_raw': PullRequest.pull_request_id,
197 197 'title': PullRequest.title,
198 198 'updated_on_raw': PullRequest.updated_on,
199 199 'target_repo': PullRequest.target_repo_id
200 200 }
201 201 if order_dir == 'asc':
202 202 q = q.order_by(order_map[order_by].asc())
203 203 else:
204 204 q = q.order_by(order_map[order_by].desc())
205 205
206 206 return q
207 207
208 208 def count_all(self, repo_name, source=False, statuses=None,
209 209 opened_by=None):
210 210 """
211 211 Count the number of pull requests for a specific repository.
212 212
213 213 :param repo_name: target or source repo
214 214 :param source: boolean flag to specify if repo_name refers to source
215 215 :param statuses: list of pull request statuses
216 216 :param opened_by: author user of the pull request
217 217 :returns: int number of pull requests
218 218 """
219 219 q = self._prepare_get_all_query(
220 220 repo_name, source=source, statuses=statuses, opened_by=opened_by)
221 221
222 222 return q.count()
223 223
224 224 def get_all(self, repo_name, source=False, statuses=None, opened_by=None,
225 225 offset=0, length=None, order_by=None, order_dir='desc'):
226 226 """
227 227 Get all pull requests for a specific repository.
228 228
229 229 :param repo_name: target or source repo
230 230 :param source: boolean flag to specify if repo_name refers to source
231 231 :param statuses: list of pull request statuses
232 232 :param opened_by: author user of the pull request
233 233 :param offset: pagination offset
234 234 :param length: length of returned list
235 235 :param order_by: order of the returned list
236 236 :param order_dir: 'asc' or 'desc' ordering direction
237 237 :returns: list of pull requests
238 238 """
239 239 q = self._prepare_get_all_query(
240 240 repo_name, source=source, statuses=statuses, opened_by=opened_by,
241 241 order_by=order_by, order_dir=order_dir)
242 242
243 243 if length:
244 244 pull_requests = q.limit(length).offset(offset).all()
245 245 else:
246 246 pull_requests = q.all()
247 247
248 248 return pull_requests
249 249
250 250 def count_awaiting_review(self, repo_name, source=False, statuses=None,
251 251 opened_by=None):
252 252 """
253 253 Count the number of pull requests for a specific repository that are
254 254 awaiting review.
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 :returns: int number of pull requests
261 261 """
262 262 pull_requests = self.get_awaiting_review(
263 263 repo_name, source=source, statuses=statuses, opened_by=opened_by)
264 264
265 265 return len(pull_requests)
266 266
267 267 def get_awaiting_review(self, repo_name, source=False, statuses=None,
268 268 opened_by=None, offset=0, length=None,
269 269 order_by=None, order_dir='desc'):
270 270 """
271 271 Get all pull requests for a specific repository that are awaiting
272 272 review.
273 273
274 274 :param repo_name: target or source repo
275 275 :param source: boolean flag to specify if repo_name refers to source
276 276 :param statuses: list of pull request statuses
277 277 :param opened_by: author user of the pull request
278 278 :param offset: pagination offset
279 279 :param length: length of returned list
280 280 :param order_by: order of the returned list
281 281 :param order_dir: 'asc' or 'desc' ordering direction
282 282 :returns: list of pull requests
283 283 """
284 284 pull_requests = self.get_all(
285 285 repo_name, source=source, statuses=statuses, opened_by=opened_by,
286 286 order_by=order_by, order_dir=order_dir)
287 287
288 288 _filtered_pull_requests = []
289 289 for pr in pull_requests:
290 290 status = pr.calculated_review_status()
291 291 if status in [ChangesetStatus.STATUS_NOT_REVIEWED,
292 292 ChangesetStatus.STATUS_UNDER_REVIEW]:
293 293 _filtered_pull_requests.append(pr)
294 294 if length:
295 295 return _filtered_pull_requests[offset:offset+length]
296 296 else:
297 297 return _filtered_pull_requests
298 298
299 299 def count_awaiting_my_review(self, repo_name, source=False, statuses=None,
300 300 opened_by=None, user_id=None):
301 301 """
302 302 Count the number of pull requests for a specific repository that are
303 303 awaiting review from a specific user.
304 304
305 305 :param repo_name: target or source repo
306 306 :param source: boolean flag to specify if repo_name refers to source
307 307 :param statuses: list of pull request statuses
308 308 :param opened_by: author user of the pull request
309 309 :param user_id: reviewer user of the pull request
310 310 :returns: int number of pull requests
311 311 """
312 312 pull_requests = self.get_awaiting_my_review(
313 313 repo_name, source=source, statuses=statuses, opened_by=opened_by,
314 314 user_id=user_id)
315 315
316 316 return len(pull_requests)
317 317
318 318 def get_awaiting_my_review(self, repo_name, source=False, statuses=None,
319 319 opened_by=None, user_id=None, offset=0,
320 320 length=None, order_by=None, order_dir='desc'):
321 321 """
322 322 Get all pull requests for a specific repository that are awaiting
323 323 review from a specific user.
324 324
325 325 :param repo_name: target or source repo
326 326 :param source: boolean flag to specify if repo_name refers to source
327 327 :param statuses: list of pull request statuses
328 328 :param opened_by: author user of the pull request
329 329 :param user_id: reviewer user of the pull request
330 330 :param offset: pagination offset
331 331 :param length: length of returned list
332 332 :param order_by: order of the returned list
333 333 :param order_dir: 'asc' or 'desc' ordering direction
334 334 :returns: list of pull requests
335 335 """
336 336 pull_requests = self.get_all(
337 337 repo_name, source=source, statuses=statuses, opened_by=opened_by,
338 338 order_by=order_by, order_dir=order_dir)
339 339
340 340 _my = PullRequestModel().get_not_reviewed(user_id)
341 341 my_participation = []
342 342 for pr in pull_requests:
343 343 if pr in _my:
344 344 my_participation.append(pr)
345 345 _filtered_pull_requests = my_participation
346 346 if length:
347 347 return _filtered_pull_requests[offset:offset+length]
348 348 else:
349 349 return _filtered_pull_requests
350 350
351 351 def get_not_reviewed(self, user_id):
352 352 return [
353 353 x.pull_request for x in PullRequestReviewers.query().filter(
354 354 PullRequestReviewers.user_id == user_id).all()
355 355 ]
356 356
357 357 def _prepare_participating_query(self, user_id=None, statuses=None,
358 358 order_by=None, order_dir='desc'):
359 359 q = PullRequest.query()
360 360 if user_id:
361 361 reviewers_subquery = Session().query(
362 362 PullRequestReviewers.pull_request_id).filter(
363 363 PullRequestReviewers.user_id == user_id).subquery()
364 364 user_filter= or_(
365 365 PullRequest.user_id == user_id,
366 366 PullRequest.pull_request_id.in_(reviewers_subquery)
367 367 )
368 368 q = PullRequest.query().filter(user_filter)
369 369
370 370 # closed,opened
371 371 if statuses:
372 372 q = q.filter(PullRequest.status.in_(statuses))
373 373
374 374 if order_by:
375 375 order_map = {
376 376 'name_raw': PullRequest.pull_request_id,
377 377 'title': PullRequest.title,
378 378 'updated_on_raw': PullRequest.updated_on,
379 379 'target_repo': PullRequest.target_repo_id
380 380 }
381 381 if order_dir == 'asc':
382 382 q = q.order_by(order_map[order_by].asc())
383 383 else:
384 384 q = q.order_by(order_map[order_by].desc())
385 385
386 386 return q
387 387
388 388 def count_im_participating_in(self, user_id=None, statuses=None):
389 389 q = self._prepare_participating_query(user_id, statuses=statuses)
390 390 return q.count()
391 391
392 392 def get_im_participating_in(
393 393 self, user_id=None, statuses=None, offset=0,
394 394 length=None, order_by=None, order_dir='desc'):
395 395 """
396 396 Get all Pull requests that i'm participating in, or i have opened
397 397 """
398 398
399 399 q = self._prepare_participating_query(
400 400 user_id, statuses=statuses, order_by=order_by,
401 401 order_dir=order_dir)
402 402
403 403 if length:
404 404 pull_requests = q.limit(length).offset(offset).all()
405 405 else:
406 406 pull_requests = q.all()
407 407
408 408 return pull_requests
409 409
410 410 def get_versions(self, pull_request):
411 411 """
412 412 returns version of pull request sorted by ID descending
413 413 """
414 414 return PullRequestVersion.query()\
415 415 .filter(PullRequestVersion.pull_request == pull_request)\
416 416 .order_by(PullRequestVersion.pull_request_version_id.asc())\
417 417 .all()
418 418
419 419 def create(self, created_by, source_repo, source_ref, target_repo,
420 420 target_ref, revisions, reviewers, title, description=None,
421 421 reviewer_data=None):
422 422
423 423 created_by_user = self._get_user(created_by)
424 424 source_repo = self._get_repo(source_repo)
425 425 target_repo = self._get_repo(target_repo)
426 426
427 427 pull_request = PullRequest()
428 428 pull_request.source_repo = source_repo
429 429 pull_request.source_ref = source_ref
430 430 pull_request.target_repo = target_repo
431 431 pull_request.target_ref = target_ref
432 432 pull_request.revisions = revisions
433 433 pull_request.title = title
434 434 pull_request.description = description
435 435 pull_request.author = created_by_user
436 436 pull_request.reviewer_data = reviewer_data
437 437
438 438 Session().add(pull_request)
439 439 Session().flush()
440 440
441 441 reviewer_ids = set()
442 442 # members / reviewers
443 443 for reviewer_object in reviewers:
444 444 user_id, reasons, mandatory = reviewer_object
445 user = self._get_user(user_id)
445 446
446 user = self._get_user(user_id)
447 # skip duplicates
448 if user.user_id in reviewer_ids:
449 continue
450
447 451 reviewer_ids.add(user.user_id)
448 452
449 453 reviewer = PullRequestReviewers()
450 454 reviewer.user = user
451 455 reviewer.pull_request = pull_request
452 456 reviewer.reasons = reasons
453 457 reviewer.mandatory = mandatory
454 458 Session().add(reviewer)
455 459
456 460 # Set approval status to "Under Review" for all commits which are
457 461 # part of this pull request.
458 462 ChangesetStatusModel().set_status(
459 463 repo=target_repo,
460 464 status=ChangesetStatus.STATUS_UNDER_REVIEW,
461 465 user=created_by_user,
462 466 pull_request=pull_request
463 467 )
464 468
465 469 self.notify_reviewers(pull_request, reviewer_ids)
466 470 self._trigger_pull_request_hook(
467 471 pull_request, created_by_user, 'create')
468 472
469 473 return pull_request
470 474
471 475 def _trigger_pull_request_hook(self, pull_request, user, action):
472 476 pull_request = self.__get_pull_request(pull_request)
473 477 target_scm = pull_request.target_repo.scm_instance()
474 478 if action == 'create':
475 479 trigger_hook = hooks_utils.trigger_log_create_pull_request_hook
476 480 elif action == 'merge':
477 481 trigger_hook = hooks_utils.trigger_log_merge_pull_request_hook
478 482 elif action == 'close':
479 483 trigger_hook = hooks_utils.trigger_log_close_pull_request_hook
480 484 elif action == 'review_status_change':
481 485 trigger_hook = hooks_utils.trigger_log_review_pull_request_hook
482 486 elif action == 'update':
483 487 trigger_hook = hooks_utils.trigger_log_update_pull_request_hook
484 488 else:
485 489 return
486 490
487 491 trigger_hook(
488 492 username=user.username,
489 493 repo_name=pull_request.target_repo.repo_name,
490 494 repo_alias=target_scm.alias,
491 495 pull_request=pull_request)
492 496
493 497 def _get_commit_ids(self, pull_request):
494 498 """
495 499 Return the commit ids of the merged pull request.
496 500
497 501 This method is not dealing correctly yet with the lack of autoupdates
498 502 nor with the implicit target updates.
499 503 For example: if a commit in the source repo is already in the target it
500 504 will be reported anyways.
501 505 """
502 506 merge_rev = pull_request.merge_rev
503 507 if merge_rev is None:
504 508 raise ValueError('This pull request was not merged yet')
505 509
506 510 commit_ids = list(pull_request.revisions)
507 511 if merge_rev not in commit_ids:
508 512 commit_ids.append(merge_rev)
509 513
510 514 return commit_ids
511 515
512 516 def merge(self, pull_request, user, extras):
513 517 log.debug("Merging pull request %s", pull_request.pull_request_id)
514 518 merge_state = self._merge_pull_request(pull_request, user, extras)
515 519 if merge_state.executed:
516 520 log.debug(
517 521 "Merge was successful, updating the pull request comments.")
518 522 self._comment_and_close_pr(pull_request, user, merge_state)
519 523 self._log_action('user_merged_pull_request', user, pull_request)
520 524 else:
521 525 log.warn("Merge failed, not updating the pull request.")
522 526 return merge_state
523 527
524 528 def _merge_pull_request(self, pull_request, user, extras):
525 529 target_vcs = pull_request.target_repo.scm_instance()
526 530 source_vcs = pull_request.source_repo.scm_instance()
527 531 target_ref = self._refresh_reference(
528 532 pull_request.target_ref_parts, target_vcs)
529 533
530 534 message = _(
531 535 'Merge pull request #%(pr_id)s from '
532 536 '%(source_repo)s %(source_ref_name)s\n\n %(pr_title)s') % {
533 537 'pr_id': pull_request.pull_request_id,
534 538 'source_repo': source_vcs.name,
535 539 'source_ref_name': pull_request.source_ref_parts.name,
536 540 'pr_title': pull_request.title
537 541 }
538 542
539 543 workspace_id = self._workspace_id(pull_request)
540 544 use_rebase = self._use_rebase_for_merging(pull_request)
541 545
542 546 callback_daemon, extras = prepare_callback_daemon(
543 547 extras, protocol=vcs_settings.HOOKS_PROTOCOL,
544 548 use_direct_calls=vcs_settings.HOOKS_DIRECT_CALLS)
545 549
546 550 with callback_daemon:
547 551 # TODO: johbo: Implement a clean way to run a config_override
548 552 # for a single call.
549 553 target_vcs.config.set(
550 554 'rhodecode', 'RC_SCM_DATA', json.dumps(extras))
551 555 merge_state = target_vcs.merge(
552 556 target_ref, source_vcs, pull_request.source_ref_parts,
553 557 workspace_id, user_name=user.username,
554 558 user_email=user.email, message=message, use_rebase=use_rebase)
555 559 return merge_state
556 560
557 561 def _comment_and_close_pr(self, pull_request, user, merge_state):
558 562 pull_request.merge_rev = merge_state.merge_ref.commit_id
559 563 pull_request.updated_on = datetime.datetime.now()
560 564
561 565 CommentsModel().create(
562 566 text=unicode(_('Pull request merged and closed')),
563 567 repo=pull_request.target_repo.repo_id,
564 568 user=user.user_id,
565 569 pull_request=pull_request.pull_request_id,
566 570 f_path=None,
567 571 line_no=None,
568 572 closing_pr=True
569 573 )
570 574
571 575 Session().add(pull_request)
572 576 Session().flush()
573 577 # TODO: paris: replace invalidation with less radical solution
574 578 ScmModel().mark_for_invalidation(
575 579 pull_request.target_repo.repo_name)
576 580 self._trigger_pull_request_hook(pull_request, user, 'merge')
577 581
578 582 def has_valid_update_type(self, pull_request):
579 583 source_ref_type = pull_request.source_ref_parts.type
580 584 return source_ref_type in ['book', 'branch', 'tag']
581 585
582 586 def update_commits(self, pull_request):
583 587 """
584 588 Get the updated list of commits for the pull request
585 589 and return the new pull request version and the list
586 590 of commits processed by this update action
587 591 """
588 592 pull_request = self.__get_pull_request(pull_request)
589 593 source_ref_type = pull_request.source_ref_parts.type
590 594 source_ref_name = pull_request.source_ref_parts.name
591 595 source_ref_id = pull_request.source_ref_parts.commit_id
592 596
593 597 target_ref_type = pull_request.target_ref_parts.type
594 598 target_ref_name = pull_request.target_ref_parts.name
595 599 target_ref_id = pull_request.target_ref_parts.commit_id
596 600
597 601 if not self.has_valid_update_type(pull_request):
598 602 log.debug(
599 603 "Skipping update of pull request %s due to ref type: %s",
600 604 pull_request, source_ref_type)
601 605 return UpdateResponse(
602 606 executed=False,
603 607 reason=UpdateFailureReason.WRONG_REF_TYPE,
604 608 old=pull_request, new=None, changes=None,
605 609 source_changed=False, target_changed=False)
606 610
607 611 # source repo
608 612 source_repo = pull_request.source_repo.scm_instance()
609 613 try:
610 614 source_commit = source_repo.get_commit(commit_id=source_ref_name)
611 615 except CommitDoesNotExistError:
612 616 return UpdateResponse(
613 617 executed=False,
614 618 reason=UpdateFailureReason.MISSING_SOURCE_REF,
615 619 old=pull_request, new=None, changes=None,
616 620 source_changed=False, target_changed=False)
617 621
618 622 source_changed = source_ref_id != source_commit.raw_id
619 623
620 624 # target repo
621 625 target_repo = pull_request.target_repo.scm_instance()
622 626 try:
623 627 target_commit = target_repo.get_commit(commit_id=target_ref_name)
624 628 except CommitDoesNotExistError:
625 629 return UpdateResponse(
626 630 executed=False,
627 631 reason=UpdateFailureReason.MISSING_TARGET_REF,
628 632 old=pull_request, new=None, changes=None,
629 633 source_changed=False, target_changed=False)
630 634 target_changed = target_ref_id != target_commit.raw_id
631 635
632 636 if not (source_changed or target_changed):
633 637 log.debug("Nothing changed in pull request %s", pull_request)
634 638 return UpdateResponse(
635 639 executed=False,
636 640 reason=UpdateFailureReason.NO_CHANGE,
637 641 old=pull_request, new=None, changes=None,
638 642 source_changed=target_changed, target_changed=source_changed)
639 643
640 644 change_in_found = 'target repo' if target_changed else 'source repo'
641 645 log.debug('Updating pull request because of change in %s detected',
642 646 change_in_found)
643 647
644 648 # Finally there is a need for an update, in case of source change
645 649 # we create a new version, else just an update
646 650 if source_changed:
647 651 pull_request_version = self._create_version_from_snapshot(pull_request)
648 652 self._link_comments_to_version(pull_request_version)
649 653 else:
650 654 try:
651 655 ver = pull_request.versions[-1]
652 656 except IndexError:
653 657 ver = None
654 658
655 659 pull_request.pull_request_version_id = \
656 660 ver.pull_request_version_id if ver else None
657 661 pull_request_version = pull_request
658 662
659 663 try:
660 664 if target_ref_type in ('tag', 'branch', 'book'):
661 665 target_commit = target_repo.get_commit(target_ref_name)
662 666 else:
663 667 target_commit = target_repo.get_commit(target_ref_id)
664 668 except CommitDoesNotExistError:
665 669 return UpdateResponse(
666 670 executed=False,
667 671 reason=UpdateFailureReason.MISSING_TARGET_REF,
668 672 old=pull_request, new=None, changes=None,
669 673 source_changed=source_changed, target_changed=target_changed)
670 674
671 675 # re-compute commit ids
672 676 old_commit_ids = pull_request.revisions
673 677 pre_load = ["author", "branch", "date", "message"]
674 678 commit_ranges = target_repo.compare(
675 679 target_commit.raw_id, source_commit.raw_id, source_repo, merge=True,
676 680 pre_load=pre_load)
677 681
678 682 ancestor = target_repo.get_common_ancestor(
679 683 target_commit.raw_id, source_commit.raw_id, source_repo)
680 684
681 685 pull_request.source_ref = '%s:%s:%s' % (
682 686 source_ref_type, source_ref_name, source_commit.raw_id)
683 687 pull_request.target_ref = '%s:%s:%s' % (
684 688 target_ref_type, target_ref_name, ancestor)
685 689
686 690 pull_request.revisions = [
687 691 commit.raw_id for commit in reversed(commit_ranges)]
688 692 pull_request.updated_on = datetime.datetime.now()
689 693 Session().add(pull_request)
690 694 new_commit_ids = pull_request.revisions
691 695
692 696 old_diff_data, new_diff_data = self._generate_update_diffs(
693 697 pull_request, pull_request_version)
694 698
695 699 # calculate commit and file changes
696 700 changes = self._calculate_commit_id_changes(
697 701 old_commit_ids, new_commit_ids)
698 702 file_changes = self._calculate_file_changes(
699 703 old_diff_data, new_diff_data)
700 704
701 705 # set comments as outdated if DIFFS changed
702 706 CommentsModel().outdate_comments(
703 707 pull_request, old_diff_data=old_diff_data,
704 708 new_diff_data=new_diff_data)
705 709
706 710 commit_changes = (changes.added or changes.removed)
707 711 file_node_changes = (
708 712 file_changes.added or file_changes.modified or file_changes.removed)
709 713 pr_has_changes = commit_changes or file_node_changes
710 714
711 715 # Add an automatic comment to the pull request, in case
712 716 # anything has changed
713 717 if pr_has_changes:
714 718 update_comment = CommentsModel().create(
715 719 text=self._render_update_message(changes, file_changes),
716 720 repo=pull_request.target_repo,
717 721 user=pull_request.author,
718 722 pull_request=pull_request,
719 723 send_email=False, renderer=DEFAULT_COMMENTS_RENDERER)
720 724
721 725 # Update status to "Under Review" for added commits
722 726 for commit_id in changes.added:
723 727 ChangesetStatusModel().set_status(
724 728 repo=pull_request.source_repo,
725 729 status=ChangesetStatus.STATUS_UNDER_REVIEW,
726 730 comment=update_comment,
727 731 user=pull_request.author,
728 732 pull_request=pull_request,
729 733 revision=commit_id)
730 734
731 735 log.debug(
732 736 'Updated pull request %s, added_ids: %s, common_ids: %s, '
733 737 'removed_ids: %s', pull_request.pull_request_id,
734 738 changes.added, changes.common, changes.removed)
735 739 log.debug(
736 740 'Updated pull request with the following file changes: %s',
737 741 file_changes)
738 742
739 743 log.info(
740 744 "Updated pull request %s from commit %s to commit %s, "
741 745 "stored new version %s of this pull request.",
742 746 pull_request.pull_request_id, source_ref_id,
743 747 pull_request.source_ref_parts.commit_id,
744 748 pull_request_version.pull_request_version_id)
745 749 Session().commit()
746 750 self._trigger_pull_request_hook(
747 751 pull_request, pull_request.author, 'update')
748 752
749 753 return UpdateResponse(
750 754 executed=True, reason=UpdateFailureReason.NONE,
751 755 old=pull_request, new=pull_request_version, changes=changes,
752 756 source_changed=source_changed, target_changed=target_changed)
753 757
754 758 def _create_version_from_snapshot(self, pull_request):
755 759 version = PullRequestVersion()
756 760 version.title = pull_request.title
757 761 version.description = pull_request.description
758 762 version.status = pull_request.status
759 763 version.created_on = datetime.datetime.now()
760 764 version.updated_on = pull_request.updated_on
761 765 version.user_id = pull_request.user_id
762 766 version.source_repo = pull_request.source_repo
763 767 version.source_ref = pull_request.source_ref
764 768 version.target_repo = pull_request.target_repo
765 769 version.target_ref = pull_request.target_ref
766 770
767 771 version._last_merge_source_rev = pull_request._last_merge_source_rev
768 772 version._last_merge_target_rev = pull_request._last_merge_target_rev
769 773 version._last_merge_status = pull_request._last_merge_status
770 774 version.shadow_merge_ref = pull_request.shadow_merge_ref
771 775 version.merge_rev = pull_request.merge_rev
772 776 version.reviewer_data = pull_request.reviewer_data
773 777
774 778 version.revisions = pull_request.revisions
775 779 version.pull_request = pull_request
776 780 Session().add(version)
777 781 Session().flush()
778 782
779 783 return version
780 784
781 785 def _generate_update_diffs(self, pull_request, pull_request_version):
782 786
783 787 diff_context = (
784 788 self.DIFF_CONTEXT +
785 789 CommentsModel.needed_extra_diff_context())
786 790
787 791 source_repo = pull_request_version.source_repo
788 792 source_ref_id = pull_request_version.source_ref_parts.commit_id
789 793 target_ref_id = pull_request_version.target_ref_parts.commit_id
790 794 old_diff = self._get_diff_from_pr_or_version(
791 795 source_repo, source_ref_id, target_ref_id, context=diff_context)
792 796
793 797 source_repo = pull_request.source_repo
794 798 source_ref_id = pull_request.source_ref_parts.commit_id
795 799 target_ref_id = pull_request.target_ref_parts.commit_id
796 800
797 801 new_diff = self._get_diff_from_pr_or_version(
798 802 source_repo, source_ref_id, target_ref_id, context=diff_context)
799 803
800 804 old_diff_data = diffs.DiffProcessor(old_diff)
801 805 old_diff_data.prepare()
802 806 new_diff_data = diffs.DiffProcessor(new_diff)
803 807 new_diff_data.prepare()
804 808
805 809 return old_diff_data, new_diff_data
806 810
807 811 def _link_comments_to_version(self, pull_request_version):
808 812 """
809 813 Link all unlinked comments of this pull request to the given version.
810 814
811 815 :param pull_request_version: The `PullRequestVersion` to which
812 816 the comments shall be linked.
813 817
814 818 """
815 819 pull_request = pull_request_version.pull_request
816 820 comments = ChangesetComment.query()\
817 821 .filter(
818 822 # TODO: johbo: Should we query for the repo at all here?
819 823 # Pending decision on how comments of PRs are to be related
820 824 # to either the source repo, the target repo or no repo at all.
821 825 ChangesetComment.repo_id == pull_request.target_repo.repo_id,
822 826 ChangesetComment.pull_request == pull_request,
823 827 ChangesetComment.pull_request_version == None)\
824 828 .order_by(ChangesetComment.comment_id.asc())
825 829
826 830 # TODO: johbo: Find out why this breaks if it is done in a bulk
827 831 # operation.
828 832 for comment in comments:
829 833 comment.pull_request_version_id = (
830 834 pull_request_version.pull_request_version_id)
831 835 Session().add(comment)
832 836
833 837 def _calculate_commit_id_changes(self, old_ids, new_ids):
834 838 added = [x for x in new_ids if x not in old_ids]
835 839 common = [x for x in new_ids if x in old_ids]
836 840 removed = [x for x in old_ids if x not in new_ids]
837 841 total = new_ids
838 842 return ChangeTuple(added, common, removed, total)
839 843
840 844 def _calculate_file_changes(self, old_diff_data, new_diff_data):
841 845
842 846 old_files = OrderedDict()
843 847 for diff_data in old_diff_data.parsed_diff:
844 848 old_files[diff_data['filename']] = md5_safe(diff_data['raw_diff'])
845 849
846 850 added_files = []
847 851 modified_files = []
848 852 removed_files = []
849 853 for diff_data in new_diff_data.parsed_diff:
850 854 new_filename = diff_data['filename']
851 855 new_hash = md5_safe(diff_data['raw_diff'])
852 856
853 857 old_hash = old_files.get(new_filename)
854 858 if not old_hash:
855 859 # file is not present in old diff, means it's added
856 860 added_files.append(new_filename)
857 861 else:
858 862 if new_hash != old_hash:
859 863 modified_files.append(new_filename)
860 864 # now remove a file from old, since we have seen it already
861 865 del old_files[new_filename]
862 866
863 867 # removed files is when there are present in old, but not in NEW,
864 868 # since we remove old files that are present in new diff, left-overs
865 869 # if any should be the removed files
866 870 removed_files.extend(old_files.keys())
867 871
868 872 return FileChangeTuple(added_files, modified_files, removed_files)
869 873
870 874 def _render_update_message(self, changes, file_changes):
871 875 """
872 876 render the message using DEFAULT_COMMENTS_RENDERER (RST renderer),
873 877 so it's always looking the same disregarding on which default
874 878 renderer system is using.
875 879
876 880 :param changes: changes named tuple
877 881 :param file_changes: file changes named tuple
878 882
879 883 """
880 884 new_status = ChangesetStatus.get_status_lbl(
881 885 ChangesetStatus.STATUS_UNDER_REVIEW)
882 886
883 887 changed_files = (
884 888 file_changes.added + file_changes.modified + file_changes.removed)
885 889
886 890 params = {
887 891 'under_review_label': new_status,
888 892 'added_commits': changes.added,
889 893 'removed_commits': changes.removed,
890 894 'changed_files': changed_files,
891 895 'added_files': file_changes.added,
892 896 'modified_files': file_changes.modified,
893 897 'removed_files': file_changes.removed,
894 898 }
895 899 renderer = RstTemplateRenderer()
896 900 return renderer.render('pull_request_update.mako', **params)
897 901
898 902 def edit(self, pull_request, title, description):
899 903 pull_request = self.__get_pull_request(pull_request)
900 904 if pull_request.is_closed():
901 905 raise ValueError('This pull request is closed')
902 906 if title:
903 907 pull_request.title = title
904 908 pull_request.description = description
905 909 pull_request.updated_on = datetime.datetime.now()
906 910 Session().add(pull_request)
907 911
908 912 def update_reviewers(self, pull_request, reviewer_data):
909 913 """
910 914 Update the reviewers in the pull request
911 915
912 916 :param pull_request: the pr to update
913 917 :param reviewer_data: list of tuples
914 918 [(user, ['reason1', 'reason2'], mandatory_flag)]
915 919 """
916 920
917 921 reviewers = {}
918 922 for user_id, reasons, mandatory in reviewer_data:
919 923 if isinstance(user_id, (int, basestring)):
920 924 user_id = self._get_user(user_id).user_id
921 925 reviewers[user_id] = {
922 926 'reasons': reasons, 'mandatory': mandatory}
923 927
924 928 reviewers_ids = set(reviewers.keys())
925 929 pull_request = self.__get_pull_request(pull_request)
926 930 current_reviewers = PullRequestReviewers.query()\
927 931 .filter(PullRequestReviewers.pull_request ==
928 932 pull_request).all()
929 933 current_reviewers_ids = set([x.user.user_id for x in current_reviewers])
930 934
931 935 ids_to_add = reviewers_ids.difference(current_reviewers_ids)
932 936 ids_to_remove = current_reviewers_ids.difference(reviewers_ids)
933 937
934 938 log.debug("Adding %s reviewers", ids_to_add)
935 939 log.debug("Removing %s reviewers", ids_to_remove)
936 940 changed = False
937 941 for uid in ids_to_add:
938 942 changed = True
939 943 _usr = self._get_user(uid)
940 944 reviewer = PullRequestReviewers()
941 945 reviewer.user = _usr
942 946 reviewer.pull_request = pull_request
943 947 reviewer.reasons = reviewers[uid]['reasons']
944 948 # NOTE(marcink): mandatory shouldn't be changed now
945 949 #reviewer.mandatory = reviewers[uid]['reasons']
946 950 Session().add(reviewer)
947 951
948 952 for uid in ids_to_remove:
949 953 changed = True
950 954 reviewers = PullRequestReviewers.query()\
951 955 .filter(PullRequestReviewers.user_id == uid,
952 956 PullRequestReviewers.pull_request == pull_request)\
953 957 .all()
954 958 # use .all() in case we accidentally added the same person twice
955 959 # this CAN happen due to the lack of DB checks
956 960 for obj in reviewers:
957 961 Session().delete(obj)
958 962
959 963 if changed:
960 964 pull_request.updated_on = datetime.datetime.now()
961 965 Session().add(pull_request)
962 966
963 967 self.notify_reviewers(pull_request, ids_to_add)
964 968 return ids_to_add, ids_to_remove
965 969
966 970 def get_url(self, pull_request, request=None, permalink=False):
967 971 if not request:
968 972 request = get_current_request()
969 973
970 974 if permalink:
971 975 return request.route_url(
972 976 'pull_requests_global',
973 977 pull_request_id=pull_request.pull_request_id,)
974 978 else:
975 979 return request.route_url(
976 980 'pullrequest_show',
977 981 repo_name=safe_str(pull_request.target_repo.repo_name),
978 982 pull_request_id=pull_request.pull_request_id,)
979 983
980 984 def get_shadow_clone_url(self, pull_request):
981 985 """
982 986 Returns qualified url pointing to the shadow repository. If this pull
983 987 request is closed there is no shadow repository and ``None`` will be
984 988 returned.
985 989 """
986 990 if pull_request.is_closed():
987 991 return None
988 992 else:
989 993 pr_url = urllib.unquote(self.get_url(pull_request))
990 994 return safe_unicode('{pr_url}/repository'.format(pr_url=pr_url))
991 995
992 996 def notify_reviewers(self, pull_request, reviewers_ids):
993 997 # notification to reviewers
994 998 if not reviewers_ids:
995 999 return
996 1000
997 1001 pull_request_obj = pull_request
998 1002 # get the current participants of this pull request
999 1003 recipients = reviewers_ids
1000 1004 notification_type = EmailNotificationModel.TYPE_PULL_REQUEST
1001 1005
1002 1006 pr_source_repo = pull_request_obj.source_repo
1003 1007 pr_target_repo = pull_request_obj.target_repo
1004 1008
1005 1009 pr_url = h.url(
1006 1010 'pullrequest_show',
1007 1011 repo_name=pr_target_repo.repo_name,
1008 1012 pull_request_id=pull_request_obj.pull_request_id,
1009 1013 qualified=True,)
1010 1014
1011 1015 # set some variables for email notification
1012 1016 pr_target_repo_url = h.route_url(
1013 1017 'repo_summary', repo_name=pr_target_repo.repo_name)
1014 1018
1015 1019 pr_source_repo_url = h.route_url(
1016 1020 'repo_summary', repo_name=pr_source_repo.repo_name)
1017 1021
1018 1022 # pull request specifics
1019 1023 pull_request_commits = [
1020 1024 (x.raw_id, x.message)
1021 1025 for x in map(pr_source_repo.get_commit, pull_request.revisions)]
1022 1026
1023 1027 kwargs = {
1024 1028 'user': pull_request.author,
1025 1029 'pull_request': pull_request_obj,
1026 1030 'pull_request_commits': pull_request_commits,
1027 1031
1028 1032 'pull_request_target_repo': pr_target_repo,
1029 1033 'pull_request_target_repo_url': pr_target_repo_url,
1030 1034
1031 1035 'pull_request_source_repo': pr_source_repo,
1032 1036 'pull_request_source_repo_url': pr_source_repo_url,
1033 1037
1034 1038 'pull_request_url': pr_url,
1035 1039 }
1036 1040
1037 1041 # pre-generate the subject for notification itself
1038 1042 (subject,
1039 1043 _h, _e, # we don't care about those
1040 1044 body_plaintext) = EmailNotificationModel().render_email(
1041 1045 notification_type, **kwargs)
1042 1046
1043 1047 # create notification objects, and emails
1044 1048 NotificationModel().create(
1045 1049 created_by=pull_request.author,
1046 1050 notification_subject=subject,
1047 1051 notification_body=body_plaintext,
1048 1052 notification_type=notification_type,
1049 1053 recipients=recipients,
1050 1054 email_kwargs=kwargs,
1051 1055 )
1052 1056
1053 1057 def delete(self, pull_request):
1054 1058 pull_request = self.__get_pull_request(pull_request)
1055 1059 self._cleanup_merge_workspace(pull_request)
1056 1060 Session().delete(pull_request)
1057 1061
1058 1062 def close_pull_request(self, pull_request, user):
1059 1063 pull_request = self.__get_pull_request(pull_request)
1060 1064 self._cleanup_merge_workspace(pull_request)
1061 1065 pull_request.status = PullRequest.STATUS_CLOSED
1062 1066 pull_request.updated_on = datetime.datetime.now()
1063 1067 Session().add(pull_request)
1064 1068 self._trigger_pull_request_hook(
1065 1069 pull_request, pull_request.author, 'close')
1066 1070 self._log_action('user_closed_pull_request', user, pull_request)
1067 1071
1068 1072 def close_pull_request_with_comment(
1069 1073 self, pull_request, user, repo, message=None):
1070 1074
1071 1075 pull_request_review_status = pull_request.calculated_review_status()
1072 1076
1073 1077 if pull_request_review_status == ChangesetStatus.STATUS_APPROVED:
1074 1078 # approved only if we have voting consent
1075 1079 status = ChangesetStatus.STATUS_APPROVED
1076 1080 else:
1077 1081 status = ChangesetStatus.STATUS_REJECTED
1078 1082 status_lbl = ChangesetStatus.get_status_lbl(status)
1079 1083
1080 1084 default_message = (
1081 1085 _('Closing with status change {transition_icon} {status}.')
1082 1086 ).format(transition_icon='>', status=status_lbl)
1083 1087 text = message or default_message
1084 1088
1085 1089 # create a comment, and link it to new status
1086 1090 comment = CommentsModel().create(
1087 1091 text=text,
1088 1092 repo=repo.repo_id,
1089 1093 user=user.user_id,
1090 1094 pull_request=pull_request.pull_request_id,
1091 1095 status_change=status_lbl,
1092 1096 status_change_type=status,
1093 1097 closing_pr=True
1094 1098 )
1095 1099
1096 1100 # calculate old status before we change it
1097 1101 old_calculated_status = pull_request.calculated_review_status()
1098 1102 ChangesetStatusModel().set_status(
1099 1103 repo.repo_id,
1100 1104 status,
1101 1105 user.user_id,
1102 1106 comment=comment,
1103 1107 pull_request=pull_request.pull_request_id
1104 1108 )
1105 1109
1106 1110 Session().flush()
1107 1111 events.trigger(events.PullRequestCommentEvent(pull_request, comment))
1108 1112 # we now calculate the status of pull request again, and based on that
1109 1113 # calculation trigger status change. This might happen in cases
1110 1114 # that non-reviewer admin closes a pr, which means his vote doesn't
1111 1115 # change the status, while if he's a reviewer this might change it.
1112 1116 calculated_status = pull_request.calculated_review_status()
1113 1117 if old_calculated_status != calculated_status:
1114 1118 self._trigger_pull_request_hook(
1115 1119 pull_request, user, 'review_status_change')
1116 1120
1117 1121 # finally close the PR
1118 1122 PullRequestModel().close_pull_request(
1119 1123 pull_request.pull_request_id, user)
1120 1124
1121 1125 return comment, status
1122 1126
1123 1127 def merge_status(self, pull_request):
1124 1128 if not self._is_merge_enabled(pull_request):
1125 1129 return False, _('Server-side pull request merging is disabled.')
1126 1130 if pull_request.is_closed():
1127 1131 return False, _('This pull request is closed.')
1128 1132 merge_possible, msg = self._check_repo_requirements(
1129 1133 target=pull_request.target_repo, source=pull_request.source_repo)
1130 1134 if not merge_possible:
1131 1135 return merge_possible, msg
1132 1136
1133 1137 try:
1134 1138 resp = self._try_merge(pull_request)
1135 1139 log.debug("Merge response: %s", resp)
1136 1140 status = resp.possible, self.merge_status_message(
1137 1141 resp.failure_reason)
1138 1142 except NotImplementedError:
1139 1143 status = False, _('Pull request merging is not supported.')
1140 1144
1141 1145 return status
1142 1146
1143 1147 def _check_repo_requirements(self, target, source):
1144 1148 """
1145 1149 Check if `target` and `source` have compatible requirements.
1146 1150
1147 1151 Currently this is just checking for largefiles.
1148 1152 """
1149 1153 target_has_largefiles = self._has_largefiles(target)
1150 1154 source_has_largefiles = self._has_largefiles(source)
1151 1155 merge_possible = True
1152 1156 message = u''
1153 1157
1154 1158 if target_has_largefiles != source_has_largefiles:
1155 1159 merge_possible = False
1156 1160 if source_has_largefiles:
1157 1161 message = _(
1158 1162 'Target repository large files support is disabled.')
1159 1163 else:
1160 1164 message = _(
1161 1165 'Source repository large files support is disabled.')
1162 1166
1163 1167 return merge_possible, message
1164 1168
1165 1169 def _has_largefiles(self, repo):
1166 1170 largefiles_ui = VcsSettingsModel(repo=repo).get_ui_settings(
1167 1171 'extensions', 'largefiles')
1168 1172 return largefiles_ui and largefiles_ui[0].active
1169 1173
1170 1174 def _try_merge(self, pull_request):
1171 1175 """
1172 1176 Try to merge the pull request and return the merge status.
1173 1177 """
1174 1178 log.debug(
1175 1179 "Trying out if the pull request %s can be merged.",
1176 1180 pull_request.pull_request_id)
1177 1181 target_vcs = pull_request.target_repo.scm_instance()
1178 1182
1179 1183 # Refresh the target reference.
1180 1184 try:
1181 1185 target_ref = self._refresh_reference(
1182 1186 pull_request.target_ref_parts, target_vcs)
1183 1187 except CommitDoesNotExistError:
1184 1188 merge_state = MergeResponse(
1185 1189 False, False, None, MergeFailureReason.MISSING_TARGET_REF)
1186 1190 return merge_state
1187 1191
1188 1192 target_locked = pull_request.target_repo.locked
1189 1193 if target_locked and target_locked[0]:
1190 1194 log.debug("The target repository is locked.")
1191 1195 merge_state = MergeResponse(
1192 1196 False, False, None, MergeFailureReason.TARGET_IS_LOCKED)
1193 1197 elif self._needs_merge_state_refresh(pull_request, target_ref):
1194 1198 log.debug("Refreshing the merge status of the repository.")
1195 1199 merge_state = self._refresh_merge_state(
1196 1200 pull_request, target_vcs, target_ref)
1197 1201 else:
1198 1202 possible = pull_request.\
1199 1203 _last_merge_status == MergeFailureReason.NONE
1200 1204 merge_state = MergeResponse(
1201 1205 possible, False, None, pull_request._last_merge_status)
1202 1206
1203 1207 return merge_state
1204 1208
1205 1209 def _refresh_reference(self, reference, vcs_repository):
1206 1210 if reference.type in ('branch', 'book'):
1207 1211 name_or_id = reference.name
1208 1212 else:
1209 1213 name_or_id = reference.commit_id
1210 1214 refreshed_commit = vcs_repository.get_commit(name_or_id)
1211 1215 refreshed_reference = Reference(
1212 1216 reference.type, reference.name, refreshed_commit.raw_id)
1213 1217 return refreshed_reference
1214 1218
1215 1219 def _needs_merge_state_refresh(self, pull_request, target_reference):
1216 1220 return not(
1217 1221 pull_request.revisions and
1218 1222 pull_request.revisions[0] == pull_request._last_merge_source_rev and
1219 1223 target_reference.commit_id == pull_request._last_merge_target_rev)
1220 1224
1221 1225 def _refresh_merge_state(self, pull_request, target_vcs, target_reference):
1222 1226 workspace_id = self._workspace_id(pull_request)
1223 1227 source_vcs = pull_request.source_repo.scm_instance()
1224 1228 use_rebase = self._use_rebase_for_merging(pull_request)
1225 1229 merge_state = target_vcs.merge(
1226 1230 target_reference, source_vcs, pull_request.source_ref_parts,
1227 1231 workspace_id, dry_run=True, use_rebase=use_rebase)
1228 1232
1229 1233 # Do not store the response if there was an unknown error.
1230 1234 if merge_state.failure_reason != MergeFailureReason.UNKNOWN:
1231 1235 pull_request._last_merge_source_rev = \
1232 1236 pull_request.source_ref_parts.commit_id
1233 1237 pull_request._last_merge_target_rev = target_reference.commit_id
1234 1238 pull_request._last_merge_status = merge_state.failure_reason
1235 1239 pull_request.shadow_merge_ref = merge_state.merge_ref
1236 1240 Session().add(pull_request)
1237 1241 Session().commit()
1238 1242
1239 1243 return merge_state
1240 1244
1241 1245 def _workspace_id(self, pull_request):
1242 1246 workspace_id = 'pr-%s' % pull_request.pull_request_id
1243 1247 return workspace_id
1244 1248
1245 1249 def merge_status_message(self, status_code):
1246 1250 """
1247 1251 Return a human friendly error message for the given merge status code.
1248 1252 """
1249 1253 return self.MERGE_STATUS_MESSAGES[status_code]
1250 1254
1251 1255 def generate_repo_data(self, repo, commit_id=None, branch=None,
1252 1256 bookmark=None):
1253 1257 all_refs, selected_ref = \
1254 1258 self._get_repo_pullrequest_sources(
1255 1259 repo.scm_instance(), commit_id=commit_id,
1256 1260 branch=branch, bookmark=bookmark)
1257 1261
1258 1262 refs_select2 = []
1259 1263 for element in all_refs:
1260 1264 children = [{'id': x[0], 'text': x[1]} for x in element[0]]
1261 1265 refs_select2.append({'text': element[1], 'children': children})
1262 1266
1263 1267 return {
1264 1268 'user': {
1265 1269 'user_id': repo.user.user_id,
1266 1270 'username': repo.user.username,
1267 1271 'firstname': repo.user.firstname,
1268 1272 'lastname': repo.user.lastname,
1269 1273 'gravatar_link': h.gravatar_url(repo.user.email, 14),
1270 1274 },
1271 1275 'description': h.chop_at_smart(repo.description, '\n'),
1272 1276 'refs': {
1273 1277 'all_refs': all_refs,
1274 1278 'selected_ref': selected_ref,
1275 1279 'select2_refs': refs_select2
1276 1280 }
1277 1281 }
1278 1282
1279 1283 def generate_pullrequest_title(self, source, source_ref, target):
1280 1284 return u'{source}#{at_ref} to {target}'.format(
1281 1285 source=source,
1282 1286 at_ref=source_ref,
1283 1287 target=target,
1284 1288 )
1285 1289
1286 1290 def _cleanup_merge_workspace(self, pull_request):
1287 1291 # Merging related cleanup
1288 1292 target_scm = pull_request.target_repo.scm_instance()
1289 1293 workspace_id = 'pr-%s' % pull_request.pull_request_id
1290 1294
1291 1295 try:
1292 1296 target_scm.cleanup_merge_workspace(workspace_id)
1293 1297 except NotImplementedError:
1294 1298 pass
1295 1299
1296 1300 def _get_repo_pullrequest_sources(
1297 1301 self, repo, commit_id=None, branch=None, bookmark=None):
1298 1302 """
1299 1303 Return a structure with repo's interesting commits, suitable for
1300 1304 the selectors in pullrequest controller
1301 1305
1302 1306 :param commit_id: a commit that must be in the list somehow
1303 1307 and selected by default
1304 1308 :param branch: a branch that must be in the list and selected
1305 1309 by default - even if closed
1306 1310 :param bookmark: a bookmark that must be in the list and selected
1307 1311 """
1308 1312
1309 1313 commit_id = safe_str(commit_id) if commit_id else None
1310 1314 branch = safe_str(branch) if branch else None
1311 1315 bookmark = safe_str(bookmark) if bookmark else None
1312 1316
1313 1317 selected = None
1314 1318
1315 1319 # order matters: first source that has commit_id in it will be selected
1316 1320 sources = []
1317 1321 sources.append(('book', repo.bookmarks.items(), _('Bookmarks'), bookmark))
1318 1322 sources.append(('branch', repo.branches.items(), _('Branches'), branch))
1319 1323
1320 1324 if commit_id:
1321 1325 ref_commit = (h.short_id(commit_id), commit_id)
1322 1326 sources.append(('rev', [ref_commit], _('Commit IDs'), commit_id))
1323 1327
1324 1328 sources.append(
1325 1329 ('branch', repo.branches_closed.items(), _('Closed Branches'), branch),
1326 1330 )
1327 1331
1328 1332 groups = []
1329 1333 for group_key, ref_list, group_name, match in sources:
1330 1334 group_refs = []
1331 1335 for ref_name, ref_id in ref_list:
1332 1336 ref_key = '%s:%s:%s' % (group_key, ref_name, ref_id)
1333 1337 group_refs.append((ref_key, ref_name))
1334 1338
1335 1339 if not selected:
1336 1340 if set([commit_id, match]) & set([ref_id, ref_name]):
1337 1341 selected = ref_key
1338 1342
1339 1343 if group_refs:
1340 1344 groups.append((group_refs, group_name))
1341 1345
1342 1346 if not selected:
1343 1347 ref = commit_id or branch or bookmark
1344 1348 if ref:
1345 1349 raise CommitDoesNotExistError(
1346 1350 'No commit refs could be found matching: %s' % ref)
1347 1351 elif repo.DEFAULT_BRANCH_NAME in repo.branches:
1348 1352 selected = 'branch:%s:%s' % (
1349 1353 repo.DEFAULT_BRANCH_NAME,
1350 1354 repo.branches[repo.DEFAULT_BRANCH_NAME]
1351 1355 )
1352 1356 elif repo.commit_ids:
1353 1357 rev = repo.commit_ids[0]
1354 1358 selected = 'rev:%s:%s' % (rev, rev)
1355 1359 else:
1356 1360 raise EmptyRepositoryError()
1357 1361 return groups, selected
1358 1362
1359 1363 def get_diff(self, source_repo, source_ref_id, target_ref_id, context=DIFF_CONTEXT):
1360 1364 return self._get_diff_from_pr_or_version(
1361 1365 source_repo, source_ref_id, target_ref_id, context=context)
1362 1366
1363 1367 def _get_diff_from_pr_or_version(
1364 1368 self, source_repo, source_ref_id, target_ref_id, context):
1365 1369 target_commit = source_repo.get_commit(
1366 1370 commit_id=safe_str(target_ref_id))
1367 1371 source_commit = source_repo.get_commit(
1368 1372 commit_id=safe_str(source_ref_id))
1369 1373 if isinstance(source_repo, Repository):
1370 1374 vcs_repo = source_repo.scm_instance()
1371 1375 else:
1372 1376 vcs_repo = source_repo
1373 1377
1374 1378 # TODO: johbo: In the context of an update, we cannot reach
1375 1379 # the old commit anymore with our normal mechanisms. It needs
1376 1380 # some sort of special support in the vcs layer to avoid this
1377 1381 # workaround.
1378 1382 if (source_commit.raw_id == vcs_repo.EMPTY_COMMIT_ID and
1379 1383 vcs_repo.alias == 'git'):
1380 1384 source_commit.raw_id = safe_str(source_ref_id)
1381 1385
1382 1386 log.debug('calculating diff between '
1383 1387 'source_ref:%s and target_ref:%s for repo `%s`',
1384 1388 target_ref_id, source_ref_id,
1385 1389 safe_unicode(vcs_repo.path))
1386 1390
1387 1391 vcs_diff = vcs_repo.get_diff(
1388 1392 commit1=target_commit, commit2=source_commit, context=context)
1389 1393 return vcs_diff
1390 1394
1391 1395 def _is_merge_enabled(self, pull_request):
1392 1396 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
1393 1397 settings = settings_model.get_general_settings()
1394 1398 return settings.get('rhodecode_pr_merge_enabled', False)
1395 1399
1396 1400 def _use_rebase_for_merging(self, pull_request):
1397 1401 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
1398 1402 settings = settings_model.get_general_settings()
1399 1403 return settings.get('rhodecode_hg_use_rebase_for_merging', False)
1400 1404
1401 1405 def _log_action(self, action, user, pull_request):
1402 1406 action_logger(
1403 1407 user,
1404 1408 '{action}:{pr_id}'.format(
1405 1409 action=action, pr_id=pull_request.pull_request_id),
1406 1410 pull_request.target_repo)
1407 1411
1408 1412 def get_reviewer_functions(self):
1409 1413 """
1410 1414 Fetches functions for validation and fetching default reviewers.
1411 1415 If available we use the EE package, else we fallback to CE
1412 1416 package functions
1413 1417 """
1414 1418 try:
1415 1419 from rc_reviewers.utils import get_default_reviewers_data
1416 1420 from rc_reviewers.utils import validate_default_reviewers
1417 1421 except ImportError:
1418 1422 from rhodecode.apps.repository.utils import \
1419 1423 get_default_reviewers_data
1420 1424 from rhodecode.apps.repository.utils import \
1421 1425 validate_default_reviewers
1422 1426
1423 1427 return get_default_reviewers_data, validate_default_reviewers
1424 1428
1425 1429
1426 1430 class MergeCheck(object):
1427 1431 """
1428 1432 Perform Merge Checks and returns a check object which stores information
1429 1433 about merge errors, and merge conditions
1430 1434 """
1431 1435 TODO_CHECK = 'todo'
1432 1436 PERM_CHECK = 'perm'
1433 1437 REVIEW_CHECK = 'review'
1434 1438 MERGE_CHECK = 'merge'
1435 1439
1436 1440 def __init__(self):
1437 1441 self.review_status = None
1438 1442 self.merge_possible = None
1439 1443 self.merge_msg = ''
1440 1444 self.failed = None
1441 1445 self.errors = []
1442 1446 self.error_details = OrderedDict()
1443 1447
1444 1448 def push_error(self, error_type, message, error_key, details):
1445 1449 self.failed = True
1446 1450 self.errors.append([error_type, message])
1447 1451 self.error_details[error_key] = dict(
1448 1452 details=details,
1449 1453 error_type=error_type,
1450 1454 message=message
1451 1455 )
1452 1456
1453 1457 @classmethod
1454 1458 def validate(cls, pull_request, user, fail_early=False, translator=None):
1455 1459 # if migrated to pyramid...
1456 1460 # _ = lambda: translator or _ # use passed in translator if any
1457 1461
1458 1462 merge_check = cls()
1459 1463
1460 1464 # permissions to merge
1461 1465 user_allowed_to_merge = PullRequestModel().check_user_merge(
1462 1466 pull_request, user)
1463 1467 if not user_allowed_to_merge:
1464 1468 log.debug("MergeCheck: cannot merge, approval is pending.")
1465 1469
1466 1470 msg = _('User `{}` not allowed to perform merge.').format(user.username)
1467 1471 merge_check.push_error('error', msg, cls.PERM_CHECK, user.username)
1468 1472 if fail_early:
1469 1473 return merge_check
1470 1474
1471 1475 # review status, must be always present
1472 1476 review_status = pull_request.calculated_review_status()
1473 1477 merge_check.review_status = review_status
1474 1478
1475 1479 status_approved = review_status == ChangesetStatus.STATUS_APPROVED
1476 1480 if not status_approved:
1477 1481 log.debug("MergeCheck: cannot merge, approval is pending.")
1478 1482
1479 1483 msg = _('Pull request reviewer approval is pending.')
1480 1484
1481 1485 merge_check.push_error(
1482 1486 'warning', msg, cls.REVIEW_CHECK, review_status)
1483 1487
1484 1488 if fail_early:
1485 1489 return merge_check
1486 1490
1487 1491 # left over TODOs
1488 1492 todos = CommentsModel().get_unresolved_todos(pull_request)
1489 1493 if todos:
1490 1494 log.debug("MergeCheck: cannot merge, {} "
1491 1495 "unresolved todos left.".format(len(todos)))
1492 1496
1493 1497 if len(todos) == 1:
1494 1498 msg = _('Cannot merge, {} TODO still not resolved.').format(
1495 1499 len(todos))
1496 1500 else:
1497 1501 msg = _('Cannot merge, {} TODOs still not resolved.').format(
1498 1502 len(todos))
1499 1503
1500 1504 merge_check.push_error('warning', msg, cls.TODO_CHECK, todos)
1501 1505
1502 1506 if fail_early:
1503 1507 return merge_check
1504 1508
1505 1509 # merge possible
1506 1510 merge_status, msg = PullRequestModel().merge_status(pull_request)
1507 1511 merge_check.merge_possible = merge_status
1508 1512 merge_check.merge_msg = msg
1509 1513 if not merge_status:
1510 1514 log.debug(
1511 1515 "MergeCheck: cannot merge, pull request merge not possible.")
1512 1516 merge_check.push_error('warning', msg, cls.MERGE_CHECK, None)
1513 1517
1514 1518 if fail_early:
1515 1519 return merge_check
1516 1520
1517 1521 return merge_check
1518 1522
1519 1523
1520 1524 ChangeTuple = namedtuple('ChangeTuple',
1521 1525 ['added', 'common', 'removed', 'total'])
1522 1526
1523 1527 FileChangeTuple = namedtuple('FileChangeTuple',
1524 1528 ['added', 'modified', 'removed'])
General Comments 0
You need to be logged in to leave comments. Login now