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