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