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