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