##// END OF EJS Templates
db: use a wrapper on pull requests _last_merge_status to ensure this is always INT....
marcink -
r1968:ea1add97 default
parent child Browse files
Show More

The requested changes are too big and content was truncated. Show full diff

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