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