##// END OF EJS Templates
events: trigger 'review_status_change' when reviewers are updated....
marcink -
r3397:e49bff61 default
parent child Browse files
Show More
@@ -1,1456 +1,1471 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2011-2019 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 import logging
22 22 import collections
23 23
24 24 import formencode
25 25 import formencode.htmlfill
26 26 import peppercorn
27 27 from pyramid.httpexceptions import (
28 28 HTTPFound, HTTPNotFound, HTTPForbidden, HTTPBadRequest)
29 29 from pyramid.view import view_config
30 30 from pyramid.renderers import render
31 31
32 from rhodecode import events
33 32 from rhodecode.apps._base import RepoAppView, DataGridAppView
34 33
35 34 from rhodecode.lib import helpers as h, diffs, codeblocks, channelstream
36 35 from rhodecode.lib.base import vcs_operation_context
37 36 from rhodecode.lib.diffs import load_cached_diff, cache_diff, diff_cache_exist
38 37 from rhodecode.lib.ext_json import json
39 38 from rhodecode.lib.auth import (
40 39 LoginRequired, HasRepoPermissionAny, HasRepoPermissionAnyDecorator,
41 40 NotAnonymous, CSRFRequired)
42 41 from rhodecode.lib.utils2 import str2bool, safe_str, safe_unicode
43 42 from rhodecode.lib.vcs.backends.base import EmptyCommit, UpdateFailureReason
44 43 from rhodecode.lib.vcs.exceptions import (CommitDoesNotExistError,
45 44 RepositoryRequirementError, EmptyRepositoryError)
46 45 from rhodecode.model.changeset_status import ChangesetStatusModel
47 46 from rhodecode.model.comment import CommentsModel
48 47 from rhodecode.model.db import (func, or_, PullRequest, PullRequestVersion,
49 48 ChangesetComment, ChangesetStatus, Repository)
50 49 from rhodecode.model.forms import PullRequestForm
51 50 from rhodecode.model.meta import Session
52 51 from rhodecode.model.pull_request import PullRequestModel, MergeCheck
53 52 from rhodecode.model.scm import ScmModel
54 53
55 54 log = logging.getLogger(__name__)
56 55
57 56
58 57 class RepoPullRequestsView(RepoAppView, DataGridAppView):
59 58
60 59 def load_default_context(self):
61 60 c = self._get_local_tmpl_context(include_app_defaults=True)
62 61 c.REVIEW_STATUS_APPROVED = ChangesetStatus.STATUS_APPROVED
63 62 c.REVIEW_STATUS_REJECTED = ChangesetStatus.STATUS_REJECTED
64 63 # backward compat., we use for OLD PRs a plain renderer
65 64 c.renderer = 'plain'
66 65 return c
67 66
68 67 def _get_pull_requests_list(
69 68 self, repo_name, source, filter_type, opened_by, statuses):
70 69
71 70 draw, start, limit = self._extract_chunk(self.request)
72 71 search_q, order_by, order_dir = self._extract_ordering(self.request)
73 72 _render = self.request.get_partial_renderer(
74 73 'rhodecode:templates/data_table/_dt_elements.mako')
75 74
76 75 # pagination
77 76
78 77 if filter_type == 'awaiting_review':
79 78 pull_requests = PullRequestModel().get_awaiting_review(
80 79 repo_name, source=source, opened_by=opened_by,
81 80 statuses=statuses, offset=start, length=limit,
82 81 order_by=order_by, order_dir=order_dir)
83 82 pull_requests_total_count = PullRequestModel().count_awaiting_review(
84 83 repo_name, source=source, statuses=statuses,
85 84 opened_by=opened_by)
86 85 elif filter_type == 'awaiting_my_review':
87 86 pull_requests = PullRequestModel().get_awaiting_my_review(
88 87 repo_name, source=source, opened_by=opened_by,
89 88 user_id=self._rhodecode_user.user_id, statuses=statuses,
90 89 offset=start, length=limit, order_by=order_by,
91 90 order_dir=order_dir)
92 91 pull_requests_total_count = PullRequestModel().count_awaiting_my_review(
93 92 repo_name, source=source, user_id=self._rhodecode_user.user_id,
94 93 statuses=statuses, opened_by=opened_by)
95 94 else:
96 95 pull_requests = PullRequestModel().get_all(
97 96 repo_name, source=source, opened_by=opened_by,
98 97 statuses=statuses, offset=start, length=limit,
99 98 order_by=order_by, order_dir=order_dir)
100 99 pull_requests_total_count = PullRequestModel().count_all(
101 100 repo_name, source=source, statuses=statuses,
102 101 opened_by=opened_by)
103 102
104 103 data = []
105 104 comments_model = CommentsModel()
106 105 for pr in pull_requests:
107 106 comments = comments_model.get_all_comments(
108 107 self.db_repo.repo_id, pull_request=pr)
109 108
110 109 data.append({
111 110 'name': _render('pullrequest_name',
112 111 pr.pull_request_id, pr.target_repo.repo_name),
113 112 'name_raw': pr.pull_request_id,
114 113 'status': _render('pullrequest_status',
115 114 pr.calculated_review_status()),
116 115 'title': _render(
117 116 'pullrequest_title', pr.title, pr.description),
118 117 'description': h.escape(pr.description),
119 118 'updated_on': _render('pullrequest_updated_on',
120 119 h.datetime_to_time(pr.updated_on)),
121 120 'updated_on_raw': h.datetime_to_time(pr.updated_on),
122 121 'created_on': _render('pullrequest_updated_on',
123 122 h.datetime_to_time(pr.created_on)),
124 123 'created_on_raw': h.datetime_to_time(pr.created_on),
125 124 'author': _render('pullrequest_author',
126 125 pr.author.full_contact, ),
127 126 'author_raw': pr.author.full_name,
128 127 'comments': _render('pullrequest_comments', len(comments)),
129 128 'comments_raw': len(comments),
130 129 'closed': pr.is_closed(),
131 130 })
132 131
133 132 data = ({
134 133 'draw': draw,
135 134 'data': data,
136 135 'recordsTotal': pull_requests_total_count,
137 136 'recordsFiltered': pull_requests_total_count,
138 137 })
139 138 return data
140 139
141 140 def get_recache_flag(self):
142 141 for flag_name in ['force_recache', 'force-recache', 'no-cache']:
143 142 flag_val = self.request.GET.get(flag_name)
144 143 if str2bool(flag_val):
145 144 return True
146 145 return False
147 146
148 147 @LoginRequired()
149 148 @HasRepoPermissionAnyDecorator(
150 149 'repository.read', 'repository.write', 'repository.admin')
151 150 @view_config(
152 151 route_name='pullrequest_show_all', request_method='GET',
153 152 renderer='rhodecode:templates/pullrequests/pullrequests.mako')
154 153 def pull_request_list(self):
155 154 c = self.load_default_context()
156 155
157 156 req_get = self.request.GET
158 157 c.source = str2bool(req_get.get('source'))
159 158 c.closed = str2bool(req_get.get('closed'))
160 159 c.my = str2bool(req_get.get('my'))
161 160 c.awaiting_review = str2bool(req_get.get('awaiting_review'))
162 161 c.awaiting_my_review = str2bool(req_get.get('awaiting_my_review'))
163 162
164 163 c.active = 'open'
165 164 if c.my:
166 165 c.active = 'my'
167 166 if c.closed:
168 167 c.active = 'closed'
169 168 if c.awaiting_review and not c.source:
170 169 c.active = 'awaiting'
171 170 if c.source and not c.awaiting_review:
172 171 c.active = 'source'
173 172 if c.awaiting_my_review:
174 173 c.active = 'awaiting_my'
175 174
176 175 return self._get_template_context(c)
177 176
178 177 @LoginRequired()
179 178 @HasRepoPermissionAnyDecorator(
180 179 'repository.read', 'repository.write', 'repository.admin')
181 180 @view_config(
182 181 route_name='pullrequest_show_all_data', request_method='GET',
183 182 renderer='json_ext', xhr=True)
184 183 def pull_request_list_data(self):
185 184 self.load_default_context()
186 185
187 186 # additional filters
188 187 req_get = self.request.GET
189 188 source = str2bool(req_get.get('source'))
190 189 closed = str2bool(req_get.get('closed'))
191 190 my = str2bool(req_get.get('my'))
192 191 awaiting_review = str2bool(req_get.get('awaiting_review'))
193 192 awaiting_my_review = str2bool(req_get.get('awaiting_my_review'))
194 193
195 194 filter_type = 'awaiting_review' if awaiting_review \
196 195 else 'awaiting_my_review' if awaiting_my_review \
197 196 else None
198 197
199 198 opened_by = None
200 199 if my:
201 200 opened_by = [self._rhodecode_user.user_id]
202 201
203 202 statuses = [PullRequest.STATUS_NEW, PullRequest.STATUS_OPEN]
204 203 if closed:
205 204 statuses = [PullRequest.STATUS_CLOSED]
206 205
207 206 data = self._get_pull_requests_list(
208 207 repo_name=self.db_repo_name, source=source,
209 208 filter_type=filter_type, opened_by=opened_by, statuses=statuses)
210 209
211 210 return data
212 211
213 212 def _is_diff_cache_enabled(self, target_repo):
214 213 caching_enabled = self._get_general_setting(
215 214 target_repo, 'rhodecode_diff_cache')
216 215 log.debug('Diff caching enabled: %s', caching_enabled)
217 216 return caching_enabled
218 217
219 218 def _get_diffset(self, source_repo_name, source_repo,
220 219 source_ref_id, target_ref_id,
221 220 target_commit, source_commit, diff_limit, file_limit,
222 221 fulldiff, hide_whitespace_changes, diff_context):
223 222
224 223 vcs_diff = PullRequestModel().get_diff(
225 224 source_repo, source_ref_id, target_ref_id,
226 225 hide_whitespace_changes, diff_context)
227 226
228 227 diff_processor = diffs.DiffProcessor(
229 228 vcs_diff, format='newdiff', diff_limit=diff_limit,
230 229 file_limit=file_limit, show_full_diff=fulldiff)
231 230
232 231 _parsed = diff_processor.prepare()
233 232
234 233 diffset = codeblocks.DiffSet(
235 234 repo_name=self.db_repo_name,
236 235 source_repo_name=source_repo_name,
237 236 source_node_getter=codeblocks.diffset_node_getter(target_commit),
238 237 target_node_getter=codeblocks.diffset_node_getter(source_commit),
239 238 )
240 239 diffset = self.path_filter.render_patchset_filtered(
241 240 diffset, _parsed, target_commit.raw_id, source_commit.raw_id)
242 241
243 242 return diffset
244 243
245 244 def _get_range_diffset(self, source_scm, source_repo,
246 245 commit1, commit2, diff_limit, file_limit,
247 246 fulldiff, hide_whitespace_changes, diff_context):
248 247 vcs_diff = source_scm.get_diff(
249 248 commit1, commit2,
250 249 ignore_whitespace=hide_whitespace_changes,
251 250 context=diff_context)
252 251
253 252 diff_processor = diffs.DiffProcessor(
254 253 vcs_diff, format='newdiff', diff_limit=diff_limit,
255 254 file_limit=file_limit, show_full_diff=fulldiff)
256 255
257 256 _parsed = diff_processor.prepare()
258 257
259 258 diffset = codeblocks.DiffSet(
260 259 repo_name=source_repo.repo_name,
261 260 source_node_getter=codeblocks.diffset_node_getter(commit1),
262 261 target_node_getter=codeblocks.diffset_node_getter(commit2))
263 262
264 263 diffset = self.path_filter.render_patchset_filtered(
265 264 diffset, _parsed, commit1.raw_id, commit2.raw_id)
266 265
267 266 return diffset
268 267
269 268 @LoginRequired()
270 269 @HasRepoPermissionAnyDecorator(
271 270 'repository.read', 'repository.write', 'repository.admin')
272 271 @view_config(
273 272 route_name='pullrequest_show', request_method='GET',
274 273 renderer='rhodecode:templates/pullrequests/pullrequest_show.mako')
275 274 def pull_request_show(self):
276 275 _ = self.request.translate
277 276 c = self.load_default_context()
278 277
279 278 pull_request = PullRequest.get_or_404(
280 279 self.request.matchdict['pull_request_id'])
281 280 pull_request_id = pull_request.pull_request_id
282 281
283 282 if pull_request.pull_request_state != PullRequest.STATE_CREATED:
284 283 log.debug('show: forbidden because pull request is in state %s',
285 284 pull_request.pull_request_state)
286 285 msg = _(u'Cannot show pull requests in state other than `{}`. '
287 286 u'Current state is: `{}`').format(PullRequest.STATE_CREATED,
288 287 pull_request.pull_request_state)
289 288 h.flash(msg, category='error')
290 289 raise HTTPFound(h.route_path('pullrequest_show_all',
291 290 repo_name=self.db_repo_name))
292 291
293 292 version = self.request.GET.get('version')
294 293 from_version = self.request.GET.get('from_version') or version
295 294 merge_checks = self.request.GET.get('merge_checks')
296 295 c.fulldiff = str2bool(self.request.GET.get('fulldiff'))
297 296
298 297 # fetch global flags of ignore ws or context lines
299 298 diff_context = diffs.get_diff_context(self.request)
300 299 hide_whitespace_changes = diffs.get_diff_whitespace_flag(self.request)
301 300
302 301 force_refresh = str2bool(self.request.GET.get('force_refresh'))
303 302
304 303 (pull_request_latest,
305 304 pull_request_at_ver,
306 305 pull_request_display_obj,
307 306 at_version) = PullRequestModel().get_pr_version(
308 307 pull_request_id, version=version)
309 308 pr_closed = pull_request_latest.is_closed()
310 309
311 310 if pr_closed and (version or from_version):
312 311 # not allow to browse versions
313 312 raise HTTPFound(h.route_path(
314 313 'pullrequest_show', repo_name=self.db_repo_name,
315 314 pull_request_id=pull_request_id))
316 315
317 316 versions = pull_request_display_obj.versions()
318 317 # used to store per-commit range diffs
319 318 c.changes = collections.OrderedDict()
320 319 c.range_diff_on = self.request.GET.get('range-diff') == "1"
321 320
322 321 c.at_version = at_version
323 322 c.at_version_num = (at_version
324 323 if at_version and at_version != 'latest'
325 324 else None)
326 325 c.at_version_pos = ChangesetComment.get_index_from_version(
327 326 c.at_version_num, versions)
328 327
329 328 (prev_pull_request_latest,
330 329 prev_pull_request_at_ver,
331 330 prev_pull_request_display_obj,
332 331 prev_at_version) = PullRequestModel().get_pr_version(
333 332 pull_request_id, version=from_version)
334 333
335 334 c.from_version = prev_at_version
336 335 c.from_version_num = (prev_at_version
337 336 if prev_at_version and prev_at_version != 'latest'
338 337 else None)
339 338 c.from_version_pos = ChangesetComment.get_index_from_version(
340 339 c.from_version_num, versions)
341 340
342 341 # define if we're in COMPARE mode or VIEW at version mode
343 342 compare = at_version != prev_at_version
344 343
345 344 # pull_requests repo_name we opened it against
346 345 # ie. target_repo must match
347 346 if self.db_repo_name != pull_request_at_ver.target_repo.repo_name:
348 347 raise HTTPNotFound()
349 348
350 349 c.shadow_clone_url = PullRequestModel().get_shadow_clone_url(
351 350 pull_request_at_ver)
352 351
353 352 c.pull_request = pull_request_display_obj
354 353 c.renderer = pull_request_at_ver.description_renderer or c.renderer
355 354 c.pull_request_latest = pull_request_latest
356 355
357 356 if compare or (at_version and not at_version == 'latest'):
358 357 c.allowed_to_change_status = False
359 358 c.allowed_to_update = False
360 359 c.allowed_to_merge = False
361 360 c.allowed_to_delete = False
362 361 c.allowed_to_comment = False
363 362 c.allowed_to_close = False
364 363 else:
365 364 can_change_status = PullRequestModel().check_user_change_status(
366 365 pull_request_at_ver, self._rhodecode_user)
367 366 c.allowed_to_change_status = can_change_status and not pr_closed
368 367
369 368 c.allowed_to_update = PullRequestModel().check_user_update(
370 369 pull_request_latest, self._rhodecode_user) and not pr_closed
371 370 c.allowed_to_merge = PullRequestModel().check_user_merge(
372 371 pull_request_latest, self._rhodecode_user) and not pr_closed
373 372 c.allowed_to_delete = PullRequestModel().check_user_delete(
374 373 pull_request_latest, self._rhodecode_user) and not pr_closed
375 374 c.allowed_to_comment = not pr_closed
376 375 c.allowed_to_close = c.allowed_to_merge and not pr_closed
377 376
378 377 c.forbid_adding_reviewers = False
379 378 c.forbid_author_to_review = False
380 379 c.forbid_commit_author_to_review = False
381 380
382 381 if pull_request_latest.reviewer_data and \
383 382 'rules' in pull_request_latest.reviewer_data:
384 383 rules = pull_request_latest.reviewer_data['rules'] or {}
385 384 try:
386 385 c.forbid_adding_reviewers = rules.get(
387 386 'forbid_adding_reviewers')
388 387 c.forbid_author_to_review = rules.get(
389 388 'forbid_author_to_review')
390 389 c.forbid_commit_author_to_review = rules.get(
391 390 'forbid_commit_author_to_review')
392 391 except Exception:
393 392 pass
394 393
395 394 # check merge capabilities
396 395 _merge_check = MergeCheck.validate(
397 396 pull_request_latest, auth_user=self._rhodecode_user,
398 397 translator=self.request.translate,
399 398 force_shadow_repo_refresh=force_refresh)
400 399 c.pr_merge_errors = _merge_check.error_details
401 400 c.pr_merge_possible = not _merge_check.failed
402 401 c.pr_merge_message = _merge_check.merge_msg
403 402
404 403 c.pr_merge_info = MergeCheck.get_merge_conditions(
405 404 pull_request_latest, translator=self.request.translate)
406 405
407 406 c.pull_request_review_status = _merge_check.review_status
408 407 if merge_checks:
409 408 self.request.override_renderer = \
410 409 'rhodecode:templates/pullrequests/pullrequest_merge_checks.mako'
411 410 return self._get_template_context(c)
412 411
413 412 comments_model = CommentsModel()
414 413
415 414 # reviewers and statuses
416 415 c.pull_request_reviewers = pull_request_at_ver.reviewers_statuses()
417 416 allowed_reviewers = [x[0].user_id for x in c.pull_request_reviewers]
418 417
419 418 # GENERAL COMMENTS with versions #
420 419 q = comments_model._all_general_comments_of_pull_request(pull_request_latest)
421 420 q = q.order_by(ChangesetComment.comment_id.asc())
422 421 general_comments = q
423 422
424 423 # pick comments we want to render at current version
425 424 c.comment_versions = comments_model.aggregate_comments(
426 425 general_comments, versions, c.at_version_num)
427 426 c.comments = c.comment_versions[c.at_version_num]['until']
428 427
429 428 # INLINE COMMENTS with versions #
430 429 q = comments_model._all_inline_comments_of_pull_request(pull_request_latest)
431 430 q = q.order_by(ChangesetComment.comment_id.asc())
432 431 inline_comments = q
433 432
434 433 c.inline_versions = comments_model.aggregate_comments(
435 434 inline_comments, versions, c.at_version_num, inline=True)
436 435
437 436 # inject latest version
438 437 latest_ver = PullRequest.get_pr_display_object(
439 438 pull_request_latest, pull_request_latest)
440 439
441 440 c.versions = versions + [latest_ver]
442 441
443 442 # if we use version, then do not show later comments
444 443 # than current version
445 444 display_inline_comments = collections.defaultdict(
446 445 lambda: collections.defaultdict(list))
447 446 for co in inline_comments:
448 447 if c.at_version_num:
449 448 # pick comments that are at least UPTO given version, so we
450 449 # don't render comments for higher version
451 450 should_render = co.pull_request_version_id and \
452 451 co.pull_request_version_id <= c.at_version_num
453 452 else:
454 453 # showing all, for 'latest'
455 454 should_render = True
456 455
457 456 if should_render:
458 457 display_inline_comments[co.f_path][co.line_no].append(co)
459 458
460 459 # load diff data into template context, if we use compare mode then
461 460 # diff is calculated based on changes between versions of PR
462 461
463 462 source_repo = pull_request_at_ver.source_repo
464 463 source_ref_id = pull_request_at_ver.source_ref_parts.commit_id
465 464
466 465 target_repo = pull_request_at_ver.target_repo
467 466 target_ref_id = pull_request_at_ver.target_ref_parts.commit_id
468 467
469 468 if compare:
470 469 # in compare switch the diff base to latest commit from prev version
471 470 target_ref_id = prev_pull_request_display_obj.revisions[0]
472 471
473 472 # despite opening commits for bookmarks/branches/tags, we always
474 473 # convert this to rev to prevent changes after bookmark or branch change
475 474 c.source_ref_type = 'rev'
476 475 c.source_ref = source_ref_id
477 476
478 477 c.target_ref_type = 'rev'
479 478 c.target_ref = target_ref_id
480 479
481 480 c.source_repo = source_repo
482 481 c.target_repo = target_repo
483 482
484 483 c.commit_ranges = []
485 484 source_commit = EmptyCommit()
486 485 target_commit = EmptyCommit()
487 486 c.missing_requirements = False
488 487
489 488 source_scm = source_repo.scm_instance()
490 489 target_scm = target_repo.scm_instance()
491 490
492 491 shadow_scm = None
493 492 try:
494 493 shadow_scm = pull_request_latest.get_shadow_repo()
495 494 except Exception:
496 495 log.debug('Failed to get shadow repo', exc_info=True)
497 496 # try first the existing source_repo, and then shadow
498 497 # repo if we can obtain one
499 498 commits_source_repo = source_scm or shadow_scm
500 499
501 500 c.commits_source_repo = commits_source_repo
502 501 c.ancestor = None # set it to None, to hide it from PR view
503 502
504 503 # empty version means latest, so we keep this to prevent
505 504 # double caching
506 505 version_normalized = version or 'latest'
507 506 from_version_normalized = from_version or 'latest'
508 507
509 508 cache_path = self.rhodecode_vcs_repo.get_create_shadow_cache_pr_path(target_repo)
510 509 cache_file_path = diff_cache_exist(
511 510 cache_path, 'pull_request', pull_request_id, version_normalized,
512 511 from_version_normalized, source_ref_id, target_ref_id,
513 512 hide_whitespace_changes, diff_context, c.fulldiff)
514 513
515 514 caching_enabled = self._is_diff_cache_enabled(c.target_repo)
516 515 force_recache = self.get_recache_flag()
517 516
518 517 cached_diff = None
519 518 if caching_enabled:
520 519 cached_diff = load_cached_diff(cache_file_path)
521 520
522 521 has_proper_commit_cache = (
523 522 cached_diff and cached_diff.get('commits')
524 523 and len(cached_diff.get('commits', [])) == 5
525 524 and cached_diff.get('commits')[0]
526 525 and cached_diff.get('commits')[3])
527 526
528 527 if not force_recache and not c.range_diff_on and has_proper_commit_cache:
529 528 diff_commit_cache = \
530 529 (ancestor_commit, commit_cache, missing_requirements,
531 530 source_commit, target_commit) = cached_diff['commits']
532 531 else:
533 532 diff_commit_cache = \
534 533 (ancestor_commit, commit_cache, missing_requirements,
535 534 source_commit, target_commit) = self.get_commits(
536 535 commits_source_repo,
537 536 pull_request_at_ver,
538 537 source_commit,
539 538 source_ref_id,
540 539 source_scm,
541 540 target_commit,
542 541 target_ref_id,
543 542 target_scm)
544 543
545 544 # register our commit range
546 545 for comm in commit_cache.values():
547 546 c.commit_ranges.append(comm)
548 547
549 548 c.missing_requirements = missing_requirements
550 549 c.ancestor_commit = ancestor_commit
551 550 c.statuses = source_repo.statuses(
552 551 [x.raw_id for x in c.commit_ranges])
553 552
554 553 # auto collapse if we have more than limit
555 554 collapse_limit = diffs.DiffProcessor._collapse_commits_over
556 555 c.collapse_all_commits = len(c.commit_ranges) > collapse_limit
557 556 c.compare_mode = compare
558 557
559 558 # diff_limit is the old behavior, will cut off the whole diff
560 559 # if the limit is applied otherwise will just hide the
561 560 # big files from the front-end
562 561 diff_limit = c.visual.cut_off_limit_diff
563 562 file_limit = c.visual.cut_off_limit_file
564 563
565 564 c.missing_commits = False
566 565 if (c.missing_requirements
567 566 or isinstance(source_commit, EmptyCommit)
568 567 or source_commit == target_commit):
569 568
570 569 c.missing_commits = True
571 570 else:
572 571 c.inline_comments = display_inline_comments
573 572
574 573 has_proper_diff_cache = cached_diff and cached_diff.get('commits')
575 574 if not force_recache and has_proper_diff_cache:
576 575 c.diffset = cached_diff['diff']
577 576 (ancestor_commit, commit_cache, missing_requirements,
578 577 source_commit, target_commit) = cached_diff['commits']
579 578 else:
580 579 c.diffset = self._get_diffset(
581 580 c.source_repo.repo_name, commits_source_repo,
582 581 source_ref_id, target_ref_id,
583 582 target_commit, source_commit,
584 583 diff_limit, file_limit, c.fulldiff,
585 584 hide_whitespace_changes, diff_context)
586 585
587 586 # save cached diff
588 587 if caching_enabled:
589 588 cache_diff(cache_file_path, c.diffset, diff_commit_cache)
590 589
591 590 c.limited_diff = c.diffset.limited_diff
592 591
593 592 # calculate removed files that are bound to comments
594 593 comment_deleted_files = [
595 594 fname for fname in display_inline_comments
596 595 if fname not in c.diffset.file_stats]
597 596
598 597 c.deleted_files_comments = collections.defaultdict(dict)
599 598 for fname, per_line_comments in display_inline_comments.items():
600 599 if fname in comment_deleted_files:
601 600 c.deleted_files_comments[fname]['stats'] = 0
602 601 c.deleted_files_comments[fname]['comments'] = list()
603 602 for lno, comments in per_line_comments.items():
604 603 c.deleted_files_comments[fname]['comments'].extend(comments)
605 604
606 605 # maybe calculate the range diff
607 606 if c.range_diff_on:
608 607 # TODO(marcink): set whitespace/context
609 608 context_lcl = 3
610 609 ign_whitespace_lcl = False
611 610
612 611 for commit in c.commit_ranges:
613 612 commit2 = commit
614 613 commit1 = commit.first_parent
615 614
616 615 range_diff_cache_file_path = diff_cache_exist(
617 616 cache_path, 'diff', commit.raw_id,
618 617 ign_whitespace_lcl, context_lcl, c.fulldiff)
619 618
620 619 cached_diff = None
621 620 if caching_enabled:
622 621 cached_diff = load_cached_diff(range_diff_cache_file_path)
623 622
624 623 has_proper_diff_cache = cached_diff and cached_diff.get('diff')
625 624 if not force_recache and has_proper_diff_cache:
626 625 diffset = cached_diff['diff']
627 626 else:
628 627 diffset = self._get_range_diffset(
629 628 source_scm, source_repo,
630 629 commit1, commit2, diff_limit, file_limit,
631 630 c.fulldiff, ign_whitespace_lcl, context_lcl
632 631 )
633 632
634 633 # save cached diff
635 634 if caching_enabled:
636 635 cache_diff(range_diff_cache_file_path, diffset, None)
637 636
638 637 c.changes[commit.raw_id] = diffset
639 638
640 639 # this is a hack to properly display links, when creating PR, the
641 640 # compare view and others uses different notation, and
642 641 # compare_commits.mako renders links based on the target_repo.
643 642 # We need to swap that here to generate it properly on the html side
644 643 c.target_repo = c.source_repo
645 644
646 645 c.commit_statuses = ChangesetStatus.STATUSES
647 646
648 647 c.show_version_changes = not pr_closed
649 648 if c.show_version_changes:
650 649 cur_obj = pull_request_at_ver
651 650 prev_obj = prev_pull_request_at_ver
652 651
653 652 old_commit_ids = prev_obj.revisions
654 653 new_commit_ids = cur_obj.revisions
655 654 commit_changes = PullRequestModel()._calculate_commit_id_changes(
656 655 old_commit_ids, new_commit_ids)
657 656 c.commit_changes_summary = commit_changes
658 657
659 658 # calculate the diff for commits between versions
660 659 c.commit_changes = []
661 660 mark = lambda cs, fw: list(
662 661 h.itertools.izip_longest([], cs, fillvalue=fw))
663 662 for c_type, raw_id in mark(commit_changes.added, 'a') \
664 663 + mark(commit_changes.removed, 'r') \
665 664 + mark(commit_changes.common, 'c'):
666 665
667 666 if raw_id in commit_cache:
668 667 commit = commit_cache[raw_id]
669 668 else:
670 669 try:
671 670 commit = commits_source_repo.get_commit(raw_id)
672 671 except CommitDoesNotExistError:
673 672 # in case we fail extracting still use "dummy" commit
674 673 # for display in commit diff
675 674 commit = h.AttributeDict(
676 675 {'raw_id': raw_id,
677 676 'message': 'EMPTY or MISSING COMMIT'})
678 677 c.commit_changes.append([c_type, commit])
679 678
680 679 # current user review statuses for each version
681 680 c.review_versions = {}
682 681 if self._rhodecode_user.user_id in allowed_reviewers:
683 682 for co in general_comments:
684 683 if co.author.user_id == self._rhodecode_user.user_id:
685 684 status = co.status_change
686 685 if status:
687 686 _ver_pr = status[0].comment.pull_request_version_id
688 687 c.review_versions[_ver_pr] = status[0]
689 688
690 689 return self._get_template_context(c)
691 690
692 691 def get_commits(
693 692 self, commits_source_repo, pull_request_at_ver, source_commit,
694 693 source_ref_id, source_scm, target_commit, target_ref_id, target_scm):
695 694 commit_cache = collections.OrderedDict()
696 695 missing_requirements = False
697 696 try:
698 697 pre_load = ["author", "branch", "date", "message", "parents"]
699 698 show_revs = pull_request_at_ver.revisions
700 699 for rev in show_revs:
701 700 comm = commits_source_repo.get_commit(
702 701 commit_id=rev, pre_load=pre_load)
703 702 commit_cache[comm.raw_id] = comm
704 703
705 704 # Order here matters, we first need to get target, and then
706 705 # the source
707 706 target_commit = commits_source_repo.get_commit(
708 707 commit_id=safe_str(target_ref_id))
709 708
710 709 source_commit = commits_source_repo.get_commit(
711 710 commit_id=safe_str(source_ref_id))
712 711 except CommitDoesNotExistError:
713 712 log.warning(
714 713 'Failed to get commit from `{}` repo'.format(
715 714 commits_source_repo), exc_info=True)
716 715 except RepositoryRequirementError:
717 716 log.warning(
718 717 'Failed to get all required data from repo', exc_info=True)
719 718 missing_requirements = True
720 719 ancestor_commit = None
721 720 try:
722 721 ancestor_id = source_scm.get_common_ancestor(
723 722 source_commit.raw_id, target_commit.raw_id, target_scm)
724 723 ancestor_commit = source_scm.get_commit(ancestor_id)
725 724 except Exception:
726 725 ancestor_commit = None
727 726 return ancestor_commit, commit_cache, missing_requirements, source_commit, target_commit
728 727
729 728 def assure_not_empty_repo(self):
730 729 _ = self.request.translate
731 730
732 731 try:
733 732 self.db_repo.scm_instance().get_commit()
734 733 except EmptyRepositoryError:
735 734 h.flash(h.literal(_('There are no commits yet')),
736 735 category='warning')
737 736 raise HTTPFound(
738 737 h.route_path('repo_summary', repo_name=self.db_repo.repo_name))
739 738
740 739 @LoginRequired()
741 740 @NotAnonymous()
742 741 @HasRepoPermissionAnyDecorator(
743 742 'repository.read', 'repository.write', 'repository.admin')
744 743 @view_config(
745 744 route_name='pullrequest_new', request_method='GET',
746 745 renderer='rhodecode:templates/pullrequests/pullrequest.mako')
747 746 def pull_request_new(self):
748 747 _ = self.request.translate
749 748 c = self.load_default_context()
750 749
751 750 self.assure_not_empty_repo()
752 751 source_repo = self.db_repo
753 752
754 753 commit_id = self.request.GET.get('commit')
755 754 branch_ref = self.request.GET.get('branch')
756 755 bookmark_ref = self.request.GET.get('bookmark')
757 756
758 757 try:
759 758 source_repo_data = PullRequestModel().generate_repo_data(
760 759 source_repo, commit_id=commit_id,
761 760 branch=branch_ref, bookmark=bookmark_ref,
762 761 translator=self.request.translate)
763 762 except CommitDoesNotExistError as e:
764 763 log.exception(e)
765 764 h.flash(_('Commit does not exist'), 'error')
766 765 raise HTTPFound(
767 766 h.route_path('pullrequest_new', repo_name=source_repo.repo_name))
768 767
769 768 default_target_repo = source_repo
770 769
771 770 if source_repo.parent and c.has_origin_repo_read_perm:
772 771 parent_vcs_obj = source_repo.parent.scm_instance()
773 772 if parent_vcs_obj and not parent_vcs_obj.is_empty():
774 773 # change default if we have a parent repo
775 774 default_target_repo = source_repo.parent
776 775
777 776 target_repo_data = PullRequestModel().generate_repo_data(
778 777 default_target_repo, translator=self.request.translate)
779 778
780 779 selected_source_ref = source_repo_data['refs']['selected_ref']
781 780 title_source_ref = ''
782 781 if selected_source_ref:
783 782 title_source_ref = selected_source_ref.split(':', 2)[1]
784 783 c.default_title = PullRequestModel().generate_pullrequest_title(
785 784 source=source_repo.repo_name,
786 785 source_ref=title_source_ref,
787 786 target=default_target_repo.repo_name
788 787 )
789 788
790 789 c.default_repo_data = {
791 790 'source_repo_name': source_repo.repo_name,
792 791 'source_refs_json': json.dumps(source_repo_data),
793 792 'target_repo_name': default_target_repo.repo_name,
794 793 'target_refs_json': json.dumps(target_repo_data),
795 794 }
796 795 c.default_source_ref = selected_source_ref
797 796
798 797 return self._get_template_context(c)
799 798
800 799 @LoginRequired()
801 800 @NotAnonymous()
802 801 @HasRepoPermissionAnyDecorator(
803 802 'repository.read', 'repository.write', 'repository.admin')
804 803 @view_config(
805 804 route_name='pullrequest_repo_refs', request_method='GET',
806 805 renderer='json_ext', xhr=True)
807 806 def pull_request_repo_refs(self):
808 807 self.load_default_context()
809 808 target_repo_name = self.request.matchdict['target_repo_name']
810 809 repo = Repository.get_by_repo_name(target_repo_name)
811 810 if not repo:
812 811 raise HTTPNotFound()
813 812
814 813 target_perm = HasRepoPermissionAny(
815 814 'repository.read', 'repository.write', 'repository.admin')(
816 815 target_repo_name)
817 816 if not target_perm:
818 817 raise HTTPNotFound()
819 818
820 819 return PullRequestModel().generate_repo_data(
821 820 repo, translator=self.request.translate)
822 821
823 822 @LoginRequired()
824 823 @NotAnonymous()
825 824 @HasRepoPermissionAnyDecorator(
826 825 'repository.read', 'repository.write', 'repository.admin')
827 826 @view_config(
828 827 route_name='pullrequest_repo_targets', request_method='GET',
829 828 renderer='json_ext', xhr=True)
830 829 def pullrequest_repo_targets(self):
831 830 _ = self.request.translate
832 831 filter_query = self.request.GET.get('query')
833 832
834 833 # get the parents
835 834 parent_target_repos = []
836 835 if self.db_repo.parent:
837 836 parents_query = Repository.query() \
838 837 .order_by(func.length(Repository.repo_name)) \
839 838 .filter(Repository.fork_id == self.db_repo.parent.repo_id)
840 839
841 840 if filter_query:
842 841 ilike_expression = u'%{}%'.format(safe_unicode(filter_query))
843 842 parents_query = parents_query.filter(
844 843 Repository.repo_name.ilike(ilike_expression))
845 844 parents = parents_query.limit(20).all()
846 845
847 846 for parent in parents:
848 847 parent_vcs_obj = parent.scm_instance()
849 848 if parent_vcs_obj and not parent_vcs_obj.is_empty():
850 849 parent_target_repos.append(parent)
851 850
852 851 # get other forks, and repo itself
853 852 query = Repository.query() \
854 853 .order_by(func.length(Repository.repo_name)) \
855 854 .filter(
856 855 or_(Repository.repo_id == self.db_repo.repo_id, # repo itself
857 856 Repository.fork_id == self.db_repo.repo_id) # forks of this repo
858 857 ) \
859 858 .filter(~Repository.repo_id.in_([x.repo_id for x in parent_target_repos]))
860 859
861 860 if filter_query:
862 861 ilike_expression = u'%{}%'.format(safe_unicode(filter_query))
863 862 query = query.filter(Repository.repo_name.ilike(ilike_expression))
864 863
865 864 limit = max(20 - len(parent_target_repos), 5) # not less then 5
866 865 target_repos = query.limit(limit).all()
867 866
868 867 all_target_repos = target_repos + parent_target_repos
869 868
870 869 repos = []
871 870 # This checks permissions to the repositories
872 871 for obj in ScmModel().get_repos(all_target_repos):
873 872 repos.append({
874 873 'id': obj['name'],
875 874 'text': obj['name'],
876 875 'type': 'repo',
877 876 'repo_id': obj['dbrepo']['repo_id'],
878 877 'repo_type': obj['dbrepo']['repo_type'],
879 878 'private': obj['dbrepo']['private'],
880 879
881 880 })
882 881
883 882 data = {
884 883 'more': False,
885 884 'results': [{
886 885 'text': _('Repositories'),
887 886 'children': repos
888 887 }] if repos else []
889 888 }
890 889 return data
891 890
892 891 @LoginRequired()
893 892 @NotAnonymous()
894 893 @HasRepoPermissionAnyDecorator(
895 894 'repository.read', 'repository.write', 'repository.admin')
896 895 @CSRFRequired()
897 896 @view_config(
898 897 route_name='pullrequest_create', request_method='POST',
899 898 renderer=None)
900 899 def pull_request_create(self):
901 900 _ = self.request.translate
902 901 self.assure_not_empty_repo()
903 902 self.load_default_context()
904 903
905 904 controls = peppercorn.parse(self.request.POST.items())
906 905
907 906 try:
908 907 form = PullRequestForm(
909 908 self.request.translate, self.db_repo.repo_id)()
910 909 _form = form.to_python(controls)
911 910 except formencode.Invalid as errors:
912 911 if errors.error_dict.get('revisions'):
913 912 msg = 'Revisions: %s' % errors.error_dict['revisions']
914 913 elif errors.error_dict.get('pullrequest_title'):
915 914 msg = errors.error_dict.get('pullrequest_title')
916 915 else:
917 916 msg = _('Error creating pull request: {}').format(errors)
918 917 log.exception(msg)
919 918 h.flash(msg, 'error')
920 919
921 920 # would rather just go back to form ...
922 921 raise HTTPFound(
923 922 h.route_path('pullrequest_new', repo_name=self.db_repo_name))
924 923
925 924 source_repo = _form['source_repo']
926 925 source_ref = _form['source_ref']
927 926 target_repo = _form['target_repo']
928 927 target_ref = _form['target_ref']
929 928 commit_ids = _form['revisions'][::-1]
930 929
931 930 # find the ancestor for this pr
932 931 source_db_repo = Repository.get_by_repo_name(_form['source_repo'])
933 932 target_db_repo = Repository.get_by_repo_name(_form['target_repo'])
934 933
935 934 if not (source_db_repo or target_db_repo):
936 935 h.flash(_('source_repo or target repo not found'), category='error')
937 936 raise HTTPFound(
938 937 h.route_path('pullrequest_new', repo_name=self.db_repo_name))
939 938
940 939 # re-check permissions again here
941 940 # source_repo we must have read permissions
942 941
943 942 source_perm = HasRepoPermissionAny(
944 943 'repository.read', 'repository.write', 'repository.admin')(
945 944 source_db_repo.repo_name)
946 945 if not source_perm:
947 946 msg = _('Not Enough permissions to source repo `{}`.'.format(
948 947 source_db_repo.repo_name))
949 948 h.flash(msg, category='error')
950 949 # copy the args back to redirect
951 950 org_query = self.request.GET.mixed()
952 951 raise HTTPFound(
953 952 h.route_path('pullrequest_new', repo_name=self.db_repo_name,
954 953 _query=org_query))
955 954
956 955 # target repo we must have read permissions, and also later on
957 956 # we want to check branch permissions here
958 957 target_perm = HasRepoPermissionAny(
959 958 'repository.read', 'repository.write', 'repository.admin')(
960 959 target_db_repo.repo_name)
961 960 if not target_perm:
962 961 msg = _('Not Enough permissions to target repo `{}`.'.format(
963 962 target_db_repo.repo_name))
964 963 h.flash(msg, category='error')
965 964 # copy the args back to redirect
966 965 org_query = self.request.GET.mixed()
967 966 raise HTTPFound(
968 967 h.route_path('pullrequest_new', repo_name=self.db_repo_name,
969 968 _query=org_query))
970 969
971 970 source_scm = source_db_repo.scm_instance()
972 971 target_scm = target_db_repo.scm_instance()
973 972
974 973 source_commit = source_scm.get_commit(source_ref.split(':')[-1])
975 974 target_commit = target_scm.get_commit(target_ref.split(':')[-1])
976 975
977 976 ancestor = source_scm.get_common_ancestor(
978 977 source_commit.raw_id, target_commit.raw_id, target_scm)
979 978
980 979 # recalculate target ref based on ancestor
981 980 target_ref_type, target_ref_name, __ = _form['target_ref'].split(':')
982 981 target_ref = ':'.join((target_ref_type, target_ref_name, ancestor))
983 982
984 983 get_default_reviewers_data, validate_default_reviewers = \
985 984 PullRequestModel().get_reviewer_functions()
986 985
987 986 # recalculate reviewers logic, to make sure we can validate this
988 987 reviewer_rules = get_default_reviewers_data(
989 988 self._rhodecode_db_user, source_db_repo,
990 989 source_commit, target_db_repo, target_commit)
991 990
992 991 given_reviewers = _form['review_members']
993 992 reviewers = validate_default_reviewers(
994 993 given_reviewers, reviewer_rules)
995 994
996 995 pullrequest_title = _form['pullrequest_title']
997 996 title_source_ref = source_ref.split(':', 2)[1]
998 997 if not pullrequest_title:
999 998 pullrequest_title = PullRequestModel().generate_pullrequest_title(
1000 999 source=source_repo,
1001 1000 source_ref=title_source_ref,
1002 1001 target=target_repo
1003 1002 )
1004 1003
1005 1004 description = _form['pullrequest_desc']
1006 1005 description_renderer = _form['description_renderer']
1007 1006
1008 1007 try:
1009 1008 pull_request = PullRequestModel().create(
1010 1009 created_by=self._rhodecode_user.user_id,
1011 1010 source_repo=source_repo,
1012 1011 source_ref=source_ref,
1013 1012 target_repo=target_repo,
1014 1013 target_ref=target_ref,
1015 1014 revisions=commit_ids,
1016 1015 reviewers=reviewers,
1017 1016 title=pullrequest_title,
1018 1017 description=description,
1019 1018 description_renderer=description_renderer,
1020 1019 reviewer_data=reviewer_rules,
1021 1020 auth_user=self._rhodecode_user
1022 1021 )
1023 1022 Session().commit()
1024 1023
1025 1024 h.flash(_('Successfully opened new pull request'),
1026 1025 category='success')
1027 1026 except Exception:
1028 1027 msg = _('Error occurred during creation of this pull request.')
1029 1028 log.exception(msg)
1030 1029 h.flash(msg, category='error')
1031 1030
1032 1031 # copy the args back to redirect
1033 1032 org_query = self.request.GET.mixed()
1034 1033 raise HTTPFound(
1035 1034 h.route_path('pullrequest_new', repo_name=self.db_repo_name,
1036 1035 _query=org_query))
1037 1036
1038 1037 raise HTTPFound(
1039 1038 h.route_path('pullrequest_show', repo_name=target_repo,
1040 1039 pull_request_id=pull_request.pull_request_id))
1041 1040
1042 1041 @LoginRequired()
1043 1042 @NotAnonymous()
1044 1043 @HasRepoPermissionAnyDecorator(
1045 1044 'repository.read', 'repository.write', 'repository.admin')
1046 1045 @CSRFRequired()
1047 1046 @view_config(
1048 1047 route_name='pullrequest_update', request_method='POST',
1049 1048 renderer='json_ext')
1050 1049 def pull_request_update(self):
1051 1050 pull_request = PullRequest.get_or_404(
1052 1051 self.request.matchdict['pull_request_id'])
1053 1052 _ = self.request.translate
1054 1053
1055 1054 self.load_default_context()
1056 1055
1057 1056 if pull_request.is_closed():
1058 1057 log.debug('update: forbidden because pull request is closed')
1059 1058 msg = _(u'Cannot update closed pull requests.')
1060 1059 h.flash(msg, category='error')
1061 1060 return True
1062 1061
1063 1062 if pull_request.pull_request_state != PullRequest.STATE_CREATED:
1064 1063 log.debug('update: forbidden because pull request is in state %s',
1065 1064 pull_request.pull_request_state)
1066 1065 msg = _(u'Cannot update pull requests in state other than `{}`. '
1067 1066 u'Current state is: `{}`').format(PullRequest.STATE_CREATED,
1068 1067 pull_request.pull_request_state)
1069 1068 h.flash(msg, category='error')
1070 1069 return True
1071 1070
1072 1071 # only owner or admin can update it
1073 1072 allowed_to_update = PullRequestModel().check_user_update(
1074 1073 pull_request, self._rhodecode_user)
1075 1074 if allowed_to_update:
1076 1075 controls = peppercorn.parse(self.request.POST.items())
1077 1076
1078 1077 if 'review_members' in controls:
1079 1078 self._update_reviewers(
1080 1079 pull_request, controls['review_members'],
1081 1080 pull_request.reviewer_data)
1082 1081 elif str2bool(self.request.POST.get('update_commits', 'false')):
1083 1082 self._update_commits(pull_request)
1084 1083 elif str2bool(self.request.POST.get('edit_pull_request', 'false')):
1085 1084 self._edit_pull_request(pull_request)
1086 1085 else:
1087 1086 raise HTTPBadRequest()
1088 1087 return True
1089 1088 raise HTTPForbidden()
1090 1089
1091 1090 def _edit_pull_request(self, pull_request):
1092 1091 _ = self.request.translate
1093 1092
1094 1093 try:
1095 1094 PullRequestModel().edit(
1096 1095 pull_request,
1097 1096 self.request.POST.get('title'),
1098 1097 self.request.POST.get('description'),
1099 1098 self.request.POST.get('description_renderer'),
1100 1099 self._rhodecode_user)
1101 1100 except ValueError:
1102 1101 msg = _(u'Cannot update closed pull requests.')
1103 1102 h.flash(msg, category='error')
1104 1103 return
1105 1104 else:
1106 1105 Session().commit()
1107 1106
1108 1107 msg = _(u'Pull request title & description updated.')
1109 1108 h.flash(msg, category='success')
1110 1109 return
1111 1110
1112 1111 def _update_commits(self, pull_request):
1113 1112 _ = self.request.translate
1114 1113
1115 1114 with pull_request.set_state(PullRequest.STATE_UPDATING):
1116 1115 resp = PullRequestModel().update_commits(pull_request)
1117 1116
1118 1117 if resp.executed:
1119 1118
1120 1119 if resp.target_changed and resp.source_changed:
1121 1120 changed = 'target and source repositories'
1122 1121 elif resp.target_changed and not resp.source_changed:
1123 1122 changed = 'target repository'
1124 1123 elif not resp.target_changed and resp.source_changed:
1125 1124 changed = 'source repository'
1126 1125 else:
1127 1126 changed = 'nothing'
1128 1127
1129 1128 msg = _(u'Pull request updated to "{source_commit_id}" with '
1130 1129 u'{count_added} added, {count_removed} removed commits. '
1131 1130 u'Source of changes: {change_source}')
1132 1131 msg = msg.format(
1133 1132 source_commit_id=pull_request.source_ref_parts.commit_id,
1134 1133 count_added=len(resp.changes.added),
1135 1134 count_removed=len(resp.changes.removed),
1136 1135 change_source=changed)
1137 1136 h.flash(msg, category='success')
1138 1137
1139 1138 channel = '/repo${}$/pr/{}'.format(
1140 1139 pull_request.target_repo.repo_name, pull_request.pull_request_id)
1141 1140 message = msg + (
1142 1141 ' - <a onclick="window.location.reload()">'
1143 1142 '<strong>{}</strong></a>'.format(_('Reload page')))
1144 1143 channelstream.post_message(
1145 1144 channel, message, self._rhodecode_user.username,
1146 1145 registry=self.request.registry)
1147 1146 else:
1148 1147 msg = PullRequestModel.UPDATE_STATUS_MESSAGES[resp.reason]
1149 1148 warning_reasons = [
1150 1149 UpdateFailureReason.NO_CHANGE,
1151 1150 UpdateFailureReason.WRONG_REF_TYPE,
1152 1151 ]
1153 1152 category = 'warning' if resp.reason in warning_reasons else 'error'
1154 1153 h.flash(msg, category=category)
1155 1154
1156 1155 @LoginRequired()
1157 1156 @NotAnonymous()
1158 1157 @HasRepoPermissionAnyDecorator(
1159 1158 'repository.read', 'repository.write', 'repository.admin')
1160 1159 @CSRFRequired()
1161 1160 @view_config(
1162 1161 route_name='pullrequest_merge', request_method='POST',
1163 1162 renderer='json_ext')
1164 1163 def pull_request_merge(self):
1165 1164 """
1166 1165 Merge will perform a server-side merge of the specified
1167 1166 pull request, if the pull request is approved and mergeable.
1168 1167 After successful merging, the pull request is automatically
1169 1168 closed, with a relevant comment.
1170 1169 """
1171 1170 pull_request = PullRequest.get_or_404(
1172 1171 self.request.matchdict['pull_request_id'])
1173 1172 _ = self.request.translate
1174 1173
1175 1174 if pull_request.pull_request_state != PullRequest.STATE_CREATED:
1176 1175 log.debug('show: forbidden because pull request is in state %s',
1177 1176 pull_request.pull_request_state)
1178 1177 msg = _(u'Cannot merge pull requests in state other than `{}`. '
1179 1178 u'Current state is: `{}`').format(PullRequest.STATE_CREATED,
1180 1179 pull_request.pull_request_state)
1181 1180 h.flash(msg, category='error')
1182 1181 raise HTTPFound(
1183 1182 h.route_path('pullrequest_show',
1184 1183 repo_name=pull_request.target_repo.repo_name,
1185 1184 pull_request_id=pull_request.pull_request_id))
1186 1185
1187 1186 self.load_default_context()
1188 1187
1189 1188 with pull_request.set_state(PullRequest.STATE_UPDATING):
1190 1189 check = MergeCheck.validate(
1191 1190 pull_request, auth_user=self._rhodecode_user,
1192 1191 translator=self.request.translate)
1193 1192 merge_possible = not check.failed
1194 1193
1195 1194 for err_type, error_msg in check.errors:
1196 1195 h.flash(error_msg, category=err_type)
1197 1196
1198 1197 if merge_possible:
1199 1198 log.debug("Pre-conditions checked, trying to merge.")
1200 1199 extras = vcs_operation_context(
1201 1200 self.request.environ, repo_name=pull_request.target_repo.repo_name,
1202 1201 username=self._rhodecode_db_user.username, action='push',
1203 1202 scm=pull_request.target_repo.repo_type)
1204 1203 with pull_request.set_state(PullRequest.STATE_UPDATING):
1205 1204 self._merge_pull_request(
1206 1205 pull_request, self._rhodecode_db_user, extras)
1207 1206 else:
1208 1207 log.debug("Pre-conditions failed, NOT merging.")
1209 1208
1210 1209 raise HTTPFound(
1211 1210 h.route_path('pullrequest_show',
1212 1211 repo_name=pull_request.target_repo.repo_name,
1213 1212 pull_request_id=pull_request.pull_request_id))
1214 1213
1215 1214 def _merge_pull_request(self, pull_request, user, extras):
1216 1215 _ = self.request.translate
1217 1216 merge_resp = PullRequestModel().merge_repo(pull_request, user, extras=extras)
1218 1217
1219 1218 if merge_resp.executed:
1220 1219 log.debug("The merge was successful, closing the pull request.")
1221 1220 PullRequestModel().close_pull_request(
1222 1221 pull_request.pull_request_id, user)
1223 1222 Session().commit()
1224 1223 msg = _('Pull request was successfully merged and closed.')
1225 1224 h.flash(msg, category='success')
1226 1225 else:
1227 1226 log.debug(
1228 1227 "The merge was not successful. Merge response: %s", merge_resp)
1229 1228 msg = merge_resp.merge_status_message
1230 1229 h.flash(msg, category='error')
1231 1230
1232 1231 def _update_reviewers(self, pull_request, review_members, reviewer_rules):
1233 1232 _ = self.request.translate
1233
1234 1234 get_default_reviewers_data, validate_default_reviewers = \
1235 1235 PullRequestModel().get_reviewer_functions()
1236 1236
1237 1237 try:
1238 1238 reviewers = validate_default_reviewers(review_members, reviewer_rules)
1239 1239 except ValueError as e:
1240 1240 log.error('Reviewers Validation: {}'.format(e))
1241 1241 h.flash(e, category='error')
1242 1242 return
1243 1243
1244 old_calculated_status = pull_request.calculated_review_status()
1244 1245 PullRequestModel().update_reviewers(
1245 1246 pull_request, reviewers, self._rhodecode_user)
1246 1247 h.flash(_('Pull request reviewers updated.'), category='success')
1247 1248 Session().commit()
1248 1249
1250 # trigger status changed if change in reviewers changes the status
1251 calculated_status = pull_request.calculated_review_status()
1252 if old_calculated_status != calculated_status:
1253 PullRequestModel().trigger_pull_request_hook(
1254 pull_request, self._rhodecode_user, 'review_status_change',
1255 data={'status': calculated_status})
1256
1249 1257 @LoginRequired()
1250 1258 @NotAnonymous()
1251 1259 @HasRepoPermissionAnyDecorator(
1252 1260 'repository.read', 'repository.write', 'repository.admin')
1253 1261 @CSRFRequired()
1254 1262 @view_config(
1255 1263 route_name='pullrequest_delete', request_method='POST',
1256 1264 renderer='json_ext')
1257 1265 def pull_request_delete(self):
1258 1266 _ = self.request.translate
1259 1267
1260 1268 pull_request = PullRequest.get_or_404(
1261 1269 self.request.matchdict['pull_request_id'])
1262 1270 self.load_default_context()
1263 1271
1264 1272 pr_closed = pull_request.is_closed()
1265 1273 allowed_to_delete = PullRequestModel().check_user_delete(
1266 1274 pull_request, self._rhodecode_user) and not pr_closed
1267 1275
1268 1276 # only owner can delete it !
1269 1277 if allowed_to_delete:
1270 1278 PullRequestModel().delete(pull_request, self._rhodecode_user)
1271 1279 Session().commit()
1272 1280 h.flash(_('Successfully deleted pull request'),
1273 1281 category='success')
1274 1282 raise HTTPFound(h.route_path('pullrequest_show_all',
1275 1283 repo_name=self.db_repo_name))
1276 1284
1277 1285 log.warning('user %s tried to delete pull request without access',
1278 1286 self._rhodecode_user)
1279 1287 raise HTTPNotFound()
1280 1288
1281 1289 @LoginRequired()
1282 1290 @NotAnonymous()
1283 1291 @HasRepoPermissionAnyDecorator(
1284 1292 'repository.read', 'repository.write', 'repository.admin')
1285 1293 @CSRFRequired()
1286 1294 @view_config(
1287 1295 route_name='pullrequest_comment_create', request_method='POST',
1288 1296 renderer='json_ext')
1289 1297 def pull_request_comment_create(self):
1290 1298 _ = self.request.translate
1291 1299
1292 1300 pull_request = PullRequest.get_or_404(
1293 1301 self.request.matchdict['pull_request_id'])
1294 1302 pull_request_id = pull_request.pull_request_id
1295 1303
1296 1304 if pull_request.is_closed():
1297 1305 log.debug('comment: forbidden because pull request is closed')
1298 1306 raise HTTPForbidden()
1299 1307
1300 1308 allowed_to_comment = PullRequestModel().check_user_comment(
1301 1309 pull_request, self._rhodecode_user)
1302 1310 if not allowed_to_comment:
1303 1311 log.debug(
1304 1312 'comment: forbidden because pull request is from forbidden repo')
1305 1313 raise HTTPForbidden()
1306 1314
1307 1315 c = self.load_default_context()
1308 1316
1309 1317 status = self.request.POST.get('changeset_status', None)
1310 1318 text = self.request.POST.get('text')
1311 1319 comment_type = self.request.POST.get('comment_type')
1312 1320 resolves_comment_id = self.request.POST.get('resolves_comment_id', None)
1313 1321 close_pull_request = self.request.POST.get('close_pull_request')
1314 1322
1315 1323 # the logic here should work like following, if we submit close
1316 1324 # pr comment, use `close_pull_request_with_comment` function
1317 1325 # else handle regular comment logic
1318 1326
1319 1327 if close_pull_request:
1320 1328 # only owner or admin or person with write permissions
1321 1329 allowed_to_close = PullRequestModel().check_user_update(
1322 1330 pull_request, self._rhodecode_user)
1323 1331 if not allowed_to_close:
1324 1332 log.debug('comment: forbidden because not allowed to close '
1325 1333 'pull request %s', pull_request_id)
1326 1334 raise HTTPForbidden()
1335
1336 # This also triggers `review_status_change`
1327 1337 comment, status = PullRequestModel().close_pull_request_with_comment(
1328 1338 pull_request, self._rhodecode_user, self.db_repo, message=text,
1329 1339 auth_user=self._rhodecode_user)
1330 1340 Session().flush()
1331 events.trigger(
1332 events.PullRequestCommentEvent(pull_request, comment))
1341
1342 PullRequestModel().trigger_pull_request_hook(
1343 pull_request, self._rhodecode_user, 'comment',
1344 data={'comment': comment})
1333 1345
1334 1346 else:
1335 1347 # regular comment case, could be inline, or one with status.
1336 1348 # for that one we check also permissions
1337 1349
1338 1350 allowed_to_change_status = PullRequestModel().check_user_change_status(
1339 1351 pull_request, self._rhodecode_user)
1340 1352
1341 1353 if status and allowed_to_change_status:
1342 1354 message = (_('Status change %(transition_icon)s %(status)s')
1343 1355 % {'transition_icon': '>',
1344 1356 'status': ChangesetStatus.get_status_lbl(status)})
1345 1357 text = text or message
1346 1358
1347 1359 comment = CommentsModel().create(
1348 1360 text=text,
1349 1361 repo=self.db_repo.repo_id,
1350 1362 user=self._rhodecode_user.user_id,
1351 1363 pull_request=pull_request,
1352 1364 f_path=self.request.POST.get('f_path'),
1353 1365 line_no=self.request.POST.get('line'),
1354 1366 status_change=(ChangesetStatus.get_status_lbl(status)
1355 1367 if status and allowed_to_change_status else None),
1356 1368 status_change_type=(status
1357 1369 if status and allowed_to_change_status else None),
1358 1370 comment_type=comment_type,
1359 1371 resolves_comment_id=resolves_comment_id,
1360 1372 auth_user=self._rhodecode_user
1361 1373 )
1362 1374
1363 1375 if allowed_to_change_status:
1364 1376 # calculate old status before we change it
1365 1377 old_calculated_status = pull_request.calculated_review_status()
1366 1378
1367 1379 # get status if set !
1368 1380 if status:
1369 1381 ChangesetStatusModel().set_status(
1370 1382 self.db_repo.repo_id,
1371 1383 status,
1372 1384 self._rhodecode_user.user_id,
1373 1385 comment,
1374 1386 pull_request=pull_request
1375 1387 )
1376 1388
1377 1389 Session().flush()
1378 1390 # this is somehow required to get access to some relationship
1379 1391 # loaded on comment
1380 1392 Session().refresh(comment)
1381 1393
1382 events.trigger(
1383 events.PullRequestCommentEvent(pull_request, comment))
1394 PullRequestModel().trigger_pull_request_hook(
1395 pull_request, self._rhodecode_user, 'comment',
1396 data={'comment': comment})
1384 1397
1385 1398 # we now calculate the status of pull request, and based on that
1386 1399 # calculation we set the commits status
1387 1400 calculated_status = pull_request.calculated_review_status()
1388 1401 if old_calculated_status != calculated_status:
1389 PullRequestModel()._trigger_pull_request_hook(
1390 pull_request, self._rhodecode_user, 'review_status_change')
1402 PullRequestModel().trigger_pull_request_hook(
1403 pull_request, self._rhodecode_user, 'review_status_change',
1404 data={'status': calculated_status})
1391 1405
1392 1406 Session().commit()
1393 1407
1394 1408 data = {
1395 1409 'target_id': h.safeid(h.safe_unicode(
1396 1410 self.request.POST.get('f_path'))),
1397 1411 }
1398 1412 if comment:
1399 1413 c.co = comment
1400 1414 rendered_comment = render(
1401 1415 'rhodecode:templates/changeset/changeset_comment_block.mako',
1402 1416 self._get_template_context(c), self.request)
1403 1417
1404 1418 data.update(comment.get_dict())
1405 1419 data.update({'rendered_text': rendered_comment})
1406 1420
1407 1421 return data
1408 1422
1409 1423 @LoginRequired()
1410 1424 @NotAnonymous()
1411 1425 @HasRepoPermissionAnyDecorator(
1412 1426 'repository.read', 'repository.write', 'repository.admin')
1413 1427 @CSRFRequired()
1414 1428 @view_config(
1415 1429 route_name='pullrequest_comment_delete', request_method='POST',
1416 1430 renderer='json_ext')
1417 1431 def pull_request_comment_delete(self):
1418 1432 pull_request = PullRequest.get_or_404(
1419 1433 self.request.matchdict['pull_request_id'])
1420 1434
1421 1435 comment = ChangesetComment.get_or_404(
1422 1436 self.request.matchdict['comment_id'])
1423 1437 comment_id = comment.comment_id
1424 1438
1425 1439 if pull_request.is_closed():
1426 1440 log.debug('comment: forbidden because pull request is closed')
1427 1441 raise HTTPForbidden()
1428 1442
1429 1443 if not comment:
1430 1444 log.debug('Comment with id:%s not found, skipping', comment_id)
1431 1445 # comment already deleted in another call probably
1432 1446 return True
1433 1447
1434 1448 if comment.pull_request.is_closed():
1435 1449 # don't allow deleting comments on closed pull request
1436 1450 raise HTTPForbidden()
1437 1451
1438 1452 is_repo_admin = h.HasRepoPermissionAny('repository.admin')(self.db_repo_name)
1439 1453 super_admin = h.HasPermissionAny('hg.admin')()
1440 1454 comment_owner = comment.author.user_id == self._rhodecode_user.user_id
1441 1455 is_repo_comment = comment.repo.repo_name == self.db_repo_name
1442 1456 comment_repo_admin = is_repo_admin and is_repo_comment
1443 1457
1444 1458 if super_admin or comment_owner or comment_repo_admin:
1445 1459 old_calculated_status = comment.pull_request.calculated_review_status()
1446 1460 CommentsModel().delete(comment=comment, auth_user=self._rhodecode_user)
1447 1461 Session().commit()
1448 1462 calculated_status = comment.pull_request.calculated_review_status()
1449 1463 if old_calculated_status != calculated_status:
1450 PullRequestModel()._trigger_pull_request_hook(
1451 comment.pull_request, self._rhodecode_user, 'review_status_change')
1464 PullRequestModel().trigger_pull_request_hook(
1465 comment.pull_request, self._rhodecode_user, 'review_status_change',
1466 data={'status': calculated_status})
1452 1467 return True
1453 1468 else:
1454 1469 log.warning('No permissions for user %s to delete comment_id: %s',
1455 1470 self._rhodecode_db_user, comment_id)
1456 1471 raise HTTPNotFound()
@@ -1,151 +1,155 b''
1 1 # Copyright (C) 2016-2019 RhodeCode GmbH
2 2 #
3 3 # This program is free software: you can redistribute it and/or modify
4 4 # it under the terms of the GNU Affero General Public License, version 3
5 5 # (only), as published by the Free Software Foundation.
6 6 #
7 7 # This program is distributed in the hope that it will be useful,
8 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
9 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10 10 # GNU General Public License for more details.
11 11 #
12 12 # You should have received a copy of the GNU Affero General Public License
13 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
14 14 #
15 15 # This program is dual-licensed. If you wish to learn more about the
16 16 # RhodeCode Enterprise Edition, including its added features, Support services,
17 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
18 18
19 19 import logging
20 20
21 21 from rhodecode.translation import lazy_ugettext
22 22 from rhodecode.events.repo import (
23 23 RepoEvent, _commits_as_dict, _issues_as_dict)
24 24
25 25 log = logging.getLogger(__name__)
26 26
27 27
28 28 class PullRequestEvent(RepoEvent):
29 29 """
30 30 Base class for pull request events.
31 31
32 32 :param pullrequest: a :class:`PullRequest` instance
33 33 """
34 34
35 35 def __init__(self, pullrequest):
36 36 super(PullRequestEvent, self).__init__(pullrequest.target_repo)
37 37 self.pullrequest = pullrequest
38 38
39 39 def as_dict(self):
40 40 from rhodecode.lib.utils2 import md5_safe
41 41 from rhodecode.model.pull_request import PullRequestModel
42 42 data = super(PullRequestEvent, self).as_dict()
43 43
44 44 commits = _commits_as_dict(
45 45 self,
46 46 commit_ids=self.pullrequest.revisions,
47 47 repos=[self.pullrequest.source_repo]
48 48 )
49 49 issues = _issues_as_dict(commits)
50 50 # calculate hashes of all commits for unique identifier of commits
51 51 # inside that pull request
52 52 commits_hash = md5_safe(':'.join(x.get('raw_id', '') for x in commits))
53 53
54 54 data.update({
55 55 'pullrequest': {
56 56 'title': self.pullrequest.title,
57 57 'issues': issues,
58 58 'pull_request_id': self.pullrequest.pull_request_id,
59 59 'url': PullRequestModel().get_url(
60 60 self.pullrequest, request=self.request),
61 61 'permalink_url': PullRequestModel().get_url(
62 62 self.pullrequest, request=self.request, permalink=True),
63 63 'shadow_url': PullRequestModel().get_shadow_clone_url(
64 64 self.pullrequest, request=self.request),
65 65 'status': self.pullrequest.calculated_review_status(),
66 66 'commits_uid': commits_hash,
67 67 'commits': commits,
68 68 }
69 69 })
70 70 return data
71 71
72 72
73 73 class PullRequestCreateEvent(PullRequestEvent):
74 74 """
75 75 An instance of this class is emitted as an :term:`event` after a pull
76 76 request is created.
77 77 """
78 78 name = 'pullrequest-create'
79 79 display_name = lazy_ugettext('pullrequest created')
80 80
81 81
82 82 class PullRequestCloseEvent(PullRequestEvent):
83 83 """
84 84 An instance of this class is emitted as an :term:`event` after a pull
85 85 request is closed.
86 86 """
87 87 name = 'pullrequest-close'
88 88 display_name = lazy_ugettext('pullrequest closed')
89 89
90 90
91 91 class PullRequestUpdateEvent(PullRequestEvent):
92 92 """
93 93 An instance of this class is emitted as an :term:`event` after a pull
94 94 request's commits have been updated.
95 95 """
96 96 name = 'pullrequest-update'
97 97 display_name = lazy_ugettext('pullrequest commits updated')
98 98
99 99
100 100 class PullRequestReviewEvent(PullRequestEvent):
101 101 """
102 102 An instance of this class is emitted as an :term:`event` after a pull
103 request review has changed.
103 request review has changed. A status defines new status of review.
104 104 """
105 105 name = 'pullrequest-review'
106 106 display_name = lazy_ugettext('pullrequest review changed')
107 107
108 def __init__(self, pullrequest, status):
109 super(PullRequestReviewEvent, self).__init__(pullrequest)
110 self.status = status
111
108 112
109 113 class PullRequestMergeEvent(PullRequestEvent):
110 114 """
111 115 An instance of this class is emitted as an :term:`event` after a pull
112 116 request is merged.
113 117 """
114 118 name = 'pullrequest-merge'
115 119 display_name = lazy_ugettext('pullrequest merged')
116 120
117 121
118 122 class PullRequestCommentEvent(PullRequestEvent):
119 123 """
120 124 An instance of this class is emitted as an :term:`event` after a pull
121 125 request comment is created.
122 126 """
123 127 name = 'pullrequest-comment'
124 128 display_name = lazy_ugettext('pullrequest commented')
125 129
126 130 def __init__(self, pullrequest, comment):
127 131 super(PullRequestCommentEvent, self).__init__(pullrequest)
128 132 self.comment = comment
129 133
130 134 def as_dict(self):
131 135 from rhodecode.model.comment import CommentsModel
132 136 data = super(PullRequestCommentEvent, self).as_dict()
133 137
134 138 status = None
135 139 if self.comment.status_change:
136 140 status = self.comment.status_change[0].status
137 141
138 142 data.update({
139 143 'comment': {
140 144 'status': status,
141 145 'text': self.comment.text,
142 146 'type': self.comment.comment_type,
143 147 'file': self.comment.f_path,
144 148 'line': self.comment.line_no,
145 149 'url': CommentsModel().get_url(
146 150 self.comment, request=self.request),
147 151 'permalink_url': CommentsModel().get_url(
148 152 self.comment, request=self.request, permalink=True),
149 153 }
150 154 })
151 155 return data
@@ -1,163 +1,169 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2019 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 import webob
22 22 from pyramid.threadlocal import get_current_request
23 23
24 24 from rhodecode import events
25 25 from rhodecode.lib import hooks_base
26 26 from rhodecode.lib import utils2
27 27
28 28
29 29 def _get_rc_scm_extras(username, repo_name, repo_alias, action):
30 30 # TODO: johbo: Replace by vcs_operation_context and remove fully
31 31 from rhodecode.lib.base import vcs_operation_context
32 32 check_locking = action in ('pull', 'push')
33 33
34 34 request = get_current_request()
35 35
36 36 # default
37 37 dummy_environ = webob.Request.blank('').environ
38 38 try:
39 39 environ = request.environ or dummy_environ
40 40 except TypeError:
41 41 # we might use this outside of request context
42 42 environ = dummy_environ
43 43
44 44 extras = vcs_operation_context(
45 45 environ, repo_name, username, action, repo_alias, check_locking)
46 46 return utils2.AttributeDict(extras)
47 47
48 48
49 49 def trigger_post_push_hook(
50 50 username, action, hook_type, repo_name, repo_alias, commit_ids):
51 51 """
52 52 Triggers push action hooks
53 53
54 54 :param username: username who pushes
55 55 :param action: push/push_local/push_remote
56 56 :param repo_name: name of repo
57 57 :param repo_alias: the type of SCM repo
58 58 :param commit_ids: list of commit ids that we pushed
59 59 """
60 60 extras = _get_rc_scm_extras(username, repo_name, repo_alias, action)
61 61 extras.commit_ids = commit_ids
62 62 extras.hook_type = hook_type
63 63 hooks_base.post_push(extras)
64 64
65 65
66 66 def trigger_log_create_pull_request_hook(username, repo_name, repo_alias,
67 pull_request):
67 pull_request, data=None):
68 68 """
69 69 Triggers create pull request action hooks
70 70
71 71 :param username: username who creates the pull request
72 72 :param repo_name: name of target repo
73 73 :param repo_alias: the type of SCM target repo
74 74 :param pull_request: the pull request that was created
75 :param data: extra data for specific events e.g {'comment': comment_obj}
75 76 """
76 77 if repo_alias not in ('hg', 'git'):
77 78 return
78 79
79 80 extras = _get_rc_scm_extras(username, repo_name, repo_alias,
80 81 'create_pull_request')
81 82 events.trigger(events.PullRequestCreateEvent(pull_request))
82 83 extras.update(pull_request.get_api_data())
83 84 hooks_base.log_create_pull_request(**extras)
84 85
85 86
86 87 def trigger_log_merge_pull_request_hook(username, repo_name, repo_alias,
87 pull_request):
88 pull_request, data=None):
88 89 """
89 90 Triggers merge pull request action hooks
90 91
91 92 :param username: username who creates the pull request
92 93 :param repo_name: name of target repo
93 94 :param repo_alias: the type of SCM target repo
94 95 :param pull_request: the pull request that was merged
96 :param data: extra data for specific events e.g {'comment': comment_obj}
95 97 """
96 98 if repo_alias not in ('hg', 'git'):
97 99 return
98 100
99 101 extras = _get_rc_scm_extras(username, repo_name, repo_alias,
100 102 'merge_pull_request')
101 103 events.trigger(events.PullRequestMergeEvent(pull_request))
102 104 extras.update(pull_request.get_api_data())
103 105 hooks_base.log_merge_pull_request(**extras)
104 106
105 107
106 108 def trigger_log_close_pull_request_hook(username, repo_name, repo_alias,
107 pull_request):
109 pull_request, data=None):
108 110 """
109 111 Triggers close pull request action hooks
110 112
111 113 :param username: username who creates the pull request
112 114 :param repo_name: name of target repo
113 115 :param repo_alias: the type of SCM target repo
114 116 :param pull_request: the pull request that was closed
117 :param data: extra data for specific events e.g {'comment': comment_obj}
115 118 """
116 119 if repo_alias not in ('hg', 'git'):
117 120 return
118 121
119 122 extras = _get_rc_scm_extras(username, repo_name, repo_alias,
120 123 'close_pull_request')
121 124 events.trigger(events.PullRequestCloseEvent(pull_request))
122 125 extras.update(pull_request.get_api_data())
123 126 hooks_base.log_close_pull_request(**extras)
124 127
125 128
126 129 def trigger_log_review_pull_request_hook(username, repo_name, repo_alias,
127 pull_request):
130 pull_request, data=None):
128 131 """
129 132 Triggers review status change pull request action hooks
130 133
131 134 :param username: username who creates the pull request
132 135 :param repo_name: name of target repo
133 136 :param repo_alias: the type of SCM target repo
134 137 :param pull_request: the pull request that review status changed
138 :param data: extra data for specific events e.g {'comment': comment_obj}
135 139 """
136 140 if repo_alias not in ('hg', 'git'):
137 141 return
138 142
139 143 extras = _get_rc_scm_extras(username, repo_name, repo_alias,
140 144 'review_pull_request')
141 events.trigger(events.PullRequestReviewEvent(pull_request))
145 status = data.get('status')
146 events.trigger(events.PullRequestReviewEvent(pull_request, status))
142 147 extras.update(pull_request.get_api_data())
143 148 hooks_base.log_review_pull_request(**extras)
144 149
145 150
146 151 def trigger_log_update_pull_request_hook(username, repo_name, repo_alias,
147 pull_request):
152 pull_request, data=None):
148 153 """
149 154 Triggers update pull request action hooks
150 155
151 156 :param username: username who creates the pull request
152 157 :param repo_name: name of target repo
153 158 :param repo_alias: the type of SCM target repo
154 159 :param pull_request: the pull request that was updated
160 :param data: extra data for specific events e.g {'comment': comment_obj}
155 161 """
156 162 if repo_alias not in ('hg', 'git'):
157 163 return
158 164
159 165 extras = _get_rc_scm_extras(username, repo_name, repo_alias,
160 166 'update_pull_request')
161 167 events.trigger(events.PullRequestUpdateEvent(pull_request))
162 168 extras.update(pull_request.get_api_data())
163 169 hooks_base.log_update_pull_request(**extras)
@@ -1,1706 +1,1714 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2012-2019 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
27 27 import json
28 28 import logging
29 29 import datetime
30 30 import urllib
31 31 import collections
32 32
33 33 from pyramid.threadlocal import get_current_request
34 34
35 35 from rhodecode import events
36 36 from rhodecode.translation import lazy_ugettext
37 37 from rhodecode.lib import helpers as h, hooks_utils, diffs
38 38 from rhodecode.lib import audit_logger
39 39 from rhodecode.lib.compat import OrderedDict
40 40 from rhodecode.lib.hooks_daemon import prepare_callback_daemon
41 41 from rhodecode.lib.markup_renderer import (
42 42 DEFAULT_COMMENTS_RENDERER, RstTemplateRenderer)
43 43 from rhodecode.lib.utils2 import safe_unicode, safe_str, md5_safe
44 44 from rhodecode.lib.vcs.backends.base import (
45 45 Reference, MergeResponse, MergeFailureReason, UpdateFailureReason)
46 46 from rhodecode.lib.vcs.conf import settings as vcs_settings
47 47 from rhodecode.lib.vcs.exceptions import (
48 48 CommitDoesNotExistError, EmptyRepositoryError)
49 49 from rhodecode.model import BaseModel
50 50 from rhodecode.model.changeset_status import ChangesetStatusModel
51 51 from rhodecode.model.comment import CommentsModel
52 52 from rhodecode.model.db import (
53 53 or_, PullRequest, PullRequestReviewers, ChangesetStatus,
54 54 PullRequestVersion, ChangesetComment, Repository, RepoReviewRule)
55 55 from rhodecode.model.meta import Session
56 56 from rhodecode.model.notification import NotificationModel, \
57 57 EmailNotificationModel
58 58 from rhodecode.model.scm import ScmModel
59 59 from rhodecode.model.settings import VcsSettingsModel
60 60
61 61
62 62 log = logging.getLogger(__name__)
63 63
64 64
65 65 # Data structure to hold the response data when updating commits during a pull
66 66 # request update.
67 67 UpdateResponse = collections.namedtuple('UpdateResponse', [
68 68 'executed', 'reason', 'new', 'old', 'changes',
69 69 'source_changed', 'target_changed'])
70 70
71 71
72 72 class PullRequestModel(BaseModel):
73 73
74 74 cls = PullRequest
75 75
76 76 DIFF_CONTEXT = diffs.DEFAULT_CONTEXT
77 77
78 78 UPDATE_STATUS_MESSAGES = {
79 79 UpdateFailureReason.NONE: lazy_ugettext(
80 80 'Pull request update successful.'),
81 81 UpdateFailureReason.UNKNOWN: lazy_ugettext(
82 82 'Pull request update failed because of an unknown error.'),
83 83 UpdateFailureReason.NO_CHANGE: lazy_ugettext(
84 84 'No update needed because the source and target have not changed.'),
85 85 UpdateFailureReason.WRONG_REF_TYPE: lazy_ugettext(
86 86 'Pull request cannot be updated because the reference type is '
87 87 'not supported for an update. Only Branch, Tag or Bookmark is allowed.'),
88 88 UpdateFailureReason.MISSING_TARGET_REF: lazy_ugettext(
89 89 'This pull request cannot be updated because the target '
90 90 'reference is missing.'),
91 91 UpdateFailureReason.MISSING_SOURCE_REF: lazy_ugettext(
92 92 'This pull request cannot be updated because the source '
93 93 'reference is missing.'),
94 94 }
95 95 REF_TYPES = ['bookmark', 'book', 'tag', 'branch']
96 96 UPDATABLE_REF_TYPES = ['bookmark', 'book', 'branch']
97 97
98 98 def __get_pull_request(self, pull_request):
99 99 return self._get_instance((
100 100 PullRequest, PullRequestVersion), pull_request)
101 101
102 102 def _check_perms(self, perms, pull_request, user, api=False):
103 103 if not api:
104 104 return h.HasRepoPermissionAny(*perms)(
105 105 user=user, repo_name=pull_request.target_repo.repo_name)
106 106 else:
107 107 return h.HasRepoPermissionAnyApi(*perms)(
108 108 user=user, repo_name=pull_request.target_repo.repo_name)
109 109
110 110 def check_user_read(self, pull_request, user, api=False):
111 111 _perms = ('repository.admin', 'repository.write', 'repository.read',)
112 112 return self._check_perms(_perms, pull_request, user, api)
113 113
114 114 def check_user_merge(self, pull_request, user, api=False):
115 115 _perms = ('repository.admin', 'repository.write', 'hg.admin',)
116 116 return self._check_perms(_perms, pull_request, user, api)
117 117
118 118 def check_user_update(self, pull_request, user, api=False):
119 119 owner = user.user_id == pull_request.user_id
120 120 return self.check_user_merge(pull_request, user, api) or owner
121 121
122 122 def check_user_delete(self, pull_request, user):
123 123 owner = user.user_id == pull_request.user_id
124 124 _perms = ('repository.admin',)
125 125 return self._check_perms(_perms, pull_request, user) or owner
126 126
127 127 def check_user_change_status(self, pull_request, user, api=False):
128 128 reviewer = user.user_id in [x.user_id for x in
129 129 pull_request.reviewers]
130 130 return self.check_user_update(pull_request, user, api) or reviewer
131 131
132 132 def check_user_comment(self, pull_request, user):
133 133 owner = user.user_id == pull_request.user_id
134 134 return self.check_user_read(pull_request, user) or owner
135 135
136 136 def get(self, pull_request):
137 137 return self.__get_pull_request(pull_request)
138 138
139 139 def _prepare_get_all_query(self, repo_name, source=False, statuses=None,
140 140 opened_by=None, order_by=None,
141 141 order_dir='desc', only_created=True):
142 142 repo = None
143 143 if repo_name:
144 144 repo = self._get_repo(repo_name)
145 145
146 146 q = PullRequest.query()
147 147
148 148 # source or target
149 149 if repo and source:
150 150 q = q.filter(PullRequest.source_repo == repo)
151 151 elif repo:
152 152 q = q.filter(PullRequest.target_repo == repo)
153 153
154 154 # closed,opened
155 155 if statuses:
156 156 q = q.filter(PullRequest.status.in_(statuses))
157 157
158 158 # opened by filter
159 159 if opened_by:
160 160 q = q.filter(PullRequest.user_id.in_(opened_by))
161 161
162 162 # only get those that are in "created" state
163 163 if only_created:
164 164 q = q.filter(PullRequest.pull_request_state == PullRequest.STATE_CREATED)
165 165
166 166 if order_by:
167 167 order_map = {
168 168 'name_raw': PullRequest.pull_request_id,
169 169 'title': PullRequest.title,
170 170 'updated_on_raw': PullRequest.updated_on,
171 171 'target_repo': PullRequest.target_repo_id
172 172 }
173 173 if order_dir == 'asc':
174 174 q = q.order_by(order_map[order_by].asc())
175 175 else:
176 176 q = q.order_by(order_map[order_by].desc())
177 177
178 178 return q
179 179
180 180 def count_all(self, repo_name, source=False, statuses=None,
181 181 opened_by=None):
182 182 """
183 183 Count the number of pull requests for a specific repository.
184 184
185 185 :param repo_name: target or source repo
186 186 :param source: boolean flag to specify if repo_name refers to source
187 187 :param statuses: list of pull request statuses
188 188 :param opened_by: author user of the pull request
189 189 :returns: int number of pull requests
190 190 """
191 191 q = self._prepare_get_all_query(
192 192 repo_name, source=source, statuses=statuses, opened_by=opened_by)
193 193
194 194 return q.count()
195 195
196 196 def get_all(self, repo_name, source=False, statuses=None, opened_by=None,
197 197 offset=0, length=None, order_by=None, order_dir='desc'):
198 198 """
199 199 Get all pull requests for a specific repository.
200 200
201 201 :param repo_name: target or source repo
202 202 :param source: boolean flag to specify if repo_name refers to source
203 203 :param statuses: list of pull request statuses
204 204 :param opened_by: author user of the pull request
205 205 :param offset: pagination offset
206 206 :param length: length of returned list
207 207 :param order_by: order of the returned list
208 208 :param order_dir: 'asc' or 'desc' ordering direction
209 209 :returns: list of pull requests
210 210 """
211 211 q = self._prepare_get_all_query(
212 212 repo_name, source=source, statuses=statuses, opened_by=opened_by,
213 213 order_by=order_by, order_dir=order_dir)
214 214
215 215 if length:
216 216 pull_requests = q.limit(length).offset(offset).all()
217 217 else:
218 218 pull_requests = q.all()
219 219
220 220 return pull_requests
221 221
222 222 def count_awaiting_review(self, repo_name, source=False, statuses=None,
223 223 opened_by=None):
224 224 """
225 225 Count the number of pull requests for a specific repository that are
226 226 awaiting review.
227 227
228 228 :param repo_name: target or source repo
229 229 :param source: boolean flag to specify if repo_name refers to source
230 230 :param statuses: list of pull request statuses
231 231 :param opened_by: author user of the pull request
232 232 :returns: int number of pull requests
233 233 """
234 234 pull_requests = self.get_awaiting_review(
235 235 repo_name, source=source, statuses=statuses, opened_by=opened_by)
236 236
237 237 return len(pull_requests)
238 238
239 239 def get_awaiting_review(self, repo_name, source=False, statuses=None,
240 240 opened_by=None, offset=0, length=None,
241 241 order_by=None, order_dir='desc'):
242 242 """
243 243 Get all pull requests for a specific repository that are awaiting
244 244 review.
245 245
246 246 :param repo_name: target or source repo
247 247 :param source: boolean flag to specify if repo_name refers to source
248 248 :param statuses: list of pull request statuses
249 249 :param opened_by: author user of the pull request
250 250 :param offset: pagination offset
251 251 :param length: length of returned list
252 252 :param order_by: order of the returned list
253 253 :param order_dir: 'asc' or 'desc' ordering direction
254 254 :returns: list of pull requests
255 255 """
256 256 pull_requests = self.get_all(
257 257 repo_name, source=source, statuses=statuses, opened_by=opened_by,
258 258 order_by=order_by, order_dir=order_dir)
259 259
260 260 _filtered_pull_requests = []
261 261 for pr in pull_requests:
262 262 status = pr.calculated_review_status()
263 263 if status in [ChangesetStatus.STATUS_NOT_REVIEWED,
264 264 ChangesetStatus.STATUS_UNDER_REVIEW]:
265 265 _filtered_pull_requests.append(pr)
266 266 if length:
267 267 return _filtered_pull_requests[offset:offset+length]
268 268 else:
269 269 return _filtered_pull_requests
270 270
271 271 def count_awaiting_my_review(self, repo_name, source=False, statuses=None,
272 272 opened_by=None, user_id=None):
273 273 """
274 274 Count the number of pull requests for a specific repository that are
275 275 awaiting review from a specific user.
276 276
277 277 :param repo_name: target or source repo
278 278 :param source: boolean flag to specify if repo_name refers to source
279 279 :param statuses: list of pull request statuses
280 280 :param opened_by: author user of the pull request
281 281 :param user_id: reviewer user of the pull request
282 282 :returns: int number of pull requests
283 283 """
284 284 pull_requests = self.get_awaiting_my_review(
285 285 repo_name, source=source, statuses=statuses, opened_by=opened_by,
286 286 user_id=user_id)
287 287
288 288 return len(pull_requests)
289 289
290 290 def get_awaiting_my_review(self, repo_name, source=False, statuses=None,
291 291 opened_by=None, user_id=None, offset=0,
292 292 length=None, order_by=None, order_dir='desc'):
293 293 """
294 294 Get all pull requests for a specific repository that are awaiting
295 295 review from a specific user.
296 296
297 297 :param repo_name: target or source repo
298 298 :param source: boolean flag to specify if repo_name refers to source
299 299 :param statuses: list of pull request statuses
300 300 :param opened_by: author user of the pull request
301 301 :param user_id: reviewer user of the pull request
302 302 :param offset: pagination offset
303 303 :param length: length of returned list
304 304 :param order_by: order of the returned list
305 305 :param order_dir: 'asc' or 'desc' ordering direction
306 306 :returns: list of pull requests
307 307 """
308 308 pull_requests = self.get_all(
309 309 repo_name, source=source, statuses=statuses, opened_by=opened_by,
310 310 order_by=order_by, order_dir=order_dir)
311 311
312 312 _my = PullRequestModel().get_not_reviewed(user_id)
313 313 my_participation = []
314 314 for pr in pull_requests:
315 315 if pr in _my:
316 316 my_participation.append(pr)
317 317 _filtered_pull_requests = my_participation
318 318 if length:
319 319 return _filtered_pull_requests[offset:offset+length]
320 320 else:
321 321 return _filtered_pull_requests
322 322
323 323 def get_not_reviewed(self, user_id):
324 324 return [
325 325 x.pull_request for x in PullRequestReviewers.query().filter(
326 326 PullRequestReviewers.user_id == user_id).all()
327 327 ]
328 328
329 329 def _prepare_participating_query(self, user_id=None, statuses=None,
330 330 order_by=None, order_dir='desc'):
331 331 q = PullRequest.query()
332 332 if user_id:
333 333 reviewers_subquery = Session().query(
334 334 PullRequestReviewers.pull_request_id).filter(
335 335 PullRequestReviewers.user_id == user_id).subquery()
336 336 user_filter = or_(
337 337 PullRequest.user_id == user_id,
338 338 PullRequest.pull_request_id.in_(reviewers_subquery)
339 339 )
340 340 q = PullRequest.query().filter(user_filter)
341 341
342 342 # closed,opened
343 343 if statuses:
344 344 q = q.filter(PullRequest.status.in_(statuses))
345 345
346 346 if order_by:
347 347 order_map = {
348 348 'name_raw': PullRequest.pull_request_id,
349 349 'title': PullRequest.title,
350 350 'updated_on_raw': PullRequest.updated_on,
351 351 'target_repo': PullRequest.target_repo_id
352 352 }
353 353 if order_dir == 'asc':
354 354 q = q.order_by(order_map[order_by].asc())
355 355 else:
356 356 q = q.order_by(order_map[order_by].desc())
357 357
358 358 return q
359 359
360 360 def count_im_participating_in(self, user_id=None, statuses=None):
361 361 q = self._prepare_participating_query(user_id, statuses=statuses)
362 362 return q.count()
363 363
364 364 def get_im_participating_in(
365 365 self, user_id=None, statuses=None, offset=0,
366 366 length=None, order_by=None, order_dir='desc'):
367 367 """
368 368 Get all Pull requests that i'm participating in, or i have opened
369 369 """
370 370
371 371 q = self._prepare_participating_query(
372 372 user_id, statuses=statuses, order_by=order_by,
373 373 order_dir=order_dir)
374 374
375 375 if length:
376 376 pull_requests = q.limit(length).offset(offset).all()
377 377 else:
378 378 pull_requests = q.all()
379 379
380 380 return pull_requests
381 381
382 382 def get_versions(self, pull_request):
383 383 """
384 384 returns version of pull request sorted by ID descending
385 385 """
386 386 return PullRequestVersion.query()\
387 387 .filter(PullRequestVersion.pull_request == pull_request)\
388 388 .order_by(PullRequestVersion.pull_request_version_id.asc())\
389 389 .all()
390 390
391 391 def get_pr_version(self, pull_request_id, version=None):
392 392 at_version = None
393 393
394 394 if version and version == 'latest':
395 395 pull_request_ver = PullRequest.get(pull_request_id)
396 396 pull_request_obj = pull_request_ver
397 397 _org_pull_request_obj = pull_request_obj
398 398 at_version = 'latest'
399 399 elif version:
400 400 pull_request_ver = PullRequestVersion.get_or_404(version)
401 401 pull_request_obj = pull_request_ver
402 402 _org_pull_request_obj = pull_request_ver.pull_request
403 403 at_version = pull_request_ver.pull_request_version_id
404 404 else:
405 405 _org_pull_request_obj = pull_request_obj = PullRequest.get_or_404(
406 406 pull_request_id)
407 407
408 408 pull_request_display_obj = PullRequest.get_pr_display_object(
409 409 pull_request_obj, _org_pull_request_obj)
410 410
411 411 return _org_pull_request_obj, pull_request_obj, \
412 412 pull_request_display_obj, at_version
413 413
414 414 def create(self, created_by, source_repo, source_ref, target_repo,
415 415 target_ref, revisions, reviewers, title, description=None,
416 416 description_renderer=None,
417 417 reviewer_data=None, translator=None, auth_user=None):
418 418 translator = translator or get_current_request().translate
419 419
420 420 created_by_user = self._get_user(created_by)
421 421 auth_user = auth_user or created_by_user.AuthUser()
422 422 source_repo = self._get_repo(source_repo)
423 423 target_repo = self._get_repo(target_repo)
424 424
425 425 pull_request = PullRequest()
426 426 pull_request.source_repo = source_repo
427 427 pull_request.source_ref = source_ref
428 428 pull_request.target_repo = target_repo
429 429 pull_request.target_ref = target_ref
430 430 pull_request.revisions = revisions
431 431 pull_request.title = title
432 432 pull_request.description = description
433 433 pull_request.description_renderer = description_renderer
434 434 pull_request.author = created_by_user
435 435 pull_request.reviewer_data = reviewer_data
436 436 pull_request.pull_request_state = pull_request.STATE_CREATING
437 437 Session().add(pull_request)
438 438 Session().flush()
439 439
440 440 reviewer_ids = set()
441 441 # members / reviewers
442 442 for reviewer_object in reviewers:
443 443 user_id, reasons, mandatory, rules = reviewer_object
444 444 user = self._get_user(user_id)
445 445
446 446 # skip duplicates
447 447 if user.user_id in reviewer_ids:
448 448 continue
449 449
450 450 reviewer_ids.add(user.user_id)
451 451
452 452 reviewer = PullRequestReviewers()
453 453 reviewer.user = user
454 454 reviewer.pull_request = pull_request
455 455 reviewer.reasons = reasons
456 456 reviewer.mandatory = mandatory
457 457
458 458 # NOTE(marcink): pick only first rule for now
459 459 rule_id = list(rules)[0] if rules else None
460 460 rule = RepoReviewRule.get(rule_id) if rule_id else None
461 461 if rule:
462 462 review_group = rule.user_group_vote_rule(user_id)
463 463 # we check if this particular reviewer is member of a voting group
464 464 if review_group:
465 465 # NOTE(marcink):
466 466 # can be that user is member of more but we pick the first same,
467 467 # same as default reviewers algo
468 468 review_group = review_group[0]
469 469
470 470 rule_data = {
471 471 'rule_name':
472 472 rule.review_rule_name,
473 473 'rule_user_group_entry_id':
474 474 review_group.repo_review_rule_users_group_id,
475 475 'rule_user_group_name':
476 476 review_group.users_group.users_group_name,
477 477 'rule_user_group_members':
478 478 [x.user.username for x in review_group.users_group.members],
479 479 'rule_user_group_members_id':
480 480 [x.user.user_id for x in review_group.users_group.members],
481 481 }
482 482 # e.g {'vote_rule': -1, 'mandatory': True}
483 483 rule_data.update(review_group.rule_data())
484 484
485 485 reviewer.rule_data = rule_data
486 486
487 487 Session().add(reviewer)
488 488 Session().flush()
489 489
490 490 # Set approval status to "Under Review" for all commits which are
491 491 # part of this pull request.
492 492 ChangesetStatusModel().set_status(
493 493 repo=target_repo,
494 494 status=ChangesetStatus.STATUS_UNDER_REVIEW,
495 495 user=created_by_user,
496 496 pull_request=pull_request
497 497 )
498 498 # we commit early at this point. This has to do with a fact
499 499 # that before queries do some row-locking. And because of that
500 500 # we need to commit and finish transaction before below validate call
501 501 # that for large repos could be long resulting in long row locks
502 502 Session().commit()
503 503
504 504 # prepare workspace, and run initial merge simulation. Set state during that
505 505 # operation
506 506 pull_request = PullRequest.get(pull_request.pull_request_id)
507 507
508 508 # set as merging, for simulation, and if finished to created so we mark
509 509 # simulation is working fine
510 510 with pull_request.set_state(PullRequest.STATE_MERGING,
511 511 final_state=PullRequest.STATE_CREATED):
512 512 MergeCheck.validate(
513 513 pull_request, auth_user=auth_user, translator=translator)
514 514
515 515 self.notify_reviewers(pull_request, reviewer_ids)
516 self._trigger_pull_request_hook(
516 self.trigger_pull_request_hook(
517 517 pull_request, created_by_user, 'create')
518 518
519 519 creation_data = pull_request.get_api_data(with_merge_state=False)
520 520 self._log_audit_action(
521 521 'repo.pull_request.create', {'data': creation_data},
522 522 auth_user, pull_request)
523 523
524 524 return pull_request
525 525
526 def _trigger_pull_request_hook(self, pull_request, user, action):
526 def trigger_pull_request_hook(self, pull_request, user, action, data=None):
527 527 pull_request = self.__get_pull_request(pull_request)
528 528 target_scm = pull_request.target_repo.scm_instance()
529 529 if action == 'create':
530 530 trigger_hook = hooks_utils.trigger_log_create_pull_request_hook
531 531 elif action == 'merge':
532 532 trigger_hook = hooks_utils.trigger_log_merge_pull_request_hook
533 533 elif action == 'close':
534 534 trigger_hook = hooks_utils.trigger_log_close_pull_request_hook
535 535 elif action == 'review_status_change':
536 536 trigger_hook = hooks_utils.trigger_log_review_pull_request_hook
537 537 elif action == 'update':
538 538 trigger_hook = hooks_utils.trigger_log_update_pull_request_hook
539 elif action == 'comment':
540 # dummy hook ! for comment. We want this function to handle all cases
541 def trigger_hook(*args, **kwargs):
542 pass
543 comment = data['comment']
544 events.trigger(events.PullRequestCommentEvent(pull_request, comment))
539 545 else:
540 546 return
541 547
542 548 trigger_hook(
543 549 username=user.username,
544 550 repo_name=pull_request.target_repo.repo_name,
545 551 repo_alias=target_scm.alias,
546 pull_request=pull_request)
552 pull_request=pull_request,
553 data=data)
547 554
548 555 def _get_commit_ids(self, pull_request):
549 556 """
550 557 Return the commit ids of the merged pull request.
551 558
552 559 This method is not dealing correctly yet with the lack of autoupdates
553 560 nor with the implicit target updates.
554 561 For example: if a commit in the source repo is already in the target it
555 562 will be reported anyways.
556 563 """
557 564 merge_rev = pull_request.merge_rev
558 565 if merge_rev is None:
559 566 raise ValueError('This pull request was not merged yet')
560 567
561 568 commit_ids = list(pull_request.revisions)
562 569 if merge_rev not in commit_ids:
563 570 commit_ids.append(merge_rev)
564 571
565 572 return commit_ids
566 573
567 574 def merge_repo(self, pull_request, user, extras):
568 575 log.debug("Merging pull request %s", pull_request.pull_request_id)
569 576 extras['user_agent'] = 'internal-merge'
570 577 merge_state = self._merge_pull_request(pull_request, user, extras)
571 578 if merge_state.executed:
572 579 log.debug("Merge was successful, updating the pull request comments.")
573 580 self._comment_and_close_pr(pull_request, user, merge_state)
574 581
575 582 self._log_audit_action(
576 583 'repo.pull_request.merge',
577 584 {'merge_state': merge_state.__dict__},
578 585 user, pull_request)
579 586
580 587 else:
581 588 log.warn("Merge failed, not updating the pull request.")
582 589 return merge_state
583 590
584 591 def _merge_pull_request(self, pull_request, user, extras, merge_msg=None):
585 592 target_vcs = pull_request.target_repo.scm_instance()
586 593 source_vcs = pull_request.source_repo.scm_instance()
587 594
588 595 message = safe_unicode(merge_msg or vcs_settings.MERGE_MESSAGE_TMPL).format(
589 596 pr_id=pull_request.pull_request_id,
590 597 pr_title=pull_request.title,
591 598 source_repo=source_vcs.name,
592 599 source_ref_name=pull_request.source_ref_parts.name,
593 600 target_repo=target_vcs.name,
594 601 target_ref_name=pull_request.target_ref_parts.name,
595 602 )
596 603
597 604 workspace_id = self._workspace_id(pull_request)
598 605 repo_id = pull_request.target_repo.repo_id
599 606 use_rebase = self._use_rebase_for_merging(pull_request)
600 607 close_branch = self._close_branch_before_merging(pull_request)
601 608
602 609 target_ref = self._refresh_reference(
603 610 pull_request.target_ref_parts, target_vcs)
604 611
605 612 callback_daemon, extras = prepare_callback_daemon(
606 613 extras, protocol=vcs_settings.HOOKS_PROTOCOL,
607 614 host=vcs_settings.HOOKS_HOST,
608 615 use_direct_calls=vcs_settings.HOOKS_DIRECT_CALLS)
609 616
610 617 with callback_daemon:
611 618 # TODO: johbo: Implement a clean way to run a config_override
612 619 # for a single call.
613 620 target_vcs.config.set(
614 621 'rhodecode', 'RC_SCM_DATA', json.dumps(extras))
615 622
616 623 user_name = user.short_contact
617 624 merge_state = target_vcs.merge(
618 625 repo_id, workspace_id, target_ref, source_vcs,
619 626 pull_request.source_ref_parts,
620 627 user_name=user_name, user_email=user.email,
621 628 message=message, use_rebase=use_rebase,
622 629 close_branch=close_branch)
623 630 return merge_state
624 631
625 632 def _comment_and_close_pr(self, pull_request, user, merge_state, close_msg=None):
626 633 pull_request.merge_rev = merge_state.merge_ref.commit_id
627 634 pull_request.updated_on = datetime.datetime.now()
628 635 close_msg = close_msg or 'Pull request merged and closed'
629 636
630 637 CommentsModel().create(
631 638 text=safe_unicode(close_msg),
632 639 repo=pull_request.target_repo.repo_id,
633 640 user=user.user_id,
634 641 pull_request=pull_request.pull_request_id,
635 642 f_path=None,
636 643 line_no=None,
637 644 closing_pr=True
638 645 )
639 646
640 647 Session().add(pull_request)
641 648 Session().flush()
642 649 # TODO: paris: replace invalidation with less radical solution
643 650 ScmModel().mark_for_invalidation(
644 651 pull_request.target_repo.repo_name)
645 self._trigger_pull_request_hook(pull_request, user, 'merge')
652 self.trigger_pull_request_hook(pull_request, user, 'merge')
646 653
647 654 def has_valid_update_type(self, pull_request):
648 655 source_ref_type = pull_request.source_ref_parts.type
649 656 return source_ref_type in self.REF_TYPES
650 657
651 658 def update_commits(self, pull_request):
652 659 """
653 660 Get the updated list of commits for the pull request
654 661 and return the new pull request version and the list
655 662 of commits processed by this update action
656 663 """
657 664 pull_request = self.__get_pull_request(pull_request)
658 665 source_ref_type = pull_request.source_ref_parts.type
659 666 source_ref_name = pull_request.source_ref_parts.name
660 667 source_ref_id = pull_request.source_ref_parts.commit_id
661 668
662 669 target_ref_type = pull_request.target_ref_parts.type
663 670 target_ref_name = pull_request.target_ref_parts.name
664 671 target_ref_id = pull_request.target_ref_parts.commit_id
665 672
666 673 if not self.has_valid_update_type(pull_request):
667 674 log.debug("Skipping update of pull request %s due to ref type: %s",
668 675 pull_request, source_ref_type)
669 676 return UpdateResponse(
670 677 executed=False,
671 678 reason=UpdateFailureReason.WRONG_REF_TYPE,
672 679 old=pull_request, new=None, changes=None,
673 680 source_changed=False, target_changed=False)
674 681
675 682 # source repo
676 683 source_repo = pull_request.source_repo.scm_instance()
677 684 try:
678 685 source_commit = source_repo.get_commit(commit_id=source_ref_name)
679 686 except CommitDoesNotExistError:
680 687 return UpdateResponse(
681 688 executed=False,
682 689 reason=UpdateFailureReason.MISSING_SOURCE_REF,
683 690 old=pull_request, new=None, changes=None,
684 691 source_changed=False, target_changed=False)
685 692
686 693 source_changed = source_ref_id != source_commit.raw_id
687 694
688 695 # target repo
689 696 target_repo = pull_request.target_repo.scm_instance()
690 697 try:
691 698 target_commit = target_repo.get_commit(commit_id=target_ref_name)
692 699 except CommitDoesNotExistError:
693 700 return UpdateResponse(
694 701 executed=False,
695 702 reason=UpdateFailureReason.MISSING_TARGET_REF,
696 703 old=pull_request, new=None, changes=None,
697 704 source_changed=False, target_changed=False)
698 705 target_changed = target_ref_id != target_commit.raw_id
699 706
700 707 if not (source_changed or target_changed):
701 708 log.debug("Nothing changed in pull request %s", pull_request)
702 709 return UpdateResponse(
703 710 executed=False,
704 711 reason=UpdateFailureReason.NO_CHANGE,
705 712 old=pull_request, new=None, changes=None,
706 713 source_changed=target_changed, target_changed=source_changed)
707 714
708 715 change_in_found = 'target repo' if target_changed else 'source repo'
709 716 log.debug('Updating pull request because of change in %s detected',
710 717 change_in_found)
711 718
712 719 # Finally there is a need for an update, in case of source change
713 720 # we create a new version, else just an update
714 721 if source_changed:
715 722 pull_request_version = self._create_version_from_snapshot(pull_request)
716 723 self._link_comments_to_version(pull_request_version)
717 724 else:
718 725 try:
719 726 ver = pull_request.versions[-1]
720 727 except IndexError:
721 728 ver = None
722 729
723 730 pull_request.pull_request_version_id = \
724 731 ver.pull_request_version_id if ver else None
725 732 pull_request_version = pull_request
726 733
727 734 try:
728 735 if target_ref_type in self.REF_TYPES:
729 736 target_commit = target_repo.get_commit(target_ref_name)
730 737 else:
731 738 target_commit = target_repo.get_commit(target_ref_id)
732 739 except CommitDoesNotExistError:
733 740 return UpdateResponse(
734 741 executed=False,
735 742 reason=UpdateFailureReason.MISSING_TARGET_REF,
736 743 old=pull_request, new=None, changes=None,
737 744 source_changed=source_changed, target_changed=target_changed)
738 745
739 746 # re-compute commit ids
740 747 old_commit_ids = pull_request.revisions
741 748 pre_load = ["author", "branch", "date", "message"]
742 749 commit_ranges = target_repo.compare(
743 750 target_commit.raw_id, source_commit.raw_id, source_repo, merge=True,
744 751 pre_load=pre_load)
745 752
746 753 ancestor = target_repo.get_common_ancestor(
747 754 target_commit.raw_id, source_commit.raw_id, source_repo)
748 755
749 756 pull_request.source_ref = '%s:%s:%s' % (
750 757 source_ref_type, source_ref_name, source_commit.raw_id)
751 758 pull_request.target_ref = '%s:%s:%s' % (
752 759 target_ref_type, target_ref_name, ancestor)
753 760
754 761 pull_request.revisions = [
755 762 commit.raw_id for commit in reversed(commit_ranges)]
756 763 pull_request.updated_on = datetime.datetime.now()
757 764 Session().add(pull_request)
758 765 new_commit_ids = pull_request.revisions
759 766
760 767 old_diff_data, new_diff_data = self._generate_update_diffs(
761 768 pull_request, pull_request_version)
762 769
763 770 # calculate commit and file changes
764 771 changes = self._calculate_commit_id_changes(
765 772 old_commit_ids, new_commit_ids)
766 773 file_changes = self._calculate_file_changes(
767 774 old_diff_data, new_diff_data)
768 775
769 776 # set comments as outdated if DIFFS changed
770 777 CommentsModel().outdate_comments(
771 778 pull_request, old_diff_data=old_diff_data,
772 779 new_diff_data=new_diff_data)
773 780
774 781 commit_changes = (changes.added or changes.removed)
775 782 file_node_changes = (
776 783 file_changes.added or file_changes.modified or file_changes.removed)
777 784 pr_has_changes = commit_changes or file_node_changes
778 785
779 786 # Add an automatic comment to the pull request, in case
780 787 # anything has changed
781 788 if pr_has_changes:
782 789 update_comment = CommentsModel().create(
783 790 text=self._render_update_message(changes, file_changes),
784 791 repo=pull_request.target_repo,
785 792 user=pull_request.author,
786 793 pull_request=pull_request,
787 794 send_email=False, renderer=DEFAULT_COMMENTS_RENDERER)
788 795
789 796 # Update status to "Under Review" for added commits
790 797 for commit_id in changes.added:
791 798 ChangesetStatusModel().set_status(
792 799 repo=pull_request.source_repo,
793 800 status=ChangesetStatus.STATUS_UNDER_REVIEW,
794 801 comment=update_comment,
795 802 user=pull_request.author,
796 803 pull_request=pull_request,
797 804 revision=commit_id)
798 805
799 806 log.debug(
800 807 'Updated pull request %s, added_ids: %s, common_ids: %s, '
801 808 'removed_ids: %s', pull_request.pull_request_id,
802 809 changes.added, changes.common, changes.removed)
803 810 log.debug(
804 811 'Updated pull request with the following file changes: %s',
805 812 file_changes)
806 813
807 814 log.info(
808 815 "Updated pull request %s from commit %s to commit %s, "
809 816 "stored new version %s of this pull request.",
810 817 pull_request.pull_request_id, source_ref_id,
811 818 pull_request.source_ref_parts.commit_id,
812 819 pull_request_version.pull_request_version_id)
813 820 Session().commit()
814 self._trigger_pull_request_hook(pull_request, pull_request.author, 'update')
821 self.trigger_pull_request_hook(pull_request, pull_request.author, 'update')
815 822
816 823 return UpdateResponse(
817 824 executed=True, reason=UpdateFailureReason.NONE,
818 825 old=pull_request, new=pull_request_version, changes=changes,
819 826 source_changed=source_changed, target_changed=target_changed)
820 827
821 828 def _create_version_from_snapshot(self, pull_request):
822 829 version = PullRequestVersion()
823 830 version.title = pull_request.title
824 831 version.description = pull_request.description
825 832 version.status = pull_request.status
826 833 version.pull_request_state = pull_request.pull_request_state
827 834 version.created_on = datetime.datetime.now()
828 835 version.updated_on = pull_request.updated_on
829 836 version.user_id = pull_request.user_id
830 837 version.source_repo = pull_request.source_repo
831 838 version.source_ref = pull_request.source_ref
832 839 version.target_repo = pull_request.target_repo
833 840 version.target_ref = pull_request.target_ref
834 841
835 842 version._last_merge_source_rev = pull_request._last_merge_source_rev
836 843 version._last_merge_target_rev = pull_request._last_merge_target_rev
837 844 version.last_merge_status = pull_request.last_merge_status
838 845 version.shadow_merge_ref = pull_request.shadow_merge_ref
839 846 version.merge_rev = pull_request.merge_rev
840 847 version.reviewer_data = pull_request.reviewer_data
841 848
842 849 version.revisions = pull_request.revisions
843 850 version.pull_request = pull_request
844 851 Session().add(version)
845 852 Session().flush()
846 853
847 854 return version
848 855
849 856 def _generate_update_diffs(self, pull_request, pull_request_version):
850 857
851 858 diff_context = (
852 859 self.DIFF_CONTEXT +
853 860 CommentsModel.needed_extra_diff_context())
854 861 hide_whitespace_changes = False
855 862 source_repo = pull_request_version.source_repo
856 863 source_ref_id = pull_request_version.source_ref_parts.commit_id
857 864 target_ref_id = pull_request_version.target_ref_parts.commit_id
858 865 old_diff = self._get_diff_from_pr_or_version(
859 866 source_repo, source_ref_id, target_ref_id,
860 867 hide_whitespace_changes=hide_whitespace_changes, diff_context=diff_context)
861 868
862 869 source_repo = pull_request.source_repo
863 870 source_ref_id = pull_request.source_ref_parts.commit_id
864 871 target_ref_id = pull_request.target_ref_parts.commit_id
865 872
866 873 new_diff = self._get_diff_from_pr_or_version(
867 874 source_repo, source_ref_id, target_ref_id,
868 875 hide_whitespace_changes=hide_whitespace_changes, diff_context=diff_context)
869 876
870 877 old_diff_data = diffs.DiffProcessor(old_diff)
871 878 old_diff_data.prepare()
872 879 new_diff_data = diffs.DiffProcessor(new_diff)
873 880 new_diff_data.prepare()
874 881
875 882 return old_diff_data, new_diff_data
876 883
877 884 def _link_comments_to_version(self, pull_request_version):
878 885 """
879 886 Link all unlinked comments of this pull request to the given version.
880 887
881 888 :param pull_request_version: The `PullRequestVersion` to which
882 889 the comments shall be linked.
883 890
884 891 """
885 892 pull_request = pull_request_version.pull_request
886 893 comments = ChangesetComment.query()\
887 894 .filter(
888 895 # TODO: johbo: Should we query for the repo at all here?
889 896 # Pending decision on how comments of PRs are to be related
890 897 # to either the source repo, the target repo or no repo at all.
891 898 ChangesetComment.repo_id == pull_request.target_repo.repo_id,
892 899 ChangesetComment.pull_request == pull_request,
893 900 ChangesetComment.pull_request_version == None)\
894 901 .order_by(ChangesetComment.comment_id.asc())
895 902
896 903 # TODO: johbo: Find out why this breaks if it is done in a bulk
897 904 # operation.
898 905 for comment in comments:
899 906 comment.pull_request_version_id = (
900 907 pull_request_version.pull_request_version_id)
901 908 Session().add(comment)
902 909
903 910 def _calculate_commit_id_changes(self, old_ids, new_ids):
904 911 added = [x for x in new_ids if x not in old_ids]
905 912 common = [x for x in new_ids if x in old_ids]
906 913 removed = [x for x in old_ids if x not in new_ids]
907 914 total = new_ids
908 915 return ChangeTuple(added, common, removed, total)
909 916
910 917 def _calculate_file_changes(self, old_diff_data, new_diff_data):
911 918
912 919 old_files = OrderedDict()
913 920 for diff_data in old_diff_data.parsed_diff:
914 921 old_files[diff_data['filename']] = md5_safe(diff_data['raw_diff'])
915 922
916 923 added_files = []
917 924 modified_files = []
918 925 removed_files = []
919 926 for diff_data in new_diff_data.parsed_diff:
920 927 new_filename = diff_data['filename']
921 928 new_hash = md5_safe(diff_data['raw_diff'])
922 929
923 930 old_hash = old_files.get(new_filename)
924 931 if not old_hash:
925 932 # file is not present in old diff, means it's added
926 933 added_files.append(new_filename)
927 934 else:
928 935 if new_hash != old_hash:
929 936 modified_files.append(new_filename)
930 937 # now remove a file from old, since we have seen it already
931 938 del old_files[new_filename]
932 939
933 940 # removed files is when there are present in old, but not in NEW,
934 941 # since we remove old files that are present in new diff, left-overs
935 942 # if any should be the removed files
936 943 removed_files.extend(old_files.keys())
937 944
938 945 return FileChangeTuple(added_files, modified_files, removed_files)
939 946
940 947 def _render_update_message(self, changes, file_changes):
941 948 """
942 949 render the message using DEFAULT_COMMENTS_RENDERER (RST renderer),
943 950 so it's always looking the same disregarding on which default
944 951 renderer system is using.
945 952
946 953 :param changes: changes named tuple
947 954 :param file_changes: file changes named tuple
948 955
949 956 """
950 957 new_status = ChangesetStatus.get_status_lbl(
951 958 ChangesetStatus.STATUS_UNDER_REVIEW)
952 959
953 960 changed_files = (
954 961 file_changes.added + file_changes.modified + file_changes.removed)
955 962
956 963 params = {
957 964 'under_review_label': new_status,
958 965 'added_commits': changes.added,
959 966 'removed_commits': changes.removed,
960 967 'changed_files': changed_files,
961 968 'added_files': file_changes.added,
962 969 'modified_files': file_changes.modified,
963 970 'removed_files': file_changes.removed,
964 971 }
965 972 renderer = RstTemplateRenderer()
966 973 return renderer.render('pull_request_update.mako', **params)
967 974
968 975 def edit(self, pull_request, title, description, description_renderer, user):
969 976 pull_request = self.__get_pull_request(pull_request)
970 977 old_data = pull_request.get_api_data(with_merge_state=False)
971 978 if pull_request.is_closed():
972 979 raise ValueError('This pull request is closed')
973 980 if title:
974 981 pull_request.title = title
975 982 pull_request.description = description
976 983 pull_request.updated_on = datetime.datetime.now()
977 984 pull_request.description_renderer = description_renderer
978 985 Session().add(pull_request)
979 986 self._log_audit_action(
980 987 'repo.pull_request.edit', {'old_data': old_data},
981 988 user, pull_request)
982 989
983 990 def update_reviewers(self, pull_request, reviewer_data, user):
984 991 """
985 992 Update the reviewers in the pull request
986 993
987 994 :param pull_request: the pr to update
988 995 :param reviewer_data: list of tuples
989 996 [(user, ['reason1', 'reason2'], mandatory_flag, [rules])]
990 997 """
991 998 pull_request = self.__get_pull_request(pull_request)
992 999 if pull_request.is_closed():
993 1000 raise ValueError('This pull request is closed')
994 1001
995 1002 reviewers = {}
996 1003 for user_id, reasons, mandatory, rules in reviewer_data:
997 1004 if isinstance(user_id, (int, basestring)):
998 1005 user_id = self._get_user(user_id).user_id
999 1006 reviewers[user_id] = {
1000 1007 'reasons': reasons, 'mandatory': mandatory}
1001 1008
1002 1009 reviewers_ids = set(reviewers.keys())
1003 1010 current_reviewers = PullRequestReviewers.query()\
1004 1011 .filter(PullRequestReviewers.pull_request ==
1005 1012 pull_request).all()
1006 1013 current_reviewers_ids = set([x.user.user_id for x in current_reviewers])
1007 1014
1008 1015 ids_to_add = reviewers_ids.difference(current_reviewers_ids)
1009 1016 ids_to_remove = current_reviewers_ids.difference(reviewers_ids)
1010 1017
1011 1018 log.debug("Adding %s reviewers", ids_to_add)
1012 1019 log.debug("Removing %s reviewers", ids_to_remove)
1013 1020 changed = False
1014 1021 for uid in ids_to_add:
1015 1022 changed = True
1016 1023 _usr = self._get_user(uid)
1017 1024 reviewer = PullRequestReviewers()
1018 1025 reviewer.user = _usr
1019 1026 reviewer.pull_request = pull_request
1020 1027 reviewer.reasons = reviewers[uid]['reasons']
1021 1028 # NOTE(marcink): mandatory shouldn't be changed now
1022 1029 # reviewer.mandatory = reviewers[uid]['reasons']
1023 1030 Session().add(reviewer)
1024 1031 self._log_audit_action(
1025 1032 'repo.pull_request.reviewer.add', {'data': reviewer.get_dict()},
1026 1033 user, pull_request)
1027 1034
1028 1035 for uid in ids_to_remove:
1029 1036 changed = True
1030 1037 reviewers = PullRequestReviewers.query()\
1031 1038 .filter(PullRequestReviewers.user_id == uid,
1032 1039 PullRequestReviewers.pull_request == pull_request)\
1033 1040 .all()
1034 1041 # use .all() in case we accidentally added the same person twice
1035 1042 # this CAN happen due to the lack of DB checks
1036 1043 for obj in reviewers:
1037 1044 old_data = obj.get_dict()
1038 1045 Session().delete(obj)
1039 1046 self._log_audit_action(
1040 1047 'repo.pull_request.reviewer.delete',
1041 1048 {'old_data': old_data}, user, pull_request)
1042 1049
1043 1050 if changed:
1044 1051 pull_request.updated_on = datetime.datetime.now()
1045 1052 Session().add(pull_request)
1046 1053
1047 1054 self.notify_reviewers(pull_request, ids_to_add)
1048 1055 return ids_to_add, ids_to_remove
1049 1056
1050 1057 def get_url(self, pull_request, request=None, permalink=False):
1051 1058 if not request:
1052 1059 request = get_current_request()
1053 1060
1054 1061 if permalink:
1055 1062 return request.route_url(
1056 1063 'pull_requests_global',
1057 1064 pull_request_id=pull_request.pull_request_id,)
1058 1065 else:
1059 1066 return request.route_url('pullrequest_show',
1060 1067 repo_name=safe_str(pull_request.target_repo.repo_name),
1061 1068 pull_request_id=pull_request.pull_request_id,)
1062 1069
1063 1070 def get_shadow_clone_url(self, pull_request, request=None):
1064 1071 """
1065 1072 Returns qualified url pointing to the shadow repository. If this pull
1066 1073 request is closed there is no shadow repository and ``None`` will be
1067 1074 returned.
1068 1075 """
1069 1076 if pull_request.is_closed():
1070 1077 return None
1071 1078 else:
1072 1079 pr_url = urllib.unquote(self.get_url(pull_request, request=request))
1073 1080 return safe_unicode('{pr_url}/repository'.format(pr_url=pr_url))
1074 1081
1075 1082 def notify_reviewers(self, pull_request, reviewers_ids):
1076 1083 # notification to reviewers
1077 1084 if not reviewers_ids:
1078 1085 return
1079 1086
1080 1087 pull_request_obj = pull_request
1081 1088 # get the current participants of this pull request
1082 1089 recipients = reviewers_ids
1083 1090 notification_type = EmailNotificationModel.TYPE_PULL_REQUEST
1084 1091
1085 1092 pr_source_repo = pull_request_obj.source_repo
1086 1093 pr_target_repo = pull_request_obj.target_repo
1087 1094
1088 1095 pr_url = h.route_url('pullrequest_show',
1089 1096 repo_name=pr_target_repo.repo_name,
1090 1097 pull_request_id=pull_request_obj.pull_request_id,)
1091 1098
1092 1099 # set some variables for email notification
1093 1100 pr_target_repo_url = h.route_url(
1094 1101 'repo_summary', repo_name=pr_target_repo.repo_name)
1095 1102
1096 1103 pr_source_repo_url = h.route_url(
1097 1104 'repo_summary', repo_name=pr_source_repo.repo_name)
1098 1105
1099 1106 # pull request specifics
1100 1107 pull_request_commits = [
1101 1108 (x.raw_id, x.message)
1102 1109 for x in map(pr_source_repo.get_commit, pull_request.revisions)]
1103 1110
1104 1111 kwargs = {
1105 1112 'user': pull_request.author,
1106 1113 'pull_request': pull_request_obj,
1107 1114 'pull_request_commits': pull_request_commits,
1108 1115
1109 1116 'pull_request_target_repo': pr_target_repo,
1110 1117 'pull_request_target_repo_url': pr_target_repo_url,
1111 1118
1112 1119 'pull_request_source_repo': pr_source_repo,
1113 1120 'pull_request_source_repo_url': pr_source_repo_url,
1114 1121
1115 1122 'pull_request_url': pr_url,
1116 1123 }
1117 1124
1118 1125 # pre-generate the subject for notification itself
1119 1126 (subject,
1120 1127 _h, _e, # we don't care about those
1121 1128 body_plaintext) = EmailNotificationModel().render_email(
1122 1129 notification_type, **kwargs)
1123 1130
1124 1131 # create notification objects, and emails
1125 1132 NotificationModel().create(
1126 1133 created_by=pull_request.author,
1127 1134 notification_subject=subject,
1128 1135 notification_body=body_plaintext,
1129 1136 notification_type=notification_type,
1130 1137 recipients=recipients,
1131 1138 email_kwargs=kwargs,
1132 1139 )
1133 1140
1134 1141 def delete(self, pull_request, user):
1135 1142 pull_request = self.__get_pull_request(pull_request)
1136 1143 old_data = pull_request.get_api_data(with_merge_state=False)
1137 1144 self._cleanup_merge_workspace(pull_request)
1138 1145 self._log_audit_action(
1139 1146 'repo.pull_request.delete', {'old_data': old_data},
1140 1147 user, pull_request)
1141 1148 Session().delete(pull_request)
1142 1149
1143 1150 def close_pull_request(self, pull_request, user):
1144 1151 pull_request = self.__get_pull_request(pull_request)
1145 1152 self._cleanup_merge_workspace(pull_request)
1146 1153 pull_request.status = PullRequest.STATUS_CLOSED
1147 1154 pull_request.updated_on = datetime.datetime.now()
1148 1155 Session().add(pull_request)
1149 self._trigger_pull_request_hook(
1156 self.trigger_pull_request_hook(
1150 1157 pull_request, pull_request.author, 'close')
1151 1158
1152 1159 pr_data = pull_request.get_api_data(with_merge_state=False)
1153 1160 self._log_audit_action(
1154 1161 'repo.pull_request.close', {'data': pr_data}, user, pull_request)
1155 1162
1156 1163 def close_pull_request_with_comment(
1157 1164 self, pull_request, user, repo, message=None, auth_user=None):
1158 1165
1159 1166 pull_request_review_status = pull_request.calculated_review_status()
1160 1167
1161 1168 if pull_request_review_status == ChangesetStatus.STATUS_APPROVED:
1162 1169 # approved only if we have voting consent
1163 1170 status = ChangesetStatus.STATUS_APPROVED
1164 1171 else:
1165 1172 status = ChangesetStatus.STATUS_REJECTED
1166 1173 status_lbl = ChangesetStatus.get_status_lbl(status)
1167 1174
1168 1175 default_message = (
1169 1176 'Closing with status change {transition_icon} {status}.'
1170 1177 ).format(transition_icon='>', status=status_lbl)
1171 1178 text = message or default_message
1172 1179
1173 1180 # create a comment, and link it to new status
1174 1181 comment = CommentsModel().create(
1175 1182 text=text,
1176 1183 repo=repo.repo_id,
1177 1184 user=user.user_id,
1178 1185 pull_request=pull_request.pull_request_id,
1179 1186 status_change=status_lbl,
1180 1187 status_change_type=status,
1181 1188 closing_pr=True,
1182 1189 auth_user=auth_user,
1183 1190 )
1184 1191
1185 1192 # calculate old status before we change it
1186 1193 old_calculated_status = pull_request.calculated_review_status()
1187 1194 ChangesetStatusModel().set_status(
1188 1195 repo.repo_id,
1189 1196 status,
1190 1197 user.user_id,
1191 1198 comment=comment,
1192 1199 pull_request=pull_request.pull_request_id
1193 1200 )
1194 1201
1195 1202 Session().flush()
1196 1203 events.trigger(events.PullRequestCommentEvent(pull_request, comment))
1197 1204 # we now calculate the status of pull request again, and based on that
1198 1205 # calculation trigger status change. This might happen in cases
1199 1206 # that non-reviewer admin closes a pr, which means his vote doesn't
1200 1207 # change the status, while if he's a reviewer this might change it.
1201 1208 calculated_status = pull_request.calculated_review_status()
1202 1209 if old_calculated_status != calculated_status:
1203 self._trigger_pull_request_hook(
1204 pull_request, user, 'review_status_change')
1210 self.trigger_pull_request_hook(
1211 pull_request, user, 'review_status_change',
1212 data={'status': calculated_status})
1205 1213
1206 1214 # finally close the PR
1207 1215 PullRequestModel().close_pull_request(
1208 1216 pull_request.pull_request_id, user)
1209 1217
1210 1218 return comment, status
1211 1219
1212 1220 def merge_status(self, pull_request, translator=None,
1213 1221 force_shadow_repo_refresh=False):
1214 1222 _ = translator or get_current_request().translate
1215 1223
1216 1224 if not self._is_merge_enabled(pull_request):
1217 1225 return False, _('Server-side pull request merging is disabled.')
1218 1226 if pull_request.is_closed():
1219 1227 return False, _('This pull request is closed.')
1220 1228 merge_possible, msg = self._check_repo_requirements(
1221 1229 target=pull_request.target_repo, source=pull_request.source_repo,
1222 1230 translator=_)
1223 1231 if not merge_possible:
1224 1232 return merge_possible, msg
1225 1233
1226 1234 try:
1227 1235 resp = self._try_merge(
1228 1236 pull_request,
1229 1237 force_shadow_repo_refresh=force_shadow_repo_refresh)
1230 1238 log.debug("Merge response: %s", resp)
1231 1239 status = resp.possible, resp.merge_status_message
1232 1240 except NotImplementedError:
1233 1241 status = False, _('Pull request merging is not supported.')
1234 1242
1235 1243 return status
1236 1244
1237 1245 def _check_repo_requirements(self, target, source, translator):
1238 1246 """
1239 1247 Check if `target` and `source` have compatible requirements.
1240 1248
1241 1249 Currently this is just checking for largefiles.
1242 1250 """
1243 1251 _ = translator
1244 1252 target_has_largefiles = self._has_largefiles(target)
1245 1253 source_has_largefiles = self._has_largefiles(source)
1246 1254 merge_possible = True
1247 1255 message = u''
1248 1256
1249 1257 if target_has_largefiles != source_has_largefiles:
1250 1258 merge_possible = False
1251 1259 if source_has_largefiles:
1252 1260 message = _(
1253 1261 'Target repository large files support is disabled.')
1254 1262 else:
1255 1263 message = _(
1256 1264 'Source repository large files support is disabled.')
1257 1265
1258 1266 return merge_possible, message
1259 1267
1260 1268 def _has_largefiles(self, repo):
1261 1269 largefiles_ui = VcsSettingsModel(repo=repo).get_ui_settings(
1262 1270 'extensions', 'largefiles')
1263 1271 return largefiles_ui and largefiles_ui[0].active
1264 1272
1265 1273 def _try_merge(self, pull_request, force_shadow_repo_refresh=False):
1266 1274 """
1267 1275 Try to merge the pull request and return the merge status.
1268 1276 """
1269 1277 log.debug(
1270 1278 "Trying out if the pull request %s can be merged. Force_refresh=%s",
1271 1279 pull_request.pull_request_id, force_shadow_repo_refresh)
1272 1280 target_vcs = pull_request.target_repo.scm_instance()
1273 1281 # Refresh the target reference.
1274 1282 try:
1275 1283 target_ref = self._refresh_reference(
1276 1284 pull_request.target_ref_parts, target_vcs)
1277 1285 except CommitDoesNotExistError:
1278 1286 merge_state = MergeResponse(
1279 1287 False, False, None, MergeFailureReason.MISSING_TARGET_REF,
1280 1288 metadata={'target_ref': pull_request.target_ref_parts})
1281 1289 return merge_state
1282 1290
1283 1291 target_locked = pull_request.target_repo.locked
1284 1292 if target_locked and target_locked[0]:
1285 1293 locked_by = 'user:{}'.format(target_locked[0])
1286 1294 log.debug("The target repository is locked by %s.", locked_by)
1287 1295 merge_state = MergeResponse(
1288 1296 False, False, None, MergeFailureReason.TARGET_IS_LOCKED,
1289 1297 metadata={'locked_by': locked_by})
1290 1298 elif force_shadow_repo_refresh or self._needs_merge_state_refresh(
1291 1299 pull_request, target_ref):
1292 1300 log.debug("Refreshing the merge status of the repository.")
1293 1301 merge_state = self._refresh_merge_state(
1294 1302 pull_request, target_vcs, target_ref)
1295 1303 else:
1296 1304 possible = pull_request.\
1297 1305 last_merge_status == MergeFailureReason.NONE
1298 1306 merge_state = MergeResponse(
1299 1307 possible, False, None, pull_request.last_merge_status)
1300 1308
1301 1309 return merge_state
1302 1310
1303 1311 def _refresh_reference(self, reference, vcs_repository):
1304 1312 if reference.type in self.UPDATABLE_REF_TYPES:
1305 1313 name_or_id = reference.name
1306 1314 else:
1307 1315 name_or_id = reference.commit_id
1308 1316 refreshed_commit = vcs_repository.get_commit(name_or_id)
1309 1317 refreshed_reference = Reference(
1310 1318 reference.type, reference.name, refreshed_commit.raw_id)
1311 1319 return refreshed_reference
1312 1320
1313 1321 def _needs_merge_state_refresh(self, pull_request, target_reference):
1314 1322 return not(
1315 1323 pull_request.revisions and
1316 1324 pull_request.revisions[0] == pull_request._last_merge_source_rev and
1317 1325 target_reference.commit_id == pull_request._last_merge_target_rev)
1318 1326
1319 1327 def _refresh_merge_state(self, pull_request, target_vcs, target_reference):
1320 1328 workspace_id = self._workspace_id(pull_request)
1321 1329 source_vcs = pull_request.source_repo.scm_instance()
1322 1330 repo_id = pull_request.target_repo.repo_id
1323 1331 use_rebase = self._use_rebase_for_merging(pull_request)
1324 1332 close_branch = self._close_branch_before_merging(pull_request)
1325 1333 merge_state = target_vcs.merge(
1326 1334 repo_id, workspace_id,
1327 1335 target_reference, source_vcs, pull_request.source_ref_parts,
1328 1336 dry_run=True, use_rebase=use_rebase,
1329 1337 close_branch=close_branch)
1330 1338
1331 1339 # Do not store the response if there was an unknown error.
1332 1340 if merge_state.failure_reason != MergeFailureReason.UNKNOWN:
1333 1341 pull_request._last_merge_source_rev = \
1334 1342 pull_request.source_ref_parts.commit_id
1335 1343 pull_request._last_merge_target_rev = target_reference.commit_id
1336 1344 pull_request.last_merge_status = merge_state.failure_reason
1337 1345 pull_request.shadow_merge_ref = merge_state.merge_ref
1338 1346 Session().add(pull_request)
1339 1347 Session().commit()
1340 1348
1341 1349 return merge_state
1342 1350
1343 1351 def _workspace_id(self, pull_request):
1344 1352 workspace_id = 'pr-%s' % pull_request.pull_request_id
1345 1353 return workspace_id
1346 1354
1347 1355 def generate_repo_data(self, repo, commit_id=None, branch=None,
1348 1356 bookmark=None, translator=None):
1349 1357 from rhodecode.model.repo import RepoModel
1350 1358
1351 1359 all_refs, selected_ref = \
1352 1360 self._get_repo_pullrequest_sources(
1353 1361 repo.scm_instance(), commit_id=commit_id,
1354 1362 branch=branch, bookmark=bookmark, translator=translator)
1355 1363
1356 1364 refs_select2 = []
1357 1365 for element in all_refs:
1358 1366 children = [{'id': x[0], 'text': x[1]} for x in element[0]]
1359 1367 refs_select2.append({'text': element[1], 'children': children})
1360 1368
1361 1369 return {
1362 1370 'user': {
1363 1371 'user_id': repo.user.user_id,
1364 1372 'username': repo.user.username,
1365 1373 'firstname': repo.user.first_name,
1366 1374 'lastname': repo.user.last_name,
1367 1375 'gravatar_link': h.gravatar_url(repo.user.email, 14),
1368 1376 },
1369 1377 'name': repo.repo_name,
1370 1378 'link': RepoModel().get_url(repo),
1371 1379 'description': h.chop_at_smart(repo.description_safe, '\n'),
1372 1380 'refs': {
1373 1381 'all_refs': all_refs,
1374 1382 'selected_ref': selected_ref,
1375 1383 'select2_refs': refs_select2
1376 1384 }
1377 1385 }
1378 1386
1379 1387 def generate_pullrequest_title(self, source, source_ref, target):
1380 1388 return u'{source}#{at_ref} to {target}'.format(
1381 1389 source=source,
1382 1390 at_ref=source_ref,
1383 1391 target=target,
1384 1392 )
1385 1393
1386 1394 def _cleanup_merge_workspace(self, pull_request):
1387 1395 # Merging related cleanup
1388 1396 repo_id = pull_request.target_repo.repo_id
1389 1397 target_scm = pull_request.target_repo.scm_instance()
1390 1398 workspace_id = self._workspace_id(pull_request)
1391 1399
1392 1400 try:
1393 1401 target_scm.cleanup_merge_workspace(repo_id, workspace_id)
1394 1402 except NotImplementedError:
1395 1403 pass
1396 1404
1397 1405 def _get_repo_pullrequest_sources(
1398 1406 self, repo, commit_id=None, branch=None, bookmark=None,
1399 1407 translator=None):
1400 1408 """
1401 1409 Return a structure with repo's interesting commits, suitable for
1402 1410 the selectors in pullrequest controller
1403 1411
1404 1412 :param commit_id: a commit that must be in the list somehow
1405 1413 and selected by default
1406 1414 :param branch: a branch that must be in the list and selected
1407 1415 by default - even if closed
1408 1416 :param bookmark: a bookmark that must be in the list and selected
1409 1417 """
1410 1418 _ = translator or get_current_request().translate
1411 1419
1412 1420 commit_id = safe_str(commit_id) if commit_id else None
1413 1421 branch = safe_str(branch) if branch else None
1414 1422 bookmark = safe_str(bookmark) if bookmark else None
1415 1423
1416 1424 selected = None
1417 1425
1418 1426 # order matters: first source that has commit_id in it will be selected
1419 1427 sources = []
1420 1428 sources.append(('book', repo.bookmarks.items(), _('Bookmarks'), bookmark))
1421 1429 sources.append(('branch', repo.branches.items(), _('Branches'), branch))
1422 1430
1423 1431 if commit_id:
1424 1432 ref_commit = (h.short_id(commit_id), commit_id)
1425 1433 sources.append(('rev', [ref_commit], _('Commit IDs'), commit_id))
1426 1434
1427 1435 sources.append(
1428 1436 ('branch', repo.branches_closed.items(), _('Closed Branches'), branch),
1429 1437 )
1430 1438
1431 1439 groups = []
1432 1440 for group_key, ref_list, group_name, match in sources:
1433 1441 group_refs = []
1434 1442 for ref_name, ref_id in ref_list:
1435 1443 ref_key = '%s:%s:%s' % (group_key, ref_name, ref_id)
1436 1444 group_refs.append((ref_key, ref_name))
1437 1445
1438 1446 if not selected:
1439 1447 if set([commit_id, match]) & set([ref_id, ref_name]):
1440 1448 selected = ref_key
1441 1449
1442 1450 if group_refs:
1443 1451 groups.append((group_refs, group_name))
1444 1452
1445 1453 if not selected:
1446 1454 ref = commit_id or branch or bookmark
1447 1455 if ref:
1448 1456 raise CommitDoesNotExistError(
1449 1457 'No commit refs could be found matching: %s' % ref)
1450 1458 elif repo.DEFAULT_BRANCH_NAME in repo.branches:
1451 1459 selected = 'branch:%s:%s' % (
1452 1460 repo.DEFAULT_BRANCH_NAME,
1453 1461 repo.branches[repo.DEFAULT_BRANCH_NAME]
1454 1462 )
1455 1463 elif repo.commit_ids:
1456 1464 # make the user select in this case
1457 1465 selected = None
1458 1466 else:
1459 1467 raise EmptyRepositoryError()
1460 1468 return groups, selected
1461 1469
1462 1470 def get_diff(self, source_repo, source_ref_id, target_ref_id,
1463 1471 hide_whitespace_changes, diff_context):
1464 1472
1465 1473 return self._get_diff_from_pr_or_version(
1466 1474 source_repo, source_ref_id, target_ref_id,
1467 1475 hide_whitespace_changes=hide_whitespace_changes, diff_context=diff_context)
1468 1476
1469 1477 def _get_diff_from_pr_or_version(
1470 1478 self, source_repo, source_ref_id, target_ref_id,
1471 1479 hide_whitespace_changes, diff_context):
1472 1480
1473 1481 target_commit = source_repo.get_commit(
1474 1482 commit_id=safe_str(target_ref_id))
1475 1483 source_commit = source_repo.get_commit(
1476 1484 commit_id=safe_str(source_ref_id))
1477 1485 if isinstance(source_repo, Repository):
1478 1486 vcs_repo = source_repo.scm_instance()
1479 1487 else:
1480 1488 vcs_repo = source_repo
1481 1489
1482 1490 # TODO: johbo: In the context of an update, we cannot reach
1483 1491 # the old commit anymore with our normal mechanisms. It needs
1484 1492 # some sort of special support in the vcs layer to avoid this
1485 1493 # workaround.
1486 1494 if (source_commit.raw_id == vcs_repo.EMPTY_COMMIT_ID and
1487 1495 vcs_repo.alias == 'git'):
1488 1496 source_commit.raw_id = safe_str(source_ref_id)
1489 1497
1490 1498 log.debug('calculating diff between '
1491 1499 'source_ref:%s and target_ref:%s for repo `%s`',
1492 1500 target_ref_id, source_ref_id,
1493 1501 safe_unicode(vcs_repo.path))
1494 1502
1495 1503 vcs_diff = vcs_repo.get_diff(
1496 1504 commit1=target_commit, commit2=source_commit,
1497 1505 ignore_whitespace=hide_whitespace_changes, context=diff_context)
1498 1506 return vcs_diff
1499 1507
1500 1508 def _is_merge_enabled(self, pull_request):
1501 1509 return self._get_general_setting(
1502 1510 pull_request, 'rhodecode_pr_merge_enabled')
1503 1511
1504 1512 def _use_rebase_for_merging(self, pull_request):
1505 1513 repo_type = pull_request.target_repo.repo_type
1506 1514 if repo_type == 'hg':
1507 1515 return self._get_general_setting(
1508 1516 pull_request, 'rhodecode_hg_use_rebase_for_merging')
1509 1517 elif repo_type == 'git':
1510 1518 return self._get_general_setting(
1511 1519 pull_request, 'rhodecode_git_use_rebase_for_merging')
1512 1520
1513 1521 return False
1514 1522
1515 1523 def _close_branch_before_merging(self, pull_request):
1516 1524 repo_type = pull_request.target_repo.repo_type
1517 1525 if repo_type == 'hg':
1518 1526 return self._get_general_setting(
1519 1527 pull_request, 'rhodecode_hg_close_branch_before_merging')
1520 1528 elif repo_type == 'git':
1521 1529 return self._get_general_setting(
1522 1530 pull_request, 'rhodecode_git_close_branch_before_merging')
1523 1531
1524 1532 return False
1525 1533
1526 1534 def _get_general_setting(self, pull_request, settings_key, default=False):
1527 1535 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
1528 1536 settings = settings_model.get_general_settings()
1529 1537 return settings.get(settings_key, default)
1530 1538
1531 1539 def _log_audit_action(self, action, action_data, user, pull_request):
1532 1540 audit_logger.store(
1533 1541 action=action,
1534 1542 action_data=action_data,
1535 1543 user=user,
1536 1544 repo=pull_request.target_repo)
1537 1545
1538 1546 def get_reviewer_functions(self):
1539 1547 """
1540 1548 Fetches functions for validation and fetching default reviewers.
1541 1549 If available we use the EE package, else we fallback to CE
1542 1550 package functions
1543 1551 """
1544 1552 try:
1545 1553 from rc_reviewers.utils import get_default_reviewers_data
1546 1554 from rc_reviewers.utils import validate_default_reviewers
1547 1555 except ImportError:
1548 1556 from rhodecode.apps.repository.utils import get_default_reviewers_data
1549 1557 from rhodecode.apps.repository.utils import validate_default_reviewers
1550 1558
1551 1559 return get_default_reviewers_data, validate_default_reviewers
1552 1560
1553 1561
1554 1562 class MergeCheck(object):
1555 1563 """
1556 1564 Perform Merge Checks and returns a check object which stores information
1557 1565 about merge errors, and merge conditions
1558 1566 """
1559 1567 TODO_CHECK = 'todo'
1560 1568 PERM_CHECK = 'perm'
1561 1569 REVIEW_CHECK = 'review'
1562 1570 MERGE_CHECK = 'merge'
1563 1571
1564 1572 def __init__(self):
1565 1573 self.review_status = None
1566 1574 self.merge_possible = None
1567 1575 self.merge_msg = ''
1568 1576 self.failed = None
1569 1577 self.errors = []
1570 1578 self.error_details = OrderedDict()
1571 1579
1572 1580 def push_error(self, error_type, message, error_key, details):
1573 1581 self.failed = True
1574 1582 self.errors.append([error_type, message])
1575 1583 self.error_details[error_key] = dict(
1576 1584 details=details,
1577 1585 error_type=error_type,
1578 1586 message=message
1579 1587 )
1580 1588
1581 1589 @classmethod
1582 1590 def validate(cls, pull_request, auth_user, translator, fail_early=False,
1583 1591 force_shadow_repo_refresh=False):
1584 1592 _ = translator
1585 1593 merge_check = cls()
1586 1594
1587 1595 # permissions to merge
1588 1596 user_allowed_to_merge = PullRequestModel().check_user_merge(
1589 1597 pull_request, auth_user)
1590 1598 if not user_allowed_to_merge:
1591 1599 log.debug("MergeCheck: cannot merge, approval is pending.")
1592 1600
1593 1601 msg = _('User `{}` not allowed to perform merge.').format(auth_user.username)
1594 1602 merge_check.push_error('error', msg, cls.PERM_CHECK, auth_user.username)
1595 1603 if fail_early:
1596 1604 return merge_check
1597 1605
1598 1606 # permission to merge into the target branch
1599 1607 target_commit_id = pull_request.target_ref_parts.commit_id
1600 1608 if pull_request.target_ref_parts.type == 'branch':
1601 1609 branch_name = pull_request.target_ref_parts.name
1602 1610 else:
1603 1611 # for mercurial we can always figure out the branch from the commit
1604 1612 # in case of bookmark
1605 1613 target_commit = pull_request.target_repo.get_commit(target_commit_id)
1606 1614 branch_name = target_commit.branch
1607 1615
1608 1616 rule, branch_perm = auth_user.get_rule_and_branch_permission(
1609 1617 pull_request.target_repo.repo_name, branch_name)
1610 1618 if branch_perm and branch_perm == 'branch.none':
1611 1619 msg = _('Target branch `{}` changes rejected by rule {}.').format(
1612 1620 branch_name, rule)
1613 1621 merge_check.push_error('error', msg, cls.PERM_CHECK, auth_user.username)
1614 1622 if fail_early:
1615 1623 return merge_check
1616 1624
1617 1625 # review status, must be always present
1618 1626 review_status = pull_request.calculated_review_status()
1619 1627 merge_check.review_status = review_status
1620 1628
1621 1629 status_approved = review_status == ChangesetStatus.STATUS_APPROVED
1622 1630 if not status_approved:
1623 1631 log.debug("MergeCheck: cannot merge, approval is pending.")
1624 1632
1625 1633 msg = _('Pull request reviewer approval is pending.')
1626 1634
1627 1635 merge_check.push_error('warning', msg, cls.REVIEW_CHECK, review_status)
1628 1636
1629 1637 if fail_early:
1630 1638 return merge_check
1631 1639
1632 1640 # left over TODOs
1633 1641 todos = CommentsModel().get_unresolved_todos(pull_request)
1634 1642 if todos:
1635 1643 log.debug("MergeCheck: cannot merge, {} "
1636 1644 "unresolved TODOs left.".format(len(todos)))
1637 1645
1638 1646 if len(todos) == 1:
1639 1647 msg = _('Cannot merge, {} TODO still not resolved.').format(
1640 1648 len(todos))
1641 1649 else:
1642 1650 msg = _('Cannot merge, {} TODOs still not resolved.').format(
1643 1651 len(todos))
1644 1652
1645 1653 merge_check.push_error('warning', msg, cls.TODO_CHECK, todos)
1646 1654
1647 1655 if fail_early:
1648 1656 return merge_check
1649 1657
1650 1658 # merge possible, here is the filesystem simulation + shadow repo
1651 1659 merge_status, msg = PullRequestModel().merge_status(
1652 1660 pull_request, translator=translator,
1653 1661 force_shadow_repo_refresh=force_shadow_repo_refresh)
1654 1662 merge_check.merge_possible = merge_status
1655 1663 merge_check.merge_msg = msg
1656 1664 if not merge_status:
1657 1665 log.debug("MergeCheck: cannot merge, pull request merge not possible.")
1658 1666 merge_check.push_error('warning', msg, cls.MERGE_CHECK, None)
1659 1667
1660 1668 if fail_early:
1661 1669 return merge_check
1662 1670
1663 1671 log.debug('MergeCheck: is failed: %s', merge_check.failed)
1664 1672 return merge_check
1665 1673
1666 1674 @classmethod
1667 1675 def get_merge_conditions(cls, pull_request, translator):
1668 1676 _ = translator
1669 1677 merge_details = {}
1670 1678
1671 1679 model = PullRequestModel()
1672 1680 use_rebase = model._use_rebase_for_merging(pull_request)
1673 1681
1674 1682 if use_rebase:
1675 1683 merge_details['merge_strategy'] = dict(
1676 1684 details={},
1677 1685 message=_('Merge strategy: rebase')
1678 1686 )
1679 1687 else:
1680 1688 merge_details['merge_strategy'] = dict(
1681 1689 details={},
1682 1690 message=_('Merge strategy: explicit merge commit')
1683 1691 )
1684 1692
1685 1693 close_branch = model._close_branch_before_merging(pull_request)
1686 1694 if close_branch:
1687 1695 repo_type = pull_request.target_repo.repo_type
1688 1696 close_msg = ''
1689 1697 if repo_type == 'hg':
1690 1698 close_msg = _('Source branch will be closed after merge.')
1691 1699 elif repo_type == 'git':
1692 1700 close_msg = _('Source branch will be deleted after merge.')
1693 1701
1694 1702 merge_details['close_branch'] = dict(
1695 1703 details={},
1696 1704 message=close_msg
1697 1705 )
1698 1706
1699 1707 return merge_details
1700 1708
1701 1709
1702 1710 ChangeTuple = collections.namedtuple(
1703 1711 'ChangeTuple', ['added', 'common', 'removed', 'total'])
1704 1712
1705 1713 FileChangeTuple = collections.namedtuple(
1706 1714 'FileChangeTuple', ['added', 'modified', 'removed'])
@@ -1,949 +1,949 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2019 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 import mock
22 22 import pytest
23 23 import textwrap
24 24
25 25 import rhodecode
26 26 from rhodecode.lib.utils2 import safe_unicode
27 27 from rhodecode.lib.vcs.backends import get_backend
28 28 from rhodecode.lib.vcs.backends.base import (
29 29 MergeResponse, MergeFailureReason, Reference)
30 30 from rhodecode.lib.vcs.exceptions import RepositoryError
31 31 from rhodecode.lib.vcs.nodes import FileNode
32 32 from rhodecode.model.comment import CommentsModel
33 33 from rhodecode.model.db import PullRequest, Session
34 34 from rhodecode.model.pull_request import PullRequestModel
35 35 from rhodecode.model.user import UserModel
36 36 from rhodecode.tests import TEST_USER_ADMIN_LOGIN
37 37
38 38
39 39 pytestmark = [
40 40 pytest.mark.backends("git", "hg"),
41 41 ]
42 42
43 43
44 44 @pytest.mark.usefixtures('config_stub')
45 45 class TestPullRequestModel(object):
46 46
47 47 @pytest.fixture
48 48 def pull_request(self, request, backend, pr_util):
49 49 """
50 50 A pull request combined with multiples patches.
51 51 """
52 52 BackendClass = get_backend(backend.alias)
53 53 merge_resp = MergeResponse(
54 54 False, False, None, MergeFailureReason.UNKNOWN,
55 55 metadata={'exception': 'MockError'})
56 56 self.merge_patcher = mock.patch.object(
57 57 BackendClass, 'merge', return_value=merge_resp)
58 58 self.workspace_remove_patcher = mock.patch.object(
59 59 BackendClass, 'cleanup_merge_workspace')
60 60
61 61 self.workspace_remove_mock = self.workspace_remove_patcher.start()
62 62 self.merge_mock = self.merge_patcher.start()
63 63 self.comment_patcher = mock.patch(
64 64 'rhodecode.model.changeset_status.ChangesetStatusModel.set_status')
65 65 self.comment_patcher.start()
66 66 self.notification_patcher = mock.patch(
67 67 'rhodecode.model.notification.NotificationModel.create')
68 68 self.notification_patcher.start()
69 69 self.helper_patcher = mock.patch(
70 70 'rhodecode.lib.helpers.route_path')
71 71 self.helper_patcher.start()
72 72
73 73 self.hook_patcher = mock.patch.object(PullRequestModel,
74 '_trigger_pull_request_hook')
74 'trigger_pull_request_hook')
75 75 self.hook_mock = self.hook_patcher.start()
76 76
77 77 self.invalidation_patcher = mock.patch(
78 78 'rhodecode.model.pull_request.ScmModel.mark_for_invalidation')
79 79 self.invalidation_mock = self.invalidation_patcher.start()
80 80
81 81 self.pull_request = pr_util.create_pull_request(
82 82 mergeable=True, name_suffix=u'Δ…Δ‡')
83 83 self.source_commit = self.pull_request.source_ref_parts.commit_id
84 84 self.target_commit = self.pull_request.target_ref_parts.commit_id
85 85 self.workspace_id = 'pr-%s' % self.pull_request.pull_request_id
86 86 self.repo_id = self.pull_request.target_repo.repo_id
87 87
88 88 @request.addfinalizer
89 89 def cleanup_pull_request():
90 90 calls = [mock.call(
91 91 self.pull_request, self.pull_request.author, 'create')]
92 92 self.hook_mock.assert_has_calls(calls)
93 93
94 94 self.workspace_remove_patcher.stop()
95 95 self.merge_patcher.stop()
96 96 self.comment_patcher.stop()
97 97 self.notification_patcher.stop()
98 98 self.helper_patcher.stop()
99 99 self.hook_patcher.stop()
100 100 self.invalidation_patcher.stop()
101 101
102 102 return self.pull_request
103 103
104 104 def test_get_all(self, pull_request):
105 105 prs = PullRequestModel().get_all(pull_request.target_repo)
106 106 assert isinstance(prs, list)
107 107 assert len(prs) == 1
108 108
109 109 def test_count_all(self, pull_request):
110 110 pr_count = PullRequestModel().count_all(pull_request.target_repo)
111 111 assert pr_count == 1
112 112
113 113 def test_get_awaiting_review(self, pull_request):
114 114 prs = PullRequestModel().get_awaiting_review(pull_request.target_repo)
115 115 assert isinstance(prs, list)
116 116 assert len(prs) == 1
117 117
118 118 def test_count_awaiting_review(self, pull_request):
119 119 pr_count = PullRequestModel().count_awaiting_review(
120 120 pull_request.target_repo)
121 121 assert pr_count == 1
122 122
123 123 def test_get_awaiting_my_review(self, pull_request):
124 124 PullRequestModel().update_reviewers(
125 125 pull_request, [(pull_request.author, ['author'], False, [])],
126 126 pull_request.author)
127 127 prs = PullRequestModel().get_awaiting_my_review(
128 128 pull_request.target_repo, user_id=pull_request.author.user_id)
129 129 assert isinstance(prs, list)
130 130 assert len(prs) == 1
131 131
132 132 def test_count_awaiting_my_review(self, pull_request):
133 133 PullRequestModel().update_reviewers(
134 134 pull_request, [(pull_request.author, ['author'], False, [])],
135 135 pull_request.author)
136 136 pr_count = PullRequestModel().count_awaiting_my_review(
137 137 pull_request.target_repo, user_id=pull_request.author.user_id)
138 138 assert pr_count == 1
139 139
140 140 def test_delete_calls_cleanup_merge(self, pull_request):
141 141 repo_id = pull_request.target_repo.repo_id
142 142 PullRequestModel().delete(pull_request, pull_request.author)
143 143
144 144 self.workspace_remove_mock.assert_called_once_with(
145 145 repo_id, self.workspace_id)
146 146
147 147 def test_close_calls_cleanup_and_hook(self, pull_request):
148 148 PullRequestModel().close_pull_request(
149 149 pull_request, pull_request.author)
150 150 repo_id = pull_request.target_repo.repo_id
151 151
152 152 self.workspace_remove_mock.assert_called_once_with(
153 153 repo_id, self.workspace_id)
154 154 self.hook_mock.assert_called_with(
155 155 self.pull_request, self.pull_request.author, 'close')
156 156
157 157 def test_merge_status(self, pull_request):
158 158 self.merge_mock.return_value = MergeResponse(
159 159 True, False, None, MergeFailureReason.NONE)
160 160
161 161 assert pull_request._last_merge_source_rev is None
162 162 assert pull_request._last_merge_target_rev is None
163 163 assert pull_request.last_merge_status is None
164 164
165 165 status, msg = PullRequestModel().merge_status(pull_request)
166 166 assert status is True
167 167 assert msg == 'This pull request can be automatically merged.'
168 168 self.merge_mock.assert_called_with(
169 169 self.repo_id, self.workspace_id,
170 170 pull_request.target_ref_parts,
171 171 pull_request.source_repo.scm_instance(),
172 172 pull_request.source_ref_parts, dry_run=True,
173 173 use_rebase=False, close_branch=False)
174 174
175 175 assert pull_request._last_merge_source_rev == self.source_commit
176 176 assert pull_request._last_merge_target_rev == self.target_commit
177 177 assert pull_request.last_merge_status is MergeFailureReason.NONE
178 178
179 179 self.merge_mock.reset_mock()
180 180 status, msg = PullRequestModel().merge_status(pull_request)
181 181 assert status is True
182 182 assert msg == 'This pull request can be automatically merged.'
183 183 assert self.merge_mock.called is False
184 184
185 185 def test_merge_status_known_failure(self, pull_request):
186 186 self.merge_mock.return_value = MergeResponse(
187 187 False, False, None, MergeFailureReason.MERGE_FAILED)
188 188
189 189 assert pull_request._last_merge_source_rev is None
190 190 assert pull_request._last_merge_target_rev is None
191 191 assert pull_request.last_merge_status is None
192 192
193 193 status, msg = PullRequestModel().merge_status(pull_request)
194 194 assert status is False
195 195 assert msg == 'This pull request cannot be merged because of merge conflicts.'
196 196 self.merge_mock.assert_called_with(
197 197 self.repo_id, self.workspace_id,
198 198 pull_request.target_ref_parts,
199 199 pull_request.source_repo.scm_instance(),
200 200 pull_request.source_ref_parts, dry_run=True,
201 201 use_rebase=False, close_branch=False)
202 202
203 203 assert pull_request._last_merge_source_rev == self.source_commit
204 204 assert pull_request._last_merge_target_rev == self.target_commit
205 205 assert (
206 206 pull_request.last_merge_status is MergeFailureReason.MERGE_FAILED)
207 207
208 208 self.merge_mock.reset_mock()
209 209 status, msg = PullRequestModel().merge_status(pull_request)
210 210 assert status is False
211 211 assert msg == 'This pull request cannot be merged because of merge conflicts.'
212 212 assert self.merge_mock.called is False
213 213
214 214 def test_merge_status_unknown_failure(self, pull_request):
215 215 self.merge_mock.return_value = MergeResponse(
216 216 False, False, None, MergeFailureReason.UNKNOWN,
217 217 metadata={'exception': 'MockError'})
218 218
219 219 assert pull_request._last_merge_source_rev is None
220 220 assert pull_request._last_merge_target_rev is None
221 221 assert pull_request.last_merge_status is None
222 222
223 223 status, msg = PullRequestModel().merge_status(pull_request)
224 224 assert status is False
225 225 assert msg == (
226 226 'This pull request cannot be merged because of an unhandled exception. '
227 227 'MockError')
228 228 self.merge_mock.assert_called_with(
229 229 self.repo_id, self.workspace_id,
230 230 pull_request.target_ref_parts,
231 231 pull_request.source_repo.scm_instance(),
232 232 pull_request.source_ref_parts, dry_run=True,
233 233 use_rebase=False, close_branch=False)
234 234
235 235 assert pull_request._last_merge_source_rev is None
236 236 assert pull_request._last_merge_target_rev is None
237 237 assert pull_request.last_merge_status is None
238 238
239 239 self.merge_mock.reset_mock()
240 240 status, msg = PullRequestModel().merge_status(pull_request)
241 241 assert status is False
242 242 assert msg == (
243 243 'This pull request cannot be merged because of an unhandled exception. '
244 244 'MockError')
245 245 assert self.merge_mock.called is True
246 246
247 247 def test_merge_status_when_target_is_locked(self, pull_request):
248 248 pull_request.target_repo.locked = [1, u'12345.50', 'lock_web']
249 249 status, msg = PullRequestModel().merge_status(pull_request)
250 250 assert status is False
251 251 assert msg == (
252 252 'This pull request cannot be merged because the target repository '
253 253 'is locked by user:1.')
254 254
255 255 def test_merge_status_requirements_check_target(self, pull_request):
256 256
257 257 def has_largefiles(self, repo):
258 258 return repo == pull_request.source_repo
259 259
260 260 patcher = mock.patch.object(PullRequestModel, '_has_largefiles', has_largefiles)
261 261 with patcher:
262 262 status, msg = PullRequestModel().merge_status(pull_request)
263 263
264 264 assert status is False
265 265 assert msg == 'Target repository large files support is disabled.'
266 266
267 267 def test_merge_status_requirements_check_source(self, pull_request):
268 268
269 269 def has_largefiles(self, repo):
270 270 return repo == pull_request.target_repo
271 271
272 272 patcher = mock.patch.object(PullRequestModel, '_has_largefiles', has_largefiles)
273 273 with patcher:
274 274 status, msg = PullRequestModel().merge_status(pull_request)
275 275
276 276 assert status is False
277 277 assert msg == 'Source repository large files support is disabled.'
278 278
279 279 def test_merge(self, pull_request, merge_extras):
280 280 user = UserModel().get_by_username(TEST_USER_ADMIN_LOGIN)
281 281 merge_ref = Reference(
282 282 'type', 'name', '6126b7bfcc82ad2d3deaee22af926b082ce54cc6')
283 283 self.merge_mock.return_value = MergeResponse(
284 284 True, True, merge_ref, MergeFailureReason.NONE)
285 285
286 286 merge_extras['repository'] = pull_request.target_repo.repo_name
287 287 PullRequestModel().merge_repo(
288 288 pull_request, pull_request.author, extras=merge_extras)
289 289
290 290 message = (
291 291 u'Merge pull request #{pr_id} from {source_repo} {source_ref_name}'
292 292 u'\n\n {pr_title}'.format(
293 293 pr_id=pull_request.pull_request_id,
294 294 source_repo=safe_unicode(
295 295 pull_request.source_repo.scm_instance().name),
296 296 source_ref_name=pull_request.source_ref_parts.name,
297 297 pr_title=safe_unicode(pull_request.title)
298 298 )
299 299 )
300 300 self.merge_mock.assert_called_with(
301 301 self.repo_id, self.workspace_id,
302 302 pull_request.target_ref_parts,
303 303 pull_request.source_repo.scm_instance(),
304 304 pull_request.source_ref_parts,
305 305 user_name=user.short_contact, user_email=user.email, message=message,
306 306 use_rebase=False, close_branch=False
307 307 )
308 308 self.invalidation_mock.assert_called_once_with(
309 309 pull_request.target_repo.repo_name)
310 310
311 311 self.hook_mock.assert_called_with(
312 312 self.pull_request, self.pull_request.author, 'merge')
313 313
314 314 pull_request = PullRequest.get(pull_request.pull_request_id)
315 315 assert pull_request.merge_rev == '6126b7bfcc82ad2d3deaee22af926b082ce54cc6'
316 316
317 317 def test_merge_with_status_lock(self, pull_request, merge_extras):
318 318 user = UserModel().get_by_username(TEST_USER_ADMIN_LOGIN)
319 319 merge_ref = Reference(
320 320 'type', 'name', '6126b7bfcc82ad2d3deaee22af926b082ce54cc6')
321 321 self.merge_mock.return_value = MergeResponse(
322 322 True, True, merge_ref, MergeFailureReason.NONE)
323 323
324 324 merge_extras['repository'] = pull_request.target_repo.repo_name
325 325
326 326 with pull_request.set_state(PullRequest.STATE_UPDATING):
327 327 assert pull_request.pull_request_state == PullRequest.STATE_UPDATING
328 328 PullRequestModel().merge_repo(
329 329 pull_request, pull_request.author, extras=merge_extras)
330 330
331 331 assert pull_request.pull_request_state == PullRequest.STATE_CREATED
332 332
333 333 message = (
334 334 u'Merge pull request #{pr_id} from {source_repo} {source_ref_name}'
335 335 u'\n\n {pr_title}'.format(
336 336 pr_id=pull_request.pull_request_id,
337 337 source_repo=safe_unicode(
338 338 pull_request.source_repo.scm_instance().name),
339 339 source_ref_name=pull_request.source_ref_parts.name,
340 340 pr_title=safe_unicode(pull_request.title)
341 341 )
342 342 )
343 343 self.merge_mock.assert_called_with(
344 344 self.repo_id, self.workspace_id,
345 345 pull_request.target_ref_parts,
346 346 pull_request.source_repo.scm_instance(),
347 347 pull_request.source_ref_parts,
348 348 user_name=user.short_contact, user_email=user.email, message=message,
349 349 use_rebase=False, close_branch=False
350 350 )
351 351 self.invalidation_mock.assert_called_once_with(
352 352 pull_request.target_repo.repo_name)
353 353
354 354 self.hook_mock.assert_called_with(
355 355 self.pull_request, self.pull_request.author, 'merge')
356 356
357 357 pull_request = PullRequest.get(pull_request.pull_request_id)
358 358 assert pull_request.merge_rev == '6126b7bfcc82ad2d3deaee22af926b082ce54cc6'
359 359
360 360 def test_merge_failed(self, pull_request, merge_extras):
361 361 user = UserModel().get_by_username(TEST_USER_ADMIN_LOGIN)
362 362 merge_ref = Reference(
363 363 'type', 'name', '6126b7bfcc82ad2d3deaee22af926b082ce54cc6')
364 364 self.merge_mock.return_value = MergeResponse(
365 365 False, False, merge_ref, MergeFailureReason.MERGE_FAILED)
366 366
367 367 merge_extras['repository'] = pull_request.target_repo.repo_name
368 368 PullRequestModel().merge_repo(
369 369 pull_request, pull_request.author, extras=merge_extras)
370 370
371 371 message = (
372 372 u'Merge pull request #{pr_id} from {source_repo} {source_ref_name}'
373 373 u'\n\n {pr_title}'.format(
374 374 pr_id=pull_request.pull_request_id,
375 375 source_repo=safe_unicode(
376 376 pull_request.source_repo.scm_instance().name),
377 377 source_ref_name=pull_request.source_ref_parts.name,
378 378 pr_title=safe_unicode(pull_request.title)
379 379 )
380 380 )
381 381 self.merge_mock.assert_called_with(
382 382 self.repo_id, self.workspace_id,
383 383 pull_request.target_ref_parts,
384 384 pull_request.source_repo.scm_instance(),
385 385 pull_request.source_ref_parts,
386 386 user_name=user.short_contact, user_email=user.email, message=message,
387 387 use_rebase=False, close_branch=False
388 388 )
389 389
390 390 pull_request = PullRequest.get(pull_request.pull_request_id)
391 391 assert self.invalidation_mock.called is False
392 392 assert pull_request.merge_rev is None
393 393
394 394 def test_get_commit_ids(self, pull_request):
395 395 # The PR has been not merget yet, so expect an exception
396 396 with pytest.raises(ValueError):
397 397 PullRequestModel()._get_commit_ids(pull_request)
398 398
399 399 # Merge revision is in the revisions list
400 400 pull_request.merge_rev = pull_request.revisions[0]
401 401 commit_ids = PullRequestModel()._get_commit_ids(pull_request)
402 402 assert commit_ids == pull_request.revisions
403 403
404 404 # Merge revision is not in the revisions list
405 405 pull_request.merge_rev = 'f000' * 10
406 406 commit_ids = PullRequestModel()._get_commit_ids(pull_request)
407 407 assert commit_ids == pull_request.revisions + [pull_request.merge_rev]
408 408
409 409 def test_get_diff_from_pr_version(self, pull_request):
410 410 source_repo = pull_request.source_repo
411 411 source_ref_id = pull_request.source_ref_parts.commit_id
412 412 target_ref_id = pull_request.target_ref_parts.commit_id
413 413 diff = PullRequestModel()._get_diff_from_pr_or_version(
414 414 source_repo, source_ref_id, target_ref_id,
415 415 hide_whitespace_changes=False, diff_context=6)
416 416 assert 'file_1' in diff.raw
417 417
418 418 def test_generate_title_returns_unicode(self):
419 419 title = PullRequestModel().generate_pullrequest_title(
420 420 source='source-dummy',
421 421 source_ref='source-ref-dummy',
422 422 target='target-dummy',
423 423 )
424 424 assert type(title) == unicode
425 425
426 426
427 427 @pytest.mark.usefixtures('config_stub')
428 428 class TestIntegrationMerge(object):
429 429 @pytest.mark.parametrize('extra_config', (
430 430 {'vcs.hooks.protocol': 'http', 'vcs.hooks.direct_calls': False},
431 431 ))
432 432 def test_merge_triggers_push_hooks(
433 433 self, pr_util, user_admin, capture_rcextensions, merge_extras,
434 434 extra_config):
435 435
436 436 pull_request = pr_util.create_pull_request(
437 437 approved=True, mergeable=True)
438 438 # TODO: johbo: Needed for sqlite, try to find an automatic way for it
439 439 merge_extras['repository'] = pull_request.target_repo.repo_name
440 440 Session().commit()
441 441
442 442 with mock.patch.dict(rhodecode.CONFIG, extra_config, clear=False):
443 443 merge_state = PullRequestModel().merge_repo(
444 444 pull_request, user_admin, extras=merge_extras)
445 445
446 446 assert merge_state.executed
447 447 assert '_pre_push_hook' in capture_rcextensions
448 448 assert '_push_hook' in capture_rcextensions
449 449
450 450 def test_merge_can_be_rejected_by_pre_push_hook(
451 451 self, pr_util, user_admin, capture_rcextensions, merge_extras):
452 452 pull_request = pr_util.create_pull_request(
453 453 approved=True, mergeable=True)
454 454 # TODO: johbo: Needed for sqlite, try to find an automatic way for it
455 455 merge_extras['repository'] = pull_request.target_repo.repo_name
456 456 Session().commit()
457 457
458 458 with mock.patch('rhodecode.EXTENSIONS.PRE_PUSH_HOOK') as pre_pull:
459 459 pre_pull.side_effect = RepositoryError("Disallow push!")
460 460 merge_status = PullRequestModel().merge_repo(
461 461 pull_request, user_admin, extras=merge_extras)
462 462
463 463 assert not merge_status.executed
464 464 assert 'pre_push' not in capture_rcextensions
465 465 assert 'post_push' not in capture_rcextensions
466 466
467 467 def test_merge_fails_if_target_is_locked(
468 468 self, pr_util, user_regular, merge_extras):
469 469 pull_request = pr_util.create_pull_request(
470 470 approved=True, mergeable=True)
471 471 locked_by = [user_regular.user_id + 1, 12345.50, 'lock_web']
472 472 pull_request.target_repo.locked = locked_by
473 473 # TODO: johbo: Check if this can work based on the database, currently
474 474 # all data is pre-computed, that's why just updating the DB is not
475 475 # enough.
476 476 merge_extras['locked_by'] = locked_by
477 477 merge_extras['repository'] = pull_request.target_repo.repo_name
478 478 # TODO: johbo: Needed for sqlite, try to find an automatic way for it
479 479 Session().commit()
480 480 merge_status = PullRequestModel().merge_repo(
481 481 pull_request, user_regular, extras=merge_extras)
482 482 assert not merge_status.executed
483 483
484 484
485 485 @pytest.mark.parametrize('use_outdated, inlines_count, outdated_count', [
486 486 (False, 1, 0),
487 487 (True, 0, 1),
488 488 ])
489 489 def test_outdated_comments(
490 490 pr_util, use_outdated, inlines_count, outdated_count, config_stub):
491 491 pull_request = pr_util.create_pull_request()
492 492 pr_util.create_inline_comment(file_path='not_in_updated_diff')
493 493
494 494 with outdated_comments_patcher(use_outdated) as outdated_comment_mock:
495 495 pr_util.add_one_commit()
496 496 assert_inline_comments(
497 497 pull_request, visible=inlines_count, outdated=outdated_count)
498 498 outdated_comment_mock.assert_called_with(pull_request)
499 499
500 500
501 501 @pytest.mark.parametrize('mr_type, expected_msg', [
502 502 (MergeFailureReason.NONE,
503 503 'This pull request can be automatically merged.'),
504 504 (MergeFailureReason.UNKNOWN,
505 505 'This pull request cannot be merged because of an unhandled exception. CRASH'),
506 506 (MergeFailureReason.MERGE_FAILED,
507 507 'This pull request cannot be merged because of merge conflicts.'),
508 508 (MergeFailureReason.PUSH_FAILED,
509 509 'This pull request could not be merged because push to target:`some-repo@merge_commit` failed.'),
510 510 (MergeFailureReason.TARGET_IS_NOT_HEAD,
511 511 'This pull request cannot be merged because the target `ref_name` is not a head.'),
512 512 (MergeFailureReason.HG_SOURCE_HAS_MORE_BRANCHES,
513 513 'This pull request cannot be merged because the source contains more branches than the target.'),
514 514 (MergeFailureReason.HG_TARGET_HAS_MULTIPLE_HEADS,
515 515 'This pull request cannot be merged because the target has multiple heads: `a,b,c`.'),
516 516 (MergeFailureReason.TARGET_IS_LOCKED,
517 517 'This pull request cannot be merged because the target repository is locked by user:123.'),
518 518 (MergeFailureReason.MISSING_TARGET_REF,
519 519 'This pull request cannot be merged because the target reference `ref_name` is missing.'),
520 520 (MergeFailureReason.MISSING_SOURCE_REF,
521 521 'This pull request cannot be merged because the source reference `ref_name` is missing.'),
522 522 (MergeFailureReason.SUBREPO_MERGE_FAILED,
523 523 'This pull request cannot be merged because of conflicts related to sub repositories.'),
524 524
525 525 ])
526 526 def test_merge_response_message(mr_type, expected_msg):
527 527 merge_ref = Reference('type', 'ref_name', '6126b7bfcc82ad2d3deaee22af926b082ce54cc6')
528 528 metadata = {
529 529 'exception': "CRASH",
530 530 'target': 'some-repo',
531 531 'merge_commit': 'merge_commit',
532 532 'target_ref': merge_ref,
533 533 'source_ref': merge_ref,
534 534 'heads': ','.join(['a', 'b', 'c']),
535 535 'locked_by': 'user:123'}
536 536
537 537 merge_response = MergeResponse(True, True, merge_ref, mr_type, metadata=metadata)
538 538 assert merge_response.merge_status_message == expected_msg
539 539
540 540
541 541 @pytest.fixture
542 542 def merge_extras(user_regular):
543 543 """
544 544 Context for the vcs operation when running a merge.
545 545 """
546 546 extras = {
547 547 'ip': '127.0.0.1',
548 548 'username': user_regular.username,
549 549 'user_id': user_regular.user_id,
550 550 'action': 'push',
551 551 'repository': 'fake_target_repo_name',
552 552 'scm': 'git',
553 553 'config': 'fake_config_ini_path',
554 554 'repo_store': '',
555 555 'make_lock': None,
556 556 'locked_by': [None, None, None],
557 557 'server_url': 'http://test.example.com:5000',
558 558 'hooks': ['push', 'pull'],
559 559 'is_shadow_repo': False,
560 560 }
561 561 return extras
562 562
563 563
564 564 @pytest.mark.usefixtures('config_stub')
565 565 class TestUpdateCommentHandling(object):
566 566
567 567 @pytest.fixture(autouse=True, scope='class')
568 568 def enable_outdated_comments(self, request, baseapp):
569 569 config_patch = mock.patch.dict(
570 570 'rhodecode.CONFIG', {'rhodecode_use_outdated_comments': True})
571 571 config_patch.start()
572 572
573 573 @request.addfinalizer
574 574 def cleanup():
575 575 config_patch.stop()
576 576
577 577 def test_comment_stays_unflagged_on_unchanged_diff(self, pr_util):
578 578 commits = [
579 579 {'message': 'a'},
580 580 {'message': 'b', 'added': [FileNode('file_b', 'test_content\n')]},
581 581 {'message': 'c', 'added': [FileNode('file_c', 'test_content\n')]},
582 582 ]
583 583 pull_request = pr_util.create_pull_request(
584 584 commits=commits, target_head='a', source_head='b', revisions=['b'])
585 585 pr_util.create_inline_comment(file_path='file_b')
586 586 pr_util.add_one_commit(head='c')
587 587
588 588 assert_inline_comments(pull_request, visible=1, outdated=0)
589 589
590 590 def test_comment_stays_unflagged_on_change_above(self, pr_util):
591 591 original_content = ''.join(
592 592 ['line {}\n'.format(x) for x in range(1, 11)])
593 593 updated_content = 'new_line_at_top\n' + original_content
594 594 commits = [
595 595 {'message': 'a'},
596 596 {'message': 'b', 'added': [FileNode('file_b', original_content)]},
597 597 {'message': 'c', 'changed': [FileNode('file_b', updated_content)]},
598 598 ]
599 599 pull_request = pr_util.create_pull_request(
600 600 commits=commits, target_head='a', source_head='b', revisions=['b'])
601 601
602 602 with outdated_comments_patcher():
603 603 comment = pr_util.create_inline_comment(
604 604 line_no=u'n8', file_path='file_b')
605 605 pr_util.add_one_commit(head='c')
606 606
607 607 assert_inline_comments(pull_request, visible=1, outdated=0)
608 608 assert comment.line_no == u'n9'
609 609
610 610 def test_comment_stays_unflagged_on_change_below(self, pr_util):
611 611 original_content = ''.join(['line {}\n'.format(x) for x in range(10)])
612 612 updated_content = original_content + 'new_line_at_end\n'
613 613 commits = [
614 614 {'message': 'a'},
615 615 {'message': 'b', 'added': [FileNode('file_b', original_content)]},
616 616 {'message': 'c', 'changed': [FileNode('file_b', updated_content)]},
617 617 ]
618 618 pull_request = pr_util.create_pull_request(
619 619 commits=commits, target_head='a', source_head='b', revisions=['b'])
620 620 pr_util.create_inline_comment(file_path='file_b')
621 621 pr_util.add_one_commit(head='c')
622 622
623 623 assert_inline_comments(pull_request, visible=1, outdated=0)
624 624
625 625 @pytest.mark.parametrize('line_no', ['n4', 'o4', 'n10', 'o9'])
626 626 def test_comment_flagged_on_change_around_context(self, pr_util, line_no):
627 627 base_lines = ['line {}\n'.format(x) for x in range(1, 13)]
628 628 change_lines = list(base_lines)
629 629 change_lines.insert(6, 'line 6a added\n')
630 630
631 631 # Changes on the last line of sight
632 632 update_lines = list(change_lines)
633 633 update_lines[0] = 'line 1 changed\n'
634 634 update_lines[-1] = 'line 12 changed\n'
635 635
636 636 def file_b(lines):
637 637 return FileNode('file_b', ''.join(lines))
638 638
639 639 commits = [
640 640 {'message': 'a', 'added': [file_b(base_lines)]},
641 641 {'message': 'b', 'changed': [file_b(change_lines)]},
642 642 {'message': 'c', 'changed': [file_b(update_lines)]},
643 643 ]
644 644
645 645 pull_request = pr_util.create_pull_request(
646 646 commits=commits, target_head='a', source_head='b', revisions=['b'])
647 647 pr_util.create_inline_comment(line_no=line_no, file_path='file_b')
648 648
649 649 with outdated_comments_patcher():
650 650 pr_util.add_one_commit(head='c')
651 651 assert_inline_comments(pull_request, visible=0, outdated=1)
652 652
653 653 @pytest.mark.parametrize("change, content", [
654 654 ('changed', 'changed\n'),
655 655 ('removed', ''),
656 656 ], ids=['changed', 'removed'])
657 657 def test_comment_flagged_on_change(self, pr_util, change, content):
658 658 commits = [
659 659 {'message': 'a'},
660 660 {'message': 'b', 'added': [FileNode('file_b', 'test_content\n')]},
661 661 {'message': 'c', change: [FileNode('file_b', content)]},
662 662 ]
663 663 pull_request = pr_util.create_pull_request(
664 664 commits=commits, target_head='a', source_head='b', revisions=['b'])
665 665 pr_util.create_inline_comment(file_path='file_b')
666 666
667 667 with outdated_comments_patcher():
668 668 pr_util.add_one_commit(head='c')
669 669 assert_inline_comments(pull_request, visible=0, outdated=1)
670 670
671 671
672 672 @pytest.mark.usefixtures('config_stub')
673 673 class TestUpdateChangedFiles(object):
674 674
675 675 def test_no_changes_on_unchanged_diff(self, pr_util):
676 676 commits = [
677 677 {'message': 'a'},
678 678 {'message': 'b',
679 679 'added': [FileNode('file_b', 'test_content b\n')]},
680 680 {'message': 'c',
681 681 'added': [FileNode('file_c', 'test_content c\n')]},
682 682 ]
683 683 # open a PR from a to b, adding file_b
684 684 pull_request = pr_util.create_pull_request(
685 685 commits=commits, target_head='a', source_head='b', revisions=['b'],
686 686 name_suffix='per-file-review')
687 687
688 688 # modify PR adding new file file_c
689 689 pr_util.add_one_commit(head='c')
690 690
691 691 assert_pr_file_changes(
692 692 pull_request,
693 693 added=['file_c'],
694 694 modified=[],
695 695 removed=[])
696 696
697 697 def test_modify_and_undo_modification_diff(self, pr_util):
698 698 commits = [
699 699 {'message': 'a'},
700 700 {'message': 'b',
701 701 'added': [FileNode('file_b', 'test_content b\n')]},
702 702 {'message': 'c',
703 703 'changed': [FileNode('file_b', 'test_content b modified\n')]},
704 704 {'message': 'd',
705 705 'changed': [FileNode('file_b', 'test_content b\n')]},
706 706 ]
707 707 # open a PR from a to b, adding file_b
708 708 pull_request = pr_util.create_pull_request(
709 709 commits=commits, target_head='a', source_head='b', revisions=['b'],
710 710 name_suffix='per-file-review')
711 711
712 712 # modify PR modifying file file_b
713 713 pr_util.add_one_commit(head='c')
714 714
715 715 assert_pr_file_changes(
716 716 pull_request,
717 717 added=[],
718 718 modified=['file_b'],
719 719 removed=[])
720 720
721 721 # move the head again to d, which rollbacks change,
722 722 # meaning we should indicate no changes
723 723 pr_util.add_one_commit(head='d')
724 724
725 725 assert_pr_file_changes(
726 726 pull_request,
727 727 added=[],
728 728 modified=[],
729 729 removed=[])
730 730
731 731 def test_updated_all_files_in_pr(self, pr_util):
732 732 commits = [
733 733 {'message': 'a'},
734 734 {'message': 'b', 'added': [
735 735 FileNode('file_a', 'test_content a\n'),
736 736 FileNode('file_b', 'test_content b\n'),
737 737 FileNode('file_c', 'test_content c\n')]},
738 738 {'message': 'c', 'changed': [
739 739 FileNode('file_a', 'test_content a changed\n'),
740 740 FileNode('file_b', 'test_content b changed\n'),
741 741 FileNode('file_c', 'test_content c changed\n')]},
742 742 ]
743 743 # open a PR from a to b, changing 3 files
744 744 pull_request = pr_util.create_pull_request(
745 745 commits=commits, target_head='a', source_head='b', revisions=['b'],
746 746 name_suffix='per-file-review')
747 747
748 748 pr_util.add_one_commit(head='c')
749 749
750 750 assert_pr_file_changes(
751 751 pull_request,
752 752 added=[],
753 753 modified=['file_a', 'file_b', 'file_c'],
754 754 removed=[])
755 755
756 756 def test_updated_and_removed_all_files_in_pr(self, pr_util):
757 757 commits = [
758 758 {'message': 'a'},
759 759 {'message': 'b', 'added': [
760 760 FileNode('file_a', 'test_content a\n'),
761 761 FileNode('file_b', 'test_content b\n'),
762 762 FileNode('file_c', 'test_content c\n')]},
763 763 {'message': 'c', 'removed': [
764 764 FileNode('file_a', 'test_content a changed\n'),
765 765 FileNode('file_b', 'test_content b changed\n'),
766 766 FileNode('file_c', 'test_content c changed\n')]},
767 767 ]
768 768 # open a PR from a to b, removing 3 files
769 769 pull_request = pr_util.create_pull_request(
770 770 commits=commits, target_head='a', source_head='b', revisions=['b'],
771 771 name_suffix='per-file-review')
772 772
773 773 pr_util.add_one_commit(head='c')
774 774
775 775 assert_pr_file_changes(
776 776 pull_request,
777 777 added=[],
778 778 modified=[],
779 779 removed=['file_a', 'file_b', 'file_c'])
780 780
781 781
782 782 def test_update_writes_snapshot_into_pull_request_version(pr_util, config_stub):
783 783 model = PullRequestModel()
784 784 pull_request = pr_util.create_pull_request()
785 785 pr_util.update_source_repository()
786 786
787 787 model.update_commits(pull_request)
788 788
789 789 # Expect that it has a version entry now
790 790 assert len(model.get_versions(pull_request)) == 1
791 791
792 792
793 793 def test_update_skips_new_version_if_unchanged(pr_util, config_stub):
794 794 pull_request = pr_util.create_pull_request()
795 795 model = PullRequestModel()
796 796 model.update_commits(pull_request)
797 797
798 798 # Expect that it still has no versions
799 799 assert len(model.get_versions(pull_request)) == 0
800 800
801 801
802 802 def test_update_assigns_comments_to_the_new_version(pr_util, config_stub):
803 803 model = PullRequestModel()
804 804 pull_request = pr_util.create_pull_request()
805 805 comment = pr_util.create_comment()
806 806 pr_util.update_source_repository()
807 807
808 808 model.update_commits(pull_request)
809 809
810 810 # Expect that the comment is linked to the pr version now
811 811 assert comment.pull_request_version == model.get_versions(pull_request)[0]
812 812
813 813
814 814 def test_update_adds_a_comment_to_the_pull_request_about_the_change(pr_util, config_stub):
815 815 model = PullRequestModel()
816 816 pull_request = pr_util.create_pull_request()
817 817 pr_util.update_source_repository()
818 818 pr_util.update_source_repository()
819 819
820 820 model.update_commits(pull_request)
821 821
822 822 # Expect to find a new comment about the change
823 823 expected_message = textwrap.dedent(
824 824 """\
825 825 Pull request updated. Auto status change to |under_review|
826 826
827 827 .. role:: added
828 828 .. role:: removed
829 829 .. parsed-literal::
830 830
831 831 Changed commits:
832 832 * :added:`1 added`
833 833 * :removed:`0 removed`
834 834
835 835 Changed files:
836 836 * `A file_2 <#a_c--92ed3b5f07b4>`_
837 837
838 838 .. |under_review| replace:: *"Under Review"*"""
839 839 )
840 840 pull_request_comments = sorted(
841 841 pull_request.comments, key=lambda c: c.modified_at)
842 842 update_comment = pull_request_comments[-1]
843 843 assert update_comment.text == expected_message
844 844
845 845
846 846 def test_create_version_from_snapshot_updates_attributes(pr_util, config_stub):
847 847 pull_request = pr_util.create_pull_request()
848 848
849 849 # Avoiding default values
850 850 pull_request.status = PullRequest.STATUS_CLOSED
851 851 pull_request._last_merge_source_rev = "0" * 40
852 852 pull_request._last_merge_target_rev = "1" * 40
853 853 pull_request.last_merge_status = 1
854 854 pull_request.merge_rev = "2" * 40
855 855
856 856 # Remember automatic values
857 857 created_on = pull_request.created_on
858 858 updated_on = pull_request.updated_on
859 859
860 860 # Create a new version of the pull request
861 861 version = PullRequestModel()._create_version_from_snapshot(pull_request)
862 862
863 863 # Check attributes
864 864 assert version.title == pr_util.create_parameters['title']
865 865 assert version.description == pr_util.create_parameters['description']
866 866 assert version.status == PullRequest.STATUS_CLOSED
867 867
868 868 # versions get updated created_on
869 869 assert version.created_on != created_on
870 870
871 871 assert version.updated_on == updated_on
872 872 assert version.user_id == pull_request.user_id
873 873 assert version.revisions == pr_util.create_parameters['revisions']
874 874 assert version.source_repo == pr_util.source_repository
875 875 assert version.source_ref == pr_util.create_parameters['source_ref']
876 876 assert version.target_repo == pr_util.target_repository
877 877 assert version.target_ref == pr_util.create_parameters['target_ref']
878 878 assert version._last_merge_source_rev == pull_request._last_merge_source_rev
879 879 assert version._last_merge_target_rev == pull_request._last_merge_target_rev
880 880 assert version.last_merge_status == pull_request.last_merge_status
881 881 assert version.merge_rev == pull_request.merge_rev
882 882 assert version.pull_request == pull_request
883 883
884 884
885 885 def test_link_comments_to_version_only_updates_unlinked_comments(pr_util, config_stub):
886 886 version1 = pr_util.create_version_of_pull_request()
887 887 comment_linked = pr_util.create_comment(linked_to=version1)
888 888 comment_unlinked = pr_util.create_comment()
889 889 version2 = pr_util.create_version_of_pull_request()
890 890
891 891 PullRequestModel()._link_comments_to_version(version2)
892 892
893 893 # Expect that only the new comment is linked to version2
894 894 assert (
895 895 comment_unlinked.pull_request_version_id ==
896 896 version2.pull_request_version_id)
897 897 assert (
898 898 comment_linked.pull_request_version_id ==
899 899 version1.pull_request_version_id)
900 900 assert (
901 901 comment_unlinked.pull_request_version_id !=
902 902 comment_linked.pull_request_version_id)
903 903
904 904
905 905 def test_calculate_commits():
906 906 old_ids = [1, 2, 3]
907 907 new_ids = [1, 3, 4, 5]
908 908 change = PullRequestModel()._calculate_commit_id_changes(old_ids, new_ids)
909 909 assert change.added == [4, 5]
910 910 assert change.common == [1, 3]
911 911 assert change.removed == [2]
912 912 assert change.total == [1, 3, 4, 5]
913 913
914 914
915 915 def assert_inline_comments(pull_request, visible=None, outdated=None):
916 916 if visible is not None:
917 917 inline_comments = CommentsModel().get_inline_comments(
918 918 pull_request.target_repo.repo_id, pull_request=pull_request)
919 919 inline_cnt = CommentsModel().get_inline_comments_count(
920 920 inline_comments)
921 921 assert inline_cnt == visible
922 922 if outdated is not None:
923 923 outdated_comments = CommentsModel().get_outdated_comments(
924 924 pull_request.target_repo.repo_id, pull_request)
925 925 assert len(outdated_comments) == outdated
926 926
927 927
928 928 def assert_pr_file_changes(
929 929 pull_request, added=None, modified=None, removed=None):
930 930 pr_versions = PullRequestModel().get_versions(pull_request)
931 931 # always use first version, ie original PR to calculate changes
932 932 pull_request_version = pr_versions[0]
933 933 old_diff_data, new_diff_data = PullRequestModel()._generate_update_diffs(
934 934 pull_request, pull_request_version)
935 935 file_changes = PullRequestModel()._calculate_file_changes(
936 936 old_diff_data, new_diff_data)
937 937
938 938 assert added == file_changes.added, \
939 939 'expected added:%s vs value:%s' % (added, file_changes.added)
940 940 assert modified == file_changes.modified, \
941 941 'expected modified:%s vs value:%s' % (modified, file_changes.modified)
942 942 assert removed == file_changes.removed, \
943 943 'expected removed:%s vs value:%s' % (removed, file_changes.removed)
944 944
945 945
946 946 def outdated_comments_patcher(use_outdated=True):
947 947 return mock.patch.object(
948 948 CommentsModel, 'use_outdated_comments',
949 949 return_value=use_outdated)
General Comments 0
You need to be logged in to leave comments. Login now