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