##// END OF EJS Templates
pull-requests: only allow actual reviewers to leave status/votes....
marcink -
r4513:8c4f7e94 stable
parent child Browse files
Show More
@@ -1,792 +1,791 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2020 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 from pyramid.httpexceptions import (
25 25 HTTPNotFound, HTTPBadRequest, HTTPFound, HTTPForbidden, HTTPConflict)
26 26 from pyramid.view import view_config
27 27 from pyramid.renderers import render
28 28 from pyramid.response import Response
29 29
30 30 from rhodecode.apps._base import RepoAppView
31 31 from rhodecode.apps.file_store import utils as store_utils
32 32 from rhodecode.apps.file_store.exceptions import FileNotAllowedException, FileOverSizeException
33 33
34 34 from rhodecode.lib import diffs, codeblocks, channelstream
35 35 from rhodecode.lib.auth import (
36 36 LoginRequired, HasRepoPermissionAnyDecorator, NotAnonymous, CSRFRequired)
37 37 from rhodecode.lib.ext_json import json
38 38 from rhodecode.lib.compat import OrderedDict
39 39 from rhodecode.lib.diffs import (
40 40 cache_diff, load_cached_diff, diff_cache_exist, get_diff_context,
41 41 get_diff_whitespace_flag)
42 42 from rhodecode.lib.exceptions import StatusChangeOnClosedPullRequestError, CommentVersionMismatch
43 43 import rhodecode.lib.helpers as h
44 44 from rhodecode.lib.utils2 import safe_unicode, str2bool, StrictAttributeDict
45 45 from rhodecode.lib.vcs.backends.base import EmptyCommit
46 46 from rhodecode.lib.vcs.exceptions import (
47 47 RepositoryError, CommitDoesNotExistError)
48 48 from rhodecode.model.db import ChangesetComment, ChangesetStatus, FileStore, \
49 49 ChangesetCommentHistory
50 50 from rhodecode.model.changeset_status import ChangesetStatusModel
51 51 from rhodecode.model.comment import CommentsModel
52 52 from rhodecode.model.meta import Session
53 53 from rhodecode.model.settings import VcsSettingsModel
54 54
55 55 log = logging.getLogger(__name__)
56 56
57 57
58 58 def _update_with_GET(params, request):
59 59 for k in ['diff1', 'diff2', 'diff']:
60 60 params[k] += request.GET.getall(k)
61 61
62 62
63 63 class RepoCommitsView(RepoAppView):
64 64 def load_default_context(self):
65 65 c = self._get_local_tmpl_context(include_app_defaults=True)
66 66 c.rhodecode_repo = self.rhodecode_vcs_repo
67 67
68 68 return c
69 69
70 70 def _is_diff_cache_enabled(self, target_repo):
71 71 caching_enabled = self._get_general_setting(
72 72 target_repo, 'rhodecode_diff_cache')
73 73 log.debug('Diff caching enabled: %s', caching_enabled)
74 74 return caching_enabled
75 75
76 76 def _commit(self, commit_id_range, method):
77 77 _ = self.request.translate
78 78 c = self.load_default_context()
79 79 c.fulldiff = self.request.GET.get('fulldiff')
80 80
81 81 # fetch global flags of ignore ws or context lines
82 82 diff_context = get_diff_context(self.request)
83 83 hide_whitespace_changes = get_diff_whitespace_flag(self.request)
84 84
85 85 # diff_limit will cut off the whole diff if the limit is applied
86 86 # otherwise it will just hide the big files from the front-end
87 87 diff_limit = c.visual.cut_off_limit_diff
88 88 file_limit = c.visual.cut_off_limit_file
89 89
90 90 # get ranges of commit ids if preset
91 91 commit_range = commit_id_range.split('...')[:2]
92 92
93 93 try:
94 94 pre_load = ['affected_files', 'author', 'branch', 'date',
95 95 'message', 'parents']
96 96 if self.rhodecode_vcs_repo.alias == 'hg':
97 97 pre_load += ['hidden', 'obsolete', 'phase']
98 98
99 99 if len(commit_range) == 2:
100 100 commits = self.rhodecode_vcs_repo.get_commits(
101 101 start_id=commit_range[0], end_id=commit_range[1],
102 102 pre_load=pre_load, translate_tags=False)
103 103 commits = list(commits)
104 104 else:
105 105 commits = [self.rhodecode_vcs_repo.get_commit(
106 106 commit_id=commit_id_range, pre_load=pre_load)]
107 107
108 108 c.commit_ranges = commits
109 109 if not c.commit_ranges:
110 110 raise RepositoryError('The commit range returned an empty result')
111 111 except CommitDoesNotExistError as e:
112 112 msg = _('No such commit exists. Org exception: `{}`').format(e)
113 113 h.flash(msg, category='error')
114 114 raise HTTPNotFound()
115 115 except Exception:
116 116 log.exception("General failure")
117 117 raise HTTPNotFound()
118 118 single_commit = len(c.commit_ranges) == 1
119 119
120 120 c.changes = OrderedDict()
121 121 c.lines_added = 0
122 122 c.lines_deleted = 0
123 123
124 124 # auto collapse if we have more than limit
125 125 collapse_limit = diffs.DiffProcessor._collapse_commits_over
126 126 c.collapse_all_commits = len(c.commit_ranges) > collapse_limit
127 127
128 128 c.commit_statuses = ChangesetStatus.STATUSES
129 129 c.inline_comments = []
130 130 c.files = []
131 131
132 132 c.comments = []
133 133 c.unresolved_comments = []
134 134 c.resolved_comments = []
135 135
136 136 # Single commit
137 137 if single_commit:
138 138 commit = c.commit_ranges[0]
139 139 c.comments = CommentsModel().get_comments(
140 140 self.db_repo.repo_id,
141 141 revision=commit.raw_id)
142 142
143 143 # comments from PR
144 144 statuses = ChangesetStatusModel().get_statuses(
145 145 self.db_repo.repo_id, commit.raw_id,
146 146 with_revisions=True)
147 147
148 148 prs = set()
149 149 reviewers = list()
150 150 reviewers_duplicates = set() # to not have duplicates from multiple votes
151 151 for c_status in statuses:
152 152
153 153 # extract associated pull-requests from votes
154 154 if c_status.pull_request:
155 155 prs.add(c_status.pull_request)
156 156
157 157 # extract reviewers
158 158 _user_id = c_status.author.user_id
159 159 if _user_id not in reviewers_duplicates:
160 160 reviewers.append(
161 161 StrictAttributeDict({
162 162 'user': c_status.author,
163 163
164 164 # fake attributed for commit, page that we don't have
165 165 # but we share the display with PR page
166 166 'mandatory': False,
167 167 'reasons': [],
168 168 'rule_user_group_data': lambda: None
169 169 })
170 170 )
171 171 reviewers_duplicates.add(_user_id)
172 172
173 c.allowed_reviewers = reviewers
174 173 c.reviewers_count = len(reviewers)
175 174 c.observers_count = 0
176 175
177 176 # from associated statuses, check the pull requests, and
178 177 # show comments from them
179 178 for pr in prs:
180 179 c.comments.extend(pr.comments)
181 180
182 181 c.unresolved_comments = CommentsModel()\
183 182 .get_commit_unresolved_todos(commit.raw_id)
184 183 c.resolved_comments = CommentsModel()\
185 184 .get_commit_resolved_todos(commit.raw_id)
186 185
187 186 c.inline_comments_flat = CommentsModel()\
188 187 .get_commit_inline_comments(commit.raw_id)
189 188
190 189 review_statuses = ChangesetStatusModel().aggregate_votes_by_user(
191 190 statuses, reviewers)
192 191
193 192 c.commit_review_status = ChangesetStatus.STATUS_NOT_REVIEWED
194 193
195 194 c.commit_set_reviewers_data_json = collections.OrderedDict({'reviewers': []})
196 195
197 196 for review_obj, member, reasons, mandatory, status in review_statuses:
198 197 member_reviewer = h.reviewer_as_json(
199 198 member, reasons=reasons, mandatory=mandatory, role=None,
200 199 user_group=None
201 200 )
202 201
203 202 current_review_status = status[0][1].status if status else ChangesetStatus.STATUS_NOT_REVIEWED
204 203 member_reviewer['review_status'] = current_review_status
205 204 member_reviewer['review_status_label'] = h.commit_status_lbl(current_review_status)
206 205 member_reviewer['allowed_to_update'] = False
207 206 c.commit_set_reviewers_data_json['reviewers'].append(member_reviewer)
208 207
209 208 c.commit_set_reviewers_data_json = json.dumps(c.commit_set_reviewers_data_json)
210 209
211 210 # NOTE(marcink): this uses the same voting logic as in pull-requests
212 211 c.commit_review_status = ChangesetStatusModel().calculate_status(review_statuses)
213 212 c.commit_broadcast_channel = channelstream.comment_channel(c.repo_name, commit_obj=commit)
214 213
215 214 diff = None
216 215 # Iterate over ranges (default commit view is always one commit)
217 216 for commit in c.commit_ranges:
218 217 c.changes[commit.raw_id] = []
219 218
220 219 commit2 = commit
221 220 commit1 = commit.first_parent
222 221
223 222 if method == 'show':
224 223 inline_comments = CommentsModel().get_inline_comments(
225 224 self.db_repo.repo_id, revision=commit.raw_id)
226 225 c.inline_cnt = len(CommentsModel().get_inline_comments_as_list(
227 226 inline_comments))
228 227 c.inline_comments = inline_comments
229 228
230 229 cache_path = self.rhodecode_vcs_repo.get_create_shadow_cache_pr_path(
231 230 self.db_repo)
232 231 cache_file_path = diff_cache_exist(
233 232 cache_path, 'diff', commit.raw_id,
234 233 hide_whitespace_changes, diff_context, c.fulldiff)
235 234
236 235 caching_enabled = self._is_diff_cache_enabled(self.db_repo)
237 236 force_recache = str2bool(self.request.GET.get('force_recache'))
238 237
239 238 cached_diff = None
240 239 if caching_enabled:
241 240 cached_diff = load_cached_diff(cache_file_path)
242 241
243 242 has_proper_diff_cache = cached_diff and cached_diff.get('diff')
244 243 if not force_recache and has_proper_diff_cache:
245 244 diffset = cached_diff['diff']
246 245 else:
247 246 vcs_diff = self.rhodecode_vcs_repo.get_diff(
248 247 commit1, commit2,
249 248 ignore_whitespace=hide_whitespace_changes,
250 249 context=diff_context)
251 250
252 251 diff_processor = diffs.DiffProcessor(
253 252 vcs_diff, format='newdiff', diff_limit=diff_limit,
254 253 file_limit=file_limit, show_full_diff=c.fulldiff)
255 254
256 255 _parsed = diff_processor.prepare()
257 256
258 257 diffset = codeblocks.DiffSet(
259 258 repo_name=self.db_repo_name,
260 259 source_node_getter=codeblocks.diffset_node_getter(commit1),
261 260 target_node_getter=codeblocks.diffset_node_getter(commit2))
262 261
263 262 diffset = self.path_filter.render_patchset_filtered(
264 263 diffset, _parsed, commit1.raw_id, commit2.raw_id)
265 264
266 265 # save cached diff
267 266 if caching_enabled:
268 267 cache_diff(cache_file_path, diffset, None)
269 268
270 269 c.limited_diff = diffset.limited_diff
271 270 c.changes[commit.raw_id] = diffset
272 271 else:
273 272 # TODO(marcink): no cache usage here...
274 273 _diff = self.rhodecode_vcs_repo.get_diff(
275 274 commit1, commit2,
276 275 ignore_whitespace=hide_whitespace_changes, context=diff_context)
277 276 diff_processor = diffs.DiffProcessor(
278 277 _diff, format='newdiff', diff_limit=diff_limit,
279 278 file_limit=file_limit, show_full_diff=c.fulldiff)
280 279 # downloads/raw we only need RAW diff nothing else
281 280 diff = self.path_filter.get_raw_patch(diff_processor)
282 281 c.changes[commit.raw_id] = [None, None, None, None, diff, None, None]
283 282
284 283 # sort comments by how they were generated
285 284 c.comments = sorted(c.comments, key=lambda x: x.comment_id)
286 285 c.at_version_num = None
287 286
288 287 if len(c.commit_ranges) == 1:
289 288 c.commit = c.commit_ranges[0]
290 289 c.parent_tmpl = ''.join(
291 290 '# Parent %s\n' % x.raw_id for x in c.commit.parents)
292 291
293 292 if method == 'download':
294 293 response = Response(diff)
295 294 response.content_type = 'text/plain'
296 295 response.content_disposition = (
297 296 'attachment; filename=%s.diff' % commit_id_range[:12])
298 297 return response
299 298 elif method == 'patch':
300 299 c.diff = safe_unicode(diff)
301 300 patch = render(
302 301 'rhodecode:templates/changeset/patch_changeset.mako',
303 302 self._get_template_context(c), self.request)
304 303 response = Response(patch)
305 304 response.content_type = 'text/plain'
306 305 return response
307 306 elif method == 'raw':
308 307 response = Response(diff)
309 308 response.content_type = 'text/plain'
310 309 return response
311 310 elif method == 'show':
312 311 if len(c.commit_ranges) == 1:
313 312 html = render(
314 313 'rhodecode:templates/changeset/changeset.mako',
315 314 self._get_template_context(c), self.request)
316 315 return Response(html)
317 316 else:
318 317 c.ancestor = None
319 318 c.target_repo = self.db_repo
320 319 html = render(
321 320 'rhodecode:templates/changeset/changeset_range.mako',
322 321 self._get_template_context(c), self.request)
323 322 return Response(html)
324 323
325 324 raise HTTPBadRequest()
326 325
327 326 @LoginRequired()
328 327 @HasRepoPermissionAnyDecorator(
329 328 'repository.read', 'repository.write', 'repository.admin')
330 329 @view_config(
331 330 route_name='repo_commit', request_method='GET',
332 331 renderer=None)
333 332 def repo_commit_show(self):
334 333 commit_id = self.request.matchdict['commit_id']
335 334 return self._commit(commit_id, method='show')
336 335
337 336 @LoginRequired()
338 337 @HasRepoPermissionAnyDecorator(
339 338 'repository.read', 'repository.write', 'repository.admin')
340 339 @view_config(
341 340 route_name='repo_commit_raw', request_method='GET',
342 341 renderer=None)
343 342 @view_config(
344 343 route_name='repo_commit_raw_deprecated', request_method='GET',
345 344 renderer=None)
346 345 def repo_commit_raw(self):
347 346 commit_id = self.request.matchdict['commit_id']
348 347 return self._commit(commit_id, method='raw')
349 348
350 349 @LoginRequired()
351 350 @HasRepoPermissionAnyDecorator(
352 351 'repository.read', 'repository.write', 'repository.admin')
353 352 @view_config(
354 353 route_name='repo_commit_patch', request_method='GET',
355 354 renderer=None)
356 355 def repo_commit_patch(self):
357 356 commit_id = self.request.matchdict['commit_id']
358 357 return self._commit(commit_id, method='patch')
359 358
360 359 @LoginRequired()
361 360 @HasRepoPermissionAnyDecorator(
362 361 'repository.read', 'repository.write', 'repository.admin')
363 362 @view_config(
364 363 route_name='repo_commit_download', request_method='GET',
365 364 renderer=None)
366 365 def repo_commit_download(self):
367 366 commit_id = self.request.matchdict['commit_id']
368 367 return self._commit(commit_id, method='download')
369 368
370 369 @LoginRequired()
371 370 @NotAnonymous()
372 371 @HasRepoPermissionAnyDecorator(
373 372 'repository.read', 'repository.write', 'repository.admin')
374 373 @CSRFRequired()
375 374 @view_config(
376 375 route_name='repo_commit_comment_create', request_method='POST',
377 376 renderer='json_ext')
378 377 def repo_commit_comment_create(self):
379 378 _ = self.request.translate
380 379 commit_id = self.request.matchdict['commit_id']
381 380
382 381 c = self.load_default_context()
383 382 status = self.request.POST.get('changeset_status', None)
384 383 text = self.request.POST.get('text')
385 384 comment_type = self.request.POST.get('comment_type')
386 385 resolves_comment_id = self.request.POST.get('resolves_comment_id', None)
387 386
388 387 if status:
389 388 text = text or (_('Status change %(transition_icon)s %(status)s')
390 389 % {'transition_icon': '>',
391 390 'status': ChangesetStatus.get_status_lbl(status)})
392 391
393 392 multi_commit_ids = []
394 393 for _commit_id in self.request.POST.get('commit_ids', '').split(','):
395 394 if _commit_id not in ['', None, EmptyCommit.raw_id]:
396 395 if _commit_id not in multi_commit_ids:
397 396 multi_commit_ids.append(_commit_id)
398 397
399 398 commit_ids = multi_commit_ids or [commit_id]
400 399
401 400 comment = None
402 401 for current_id in filter(None, commit_ids):
403 402 comment = CommentsModel().create(
404 403 text=text,
405 404 repo=self.db_repo.repo_id,
406 405 user=self._rhodecode_db_user.user_id,
407 406 commit_id=current_id,
408 407 f_path=self.request.POST.get('f_path'),
409 408 line_no=self.request.POST.get('line'),
410 409 status_change=(ChangesetStatus.get_status_lbl(status)
411 410 if status else None),
412 411 status_change_type=status,
413 412 comment_type=comment_type,
414 413 resolves_comment_id=resolves_comment_id,
415 414 auth_user=self._rhodecode_user
416 415 )
417 416 is_inline = bool(comment.f_path and comment.line_no)
418 417
419 418 # get status if set !
420 419 if status:
421 420 # if latest status was from pull request and it's closed
422 421 # disallow changing status !
423 422 # dont_allow_on_closed_pull_request = True !
424 423
425 424 try:
426 425 ChangesetStatusModel().set_status(
427 426 self.db_repo.repo_id,
428 427 status,
429 428 self._rhodecode_db_user.user_id,
430 429 comment,
431 430 revision=current_id,
432 431 dont_allow_on_closed_pull_request=True
433 432 )
434 433 except StatusChangeOnClosedPullRequestError:
435 434 msg = _('Changing the status of a commit associated with '
436 435 'a closed pull request is not allowed')
437 436 log.exception(msg)
438 437 h.flash(msg, category='warning')
439 438 raise HTTPFound(h.route_path(
440 439 'repo_commit', repo_name=self.db_repo_name,
441 440 commit_id=current_id))
442 441
443 442 commit = self.db_repo.get_commit(current_id)
444 443 CommentsModel().trigger_commit_comment_hook(
445 444 self.db_repo, self._rhodecode_user, 'create',
446 445 data={'comment': comment, 'commit': commit})
447 446
448 447 # finalize, commit and redirect
449 448 Session().commit()
450 449
451 450 data = {
452 451 'target_id': h.safeid(h.safe_unicode(
453 452 self.request.POST.get('f_path'))),
454 453 }
455 454 if comment:
456 455 c.co = comment
457 456 c.at_version_num = 0
458 457 rendered_comment = render(
459 458 'rhodecode:templates/changeset/changeset_comment_block.mako',
460 459 self._get_template_context(c), self.request)
461 460
462 461 data.update(comment.get_dict())
463 462 data.update({'rendered_text': rendered_comment})
464 463
465 464 comment_broadcast_channel = channelstream.comment_channel(
466 465 self.db_repo_name, commit_obj=commit)
467 466
468 467 comment_data = data
469 468 comment_type = 'inline' if is_inline else 'general'
470 469 channelstream.comment_channelstream_push(
471 470 self.request, comment_broadcast_channel, self._rhodecode_user,
472 471 _('posted a new {} comment').format(comment_type),
473 472 comment_data=comment_data)
474 473
475 474 return data
476 475
477 476 @LoginRequired()
478 477 @NotAnonymous()
479 478 @HasRepoPermissionAnyDecorator(
480 479 'repository.read', 'repository.write', 'repository.admin')
481 480 @CSRFRequired()
482 481 @view_config(
483 482 route_name='repo_commit_comment_preview', request_method='POST',
484 483 renderer='string', xhr=True)
485 484 def repo_commit_comment_preview(self):
486 485 # Technically a CSRF token is not needed as no state changes with this
487 486 # call. However, as this is a POST is better to have it, so automated
488 487 # tools don't flag it as potential CSRF.
489 488 # Post is required because the payload could be bigger than the maximum
490 489 # allowed by GET.
491 490
492 491 text = self.request.POST.get('text')
493 492 renderer = self.request.POST.get('renderer') or 'rst'
494 493 if text:
495 494 return h.render(text, renderer=renderer, mentions=True,
496 495 repo_name=self.db_repo_name)
497 496 return ''
498 497
499 498 @LoginRequired()
500 499 @HasRepoPermissionAnyDecorator(
501 500 'repository.read', 'repository.write', 'repository.admin')
502 501 @CSRFRequired()
503 502 @view_config(
504 503 route_name='repo_commit_comment_history_view', request_method='POST',
505 504 renderer='string', xhr=True)
506 505 def repo_commit_comment_history_view(self):
507 506 c = self.load_default_context()
508 507
509 508 comment_history_id = self.request.matchdict['comment_history_id']
510 509 comment_history = ChangesetCommentHistory.get_or_404(comment_history_id)
511 510 is_repo_comment = comment_history.comment.repo.repo_id == self.db_repo.repo_id
512 511
513 512 if is_repo_comment:
514 513 c.comment_history = comment_history
515 514
516 515 rendered_comment = render(
517 516 'rhodecode:templates/changeset/comment_history.mako',
518 517 self._get_template_context(c)
519 518 , self.request)
520 519 return rendered_comment
521 520 else:
522 521 log.warning('No permissions for user %s to show comment_history_id: %s',
523 522 self._rhodecode_db_user, comment_history_id)
524 523 raise HTTPNotFound()
525 524
526 525 @LoginRequired()
527 526 @NotAnonymous()
528 527 @HasRepoPermissionAnyDecorator(
529 528 'repository.read', 'repository.write', 'repository.admin')
530 529 @CSRFRequired()
531 530 @view_config(
532 531 route_name='repo_commit_comment_attachment_upload', request_method='POST',
533 532 renderer='json_ext', xhr=True)
534 533 def repo_commit_comment_attachment_upload(self):
535 534 c = self.load_default_context()
536 535 upload_key = 'attachment'
537 536
538 537 file_obj = self.request.POST.get(upload_key)
539 538
540 539 if file_obj is None:
541 540 self.request.response.status = 400
542 541 return {'store_fid': None,
543 542 'access_path': None,
544 543 'error': '{} data field is missing'.format(upload_key)}
545 544
546 545 if not hasattr(file_obj, 'filename'):
547 546 self.request.response.status = 400
548 547 return {'store_fid': None,
549 548 'access_path': None,
550 549 'error': 'filename cannot be read from the data field'}
551 550
552 551 filename = file_obj.filename
553 552 file_display_name = filename
554 553
555 554 metadata = {
556 555 'user_uploaded': {'username': self._rhodecode_user.username,
557 556 'user_id': self._rhodecode_user.user_id,
558 557 'ip': self._rhodecode_user.ip_addr}}
559 558
560 559 # TODO(marcink): allow .ini configuration for allowed_extensions, and file-size
561 560 allowed_extensions = [
562 561 'gif', '.jpeg', '.jpg', '.png', '.docx', '.gz', '.log', '.pdf',
563 562 '.pptx', '.txt', '.xlsx', '.zip']
564 563 max_file_size = 10 * 1024 * 1024 # 10MB, also validated via dropzone.js
565 564
566 565 try:
567 566 storage = store_utils.get_file_storage(self.request.registry.settings)
568 567 store_uid, metadata = storage.save_file(
569 568 file_obj.file, filename, extra_metadata=metadata,
570 569 extensions=allowed_extensions, max_filesize=max_file_size)
571 570 except FileNotAllowedException:
572 571 self.request.response.status = 400
573 572 permitted_extensions = ', '.join(allowed_extensions)
574 573 error_msg = 'File `{}` is not allowed. ' \
575 574 'Only following extensions are permitted: {}'.format(
576 575 filename, permitted_extensions)
577 576 return {'store_fid': None,
578 577 'access_path': None,
579 578 'error': error_msg}
580 579 except FileOverSizeException:
581 580 self.request.response.status = 400
582 581 limit_mb = h.format_byte_size_binary(max_file_size)
583 582 return {'store_fid': None,
584 583 'access_path': None,
585 584 'error': 'File {} is exceeding allowed limit of {}.'.format(
586 585 filename, limit_mb)}
587 586
588 587 try:
589 588 entry = FileStore.create(
590 589 file_uid=store_uid, filename=metadata["filename"],
591 590 file_hash=metadata["sha256"], file_size=metadata["size"],
592 591 file_display_name=file_display_name,
593 592 file_description=u'comment attachment `{}`'.format(safe_unicode(filename)),
594 593 hidden=True, check_acl=True, user_id=self._rhodecode_user.user_id,
595 594 scope_repo_id=self.db_repo.repo_id
596 595 )
597 596 Session().add(entry)
598 597 Session().commit()
599 598 log.debug('Stored upload in DB as %s', entry)
600 599 except Exception:
601 600 log.exception('Failed to store file %s', filename)
602 601 self.request.response.status = 400
603 602 return {'store_fid': None,
604 603 'access_path': None,
605 604 'error': 'File {} failed to store in DB.'.format(filename)}
606 605
607 606 Session().commit()
608 607
609 608 return {
610 609 'store_fid': store_uid,
611 610 'access_path': h.route_path(
612 611 'download_file', fid=store_uid),
613 612 'fqn_access_path': h.route_url(
614 613 'download_file', fid=store_uid),
615 614 'repo_access_path': h.route_path(
616 615 'repo_artifacts_get', repo_name=self.db_repo_name, uid=store_uid),
617 616 'repo_fqn_access_path': h.route_url(
618 617 'repo_artifacts_get', repo_name=self.db_repo_name, uid=store_uid),
619 618 }
620 619
621 620 @LoginRequired()
622 621 @NotAnonymous()
623 622 @HasRepoPermissionAnyDecorator(
624 623 'repository.read', 'repository.write', 'repository.admin')
625 624 @CSRFRequired()
626 625 @view_config(
627 626 route_name='repo_commit_comment_delete', request_method='POST',
628 627 renderer='json_ext')
629 628 def repo_commit_comment_delete(self):
630 629 commit_id = self.request.matchdict['commit_id']
631 630 comment_id = self.request.matchdict['comment_id']
632 631
633 632 comment = ChangesetComment.get_or_404(comment_id)
634 633 if not comment:
635 634 log.debug('Comment with id:%s not found, skipping', comment_id)
636 635 # comment already deleted in another call probably
637 636 return True
638 637
639 638 if comment.immutable:
640 639 # don't allow deleting comments that are immutable
641 640 raise HTTPForbidden()
642 641
643 642 is_repo_admin = h.HasRepoPermissionAny('repository.admin')(self.db_repo_name)
644 643 super_admin = h.HasPermissionAny('hg.admin')()
645 644 comment_owner = (comment.author.user_id == self._rhodecode_db_user.user_id)
646 645 is_repo_comment = comment.repo.repo_id == self.db_repo.repo_id
647 646 comment_repo_admin = is_repo_admin and is_repo_comment
648 647
649 648 if super_admin or comment_owner or comment_repo_admin:
650 649 CommentsModel().delete(comment=comment, auth_user=self._rhodecode_user)
651 650 Session().commit()
652 651 return True
653 652 else:
654 653 log.warning('No permissions for user %s to delete comment_id: %s',
655 654 self._rhodecode_db_user, comment_id)
656 655 raise HTTPNotFound()
657 656
658 657 @LoginRequired()
659 658 @NotAnonymous()
660 659 @HasRepoPermissionAnyDecorator(
661 660 'repository.read', 'repository.write', 'repository.admin')
662 661 @CSRFRequired()
663 662 @view_config(
664 663 route_name='repo_commit_comment_edit', request_method='POST',
665 664 renderer='json_ext')
666 665 def repo_commit_comment_edit(self):
667 666 self.load_default_context()
668 667
669 668 comment_id = self.request.matchdict['comment_id']
670 669 comment = ChangesetComment.get_or_404(comment_id)
671 670
672 671 if comment.immutable:
673 672 # don't allow deleting comments that are immutable
674 673 raise HTTPForbidden()
675 674
676 675 is_repo_admin = h.HasRepoPermissionAny('repository.admin')(self.db_repo_name)
677 676 super_admin = h.HasPermissionAny('hg.admin')()
678 677 comment_owner = (comment.author.user_id == self._rhodecode_db_user.user_id)
679 678 is_repo_comment = comment.repo.repo_id == self.db_repo.repo_id
680 679 comment_repo_admin = is_repo_admin and is_repo_comment
681 680
682 681 if super_admin or comment_owner or comment_repo_admin:
683 682 text = self.request.POST.get('text')
684 683 version = self.request.POST.get('version')
685 684 if text == comment.text:
686 685 log.warning(
687 686 'Comment(repo): '
688 687 'Trying to create new version '
689 688 'with the same comment body {}'.format(
690 689 comment_id,
691 690 )
692 691 )
693 692 raise HTTPNotFound()
694 693
695 694 if version.isdigit():
696 695 version = int(version)
697 696 else:
698 697 log.warning(
699 698 'Comment(repo): Wrong version type {} {} '
700 699 'for comment {}'.format(
701 700 version,
702 701 type(version),
703 702 comment_id,
704 703 )
705 704 )
706 705 raise HTTPNotFound()
707 706
708 707 try:
709 708 comment_history = CommentsModel().edit(
710 709 comment_id=comment_id,
711 710 text=text,
712 711 auth_user=self._rhodecode_user,
713 712 version=version,
714 713 )
715 714 except CommentVersionMismatch:
716 715 raise HTTPConflict()
717 716
718 717 if not comment_history:
719 718 raise HTTPNotFound()
720 719
721 720 commit_id = self.request.matchdict['commit_id']
722 721 commit = self.db_repo.get_commit(commit_id)
723 722 CommentsModel().trigger_commit_comment_hook(
724 723 self.db_repo, self._rhodecode_user, 'edit',
725 724 data={'comment': comment, 'commit': commit})
726 725
727 726 Session().commit()
728 727 return {
729 728 'comment_history_id': comment_history.comment_history_id,
730 729 'comment_id': comment.comment_id,
731 730 'comment_version': comment_history.version,
732 731 'comment_author_username': comment_history.author.username,
733 732 'comment_author_gravatar': h.gravatar_url(comment_history.author.email, 16),
734 733 'comment_created_on': h.age_component(comment_history.created_on,
735 734 time_is_local=True),
736 735 }
737 736 else:
738 737 log.warning('No permissions for user %s to edit comment_id: %s',
739 738 self._rhodecode_db_user, comment_id)
740 739 raise HTTPNotFound()
741 740
742 741 @LoginRequired()
743 742 @HasRepoPermissionAnyDecorator(
744 743 'repository.read', 'repository.write', 'repository.admin')
745 744 @view_config(
746 745 route_name='repo_commit_data', request_method='GET',
747 746 renderer='json_ext', xhr=True)
748 747 def repo_commit_data(self):
749 748 commit_id = self.request.matchdict['commit_id']
750 749 self.load_default_context()
751 750
752 751 try:
753 752 return self.rhodecode_vcs_repo.get_commit(commit_id=commit_id)
754 753 except CommitDoesNotExistError as e:
755 754 return EmptyCommit(message=str(e))
756 755
757 756 @LoginRequired()
758 757 @HasRepoPermissionAnyDecorator(
759 758 'repository.read', 'repository.write', 'repository.admin')
760 759 @view_config(
761 760 route_name='repo_commit_children', request_method='GET',
762 761 renderer='json_ext', xhr=True)
763 762 def repo_commit_children(self):
764 763 commit_id = self.request.matchdict['commit_id']
765 764 self.load_default_context()
766 765
767 766 try:
768 767 commit = self.rhodecode_vcs_repo.get_commit(commit_id=commit_id)
769 768 children = commit.children
770 769 except CommitDoesNotExistError:
771 770 children = []
772 771
773 772 result = {"results": children}
774 773 return result
775 774
776 775 @LoginRequired()
777 776 @HasRepoPermissionAnyDecorator(
778 777 'repository.read', 'repository.write', 'repository.admin')
779 778 @view_config(
780 779 route_name='repo_commit_parents', request_method='GET',
781 780 renderer='json_ext')
782 781 def repo_commit_parents(self):
783 782 commit_id = self.request.matchdict['commit_id']
784 783 self.load_default_context()
785 784
786 785 try:
787 786 commit = self.rhodecode_vcs_repo.get_commit(commit_id=commit_id)
788 787 parents = commit.parents
789 788 except CommitDoesNotExistError:
790 789 parents = []
791 790 result = {"results": parents}
792 791 return result
@@ -1,1812 +1,1813 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2011-2020 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, HTTPConflict)
29 29 from pyramid.view import view_config
30 30 from pyramid.renderers import render
31 31
32 32 from rhodecode.apps._base import RepoAppView, DataGridAppView
33 33
34 34 from rhodecode.lib import helpers as h, diffs, codeblocks, channelstream
35 35 from rhodecode.lib.base import vcs_operation_context
36 36 from rhodecode.lib.diffs import load_cached_diff, cache_diff, diff_cache_exist
37 37 from rhodecode.lib.exceptions import CommentVersionMismatch
38 38 from rhodecode.lib.ext_json import json
39 39 from rhodecode.lib.auth import (
40 40 LoginRequired, HasRepoPermissionAny, HasRepoPermissionAnyDecorator,
41 41 NotAnonymous, CSRFRequired)
42 42 from rhodecode.lib.utils2 import str2bool, safe_str, safe_unicode, safe_int, aslist
43 43 from rhodecode.lib.vcs.backends.base import EmptyCommit, UpdateFailureReason, Reference
44 44 from rhodecode.lib.vcs.exceptions import (
45 45 CommitDoesNotExistError, RepositoryRequirementError, EmptyRepositoryError)
46 46 from rhodecode.model.changeset_status import ChangesetStatusModel
47 47 from rhodecode.model.comment import CommentsModel
48 48 from rhodecode.model.db import (
49 49 func, or_, PullRequest, ChangesetComment, ChangesetStatus, Repository,
50 50 PullRequestReviewers)
51 51 from rhodecode.model.forms import PullRequestForm
52 52 from rhodecode.model.meta import Session
53 53 from rhodecode.model.pull_request import PullRequestModel, MergeCheck
54 54 from rhodecode.model.scm import ScmModel
55 55
56 56 log = logging.getLogger(__name__)
57 57
58 58
59 59 class RepoPullRequestsView(RepoAppView, DataGridAppView):
60 60
61 61 def load_default_context(self):
62 62 c = self._get_local_tmpl_context(include_app_defaults=True)
63 63 c.REVIEW_STATUS_APPROVED = ChangesetStatus.STATUS_APPROVED
64 64 c.REVIEW_STATUS_REJECTED = ChangesetStatus.STATUS_REJECTED
65 65 # backward compat., we use for OLD PRs a plain renderer
66 66 c.renderer = 'plain'
67 67 return c
68 68
69 69 def _get_pull_requests_list(
70 70 self, repo_name, source, filter_type, opened_by, statuses):
71 71
72 72 draw, start, limit = self._extract_chunk(self.request)
73 73 search_q, order_by, order_dir = self._extract_ordering(self.request)
74 74 _render = self.request.get_partial_renderer(
75 75 'rhodecode:templates/data_table/_dt_elements.mako')
76 76
77 77 # pagination
78 78
79 79 if filter_type == 'awaiting_review':
80 80 pull_requests = PullRequestModel().get_awaiting_review(
81 81 repo_name, search_q=search_q, source=source, opened_by=opened_by,
82 82 statuses=statuses, offset=start, length=limit,
83 83 order_by=order_by, order_dir=order_dir)
84 84 pull_requests_total_count = PullRequestModel().count_awaiting_review(
85 85 repo_name, search_q=search_q, source=source, statuses=statuses,
86 86 opened_by=opened_by)
87 87 elif filter_type == 'awaiting_my_review':
88 88 pull_requests = PullRequestModel().get_awaiting_my_review(
89 89 repo_name, search_q=search_q, source=source, opened_by=opened_by,
90 90 user_id=self._rhodecode_user.user_id, statuses=statuses,
91 91 offset=start, length=limit, order_by=order_by,
92 92 order_dir=order_dir)
93 93 pull_requests_total_count = PullRequestModel().count_awaiting_my_review(
94 94 repo_name, search_q=search_q, source=source, user_id=self._rhodecode_user.user_id,
95 95 statuses=statuses, opened_by=opened_by)
96 96 else:
97 97 pull_requests = PullRequestModel().get_all(
98 98 repo_name, search_q=search_q, source=source, opened_by=opened_by,
99 99 statuses=statuses, offset=start, length=limit,
100 100 order_by=order_by, order_dir=order_dir)
101 101 pull_requests_total_count = PullRequestModel().count_all(
102 102 repo_name, search_q=search_q, source=source, statuses=statuses,
103 103 opened_by=opened_by)
104 104
105 105 data = []
106 106 comments_model = CommentsModel()
107 107 for pr in pull_requests:
108 108 comments_count = comments_model.get_all_comments(
109 109 self.db_repo.repo_id, pull_request=pr, count_only=True)
110 110
111 111 data.append({
112 112 'name': _render('pullrequest_name',
113 113 pr.pull_request_id, pr.pull_request_state,
114 114 pr.work_in_progress, pr.target_repo.repo_name,
115 115 short=True),
116 116 'name_raw': pr.pull_request_id,
117 117 'status': _render('pullrequest_status',
118 118 pr.calculated_review_status()),
119 119 'title': _render('pullrequest_title', pr.title, pr.description),
120 120 'description': h.escape(pr.description),
121 121 'updated_on': _render('pullrequest_updated_on',
122 122 h.datetime_to_time(pr.updated_on)),
123 123 'updated_on_raw': h.datetime_to_time(pr.updated_on),
124 124 'created_on': _render('pullrequest_updated_on',
125 125 h.datetime_to_time(pr.created_on)),
126 126 'created_on_raw': h.datetime_to_time(pr.created_on),
127 127 'state': pr.pull_request_state,
128 128 'author': _render('pullrequest_author',
129 129 pr.author.full_contact, ),
130 130 'author_raw': pr.author.full_name,
131 131 'comments': _render('pullrequest_comments', comments_count),
132 132 'comments_raw': comments_count,
133 133 'closed': pr.is_closed(),
134 134 })
135 135
136 136 data = ({
137 137 'draw': draw,
138 138 'data': data,
139 139 'recordsTotal': pull_requests_total_count,
140 140 'recordsFiltered': pull_requests_total_count,
141 141 })
142 142 return data
143 143
144 144 @LoginRequired()
145 145 @HasRepoPermissionAnyDecorator(
146 146 'repository.read', 'repository.write', 'repository.admin')
147 147 @view_config(
148 148 route_name='pullrequest_show_all', request_method='GET',
149 149 renderer='rhodecode:templates/pullrequests/pullrequests.mako')
150 150 def pull_request_list(self):
151 151 c = self.load_default_context()
152 152
153 153 req_get = self.request.GET
154 154 c.source = str2bool(req_get.get('source'))
155 155 c.closed = str2bool(req_get.get('closed'))
156 156 c.my = str2bool(req_get.get('my'))
157 157 c.awaiting_review = str2bool(req_get.get('awaiting_review'))
158 158 c.awaiting_my_review = str2bool(req_get.get('awaiting_my_review'))
159 159
160 160 c.active = 'open'
161 161 if c.my:
162 162 c.active = 'my'
163 163 if c.closed:
164 164 c.active = 'closed'
165 165 if c.awaiting_review and not c.source:
166 166 c.active = 'awaiting'
167 167 if c.source and not c.awaiting_review:
168 168 c.active = 'source'
169 169 if c.awaiting_my_review:
170 170 c.active = 'awaiting_my'
171 171
172 172 return self._get_template_context(c)
173 173
174 174 @LoginRequired()
175 175 @HasRepoPermissionAnyDecorator(
176 176 'repository.read', 'repository.write', 'repository.admin')
177 177 @view_config(
178 178 route_name='pullrequest_show_all_data', request_method='GET',
179 179 renderer='json_ext', xhr=True)
180 180 def pull_request_list_data(self):
181 181 self.load_default_context()
182 182
183 183 # additional filters
184 184 req_get = self.request.GET
185 185 source = str2bool(req_get.get('source'))
186 186 closed = str2bool(req_get.get('closed'))
187 187 my = str2bool(req_get.get('my'))
188 188 awaiting_review = str2bool(req_get.get('awaiting_review'))
189 189 awaiting_my_review = str2bool(req_get.get('awaiting_my_review'))
190 190
191 191 filter_type = 'awaiting_review' if awaiting_review \
192 192 else 'awaiting_my_review' if awaiting_my_review \
193 193 else None
194 194
195 195 opened_by = None
196 196 if my:
197 197 opened_by = [self._rhodecode_user.user_id]
198 198
199 199 statuses = [PullRequest.STATUS_NEW, PullRequest.STATUS_OPEN]
200 200 if closed:
201 201 statuses = [PullRequest.STATUS_CLOSED]
202 202
203 203 data = self._get_pull_requests_list(
204 204 repo_name=self.db_repo_name, source=source,
205 205 filter_type=filter_type, opened_by=opened_by, statuses=statuses)
206 206
207 207 return data
208 208
209 209 def _is_diff_cache_enabled(self, target_repo):
210 210 caching_enabled = self._get_general_setting(
211 211 target_repo, 'rhodecode_diff_cache')
212 212 log.debug('Diff caching enabled: %s', caching_enabled)
213 213 return caching_enabled
214 214
215 215 def _get_diffset(self, source_repo_name, source_repo,
216 216 ancestor_commit,
217 217 source_ref_id, target_ref_id,
218 218 target_commit, source_commit, diff_limit, file_limit,
219 219 fulldiff, hide_whitespace_changes, diff_context, use_ancestor=True):
220 220
221 221 if use_ancestor:
222 222 # we might want to not use it for versions
223 223 target_ref_id = ancestor_commit.raw_id
224 224
225 225 vcs_diff = PullRequestModel().get_diff(
226 226 source_repo, source_ref_id, target_ref_id,
227 227 hide_whitespace_changes, diff_context)
228 228
229 229 diff_processor = diffs.DiffProcessor(
230 230 vcs_diff, format='newdiff', diff_limit=diff_limit,
231 231 file_limit=file_limit, show_full_diff=fulldiff)
232 232
233 233 _parsed = diff_processor.prepare()
234 234
235 235 diffset = codeblocks.DiffSet(
236 236 repo_name=self.db_repo_name,
237 237 source_repo_name=source_repo_name,
238 238 source_node_getter=codeblocks.diffset_node_getter(target_commit),
239 239 target_node_getter=codeblocks.diffset_node_getter(source_commit),
240 240 )
241 241 diffset = self.path_filter.render_patchset_filtered(
242 242 diffset, _parsed, target_commit.raw_id, source_commit.raw_id)
243 243
244 244 return diffset
245 245
246 246 def _get_range_diffset(self, source_scm, source_repo,
247 247 commit1, commit2, diff_limit, file_limit,
248 248 fulldiff, hide_whitespace_changes, diff_context):
249 249 vcs_diff = source_scm.get_diff(
250 250 commit1, commit2,
251 251 ignore_whitespace=hide_whitespace_changes,
252 252 context=diff_context)
253 253
254 254 diff_processor = diffs.DiffProcessor(
255 255 vcs_diff, format='newdiff', diff_limit=diff_limit,
256 256 file_limit=file_limit, show_full_diff=fulldiff)
257 257
258 258 _parsed = diff_processor.prepare()
259 259
260 260 diffset = codeblocks.DiffSet(
261 261 repo_name=source_repo.repo_name,
262 262 source_node_getter=codeblocks.diffset_node_getter(commit1),
263 263 target_node_getter=codeblocks.diffset_node_getter(commit2))
264 264
265 265 diffset = self.path_filter.render_patchset_filtered(
266 266 diffset, _parsed, commit1.raw_id, commit2.raw_id)
267 267
268 268 return diffset
269 269
270 270 def register_comments_vars(self, c, pull_request, versions):
271 271 comments_model = CommentsModel()
272 272
273 273 # GENERAL COMMENTS with versions #
274 274 q = comments_model._all_general_comments_of_pull_request(pull_request)
275 275 q = q.order_by(ChangesetComment.comment_id.asc())
276 276 general_comments = q
277 277
278 278 # pick comments we want to render at current version
279 279 c.comment_versions = comments_model.aggregate_comments(
280 280 general_comments, versions, c.at_version_num)
281 281
282 282 # INLINE COMMENTS with versions #
283 283 q = comments_model._all_inline_comments_of_pull_request(pull_request)
284 284 q = q.order_by(ChangesetComment.comment_id.asc())
285 285 inline_comments = q
286 286
287 287 c.inline_versions = comments_model.aggregate_comments(
288 288 inline_comments, versions, c.at_version_num, inline=True)
289 289
290 290 # Comments inline+general
291 291 if c.at_version:
292 292 c.inline_comments_flat = c.inline_versions[c.at_version_num]['display']
293 293 c.comments = c.comment_versions[c.at_version_num]['display']
294 294 else:
295 295 c.inline_comments_flat = c.inline_versions[c.at_version_num]['until']
296 296 c.comments = c.comment_versions[c.at_version_num]['until']
297 297
298 298 return general_comments, inline_comments
299 299
300 300 @LoginRequired()
301 301 @HasRepoPermissionAnyDecorator(
302 302 'repository.read', 'repository.write', 'repository.admin')
303 303 @view_config(
304 304 route_name='pullrequest_show', request_method='GET',
305 305 renderer='rhodecode:templates/pullrequests/pullrequest_show.mako')
306 306 def pull_request_show(self):
307 307 _ = self.request.translate
308 308 c = self.load_default_context()
309 309
310 310 pull_request = PullRequest.get_or_404(
311 311 self.request.matchdict['pull_request_id'])
312 312 pull_request_id = pull_request.pull_request_id
313 313
314 314 c.state_progressing = pull_request.is_state_changing()
315 315 c.pr_broadcast_channel = channelstream.pr_channel(pull_request)
316 316
317 317 _new_state = {
318 318 'created': PullRequest.STATE_CREATED,
319 319 }.get(self.request.GET.get('force_state'))
320 320
321 321 if c.is_super_admin and _new_state:
322 322 with pull_request.set_state(PullRequest.STATE_UPDATING, final_state=_new_state):
323 323 h.flash(
324 324 _('Pull Request state was force changed to `{}`').format(_new_state),
325 325 category='success')
326 326 Session().commit()
327 327
328 328 raise HTTPFound(h.route_path(
329 329 'pullrequest_show', repo_name=self.db_repo_name,
330 330 pull_request_id=pull_request_id))
331 331
332 332 version = self.request.GET.get('version')
333 333 from_version = self.request.GET.get('from_version') or version
334 334 merge_checks = self.request.GET.get('merge_checks')
335 335 c.fulldiff = str2bool(self.request.GET.get('fulldiff'))
336 336 force_refresh = str2bool(self.request.GET.get('force_refresh'))
337 337 c.range_diff_on = self.request.GET.get('range-diff') == "1"
338 338
339 339 # fetch global flags of ignore ws or context lines
340 340 diff_context = diffs.get_diff_context(self.request)
341 341 hide_whitespace_changes = diffs.get_diff_whitespace_flag(self.request)
342 342
343 343 (pull_request_latest,
344 344 pull_request_at_ver,
345 345 pull_request_display_obj,
346 346 at_version) = PullRequestModel().get_pr_version(
347 347 pull_request_id, version=version)
348 348
349 349 pr_closed = pull_request_latest.is_closed()
350 350
351 351 if pr_closed and (version or from_version):
352 352 # not allow to browse versions for closed PR
353 353 raise HTTPFound(h.route_path(
354 354 'pullrequest_show', repo_name=self.db_repo_name,
355 355 pull_request_id=pull_request_id))
356 356
357 357 versions = pull_request_display_obj.versions()
358 358 # used to store per-commit range diffs
359 359 c.changes = collections.OrderedDict()
360 360
361 361 c.at_version = at_version
362 362 c.at_version_num = (at_version
363 363 if at_version and at_version != PullRequest.LATEST_VER
364 364 else None)
365 365
366 366 c.at_version_index = ChangesetComment.get_index_from_version(
367 367 c.at_version_num, versions)
368 368
369 369 (prev_pull_request_latest,
370 370 prev_pull_request_at_ver,
371 371 prev_pull_request_display_obj,
372 372 prev_at_version) = PullRequestModel().get_pr_version(
373 373 pull_request_id, version=from_version)
374 374
375 375 c.from_version = prev_at_version
376 376 c.from_version_num = (prev_at_version
377 377 if prev_at_version and prev_at_version != PullRequest.LATEST_VER
378 378 else None)
379 379 c.from_version_index = ChangesetComment.get_index_from_version(
380 380 c.from_version_num, versions)
381 381
382 382 # define if we're in COMPARE mode or VIEW at version mode
383 383 compare = at_version != prev_at_version
384 384
385 385 # pull_requests repo_name we opened it against
386 386 # ie. target_repo must match
387 387 if self.db_repo_name != pull_request_at_ver.target_repo.repo_name:
388 388 log.warning('Mismatch between the current repo: %s, and target %s',
389 389 self.db_repo_name, pull_request_at_ver.target_repo.repo_name)
390 390 raise HTTPNotFound()
391 391
392 392 c.shadow_clone_url = PullRequestModel().get_shadow_clone_url(pull_request_at_ver)
393 393
394 394 c.pull_request = pull_request_display_obj
395 395 c.renderer = pull_request_at_ver.description_renderer or c.renderer
396 396 c.pull_request_latest = pull_request_latest
397 397
398 398 # inject latest version
399 399 latest_ver = PullRequest.get_pr_display_object(pull_request_latest, pull_request_latest)
400 400 c.versions = versions + [latest_ver]
401 401
402 402 if compare or (at_version and not at_version == PullRequest.LATEST_VER):
403 403 c.allowed_to_change_status = False
404 404 c.allowed_to_update = False
405 405 c.allowed_to_merge = False
406 406 c.allowed_to_delete = False
407 407 c.allowed_to_comment = False
408 408 c.allowed_to_close = False
409 409 else:
410 410 can_change_status = PullRequestModel().check_user_change_status(
411 411 pull_request_at_ver, self._rhodecode_user)
412 412 c.allowed_to_change_status = can_change_status and not pr_closed
413 413
414 414 c.allowed_to_update = PullRequestModel().check_user_update(
415 415 pull_request_latest, self._rhodecode_user) and not pr_closed
416 416 c.allowed_to_merge = PullRequestModel().check_user_merge(
417 417 pull_request_latest, self._rhodecode_user) and not pr_closed
418 418 c.allowed_to_delete = PullRequestModel().check_user_delete(
419 419 pull_request_latest, self._rhodecode_user) and not pr_closed
420 420 c.allowed_to_comment = not pr_closed
421 421 c.allowed_to_close = c.allowed_to_merge and not pr_closed
422 422
423 423 c.forbid_adding_reviewers = False
424 424 c.forbid_author_to_review = False
425 425 c.forbid_commit_author_to_review = False
426 426
427 427 if pull_request_latest.reviewer_data and \
428 428 'rules' in pull_request_latest.reviewer_data:
429 429 rules = pull_request_latest.reviewer_data['rules'] or {}
430 430 try:
431 431 c.forbid_adding_reviewers = rules.get('forbid_adding_reviewers')
432 432 c.forbid_author_to_review = rules.get('forbid_author_to_review')
433 433 c.forbid_commit_author_to_review = rules.get('forbid_commit_author_to_review')
434 434 except Exception:
435 435 pass
436 436
437 437 # check merge capabilities
438 438 _merge_check = MergeCheck.validate(
439 439 pull_request_latest, auth_user=self._rhodecode_user,
440 440 translator=self.request.translate,
441 441 force_shadow_repo_refresh=force_refresh)
442 442
443 443 c.pr_merge_errors = _merge_check.error_details
444 444 c.pr_merge_possible = not _merge_check.failed
445 445 c.pr_merge_message = _merge_check.merge_msg
446 446 c.pr_merge_source_commit = _merge_check.source_commit
447 447 c.pr_merge_target_commit = _merge_check.target_commit
448 448
449 449 c.pr_merge_info = MergeCheck.get_merge_conditions(
450 450 pull_request_latest, translator=self.request.translate)
451 451
452 452 c.pull_request_review_status = _merge_check.review_status
453 453 if merge_checks:
454 454 self.request.override_renderer = \
455 455 'rhodecode:templates/pullrequests/pullrequest_merge_checks.mako'
456 456 return self._get_template_context(c)
457 457
458 c.allowed_reviewers = [obj.user_id for obj in pull_request.reviewers if obj.user]
459 458 c.reviewers_count = pull_request.reviewers_count
460 459 c.observers_count = pull_request.observers_count
461 460
462 461 # reviewers and statuses
463 462 c.pull_request_default_reviewers_data_json = json.dumps(pull_request.reviewer_data)
464 463 c.pull_request_set_reviewers_data_json = collections.OrderedDict({'reviewers': []})
465 464 c.pull_request_set_observers_data_json = collections.OrderedDict({'observers': []})
466 465
467 466 for review_obj, member, reasons, mandatory, status in pull_request_at_ver.reviewers_statuses():
468 467 member_reviewer = h.reviewer_as_json(
469 468 member, reasons=reasons, mandatory=mandatory,
470 469 role=review_obj.role,
471 470 user_group=review_obj.rule_user_group_data()
472 471 )
473 472
474 473 current_review_status = status[0][1].status if status else ChangesetStatus.STATUS_NOT_REVIEWED
475 474 member_reviewer['review_status'] = current_review_status
476 475 member_reviewer['review_status_label'] = h.commit_status_lbl(current_review_status)
477 476 member_reviewer['allowed_to_update'] = c.allowed_to_update
478 477 c.pull_request_set_reviewers_data_json['reviewers'].append(member_reviewer)
479 478
480 479 c.pull_request_set_reviewers_data_json = json.dumps(c.pull_request_set_reviewers_data_json)
481 480
482 481 for observer_obj, member in pull_request_at_ver.observers():
483 482 member_observer = h.reviewer_as_json(
484 483 member, reasons=[], mandatory=False,
485 484 role=observer_obj.role,
486 485 user_group=observer_obj.rule_user_group_data()
487 486 )
488 487 member_observer['allowed_to_update'] = c.allowed_to_update
489 488 c.pull_request_set_observers_data_json['observers'].append(member_observer)
490 489
491 490 c.pull_request_set_observers_data_json = json.dumps(c.pull_request_set_observers_data_json)
492 491
493 492 general_comments, inline_comments = \
494 493 self.register_comments_vars(c, pull_request_latest, versions)
495 494
496 495 # TODOs
497 496 c.unresolved_comments = CommentsModel() \
498 497 .get_pull_request_unresolved_todos(pull_request_latest)
499 498 c.resolved_comments = CommentsModel() \
500 499 .get_pull_request_resolved_todos(pull_request_latest)
501 500
502 501 # if we use version, then do not show later comments
503 502 # than current version
504 503 display_inline_comments = collections.defaultdict(
505 504 lambda: collections.defaultdict(list))
506 505 for co in inline_comments:
507 506 if c.at_version_num:
508 507 # pick comments that are at least UPTO given version, so we
509 508 # don't render comments for higher version
510 509 should_render = co.pull_request_version_id and \
511 510 co.pull_request_version_id <= c.at_version_num
512 511 else:
513 512 # showing all, for 'latest'
514 513 should_render = True
515 514
516 515 if should_render:
517 516 display_inline_comments[co.f_path][co.line_no].append(co)
518 517
519 518 # load diff data into template context, if we use compare mode then
520 519 # diff is calculated based on changes between versions of PR
521 520
522 521 source_repo = pull_request_at_ver.source_repo
523 522 source_ref_id = pull_request_at_ver.source_ref_parts.commit_id
524 523
525 524 target_repo = pull_request_at_ver.target_repo
526 525 target_ref_id = pull_request_at_ver.target_ref_parts.commit_id
527 526
528 527 if compare:
529 528 # in compare switch the diff base to latest commit from prev version
530 529 target_ref_id = prev_pull_request_display_obj.revisions[0]
531 530
532 531 # despite opening commits for bookmarks/branches/tags, we always
533 532 # convert this to rev to prevent changes after bookmark or branch change
534 533 c.source_ref_type = 'rev'
535 534 c.source_ref = source_ref_id
536 535
537 536 c.target_ref_type = 'rev'
538 537 c.target_ref = target_ref_id
539 538
540 539 c.source_repo = source_repo
541 540 c.target_repo = target_repo
542 541
543 542 c.commit_ranges = []
544 543 source_commit = EmptyCommit()
545 544 target_commit = EmptyCommit()
546 545 c.missing_requirements = False
547 546
548 547 source_scm = source_repo.scm_instance()
549 548 target_scm = target_repo.scm_instance()
550 549
551 550 shadow_scm = None
552 551 try:
553 552 shadow_scm = pull_request_latest.get_shadow_repo()
554 553 except Exception:
555 554 log.debug('Failed to get shadow repo', exc_info=True)
556 555 # try first the existing source_repo, and then shadow
557 556 # repo if we can obtain one
558 557 commits_source_repo = source_scm
559 558 if shadow_scm:
560 559 commits_source_repo = shadow_scm
561 560
562 561 c.commits_source_repo = commits_source_repo
563 562 c.ancestor = None # set it to None, to hide it from PR view
564 563
565 564 # empty version means latest, so we keep this to prevent
566 565 # double caching
567 566 version_normalized = version or PullRequest.LATEST_VER
568 567 from_version_normalized = from_version or PullRequest.LATEST_VER
569 568
570 569 cache_path = self.rhodecode_vcs_repo.get_create_shadow_cache_pr_path(target_repo)
571 570 cache_file_path = diff_cache_exist(
572 571 cache_path, 'pull_request', pull_request_id, version_normalized,
573 572 from_version_normalized, source_ref_id, target_ref_id,
574 573 hide_whitespace_changes, diff_context, c.fulldiff)
575 574
576 575 caching_enabled = self._is_diff_cache_enabled(c.target_repo)
577 576 force_recache = self.get_recache_flag()
578 577
579 578 cached_diff = None
580 579 if caching_enabled:
581 580 cached_diff = load_cached_diff(cache_file_path)
582 581
583 582 has_proper_commit_cache = (
584 583 cached_diff and cached_diff.get('commits')
585 584 and len(cached_diff.get('commits', [])) == 5
586 585 and cached_diff.get('commits')[0]
587 586 and cached_diff.get('commits')[3])
588 587
589 588 if not force_recache and not c.range_diff_on and has_proper_commit_cache:
590 589 diff_commit_cache = \
591 590 (ancestor_commit, commit_cache, missing_requirements,
592 591 source_commit, target_commit) = cached_diff['commits']
593 592 else:
594 593 # NOTE(marcink): we reach potentially unreachable errors when a PR has
595 594 # merge errors resulting in potentially hidden commits in the shadow repo.
596 595 maybe_unreachable = _merge_check.MERGE_CHECK in _merge_check.error_details \
597 596 and _merge_check.merge_response
598 597 maybe_unreachable = maybe_unreachable \
599 598 and _merge_check.merge_response.metadata.get('unresolved_files')
600 599 log.debug("Using unreachable commits due to MERGE_CHECK in merge simulation")
601 600 diff_commit_cache = \
602 601 (ancestor_commit, commit_cache, missing_requirements,
603 602 source_commit, target_commit) = self.get_commits(
604 603 commits_source_repo,
605 604 pull_request_at_ver,
606 605 source_commit,
607 606 source_ref_id,
608 607 source_scm,
609 608 target_commit,
610 609 target_ref_id,
611 610 target_scm,
612 611 maybe_unreachable=maybe_unreachable)
613 612
614 613 # register our commit range
615 614 for comm in commit_cache.values():
616 615 c.commit_ranges.append(comm)
617 616
618 617 c.missing_requirements = missing_requirements
619 618 c.ancestor_commit = ancestor_commit
620 619 c.statuses = source_repo.statuses(
621 620 [x.raw_id for x in c.commit_ranges])
622 621
623 622 # auto collapse if we have more than limit
624 623 collapse_limit = diffs.DiffProcessor._collapse_commits_over
625 624 c.collapse_all_commits = len(c.commit_ranges) > collapse_limit
626 625 c.compare_mode = compare
627 626
628 627 # diff_limit is the old behavior, will cut off the whole diff
629 628 # if the limit is applied otherwise will just hide the
630 629 # big files from the front-end
631 630 diff_limit = c.visual.cut_off_limit_diff
632 631 file_limit = c.visual.cut_off_limit_file
633 632
634 633 c.missing_commits = False
635 634 if (c.missing_requirements
636 635 or isinstance(source_commit, EmptyCommit)
637 636 or source_commit == target_commit):
638 637
639 638 c.missing_commits = True
640 639 else:
641 640 c.inline_comments = display_inline_comments
642 641
643 642 use_ancestor = True
644 643 if from_version_normalized != version_normalized:
645 644 use_ancestor = False
646 645
647 646 has_proper_diff_cache = cached_diff and cached_diff.get('commits')
648 647 if not force_recache and has_proper_diff_cache:
649 648 c.diffset = cached_diff['diff']
650 649 else:
651 650 try:
652 651 c.diffset = self._get_diffset(
653 652 c.source_repo.repo_name, commits_source_repo,
654 653 c.ancestor_commit,
655 654 source_ref_id, target_ref_id,
656 655 target_commit, source_commit,
657 656 diff_limit, file_limit, c.fulldiff,
658 657 hide_whitespace_changes, diff_context,
659 658 use_ancestor=use_ancestor
660 659 )
661 660
662 661 # save cached diff
663 662 if caching_enabled:
664 663 cache_diff(cache_file_path, c.diffset, diff_commit_cache)
665 664 except CommitDoesNotExistError:
666 665 log.exception('Failed to generate diffset')
667 666 c.missing_commits = True
668 667
669 668 if not c.missing_commits:
670 669
671 670 c.limited_diff = c.diffset.limited_diff
672 671
673 672 # calculate removed files that are bound to comments
674 673 comment_deleted_files = [
675 674 fname for fname in display_inline_comments
676 675 if fname not in c.diffset.file_stats]
677 676
678 677 c.deleted_files_comments = collections.defaultdict(dict)
679 678 for fname, per_line_comments in display_inline_comments.items():
680 679 if fname in comment_deleted_files:
681 680 c.deleted_files_comments[fname]['stats'] = 0
682 681 c.deleted_files_comments[fname]['comments'] = list()
683 682 for lno, comments in per_line_comments.items():
684 683 c.deleted_files_comments[fname]['comments'].extend(comments)
685 684
686 685 # maybe calculate the range diff
687 686 if c.range_diff_on:
688 687 # TODO(marcink): set whitespace/context
689 688 context_lcl = 3
690 689 ign_whitespace_lcl = False
691 690
692 691 for commit in c.commit_ranges:
693 692 commit2 = commit
694 693 commit1 = commit.first_parent
695 694
696 695 range_diff_cache_file_path = diff_cache_exist(
697 696 cache_path, 'diff', commit.raw_id,
698 697 ign_whitespace_lcl, context_lcl, c.fulldiff)
699 698
700 699 cached_diff = None
701 700 if caching_enabled:
702 701 cached_diff = load_cached_diff(range_diff_cache_file_path)
703 702
704 703 has_proper_diff_cache = cached_diff and cached_diff.get('diff')
705 704 if not force_recache and has_proper_diff_cache:
706 705 diffset = cached_diff['diff']
707 706 else:
708 707 diffset = self._get_range_diffset(
709 708 commits_source_repo, source_repo,
710 709 commit1, commit2, diff_limit, file_limit,
711 710 c.fulldiff, ign_whitespace_lcl, context_lcl
712 711 )
713 712
714 713 # save cached diff
715 714 if caching_enabled:
716 715 cache_diff(range_diff_cache_file_path, diffset, None)
717 716
718 717 c.changes[commit.raw_id] = diffset
719 718
720 719 # this is a hack to properly display links, when creating PR, the
721 720 # compare view and others uses different notation, and
722 721 # compare_commits.mako renders links based on the target_repo.
723 722 # We need to swap that here to generate it properly on the html side
724 723 c.target_repo = c.source_repo
725 724
726 725 c.commit_statuses = ChangesetStatus.STATUSES
727 726
728 727 c.show_version_changes = not pr_closed
729 728 if c.show_version_changes:
730 729 cur_obj = pull_request_at_ver
731 730 prev_obj = prev_pull_request_at_ver
732 731
733 732 old_commit_ids = prev_obj.revisions
734 733 new_commit_ids = cur_obj.revisions
735 734 commit_changes = PullRequestModel()._calculate_commit_id_changes(
736 735 old_commit_ids, new_commit_ids)
737 736 c.commit_changes_summary = commit_changes
738 737
739 738 # calculate the diff for commits between versions
740 739 c.commit_changes = []
741 740
742 741 def mark(cs, fw):
743 742 return list(h.itertools.izip_longest([], cs, fillvalue=fw))
744 743
745 744 for c_type, raw_id in mark(commit_changes.added, 'a') \
746 745 + mark(commit_changes.removed, 'r') \
747 746 + mark(commit_changes.common, 'c'):
748 747
749 748 if raw_id in commit_cache:
750 749 commit = commit_cache[raw_id]
751 750 else:
752 751 try:
753 752 commit = commits_source_repo.get_commit(raw_id)
754 753 except CommitDoesNotExistError:
755 754 # in case we fail extracting still use "dummy" commit
756 755 # for display in commit diff
757 756 commit = h.AttributeDict(
758 757 {'raw_id': raw_id,
759 758 'message': 'EMPTY or MISSING COMMIT'})
760 759 c.commit_changes.append([c_type, commit])
761 760
762 761 # current user review statuses for each version
763 762 c.review_versions = {}
764 if self._rhodecode_user.user_id in c.allowed_reviewers:
763 is_reviewer = PullRequestModel().is_user_reviewer(
764 pull_request, self._rhodecode_user)
765 if is_reviewer:
765 766 for co in general_comments:
766 767 if co.author.user_id == self._rhodecode_user.user_id:
767 768 status = co.status_change
768 769 if status:
769 770 _ver_pr = status[0].comment.pull_request_version_id
770 771 c.review_versions[_ver_pr] = status[0]
771 772
772 773 return self._get_template_context(c)
773 774
774 775 def get_commits(
775 776 self, commits_source_repo, pull_request_at_ver, source_commit,
776 777 source_ref_id, source_scm, target_commit, target_ref_id, target_scm,
777 778 maybe_unreachable=False):
778 779
779 780 commit_cache = collections.OrderedDict()
780 781 missing_requirements = False
781 782
782 783 try:
783 784 pre_load = ["author", "date", "message", "branch", "parents"]
784 785
785 786 pull_request_commits = pull_request_at_ver.revisions
786 787 log.debug('Loading %s commits from %s',
787 788 len(pull_request_commits), commits_source_repo)
788 789
789 790 for rev in pull_request_commits:
790 791 comm = commits_source_repo.get_commit(commit_id=rev, pre_load=pre_load,
791 792 maybe_unreachable=maybe_unreachable)
792 793 commit_cache[comm.raw_id] = comm
793 794
794 795 # Order here matters, we first need to get target, and then
795 796 # the source
796 797 target_commit = commits_source_repo.get_commit(
797 798 commit_id=safe_str(target_ref_id))
798 799
799 800 source_commit = commits_source_repo.get_commit(
800 801 commit_id=safe_str(source_ref_id), maybe_unreachable=True)
801 802 except CommitDoesNotExistError:
802 803 log.warning('Failed to get commit from `{}` repo'.format(
803 804 commits_source_repo), exc_info=True)
804 805 except RepositoryRequirementError:
805 806 log.warning('Failed to get all required data from repo', exc_info=True)
806 807 missing_requirements = True
807 808
808 809 pr_ancestor_id = pull_request_at_ver.common_ancestor_id
809 810
810 811 try:
811 812 ancestor_commit = source_scm.get_commit(pr_ancestor_id)
812 813 except Exception:
813 814 ancestor_commit = None
814 815
815 816 return ancestor_commit, commit_cache, missing_requirements, source_commit, target_commit
816 817
817 818 def assure_not_empty_repo(self):
818 819 _ = self.request.translate
819 820
820 821 try:
821 822 self.db_repo.scm_instance().get_commit()
822 823 except EmptyRepositoryError:
823 824 h.flash(h.literal(_('There are no commits yet')),
824 825 category='warning')
825 826 raise HTTPFound(
826 827 h.route_path('repo_summary', repo_name=self.db_repo.repo_name))
827 828
828 829 @LoginRequired()
829 830 @NotAnonymous()
830 831 @HasRepoPermissionAnyDecorator(
831 832 'repository.read', 'repository.write', 'repository.admin')
832 833 @view_config(
833 834 route_name='pullrequest_new', request_method='GET',
834 835 renderer='rhodecode:templates/pullrequests/pullrequest.mako')
835 836 def pull_request_new(self):
836 837 _ = self.request.translate
837 838 c = self.load_default_context()
838 839
839 840 self.assure_not_empty_repo()
840 841 source_repo = self.db_repo
841 842
842 843 commit_id = self.request.GET.get('commit')
843 844 branch_ref = self.request.GET.get('branch')
844 845 bookmark_ref = self.request.GET.get('bookmark')
845 846
846 847 try:
847 848 source_repo_data = PullRequestModel().generate_repo_data(
848 849 source_repo, commit_id=commit_id,
849 850 branch=branch_ref, bookmark=bookmark_ref,
850 851 translator=self.request.translate)
851 852 except CommitDoesNotExistError as e:
852 853 log.exception(e)
853 854 h.flash(_('Commit does not exist'), 'error')
854 855 raise HTTPFound(
855 856 h.route_path('pullrequest_new', repo_name=source_repo.repo_name))
856 857
857 858 default_target_repo = source_repo
858 859
859 860 if source_repo.parent and c.has_origin_repo_read_perm:
860 861 parent_vcs_obj = source_repo.parent.scm_instance()
861 862 if parent_vcs_obj and not parent_vcs_obj.is_empty():
862 863 # change default if we have a parent repo
863 864 default_target_repo = source_repo.parent
864 865
865 866 target_repo_data = PullRequestModel().generate_repo_data(
866 867 default_target_repo, translator=self.request.translate)
867 868
868 869 selected_source_ref = source_repo_data['refs']['selected_ref']
869 870 title_source_ref = ''
870 871 if selected_source_ref:
871 872 title_source_ref = selected_source_ref.split(':', 2)[1]
872 873 c.default_title = PullRequestModel().generate_pullrequest_title(
873 874 source=source_repo.repo_name,
874 875 source_ref=title_source_ref,
875 876 target=default_target_repo.repo_name
876 877 )
877 878
878 879 c.default_repo_data = {
879 880 'source_repo_name': source_repo.repo_name,
880 881 'source_refs_json': json.dumps(source_repo_data),
881 882 'target_repo_name': default_target_repo.repo_name,
882 883 'target_refs_json': json.dumps(target_repo_data),
883 884 }
884 885 c.default_source_ref = selected_source_ref
885 886
886 887 return self._get_template_context(c)
887 888
888 889 @LoginRequired()
889 890 @NotAnonymous()
890 891 @HasRepoPermissionAnyDecorator(
891 892 'repository.read', 'repository.write', 'repository.admin')
892 893 @view_config(
893 894 route_name='pullrequest_repo_refs', request_method='GET',
894 895 renderer='json_ext', xhr=True)
895 896 def pull_request_repo_refs(self):
896 897 self.load_default_context()
897 898 target_repo_name = self.request.matchdict['target_repo_name']
898 899 repo = Repository.get_by_repo_name(target_repo_name)
899 900 if not repo:
900 901 raise HTTPNotFound()
901 902
902 903 target_perm = HasRepoPermissionAny(
903 904 'repository.read', 'repository.write', 'repository.admin')(
904 905 target_repo_name)
905 906 if not target_perm:
906 907 raise HTTPNotFound()
907 908
908 909 return PullRequestModel().generate_repo_data(
909 910 repo, translator=self.request.translate)
910 911
911 912 @LoginRequired()
912 913 @NotAnonymous()
913 914 @HasRepoPermissionAnyDecorator(
914 915 'repository.read', 'repository.write', 'repository.admin')
915 916 @view_config(
916 917 route_name='pullrequest_repo_targets', request_method='GET',
917 918 renderer='json_ext', xhr=True)
918 919 def pullrequest_repo_targets(self):
919 920 _ = self.request.translate
920 921 filter_query = self.request.GET.get('query')
921 922
922 923 # get the parents
923 924 parent_target_repos = []
924 925 if self.db_repo.parent:
925 926 parents_query = Repository.query() \
926 927 .order_by(func.length(Repository.repo_name)) \
927 928 .filter(Repository.fork_id == self.db_repo.parent.repo_id)
928 929
929 930 if filter_query:
930 931 ilike_expression = u'%{}%'.format(safe_unicode(filter_query))
931 932 parents_query = parents_query.filter(
932 933 Repository.repo_name.ilike(ilike_expression))
933 934 parents = parents_query.limit(20).all()
934 935
935 936 for parent in parents:
936 937 parent_vcs_obj = parent.scm_instance()
937 938 if parent_vcs_obj and not parent_vcs_obj.is_empty():
938 939 parent_target_repos.append(parent)
939 940
940 941 # get other forks, and repo itself
941 942 query = Repository.query() \
942 943 .order_by(func.length(Repository.repo_name)) \
943 944 .filter(
944 945 or_(Repository.repo_id == self.db_repo.repo_id, # repo itself
945 946 Repository.fork_id == self.db_repo.repo_id) # forks of this repo
946 947 ) \
947 948 .filter(~Repository.repo_id.in_([x.repo_id for x in parent_target_repos]))
948 949
949 950 if filter_query:
950 951 ilike_expression = u'%{}%'.format(safe_unicode(filter_query))
951 952 query = query.filter(Repository.repo_name.ilike(ilike_expression))
952 953
953 954 limit = max(20 - len(parent_target_repos), 5) # not less then 5
954 955 target_repos = query.limit(limit).all()
955 956
956 957 all_target_repos = target_repos + parent_target_repos
957 958
958 959 repos = []
959 960 # This checks permissions to the repositories
960 961 for obj in ScmModel().get_repos(all_target_repos):
961 962 repos.append({
962 963 'id': obj['name'],
963 964 'text': obj['name'],
964 965 'type': 'repo',
965 966 'repo_id': obj['dbrepo']['repo_id'],
966 967 'repo_type': obj['dbrepo']['repo_type'],
967 968 'private': obj['dbrepo']['private'],
968 969
969 970 })
970 971
971 972 data = {
972 973 'more': False,
973 974 'results': [{
974 975 'text': _('Repositories'),
975 976 'children': repos
976 977 }] if repos else []
977 978 }
978 979 return data
979 980
980 981 def _get_existing_ids(self, post_data):
981 982 return filter(lambda e: e, map(safe_int, aslist(post_data.get('comments'), ',')))
982 983
983 984 @LoginRequired()
984 985 @NotAnonymous()
985 986 @HasRepoPermissionAnyDecorator(
986 987 'repository.read', 'repository.write', 'repository.admin')
987 988 @view_config(
988 989 route_name='pullrequest_comments', request_method='POST',
989 990 renderer='string_html', xhr=True)
990 991 def pullrequest_comments(self):
991 992 self.load_default_context()
992 993
993 994 pull_request = PullRequest.get_or_404(
994 995 self.request.matchdict['pull_request_id'])
995 996 pull_request_id = pull_request.pull_request_id
996 997 version = self.request.GET.get('version')
997 998
998 999 _render = self.request.get_partial_renderer(
999 1000 'rhodecode:templates/base/sidebar.mako')
1000 1001 c = _render.get_call_context()
1001 1002
1002 1003 (pull_request_latest,
1003 1004 pull_request_at_ver,
1004 1005 pull_request_display_obj,
1005 1006 at_version) = PullRequestModel().get_pr_version(
1006 1007 pull_request_id, version=version)
1007 1008 versions = pull_request_display_obj.versions()
1008 1009 latest_ver = PullRequest.get_pr_display_object(pull_request_latest, pull_request_latest)
1009 1010 c.versions = versions + [latest_ver]
1010 1011
1011 1012 c.at_version = at_version
1012 1013 c.at_version_num = (at_version
1013 1014 if at_version and at_version != PullRequest.LATEST_VER
1014 1015 else None)
1015 1016
1016 1017 self.register_comments_vars(c, pull_request_latest, versions)
1017 1018 all_comments = c.inline_comments_flat + c.comments
1018 1019
1019 1020 existing_ids = self._get_existing_ids(self.request.POST)
1020 1021 return _render('comments_table', all_comments, len(all_comments),
1021 1022 existing_ids=existing_ids)
1022 1023
1023 1024 @LoginRequired()
1024 1025 @NotAnonymous()
1025 1026 @HasRepoPermissionAnyDecorator(
1026 1027 'repository.read', 'repository.write', 'repository.admin')
1027 1028 @view_config(
1028 1029 route_name='pullrequest_todos', request_method='POST',
1029 1030 renderer='string_html', xhr=True)
1030 1031 def pullrequest_todos(self):
1031 1032 self.load_default_context()
1032 1033
1033 1034 pull_request = PullRequest.get_or_404(
1034 1035 self.request.matchdict['pull_request_id'])
1035 1036 pull_request_id = pull_request.pull_request_id
1036 1037 version = self.request.GET.get('version')
1037 1038
1038 1039 _render = self.request.get_partial_renderer(
1039 1040 'rhodecode:templates/base/sidebar.mako')
1040 1041 c = _render.get_call_context()
1041 1042 (pull_request_latest,
1042 1043 pull_request_at_ver,
1043 1044 pull_request_display_obj,
1044 1045 at_version) = PullRequestModel().get_pr_version(
1045 1046 pull_request_id, version=version)
1046 1047 versions = pull_request_display_obj.versions()
1047 1048 latest_ver = PullRequest.get_pr_display_object(pull_request_latest, pull_request_latest)
1048 1049 c.versions = versions + [latest_ver]
1049 1050
1050 1051 c.at_version = at_version
1051 1052 c.at_version_num = (at_version
1052 1053 if at_version and at_version != PullRequest.LATEST_VER
1053 1054 else None)
1054 1055
1055 1056 c.unresolved_comments = CommentsModel() \
1056 1057 .get_pull_request_unresolved_todos(pull_request)
1057 1058 c.resolved_comments = CommentsModel() \
1058 1059 .get_pull_request_resolved_todos(pull_request)
1059 1060
1060 1061 all_comments = c.unresolved_comments + c.resolved_comments
1061 1062 existing_ids = self._get_existing_ids(self.request.POST)
1062 1063 return _render('comments_table', all_comments, len(c.unresolved_comments),
1063 1064 todo_comments=True, existing_ids=existing_ids)
1064 1065
1065 1066 @LoginRequired()
1066 1067 @NotAnonymous()
1067 1068 @HasRepoPermissionAnyDecorator(
1068 1069 'repository.read', 'repository.write', 'repository.admin')
1069 1070 @CSRFRequired()
1070 1071 @view_config(
1071 1072 route_name='pullrequest_create', request_method='POST',
1072 1073 renderer=None)
1073 1074 def pull_request_create(self):
1074 1075 _ = self.request.translate
1075 1076 self.assure_not_empty_repo()
1076 1077 self.load_default_context()
1077 1078
1078 1079 controls = peppercorn.parse(self.request.POST.items())
1079 1080
1080 1081 try:
1081 1082 form = PullRequestForm(
1082 1083 self.request.translate, self.db_repo.repo_id)()
1083 1084 _form = form.to_python(controls)
1084 1085 except formencode.Invalid as errors:
1085 1086 if errors.error_dict.get('revisions'):
1086 1087 msg = 'Revisions: %s' % errors.error_dict['revisions']
1087 1088 elif errors.error_dict.get('pullrequest_title'):
1088 1089 msg = errors.error_dict.get('pullrequest_title')
1089 1090 else:
1090 1091 msg = _('Error creating pull request: {}').format(errors)
1091 1092 log.exception(msg)
1092 1093 h.flash(msg, 'error')
1093 1094
1094 1095 # would rather just go back to form ...
1095 1096 raise HTTPFound(
1096 1097 h.route_path('pullrequest_new', repo_name=self.db_repo_name))
1097 1098
1098 1099 source_repo = _form['source_repo']
1099 1100 source_ref = _form['source_ref']
1100 1101 target_repo = _form['target_repo']
1101 1102 target_ref = _form['target_ref']
1102 1103 commit_ids = _form['revisions'][::-1]
1103 1104 common_ancestor_id = _form['common_ancestor']
1104 1105
1105 1106 # find the ancestor for this pr
1106 1107 source_db_repo = Repository.get_by_repo_name(_form['source_repo'])
1107 1108 target_db_repo = Repository.get_by_repo_name(_form['target_repo'])
1108 1109
1109 1110 if not (source_db_repo or target_db_repo):
1110 1111 h.flash(_('source_repo or target repo not found'), category='error')
1111 1112 raise HTTPFound(
1112 1113 h.route_path('pullrequest_new', repo_name=self.db_repo_name))
1113 1114
1114 1115 # re-check permissions again here
1115 1116 # source_repo we must have read permissions
1116 1117
1117 1118 source_perm = HasRepoPermissionAny(
1118 1119 'repository.read', 'repository.write', 'repository.admin')(
1119 1120 source_db_repo.repo_name)
1120 1121 if not source_perm:
1121 1122 msg = _('Not Enough permissions to source repo `{}`.'.format(
1122 1123 source_db_repo.repo_name))
1123 1124 h.flash(msg, category='error')
1124 1125 # copy the args back to redirect
1125 1126 org_query = self.request.GET.mixed()
1126 1127 raise HTTPFound(
1127 1128 h.route_path('pullrequest_new', repo_name=self.db_repo_name,
1128 1129 _query=org_query))
1129 1130
1130 1131 # target repo we must have read permissions, and also later on
1131 1132 # we want to check branch permissions here
1132 1133 target_perm = HasRepoPermissionAny(
1133 1134 'repository.read', 'repository.write', 'repository.admin')(
1134 1135 target_db_repo.repo_name)
1135 1136 if not target_perm:
1136 1137 msg = _('Not Enough permissions to target repo `{}`.'.format(
1137 1138 target_db_repo.repo_name))
1138 1139 h.flash(msg, category='error')
1139 1140 # copy the args back to redirect
1140 1141 org_query = self.request.GET.mixed()
1141 1142 raise HTTPFound(
1142 1143 h.route_path('pullrequest_new', repo_name=self.db_repo_name,
1143 1144 _query=org_query))
1144 1145
1145 1146 source_scm = source_db_repo.scm_instance()
1146 1147 target_scm = target_db_repo.scm_instance()
1147 1148
1148 1149 source_commit = source_scm.get_commit(source_ref.split(':')[-1])
1149 1150 target_commit = target_scm.get_commit(target_ref.split(':')[-1])
1150 1151
1151 1152 ancestor = source_scm.get_common_ancestor(
1152 1153 source_commit.raw_id, target_commit.raw_id, target_scm)
1153 1154
1154 1155 source_ref_type, source_ref_name, source_commit_id = _form['target_ref'].split(':')
1155 1156 target_ref_type, target_ref_name, target_commit_id = _form['source_ref'].split(':')
1156 1157 # recalculate target ref based on ancestor
1157 1158 target_ref = ':'.join((target_ref_type, target_ref_name, ancestor))
1158 1159
1159 1160 get_default_reviewers_data, validate_default_reviewers, validate_observers = \
1160 1161 PullRequestModel().get_reviewer_functions()
1161 1162
1162 1163 # recalculate reviewers logic, to make sure we can validate this
1163 1164 reviewer_rules = get_default_reviewers_data(
1164 1165 self._rhodecode_db_user,
1165 1166 source_db_repo,
1166 1167 Reference(source_ref_type, source_ref_name, source_commit_id),
1167 1168 target_db_repo,
1168 1169 Reference(target_ref_type, target_ref_name, target_commit_id),
1169 1170 include_diff_info=False)
1170 1171
1171 1172 reviewers = validate_default_reviewers(_form['review_members'], reviewer_rules)
1172 1173 observers = validate_observers(_form['observer_members'], reviewer_rules)
1173 1174
1174 1175 pullrequest_title = _form['pullrequest_title']
1175 1176 title_source_ref = source_ref.split(':', 2)[1]
1176 1177 if not pullrequest_title:
1177 1178 pullrequest_title = PullRequestModel().generate_pullrequest_title(
1178 1179 source=source_repo,
1179 1180 source_ref=title_source_ref,
1180 1181 target=target_repo
1181 1182 )
1182 1183
1183 1184 description = _form['pullrequest_desc']
1184 1185 description_renderer = _form['description_renderer']
1185 1186
1186 1187 try:
1187 1188 pull_request = PullRequestModel().create(
1188 1189 created_by=self._rhodecode_user.user_id,
1189 1190 source_repo=source_repo,
1190 1191 source_ref=source_ref,
1191 1192 target_repo=target_repo,
1192 1193 target_ref=target_ref,
1193 1194 revisions=commit_ids,
1194 1195 common_ancestor_id=common_ancestor_id,
1195 1196 reviewers=reviewers,
1196 1197 observers=observers,
1197 1198 title=pullrequest_title,
1198 1199 description=description,
1199 1200 description_renderer=description_renderer,
1200 1201 reviewer_data=reviewer_rules,
1201 1202 auth_user=self._rhodecode_user
1202 1203 )
1203 1204 Session().commit()
1204 1205
1205 1206 h.flash(_('Successfully opened new pull request'),
1206 1207 category='success')
1207 1208 except Exception:
1208 1209 msg = _('Error occurred during creation of this pull request.')
1209 1210 log.exception(msg)
1210 1211 h.flash(msg, category='error')
1211 1212
1212 1213 # copy the args back to redirect
1213 1214 org_query = self.request.GET.mixed()
1214 1215 raise HTTPFound(
1215 1216 h.route_path('pullrequest_new', repo_name=self.db_repo_name,
1216 1217 _query=org_query))
1217 1218
1218 1219 raise HTTPFound(
1219 1220 h.route_path('pullrequest_show', repo_name=target_repo,
1220 1221 pull_request_id=pull_request.pull_request_id))
1221 1222
1222 1223 @LoginRequired()
1223 1224 @NotAnonymous()
1224 1225 @HasRepoPermissionAnyDecorator(
1225 1226 'repository.read', 'repository.write', 'repository.admin')
1226 1227 @CSRFRequired()
1227 1228 @view_config(
1228 1229 route_name='pullrequest_update', request_method='POST',
1229 1230 renderer='json_ext')
1230 1231 def pull_request_update(self):
1231 1232 pull_request = PullRequest.get_or_404(
1232 1233 self.request.matchdict['pull_request_id'])
1233 1234 _ = self.request.translate
1234 1235
1235 1236 c = self.load_default_context()
1236 1237 redirect_url = None
1237 1238
1238 1239 if pull_request.is_closed():
1239 1240 log.debug('update: forbidden because pull request is closed')
1240 1241 msg = _(u'Cannot update closed pull requests.')
1241 1242 h.flash(msg, category='error')
1242 1243 return {'response': True,
1243 1244 'redirect_url': redirect_url}
1244 1245
1245 1246 is_state_changing = pull_request.is_state_changing()
1246 1247 c.pr_broadcast_channel = channelstream.pr_channel(pull_request)
1247 1248
1248 1249 # only owner or admin can update it
1249 1250 allowed_to_update = PullRequestModel().check_user_update(
1250 1251 pull_request, self._rhodecode_user)
1251 1252
1252 1253 if allowed_to_update:
1253 1254 controls = peppercorn.parse(self.request.POST.items())
1254 1255 force_refresh = str2bool(self.request.POST.get('force_refresh'))
1255 1256
1256 1257 if 'review_members' in controls:
1257 1258 self._update_reviewers(
1258 1259 c,
1259 1260 pull_request, controls['review_members'],
1260 1261 pull_request.reviewer_data,
1261 1262 PullRequestReviewers.ROLE_REVIEWER)
1262 1263 elif 'observer_members' in controls:
1263 1264 self._update_reviewers(
1264 1265 c,
1265 1266 pull_request, controls['observer_members'],
1266 1267 pull_request.reviewer_data,
1267 1268 PullRequestReviewers.ROLE_OBSERVER)
1268 1269 elif str2bool(self.request.POST.get('update_commits', 'false')):
1269 1270 if is_state_changing:
1270 1271 log.debug('commits update: forbidden because pull request is in state %s',
1271 1272 pull_request.pull_request_state)
1272 1273 msg = _(u'Cannot update pull requests commits in state other than `{}`. '
1273 1274 u'Current state is: `{}`').format(
1274 1275 PullRequest.STATE_CREATED, pull_request.pull_request_state)
1275 1276 h.flash(msg, category='error')
1276 1277 return {'response': True,
1277 1278 'redirect_url': redirect_url}
1278 1279
1279 1280 self._update_commits(c, pull_request)
1280 1281 if force_refresh:
1281 1282 redirect_url = h.route_path(
1282 1283 'pullrequest_show', repo_name=self.db_repo_name,
1283 1284 pull_request_id=pull_request.pull_request_id,
1284 1285 _query={"force_refresh": 1})
1285 1286 elif str2bool(self.request.POST.get('edit_pull_request', 'false')):
1286 1287 self._edit_pull_request(pull_request)
1287 1288 else:
1288 1289 log.error('Unhandled update data.')
1289 1290 raise HTTPBadRequest()
1290 1291
1291 1292 return {'response': True,
1292 1293 'redirect_url': redirect_url}
1293 1294 raise HTTPForbidden()
1294 1295
1295 1296 def _edit_pull_request(self, pull_request):
1296 1297 """
1297 1298 Edit title and description
1298 1299 """
1299 1300 _ = self.request.translate
1300 1301
1301 1302 try:
1302 1303 PullRequestModel().edit(
1303 1304 pull_request,
1304 1305 self.request.POST.get('title'),
1305 1306 self.request.POST.get('description'),
1306 1307 self.request.POST.get('description_renderer'),
1307 1308 self._rhodecode_user)
1308 1309 except ValueError:
1309 1310 msg = _(u'Cannot update closed pull requests.')
1310 1311 h.flash(msg, category='error')
1311 1312 return
1312 1313 else:
1313 1314 Session().commit()
1314 1315
1315 1316 msg = _(u'Pull request title & description updated.')
1316 1317 h.flash(msg, category='success')
1317 1318 return
1318 1319
1319 1320 def _update_commits(self, c, pull_request):
1320 1321 _ = self.request.translate
1321 1322
1322 1323 with pull_request.set_state(PullRequest.STATE_UPDATING):
1323 1324 resp = PullRequestModel().update_commits(
1324 1325 pull_request, self._rhodecode_db_user)
1325 1326
1326 1327 if resp.executed:
1327 1328
1328 1329 if resp.target_changed and resp.source_changed:
1329 1330 changed = 'target and source repositories'
1330 1331 elif resp.target_changed and not resp.source_changed:
1331 1332 changed = 'target repository'
1332 1333 elif not resp.target_changed and resp.source_changed:
1333 1334 changed = 'source repository'
1334 1335 else:
1335 1336 changed = 'nothing'
1336 1337
1337 1338 msg = _(u'Pull request updated to "{source_commit_id}" with '
1338 1339 u'{count_added} added, {count_removed} removed commits. '
1339 1340 u'Source of changes: {change_source}.')
1340 1341 msg = msg.format(
1341 1342 source_commit_id=pull_request.source_ref_parts.commit_id,
1342 1343 count_added=len(resp.changes.added),
1343 1344 count_removed=len(resp.changes.removed),
1344 1345 change_source=changed)
1345 1346 h.flash(msg, category='success')
1346 1347 channelstream.pr_update_channelstream_push(
1347 1348 self.request, c.pr_broadcast_channel, self._rhodecode_user, msg)
1348 1349 else:
1349 1350 msg = PullRequestModel.UPDATE_STATUS_MESSAGES[resp.reason]
1350 1351 warning_reasons = [
1351 1352 UpdateFailureReason.NO_CHANGE,
1352 1353 UpdateFailureReason.WRONG_REF_TYPE,
1353 1354 ]
1354 1355 category = 'warning' if resp.reason in warning_reasons else 'error'
1355 1356 h.flash(msg, category=category)
1356 1357
1357 1358 def _update_reviewers(self, c, pull_request, review_members, reviewer_rules, role):
1358 1359 _ = self.request.translate
1359 1360
1360 1361 get_default_reviewers_data, validate_default_reviewers, validate_observers = \
1361 1362 PullRequestModel().get_reviewer_functions()
1362 1363
1363 1364 if role == PullRequestReviewers.ROLE_REVIEWER:
1364 1365 try:
1365 1366 reviewers = validate_default_reviewers(review_members, reviewer_rules)
1366 1367 except ValueError as e:
1367 1368 log.error('Reviewers Validation: {}'.format(e))
1368 1369 h.flash(e, category='error')
1369 1370 return
1370 1371
1371 1372 old_calculated_status = pull_request.calculated_review_status()
1372 1373 PullRequestModel().update_reviewers(
1373 1374 pull_request, reviewers, self._rhodecode_user)
1374 1375
1375 1376 Session().commit()
1376 1377
1377 1378 msg = _('Pull request reviewers updated.')
1378 1379 h.flash(msg, category='success')
1379 1380 channelstream.pr_update_channelstream_push(
1380 1381 self.request, c.pr_broadcast_channel, self._rhodecode_user, msg)
1381 1382
1382 1383 # trigger status changed if change in reviewers changes the status
1383 1384 calculated_status = pull_request.calculated_review_status()
1384 1385 if old_calculated_status != calculated_status:
1385 1386 PullRequestModel().trigger_pull_request_hook(
1386 1387 pull_request, self._rhodecode_user, 'review_status_change',
1387 1388 data={'status': calculated_status})
1388 1389
1389 1390 elif role == PullRequestReviewers.ROLE_OBSERVER:
1390 1391 try:
1391 1392 observers = validate_observers(review_members, reviewer_rules)
1392 1393 except ValueError as e:
1393 1394 log.error('Observers Validation: {}'.format(e))
1394 1395 h.flash(e, category='error')
1395 1396 return
1396 1397
1397 1398 PullRequestModel().update_observers(
1398 1399 pull_request, observers, self._rhodecode_user)
1399 1400
1400 1401 Session().commit()
1401 1402 msg = _('Pull request observers updated.')
1402 1403 h.flash(msg, category='success')
1403 1404 channelstream.pr_update_channelstream_push(
1404 1405 self.request, c.pr_broadcast_channel, self._rhodecode_user, msg)
1405 1406
1406 1407 @LoginRequired()
1407 1408 @NotAnonymous()
1408 1409 @HasRepoPermissionAnyDecorator(
1409 1410 'repository.read', 'repository.write', 'repository.admin')
1410 1411 @CSRFRequired()
1411 1412 @view_config(
1412 1413 route_name='pullrequest_merge', request_method='POST',
1413 1414 renderer='json_ext')
1414 1415 def pull_request_merge(self):
1415 1416 """
1416 1417 Merge will perform a server-side merge of the specified
1417 1418 pull request, if the pull request is approved and mergeable.
1418 1419 After successful merging, the pull request is automatically
1419 1420 closed, with a relevant comment.
1420 1421 """
1421 1422 pull_request = PullRequest.get_or_404(
1422 1423 self.request.matchdict['pull_request_id'])
1423 1424 _ = self.request.translate
1424 1425
1425 1426 if pull_request.is_state_changing():
1426 1427 log.debug('show: forbidden because pull request is in state %s',
1427 1428 pull_request.pull_request_state)
1428 1429 msg = _(u'Cannot merge pull requests in state other than `{}`. '
1429 1430 u'Current state is: `{}`').format(PullRequest.STATE_CREATED,
1430 1431 pull_request.pull_request_state)
1431 1432 h.flash(msg, category='error')
1432 1433 raise HTTPFound(
1433 1434 h.route_path('pullrequest_show',
1434 1435 repo_name=pull_request.target_repo.repo_name,
1435 1436 pull_request_id=pull_request.pull_request_id))
1436 1437
1437 1438 self.load_default_context()
1438 1439
1439 1440 with pull_request.set_state(PullRequest.STATE_UPDATING):
1440 1441 check = MergeCheck.validate(
1441 1442 pull_request, auth_user=self._rhodecode_user,
1442 1443 translator=self.request.translate)
1443 1444 merge_possible = not check.failed
1444 1445
1445 1446 for err_type, error_msg in check.errors:
1446 1447 h.flash(error_msg, category=err_type)
1447 1448
1448 1449 if merge_possible:
1449 1450 log.debug("Pre-conditions checked, trying to merge.")
1450 1451 extras = vcs_operation_context(
1451 1452 self.request.environ, repo_name=pull_request.target_repo.repo_name,
1452 1453 username=self._rhodecode_db_user.username, action='push',
1453 1454 scm=pull_request.target_repo.repo_type)
1454 1455 with pull_request.set_state(PullRequest.STATE_UPDATING):
1455 1456 self._merge_pull_request(
1456 1457 pull_request, self._rhodecode_db_user, extras)
1457 1458 else:
1458 1459 log.debug("Pre-conditions failed, NOT merging.")
1459 1460
1460 1461 raise HTTPFound(
1461 1462 h.route_path('pullrequest_show',
1462 1463 repo_name=pull_request.target_repo.repo_name,
1463 1464 pull_request_id=pull_request.pull_request_id))
1464 1465
1465 1466 def _merge_pull_request(self, pull_request, user, extras):
1466 1467 _ = self.request.translate
1467 1468 merge_resp = PullRequestModel().merge_repo(pull_request, user, extras=extras)
1468 1469
1469 1470 if merge_resp.executed:
1470 1471 log.debug("The merge was successful, closing the pull request.")
1471 1472 PullRequestModel().close_pull_request(
1472 1473 pull_request.pull_request_id, user)
1473 1474 Session().commit()
1474 1475 msg = _('Pull request was successfully merged and closed.')
1475 1476 h.flash(msg, category='success')
1476 1477 else:
1477 1478 log.debug(
1478 1479 "The merge was not successful. Merge response: %s", merge_resp)
1479 1480 msg = merge_resp.merge_status_message
1480 1481 h.flash(msg, category='error')
1481 1482
1482 1483 @LoginRequired()
1483 1484 @NotAnonymous()
1484 1485 @HasRepoPermissionAnyDecorator(
1485 1486 'repository.read', 'repository.write', 'repository.admin')
1486 1487 @CSRFRequired()
1487 1488 @view_config(
1488 1489 route_name='pullrequest_delete', request_method='POST',
1489 1490 renderer='json_ext')
1490 1491 def pull_request_delete(self):
1491 1492 _ = self.request.translate
1492 1493
1493 1494 pull_request = PullRequest.get_or_404(
1494 1495 self.request.matchdict['pull_request_id'])
1495 1496 self.load_default_context()
1496 1497
1497 1498 pr_closed = pull_request.is_closed()
1498 1499 allowed_to_delete = PullRequestModel().check_user_delete(
1499 1500 pull_request, self._rhodecode_user) and not pr_closed
1500 1501
1501 1502 # only owner can delete it !
1502 1503 if allowed_to_delete:
1503 1504 PullRequestModel().delete(pull_request, self._rhodecode_user)
1504 1505 Session().commit()
1505 1506 h.flash(_('Successfully deleted pull request'),
1506 1507 category='success')
1507 1508 raise HTTPFound(h.route_path('pullrequest_show_all',
1508 1509 repo_name=self.db_repo_name))
1509 1510
1510 1511 log.warning('user %s tried to delete pull request without access',
1511 1512 self._rhodecode_user)
1512 1513 raise HTTPNotFound()
1513 1514
1514 1515 @LoginRequired()
1515 1516 @NotAnonymous()
1516 1517 @HasRepoPermissionAnyDecorator(
1517 1518 'repository.read', 'repository.write', 'repository.admin')
1518 1519 @CSRFRequired()
1519 1520 @view_config(
1520 1521 route_name='pullrequest_comment_create', request_method='POST',
1521 1522 renderer='json_ext')
1522 1523 def pull_request_comment_create(self):
1523 1524 _ = self.request.translate
1524 1525
1525 1526 pull_request = PullRequest.get_or_404(
1526 1527 self.request.matchdict['pull_request_id'])
1527 1528 pull_request_id = pull_request.pull_request_id
1528 1529
1529 1530 if pull_request.is_closed():
1530 1531 log.debug('comment: forbidden because pull request is closed')
1531 1532 raise HTTPForbidden()
1532 1533
1533 1534 allowed_to_comment = PullRequestModel().check_user_comment(
1534 1535 pull_request, self._rhodecode_user)
1535 1536 if not allowed_to_comment:
1536 1537 log.debug('comment: forbidden because pull request is from forbidden repo')
1537 1538 raise HTTPForbidden()
1538 1539
1539 1540 c = self.load_default_context()
1540 1541
1541 1542 status = self.request.POST.get('changeset_status', None)
1542 1543 text = self.request.POST.get('text')
1543 1544 comment_type = self.request.POST.get('comment_type')
1544 1545 resolves_comment_id = self.request.POST.get('resolves_comment_id', None)
1545 1546 close_pull_request = self.request.POST.get('close_pull_request')
1546 1547
1547 1548 # the logic here should work like following, if we submit close
1548 1549 # pr comment, use `close_pull_request_with_comment` function
1549 1550 # else handle regular comment logic
1550 1551
1551 1552 if close_pull_request:
1552 1553 # only owner or admin or person with write permissions
1553 1554 allowed_to_close = PullRequestModel().check_user_update(
1554 1555 pull_request, self._rhodecode_user)
1555 1556 if not allowed_to_close:
1556 1557 log.debug('comment: forbidden because not allowed to close '
1557 1558 'pull request %s', pull_request_id)
1558 1559 raise HTTPForbidden()
1559 1560
1560 1561 # This also triggers `review_status_change`
1561 1562 comment, status = PullRequestModel().close_pull_request_with_comment(
1562 1563 pull_request, self._rhodecode_user, self.db_repo, message=text,
1563 1564 auth_user=self._rhodecode_user)
1564 1565 Session().flush()
1565 1566
1566 1567 PullRequestModel().trigger_pull_request_hook(
1567 1568 pull_request, self._rhodecode_user, 'comment',
1568 1569 data={'comment': comment})
1569 1570
1570 1571 else:
1571 1572 # regular comment case, could be inline, or one with status.
1572 1573 # for that one we check also permissions
1573 1574
1574 1575 allowed_to_change_status = PullRequestModel().check_user_change_status(
1575 1576 pull_request, self._rhodecode_user)
1576 1577
1577 1578 if status and allowed_to_change_status:
1578 1579 message = (_('Status change %(transition_icon)s %(status)s')
1579 1580 % {'transition_icon': '>',
1580 1581 'status': ChangesetStatus.get_status_lbl(status)})
1581 1582 text = text or message
1582 1583
1583 1584 comment = CommentsModel().create(
1584 1585 text=text,
1585 1586 repo=self.db_repo.repo_id,
1586 1587 user=self._rhodecode_user.user_id,
1587 1588 pull_request=pull_request,
1588 1589 f_path=self.request.POST.get('f_path'),
1589 1590 line_no=self.request.POST.get('line'),
1590 1591 status_change=(ChangesetStatus.get_status_lbl(status)
1591 1592 if status and allowed_to_change_status else None),
1592 1593 status_change_type=(status
1593 1594 if status and allowed_to_change_status else None),
1594 1595 comment_type=comment_type,
1595 1596 resolves_comment_id=resolves_comment_id,
1596 1597 auth_user=self._rhodecode_user
1597 1598 )
1598 1599 is_inline = bool(comment.f_path and comment.line_no)
1599 1600
1600 1601 if allowed_to_change_status:
1601 1602 # calculate old status before we change it
1602 1603 old_calculated_status = pull_request.calculated_review_status()
1603 1604
1604 1605 # get status if set !
1605 1606 if status:
1606 1607 ChangesetStatusModel().set_status(
1607 1608 self.db_repo.repo_id,
1608 1609 status,
1609 1610 self._rhodecode_user.user_id,
1610 1611 comment,
1611 1612 pull_request=pull_request
1612 1613 )
1613 1614
1614 1615 Session().flush()
1615 1616 # this is somehow required to get access to some relationship
1616 1617 # loaded on comment
1617 1618 Session().refresh(comment)
1618 1619
1619 1620 PullRequestModel().trigger_pull_request_hook(
1620 1621 pull_request, self._rhodecode_user, 'comment',
1621 1622 data={'comment': comment})
1622 1623
1623 1624 # we now calculate the status of pull request, and based on that
1624 1625 # calculation we set the commits status
1625 1626 calculated_status = pull_request.calculated_review_status()
1626 1627 if old_calculated_status != calculated_status:
1627 1628 PullRequestModel().trigger_pull_request_hook(
1628 1629 pull_request, self._rhodecode_user, 'review_status_change',
1629 1630 data={'status': calculated_status})
1630 1631
1631 1632 Session().commit()
1632 1633
1633 1634 data = {
1634 1635 'target_id': h.safeid(h.safe_unicode(
1635 1636 self.request.POST.get('f_path'))),
1636 1637 }
1637 1638 if comment:
1638 1639 c.co = comment
1639 1640 c.at_version_num = None
1640 1641 rendered_comment = render(
1641 1642 'rhodecode:templates/changeset/changeset_comment_block.mako',
1642 1643 self._get_template_context(c), self.request)
1643 1644
1644 1645 data.update(comment.get_dict())
1645 1646 data.update({'rendered_text': rendered_comment})
1646 1647
1647 1648 comment_broadcast_channel = channelstream.comment_channel(
1648 1649 self.db_repo_name, pull_request_obj=pull_request)
1649 1650
1650 1651 comment_data = data
1651 1652 comment_type = 'inline' if is_inline else 'general'
1652 1653 channelstream.comment_channelstream_push(
1653 1654 self.request, comment_broadcast_channel, self._rhodecode_user,
1654 1655 _('posted a new {} comment').format(comment_type),
1655 1656 comment_data=comment_data)
1656 1657
1657 1658 return data
1658 1659
1659 1660 @LoginRequired()
1660 1661 @NotAnonymous()
1661 1662 @HasRepoPermissionAnyDecorator(
1662 1663 'repository.read', 'repository.write', 'repository.admin')
1663 1664 @CSRFRequired()
1664 1665 @view_config(
1665 1666 route_name='pullrequest_comment_delete', request_method='POST',
1666 1667 renderer='json_ext')
1667 1668 def pull_request_comment_delete(self):
1668 1669 pull_request = PullRequest.get_or_404(
1669 1670 self.request.matchdict['pull_request_id'])
1670 1671
1671 1672 comment = ChangesetComment.get_or_404(
1672 1673 self.request.matchdict['comment_id'])
1673 1674 comment_id = comment.comment_id
1674 1675
1675 1676 if comment.immutable:
1676 1677 # don't allow deleting comments that are immutable
1677 1678 raise HTTPForbidden()
1678 1679
1679 1680 if pull_request.is_closed():
1680 1681 log.debug('comment: forbidden because pull request is closed')
1681 1682 raise HTTPForbidden()
1682 1683
1683 1684 if not comment:
1684 1685 log.debug('Comment with id:%s not found, skipping', comment_id)
1685 1686 # comment already deleted in another call probably
1686 1687 return True
1687 1688
1688 1689 if comment.pull_request.is_closed():
1689 1690 # don't allow deleting comments on closed pull request
1690 1691 raise HTTPForbidden()
1691 1692
1692 1693 is_repo_admin = h.HasRepoPermissionAny('repository.admin')(self.db_repo_name)
1693 1694 super_admin = h.HasPermissionAny('hg.admin')()
1694 1695 comment_owner = comment.author.user_id == self._rhodecode_user.user_id
1695 1696 is_repo_comment = comment.repo.repo_name == self.db_repo_name
1696 1697 comment_repo_admin = is_repo_admin and is_repo_comment
1697 1698
1698 1699 if super_admin or comment_owner or comment_repo_admin:
1699 1700 old_calculated_status = comment.pull_request.calculated_review_status()
1700 1701 CommentsModel().delete(comment=comment, auth_user=self._rhodecode_user)
1701 1702 Session().commit()
1702 1703 calculated_status = comment.pull_request.calculated_review_status()
1703 1704 if old_calculated_status != calculated_status:
1704 1705 PullRequestModel().trigger_pull_request_hook(
1705 1706 comment.pull_request, self._rhodecode_user, 'review_status_change',
1706 1707 data={'status': calculated_status})
1707 1708 return True
1708 1709 else:
1709 1710 log.warning('No permissions for user %s to delete comment_id: %s',
1710 1711 self._rhodecode_db_user, comment_id)
1711 1712 raise HTTPNotFound()
1712 1713
1713 1714 @LoginRequired()
1714 1715 @NotAnonymous()
1715 1716 @HasRepoPermissionAnyDecorator(
1716 1717 'repository.read', 'repository.write', 'repository.admin')
1717 1718 @CSRFRequired()
1718 1719 @view_config(
1719 1720 route_name='pullrequest_comment_edit', request_method='POST',
1720 1721 renderer='json_ext')
1721 1722 def pull_request_comment_edit(self):
1722 1723 self.load_default_context()
1723 1724
1724 1725 pull_request = PullRequest.get_or_404(
1725 1726 self.request.matchdict['pull_request_id']
1726 1727 )
1727 1728 comment = ChangesetComment.get_or_404(
1728 1729 self.request.matchdict['comment_id']
1729 1730 )
1730 1731 comment_id = comment.comment_id
1731 1732
1732 1733 if comment.immutable:
1733 1734 # don't allow deleting comments that are immutable
1734 1735 raise HTTPForbidden()
1735 1736
1736 1737 if pull_request.is_closed():
1737 1738 log.debug('comment: forbidden because pull request is closed')
1738 1739 raise HTTPForbidden()
1739 1740
1740 1741 if not comment:
1741 1742 log.debug('Comment with id:%s not found, skipping', comment_id)
1742 1743 # comment already deleted in another call probably
1743 1744 return True
1744 1745
1745 1746 if comment.pull_request.is_closed():
1746 1747 # don't allow deleting comments on closed pull request
1747 1748 raise HTTPForbidden()
1748 1749
1749 1750 is_repo_admin = h.HasRepoPermissionAny('repository.admin')(self.db_repo_name)
1750 1751 super_admin = h.HasPermissionAny('hg.admin')()
1751 1752 comment_owner = comment.author.user_id == self._rhodecode_user.user_id
1752 1753 is_repo_comment = comment.repo.repo_name == self.db_repo_name
1753 1754 comment_repo_admin = is_repo_admin and is_repo_comment
1754 1755
1755 1756 if super_admin or comment_owner or comment_repo_admin:
1756 1757 text = self.request.POST.get('text')
1757 1758 version = self.request.POST.get('version')
1758 1759 if text == comment.text:
1759 1760 log.warning(
1760 1761 'Comment(PR): '
1761 1762 'Trying to create new version '
1762 1763 'with the same comment body {}'.format(
1763 1764 comment_id,
1764 1765 )
1765 1766 )
1766 1767 raise HTTPNotFound()
1767 1768
1768 1769 if version.isdigit():
1769 1770 version = int(version)
1770 1771 else:
1771 1772 log.warning(
1772 1773 'Comment(PR): Wrong version type {} {} '
1773 1774 'for comment {}'.format(
1774 1775 version,
1775 1776 type(version),
1776 1777 comment_id,
1777 1778 )
1778 1779 )
1779 1780 raise HTTPNotFound()
1780 1781
1781 1782 try:
1782 1783 comment_history = CommentsModel().edit(
1783 1784 comment_id=comment_id,
1784 1785 text=text,
1785 1786 auth_user=self._rhodecode_user,
1786 1787 version=version,
1787 1788 )
1788 1789 except CommentVersionMismatch:
1789 1790 raise HTTPConflict()
1790 1791
1791 1792 if not comment_history:
1792 1793 raise HTTPNotFound()
1793 1794
1794 1795 Session().commit()
1795 1796
1796 1797 PullRequestModel().trigger_pull_request_hook(
1797 1798 pull_request, self._rhodecode_user, 'comment_edit',
1798 1799 data={'comment': comment})
1799 1800
1800 1801 return {
1801 1802 'comment_history_id': comment_history.comment_history_id,
1802 1803 'comment_id': comment.comment_id,
1803 1804 'comment_version': comment_history.version,
1804 1805 'comment_author_username': comment_history.author.username,
1805 1806 'comment_author_gravatar': h.gravatar_url(comment_history.author.email, 16),
1806 1807 'comment_created_on': h.age_component(comment_history.created_on,
1807 1808 time_is_local=True),
1808 1809 }
1809 1810 else:
1810 1811 log.warning('No permissions for user %s to edit comment_id: %s',
1811 1812 self._rhodecode_db_user, comment_id)
1812 1813 raise HTTPNotFound()
@@ -1,2229 +1,2235 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2012-2020 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 os
30 30
31 31 import datetime
32 32 import urllib
33 33 import collections
34 34
35 35 from pyramid import compat
36 36 from pyramid.threadlocal import get_current_request
37 37
38 38 from rhodecode.lib.vcs.nodes import FileNode
39 39 from rhodecode.translation import lazy_ugettext
40 40 from rhodecode.lib import helpers as h, hooks_utils, diffs
41 41 from rhodecode.lib import audit_logger
42 42 from rhodecode.lib.compat import OrderedDict
43 43 from rhodecode.lib.hooks_daemon import prepare_callback_daemon
44 44 from rhodecode.lib.markup_renderer import (
45 45 DEFAULT_COMMENTS_RENDERER, RstTemplateRenderer)
46 46 from rhodecode.lib.utils2 import (
47 47 safe_unicode, safe_str, md5_safe, AttributeDict, safe_int,
48 48 get_current_rhodecode_user)
49 49 from rhodecode.lib.vcs.backends.base import (
50 50 Reference, MergeResponse, MergeFailureReason, UpdateFailureReason,
51 51 TargetRefMissing, SourceRefMissing)
52 52 from rhodecode.lib.vcs.conf import settings as vcs_settings
53 53 from rhodecode.lib.vcs.exceptions import (
54 54 CommitDoesNotExistError, EmptyRepositoryError)
55 55 from rhodecode.model import BaseModel
56 56 from rhodecode.model.changeset_status import ChangesetStatusModel
57 57 from rhodecode.model.comment import CommentsModel
58 58 from rhodecode.model.db import (
59 59 or_, String, cast, PullRequest, PullRequestReviewers, ChangesetStatus,
60 60 PullRequestVersion, ChangesetComment, Repository, RepoReviewRule, User)
61 61 from rhodecode.model.meta import Session
62 62 from rhodecode.model.notification import NotificationModel, \
63 63 EmailNotificationModel
64 64 from rhodecode.model.scm import ScmModel
65 65 from rhodecode.model.settings import VcsSettingsModel
66 66
67 67
68 68 log = logging.getLogger(__name__)
69 69
70 70
71 71 # Data structure to hold the response data when updating commits during a pull
72 72 # request update.
73 73 class UpdateResponse(object):
74 74
75 75 def __init__(self, executed, reason, new, old, common_ancestor_id,
76 76 commit_changes, source_changed, target_changed):
77 77
78 78 self.executed = executed
79 79 self.reason = reason
80 80 self.new = new
81 81 self.old = old
82 82 self.common_ancestor_id = common_ancestor_id
83 83 self.changes = commit_changes
84 84 self.source_changed = source_changed
85 85 self.target_changed = target_changed
86 86
87 87
88 88 def get_diff_info(
89 89 source_repo, source_ref, target_repo, target_ref, get_authors=False,
90 90 get_commit_authors=True):
91 91 """
92 92 Calculates detailed diff information for usage in preview of creation of a pull-request.
93 93 This is also used for default reviewers logic
94 94 """
95 95
96 96 source_scm = source_repo.scm_instance()
97 97 target_scm = target_repo.scm_instance()
98 98
99 99 ancestor_id = target_scm.get_common_ancestor(target_ref, source_ref, source_scm)
100 100 if not ancestor_id:
101 101 raise ValueError(
102 102 'cannot calculate diff info without a common ancestor. '
103 103 'Make sure both repositories are related, and have a common forking commit.')
104 104
105 105 # case here is that want a simple diff without incoming commits,
106 106 # previewing what will be merged based only on commits in the source.
107 107 log.debug('Using ancestor %s as source_ref instead of %s',
108 108 ancestor_id, source_ref)
109 109
110 110 # source of changes now is the common ancestor
111 111 source_commit = source_scm.get_commit(commit_id=ancestor_id)
112 112 # target commit becomes the source ref as it is the last commit
113 113 # for diff generation this logic gives proper diff
114 114 target_commit = source_scm.get_commit(commit_id=source_ref)
115 115
116 116 vcs_diff = \
117 117 source_scm.get_diff(commit1=source_commit, commit2=target_commit,
118 118 ignore_whitespace=False, context=3)
119 119
120 120 diff_processor = diffs.DiffProcessor(
121 121 vcs_diff, format='newdiff', diff_limit=None,
122 122 file_limit=None, show_full_diff=True)
123 123
124 124 _parsed = diff_processor.prepare()
125 125
126 126 all_files = []
127 127 all_files_changes = []
128 128 changed_lines = {}
129 129 stats = [0, 0]
130 130 for f in _parsed:
131 131 all_files.append(f['filename'])
132 132 all_files_changes.append({
133 133 'filename': f['filename'],
134 134 'stats': f['stats']
135 135 })
136 136 stats[0] += f['stats']['added']
137 137 stats[1] += f['stats']['deleted']
138 138
139 139 changed_lines[f['filename']] = []
140 140 if len(f['chunks']) < 2:
141 141 continue
142 142 # first line is "context" information
143 143 for chunks in f['chunks'][1:]:
144 144 for chunk in chunks['lines']:
145 145 if chunk['action'] not in ('del', 'mod'):
146 146 continue
147 147 changed_lines[f['filename']].append(chunk['old_lineno'])
148 148
149 149 commit_authors = []
150 150 user_counts = {}
151 151 email_counts = {}
152 152 author_counts = {}
153 153 _commit_cache = {}
154 154
155 155 commits = []
156 156 if get_commit_authors:
157 157 log.debug('Obtaining commit authors from set of commits')
158 158 _compare_data = target_scm.compare(
159 159 target_ref, source_ref, source_scm, merge=True,
160 160 pre_load=["author", "date", "message"]
161 161 )
162 162
163 163 for commit in _compare_data:
164 164 # NOTE(marcink): we serialize here, so we don't produce more vcsserver calls on data returned
165 165 # at this function which is later called via JSON serialization
166 166 serialized_commit = dict(
167 167 author=commit.author,
168 168 date=commit.date,
169 169 message=commit.message,
170 170 )
171 171 commits.append(serialized_commit)
172 172 user = User.get_from_cs_author(serialized_commit['author'])
173 173 if user and user not in commit_authors:
174 174 commit_authors.append(user)
175 175
176 176 # lines
177 177 if get_authors:
178 178 log.debug('Calculating authors of changed files')
179 179 target_commit = source_repo.get_commit(ancestor_id)
180 180
181 181 for fname, lines in changed_lines.items():
182 182
183 183 try:
184 184 node = target_commit.get_node(fname, pre_load=["is_binary"])
185 185 except Exception:
186 186 log.exception("Failed to load node with path %s", fname)
187 187 continue
188 188
189 189 if not isinstance(node, FileNode):
190 190 continue
191 191
192 192 # NOTE(marcink): for binary node we don't do annotation, just use last author
193 193 if node.is_binary:
194 194 author = node.last_commit.author
195 195 email = node.last_commit.author_email
196 196
197 197 user = User.get_from_cs_author(author)
198 198 if user:
199 199 user_counts[user.user_id] = user_counts.get(user.user_id, 0) + 1
200 200 author_counts[author] = author_counts.get(author, 0) + 1
201 201 email_counts[email] = email_counts.get(email, 0) + 1
202 202
203 203 continue
204 204
205 205 for annotation in node.annotate:
206 206 line_no, commit_id, get_commit_func, line_text = annotation
207 207 if line_no in lines:
208 208 if commit_id not in _commit_cache:
209 209 _commit_cache[commit_id] = get_commit_func()
210 210 commit = _commit_cache[commit_id]
211 211 author = commit.author
212 212 email = commit.author_email
213 213 user = User.get_from_cs_author(author)
214 214 if user:
215 215 user_counts[user.user_id] = user_counts.get(user.user_id, 0) + 1
216 216 author_counts[author] = author_counts.get(author, 0) + 1
217 217 email_counts[email] = email_counts.get(email, 0) + 1
218 218
219 219 log.debug('Default reviewers processing finished')
220 220
221 221 return {
222 222 'commits': commits,
223 223 'files': all_files_changes,
224 224 'stats': stats,
225 225 'ancestor': ancestor_id,
226 226 # original authors of modified files
227 227 'original_authors': {
228 228 'users': user_counts,
229 229 'authors': author_counts,
230 230 'emails': email_counts,
231 231 },
232 232 'commit_authors': commit_authors
233 233 }
234 234
235 235
236 236 class PullRequestModel(BaseModel):
237 237
238 238 cls = PullRequest
239 239
240 240 DIFF_CONTEXT = diffs.DEFAULT_CONTEXT
241 241
242 242 UPDATE_STATUS_MESSAGES = {
243 243 UpdateFailureReason.NONE: lazy_ugettext(
244 244 'Pull request update successful.'),
245 245 UpdateFailureReason.UNKNOWN: lazy_ugettext(
246 246 'Pull request update failed because of an unknown error.'),
247 247 UpdateFailureReason.NO_CHANGE: lazy_ugettext(
248 248 'No update needed because the source and target have not changed.'),
249 249 UpdateFailureReason.WRONG_REF_TYPE: lazy_ugettext(
250 250 'Pull request cannot be updated because the reference type is '
251 251 'not supported for an update. Only Branch, Tag or Bookmark is allowed.'),
252 252 UpdateFailureReason.MISSING_TARGET_REF: lazy_ugettext(
253 253 'This pull request cannot be updated because the target '
254 254 'reference is missing.'),
255 255 UpdateFailureReason.MISSING_SOURCE_REF: lazy_ugettext(
256 256 'This pull request cannot be updated because the source '
257 257 'reference is missing.'),
258 258 }
259 259 REF_TYPES = ['bookmark', 'book', 'tag', 'branch']
260 260 UPDATABLE_REF_TYPES = ['bookmark', 'book', 'branch']
261 261
262 262 def __get_pull_request(self, pull_request):
263 263 return self._get_instance((
264 264 PullRequest, PullRequestVersion), pull_request)
265 265
266 266 def _check_perms(self, perms, pull_request, user, api=False):
267 267 if not api:
268 268 return h.HasRepoPermissionAny(*perms)(
269 269 user=user, repo_name=pull_request.target_repo.repo_name)
270 270 else:
271 271 return h.HasRepoPermissionAnyApi(*perms)(
272 272 user=user, repo_name=pull_request.target_repo.repo_name)
273 273
274 274 def check_user_read(self, pull_request, user, api=False):
275 275 _perms = ('repository.admin', 'repository.write', 'repository.read',)
276 276 return self._check_perms(_perms, pull_request, user, api)
277 277
278 278 def check_user_merge(self, pull_request, user, api=False):
279 279 _perms = ('repository.admin', 'repository.write', 'hg.admin',)
280 280 return self._check_perms(_perms, pull_request, user, api)
281 281
282 282 def check_user_update(self, pull_request, user, api=False):
283 283 owner = user.user_id == pull_request.user_id
284 284 return self.check_user_merge(pull_request, user, api) or owner
285 285
286 286 def check_user_delete(self, pull_request, user):
287 287 owner = user.user_id == pull_request.user_id
288 288 _perms = ('repository.admin',)
289 289 return self._check_perms(_perms, pull_request, user) or owner
290 290
291 def is_user_reviewer(self, pull_request, user):
292 return user.user_id in [
293 x.user_id for x in
294 pull_request.get_pull_request_reviewers(PullRequestReviewers.ROLE_REVIEWER)
295 if x.user
296 ]
297
291 298 def check_user_change_status(self, pull_request, user, api=False):
292 reviewer = user.user_id in [x.user_id for x in
293 pull_request.reviewers]
294 return self.check_user_update(pull_request, user, api) or reviewer
299 return self.check_user_update(pull_request, user, api) \
300 or self.is_user_reviewer(pull_request, user)
295 301
296 302 def check_user_comment(self, pull_request, user):
297 303 owner = user.user_id == pull_request.user_id
298 304 return self.check_user_read(pull_request, user) or owner
299 305
300 306 def get(self, pull_request):
301 307 return self.__get_pull_request(pull_request)
302 308
303 309 def _prepare_get_all_query(self, repo_name, search_q=None, source=False,
304 310 statuses=None, opened_by=None, order_by=None,
305 311 order_dir='desc', only_created=False):
306 312 repo = None
307 313 if repo_name:
308 314 repo = self._get_repo(repo_name)
309 315
310 316 q = PullRequest.query()
311 317
312 318 if search_q:
313 319 like_expression = u'%{}%'.format(safe_unicode(search_q))
314 320 q = q.join(User)
315 321 q = q.filter(or_(
316 322 cast(PullRequest.pull_request_id, String).ilike(like_expression),
317 323 User.username.ilike(like_expression),
318 324 PullRequest.title.ilike(like_expression),
319 325 PullRequest.description.ilike(like_expression),
320 326 ))
321 327
322 328 # source or target
323 329 if repo and source:
324 330 q = q.filter(PullRequest.source_repo == repo)
325 331 elif repo:
326 332 q = q.filter(PullRequest.target_repo == repo)
327 333
328 334 # closed,opened
329 335 if statuses:
330 336 q = q.filter(PullRequest.status.in_(statuses))
331 337
332 338 # opened by filter
333 339 if opened_by:
334 340 q = q.filter(PullRequest.user_id.in_(opened_by))
335 341
336 342 # only get those that are in "created" state
337 343 if only_created:
338 344 q = q.filter(PullRequest.pull_request_state == PullRequest.STATE_CREATED)
339 345
340 346 if order_by:
341 347 order_map = {
342 348 'name_raw': PullRequest.pull_request_id,
343 349 'id': PullRequest.pull_request_id,
344 350 'title': PullRequest.title,
345 351 'updated_on_raw': PullRequest.updated_on,
346 352 'target_repo': PullRequest.target_repo_id
347 353 }
348 354 if order_dir == 'asc':
349 355 q = q.order_by(order_map[order_by].asc())
350 356 else:
351 357 q = q.order_by(order_map[order_by].desc())
352 358
353 359 return q
354 360
355 361 def count_all(self, repo_name, search_q=None, source=False, statuses=None,
356 362 opened_by=None):
357 363 """
358 364 Count the number of pull requests for a specific repository.
359 365
360 366 :param repo_name: target or source repo
361 367 :param search_q: filter by text
362 368 :param source: boolean flag to specify if repo_name refers to source
363 369 :param statuses: list of pull request statuses
364 370 :param opened_by: author user of the pull request
365 371 :returns: int number of pull requests
366 372 """
367 373 q = self._prepare_get_all_query(
368 374 repo_name, search_q=search_q, source=source, statuses=statuses,
369 375 opened_by=opened_by)
370 376
371 377 return q.count()
372 378
373 379 def get_all(self, repo_name, search_q=None, source=False, statuses=None,
374 380 opened_by=None, offset=0, length=None, order_by=None, order_dir='desc'):
375 381 """
376 382 Get all pull requests for a specific repository.
377 383
378 384 :param repo_name: target or source repo
379 385 :param search_q: filter by text
380 386 :param source: boolean flag to specify if repo_name refers to source
381 387 :param statuses: list of pull request statuses
382 388 :param opened_by: author user of the pull request
383 389 :param offset: pagination offset
384 390 :param length: length of returned list
385 391 :param order_by: order of the returned list
386 392 :param order_dir: 'asc' or 'desc' ordering direction
387 393 :returns: list of pull requests
388 394 """
389 395 q = self._prepare_get_all_query(
390 396 repo_name, search_q=search_q, source=source, statuses=statuses,
391 397 opened_by=opened_by, order_by=order_by, order_dir=order_dir)
392 398
393 399 if length:
394 400 pull_requests = q.limit(length).offset(offset).all()
395 401 else:
396 402 pull_requests = q.all()
397 403
398 404 return pull_requests
399 405
400 406 def count_awaiting_review(self, repo_name, search_q=None, source=False, statuses=None,
401 407 opened_by=None):
402 408 """
403 409 Count the number of pull requests for a specific repository that are
404 410 awaiting review.
405 411
406 412 :param repo_name: target or source repo
407 413 :param search_q: filter by text
408 414 :param source: boolean flag to specify if repo_name refers to source
409 415 :param statuses: list of pull request statuses
410 416 :param opened_by: author user of the pull request
411 417 :returns: int number of pull requests
412 418 """
413 419 pull_requests = self.get_awaiting_review(
414 420 repo_name, search_q=search_q, source=source, statuses=statuses, opened_by=opened_by)
415 421
416 422 return len(pull_requests)
417 423
418 424 def get_awaiting_review(self, repo_name, search_q=None, source=False, statuses=None,
419 425 opened_by=None, offset=0, length=None,
420 426 order_by=None, order_dir='desc'):
421 427 """
422 428 Get all pull requests for a specific repository that are awaiting
423 429 review.
424 430
425 431 :param repo_name: target or source repo
426 432 :param search_q: filter by text
427 433 :param source: boolean flag to specify if repo_name refers to source
428 434 :param statuses: list of pull request statuses
429 435 :param opened_by: author user of the pull request
430 436 :param offset: pagination offset
431 437 :param length: length of returned list
432 438 :param order_by: order of the returned list
433 439 :param order_dir: 'asc' or 'desc' ordering direction
434 440 :returns: list of pull requests
435 441 """
436 442 pull_requests = self.get_all(
437 443 repo_name, search_q=search_q, source=source, statuses=statuses,
438 444 opened_by=opened_by, order_by=order_by, order_dir=order_dir)
439 445
440 446 _filtered_pull_requests = []
441 447 for pr in pull_requests:
442 448 status = pr.calculated_review_status()
443 449 if status in [ChangesetStatus.STATUS_NOT_REVIEWED,
444 450 ChangesetStatus.STATUS_UNDER_REVIEW]:
445 451 _filtered_pull_requests.append(pr)
446 452 if length:
447 453 return _filtered_pull_requests[offset:offset+length]
448 454 else:
449 455 return _filtered_pull_requests
450 456
451 457 def count_awaiting_my_review(self, repo_name, search_q=None, source=False, statuses=None,
452 458 opened_by=None, user_id=None):
453 459 """
454 460 Count the number of pull requests for a specific repository that are
455 461 awaiting review from a specific user.
456 462
457 463 :param repo_name: target or source repo
458 464 :param search_q: filter by text
459 465 :param source: boolean flag to specify if repo_name refers to source
460 466 :param statuses: list of pull request statuses
461 467 :param opened_by: author user of the pull request
462 468 :param user_id: reviewer user of the pull request
463 469 :returns: int number of pull requests
464 470 """
465 471 pull_requests = self.get_awaiting_my_review(
466 472 repo_name, search_q=search_q, source=source, statuses=statuses,
467 473 opened_by=opened_by, user_id=user_id)
468 474
469 475 return len(pull_requests)
470 476
471 477 def get_awaiting_my_review(self, repo_name, search_q=None, source=False, statuses=None,
472 478 opened_by=None, user_id=None, offset=0,
473 479 length=None, order_by=None, order_dir='desc'):
474 480 """
475 481 Get all pull requests for a specific repository that are awaiting
476 482 review from a specific user.
477 483
478 484 :param repo_name: target or source repo
479 485 :param search_q: filter by text
480 486 :param source: boolean flag to specify if repo_name refers to source
481 487 :param statuses: list of pull request statuses
482 488 :param opened_by: author user of the pull request
483 489 :param user_id: reviewer user of the pull request
484 490 :param offset: pagination offset
485 491 :param length: length of returned list
486 492 :param order_by: order of the returned list
487 493 :param order_dir: 'asc' or 'desc' ordering direction
488 494 :returns: list of pull requests
489 495 """
490 496 pull_requests = self.get_all(
491 497 repo_name, search_q=search_q, source=source, statuses=statuses,
492 498 opened_by=opened_by, order_by=order_by, order_dir=order_dir)
493 499
494 500 _my = PullRequestModel().get_not_reviewed(user_id)
495 501 my_participation = []
496 502 for pr in pull_requests:
497 503 if pr in _my:
498 504 my_participation.append(pr)
499 505 _filtered_pull_requests = my_participation
500 506 if length:
501 507 return _filtered_pull_requests[offset:offset+length]
502 508 else:
503 509 return _filtered_pull_requests
504 510
505 511 def get_not_reviewed(self, user_id):
506 512 return [
507 513 x.pull_request for x in PullRequestReviewers.query().filter(
508 514 PullRequestReviewers.user_id == user_id).all()
509 515 ]
510 516
511 517 def _prepare_participating_query(self, user_id=None, statuses=None, query='',
512 518 order_by=None, order_dir='desc'):
513 519 q = PullRequest.query()
514 520 if user_id:
515 521 reviewers_subquery = Session().query(
516 522 PullRequestReviewers.pull_request_id).filter(
517 523 PullRequestReviewers.user_id == user_id).subquery()
518 524 user_filter = or_(
519 525 PullRequest.user_id == user_id,
520 526 PullRequest.pull_request_id.in_(reviewers_subquery)
521 527 )
522 528 q = PullRequest.query().filter(user_filter)
523 529
524 530 # closed,opened
525 531 if statuses:
526 532 q = q.filter(PullRequest.status.in_(statuses))
527 533
528 534 if query:
529 535 like_expression = u'%{}%'.format(safe_unicode(query))
530 536 q = q.join(User)
531 537 q = q.filter(or_(
532 538 cast(PullRequest.pull_request_id, String).ilike(like_expression),
533 539 User.username.ilike(like_expression),
534 540 PullRequest.title.ilike(like_expression),
535 541 PullRequest.description.ilike(like_expression),
536 542 ))
537 543 if order_by:
538 544 order_map = {
539 545 'name_raw': PullRequest.pull_request_id,
540 546 'title': PullRequest.title,
541 547 'updated_on_raw': PullRequest.updated_on,
542 548 'target_repo': PullRequest.target_repo_id
543 549 }
544 550 if order_dir == 'asc':
545 551 q = q.order_by(order_map[order_by].asc())
546 552 else:
547 553 q = q.order_by(order_map[order_by].desc())
548 554
549 555 return q
550 556
551 557 def count_im_participating_in(self, user_id=None, statuses=None, query=''):
552 558 q = self._prepare_participating_query(user_id, statuses=statuses, query=query)
553 559 return q.count()
554 560
555 561 def get_im_participating_in(
556 562 self, user_id=None, statuses=None, query='', offset=0,
557 563 length=None, order_by=None, order_dir='desc'):
558 564 """
559 565 Get all Pull requests that i'm participating in, or i have opened
560 566 """
561 567
562 568 q = self._prepare_participating_query(
563 569 user_id, statuses=statuses, query=query, order_by=order_by,
564 570 order_dir=order_dir)
565 571
566 572 if length:
567 573 pull_requests = q.limit(length).offset(offset).all()
568 574 else:
569 575 pull_requests = q.all()
570 576
571 577 return pull_requests
572 578
573 579 def get_versions(self, pull_request):
574 580 """
575 581 returns version of pull request sorted by ID descending
576 582 """
577 583 return PullRequestVersion.query()\
578 584 .filter(PullRequestVersion.pull_request == pull_request)\
579 585 .order_by(PullRequestVersion.pull_request_version_id.asc())\
580 586 .all()
581 587
582 588 def get_pr_version(self, pull_request_id, version=None):
583 589 at_version = None
584 590
585 591 if version and version == 'latest':
586 592 pull_request_ver = PullRequest.get(pull_request_id)
587 593 pull_request_obj = pull_request_ver
588 594 _org_pull_request_obj = pull_request_obj
589 595 at_version = 'latest'
590 596 elif version:
591 597 pull_request_ver = PullRequestVersion.get_or_404(version)
592 598 pull_request_obj = pull_request_ver
593 599 _org_pull_request_obj = pull_request_ver.pull_request
594 600 at_version = pull_request_ver.pull_request_version_id
595 601 else:
596 602 _org_pull_request_obj = pull_request_obj = PullRequest.get_or_404(
597 603 pull_request_id)
598 604
599 605 pull_request_display_obj = PullRequest.get_pr_display_object(
600 606 pull_request_obj, _org_pull_request_obj)
601 607
602 608 return _org_pull_request_obj, pull_request_obj, \
603 609 pull_request_display_obj, at_version
604 610
605 611 def create(self, created_by, source_repo, source_ref, target_repo,
606 612 target_ref, revisions, reviewers, observers, title, description=None,
607 613 common_ancestor_id=None,
608 614 description_renderer=None,
609 615 reviewer_data=None, translator=None, auth_user=None):
610 616 translator = translator or get_current_request().translate
611 617
612 618 created_by_user = self._get_user(created_by)
613 619 auth_user = auth_user or created_by_user.AuthUser()
614 620 source_repo = self._get_repo(source_repo)
615 621 target_repo = self._get_repo(target_repo)
616 622
617 623 pull_request = PullRequest()
618 624 pull_request.source_repo = source_repo
619 625 pull_request.source_ref = source_ref
620 626 pull_request.target_repo = target_repo
621 627 pull_request.target_ref = target_ref
622 628 pull_request.revisions = revisions
623 629 pull_request.title = title
624 630 pull_request.description = description
625 631 pull_request.description_renderer = description_renderer
626 632 pull_request.author = created_by_user
627 633 pull_request.reviewer_data = reviewer_data
628 634 pull_request.pull_request_state = pull_request.STATE_CREATING
629 635 pull_request.common_ancestor_id = common_ancestor_id
630 636
631 637 Session().add(pull_request)
632 638 Session().flush()
633 639
634 640 reviewer_ids = set()
635 641 # members / reviewers
636 642 for reviewer_object in reviewers:
637 643 user_id, reasons, mandatory, role, rules = reviewer_object
638 644 user = self._get_user(user_id)
639 645
640 646 # skip duplicates
641 647 if user.user_id in reviewer_ids:
642 648 continue
643 649
644 650 reviewer_ids.add(user.user_id)
645 651
646 652 reviewer = PullRequestReviewers()
647 653 reviewer.user = user
648 654 reviewer.pull_request = pull_request
649 655 reviewer.reasons = reasons
650 656 reviewer.mandatory = mandatory
651 657 reviewer.role = role
652 658
653 659 # NOTE(marcink): pick only first rule for now
654 660 rule_id = list(rules)[0] if rules else None
655 661 rule = RepoReviewRule.get(rule_id) if rule_id else None
656 662 if rule:
657 663 review_group = rule.user_group_vote_rule(user_id)
658 664 # we check if this particular reviewer is member of a voting group
659 665 if review_group:
660 666 # NOTE(marcink):
661 667 # can be that user is member of more but we pick the first same,
662 668 # same as default reviewers algo
663 669 review_group = review_group[0]
664 670
665 671 rule_data = {
666 672 'rule_name':
667 673 rule.review_rule_name,
668 674 'rule_user_group_entry_id':
669 675 review_group.repo_review_rule_users_group_id,
670 676 'rule_user_group_name':
671 677 review_group.users_group.users_group_name,
672 678 'rule_user_group_members':
673 679 [x.user.username for x in review_group.users_group.members],
674 680 'rule_user_group_members_id':
675 681 [x.user.user_id for x in review_group.users_group.members],
676 682 }
677 683 # e.g {'vote_rule': -1, 'mandatory': True}
678 684 rule_data.update(review_group.rule_data())
679 685
680 686 reviewer.rule_data = rule_data
681 687
682 688 Session().add(reviewer)
683 689 Session().flush()
684 690
685 691 for observer_object in observers:
686 692 user_id, reasons, mandatory, role, rules = observer_object
687 693 user = self._get_user(user_id)
688 694
689 695 # skip duplicates from reviewers
690 696 if user.user_id in reviewer_ids:
691 697 continue
692 698
693 699 #reviewer_ids.add(user.user_id)
694 700
695 701 observer = PullRequestReviewers()
696 702 observer.user = user
697 703 observer.pull_request = pull_request
698 704 observer.reasons = reasons
699 705 observer.mandatory = mandatory
700 706 observer.role = role
701 707
702 708 # NOTE(marcink): pick only first rule for now
703 709 rule_id = list(rules)[0] if rules else None
704 710 rule = RepoReviewRule.get(rule_id) if rule_id else None
705 711 if rule:
706 712 # TODO(marcink): do we need this for observers ??
707 713 pass
708 714
709 715 Session().add(observer)
710 716 Session().flush()
711 717
712 718 # Set approval status to "Under Review" for all commits which are
713 719 # part of this pull request.
714 720 ChangesetStatusModel().set_status(
715 721 repo=target_repo,
716 722 status=ChangesetStatus.STATUS_UNDER_REVIEW,
717 723 user=created_by_user,
718 724 pull_request=pull_request
719 725 )
720 726 # we commit early at this point. This has to do with a fact
721 727 # that before queries do some row-locking. And because of that
722 728 # we need to commit and finish transaction before below validate call
723 729 # that for large repos could be long resulting in long row locks
724 730 Session().commit()
725 731
726 732 # prepare workspace, and run initial merge simulation. Set state during that
727 733 # operation
728 734 pull_request = PullRequest.get(pull_request.pull_request_id)
729 735
730 736 # set as merging, for merge simulation, and if finished to created so we mark
731 737 # simulation is working fine
732 738 with pull_request.set_state(PullRequest.STATE_MERGING,
733 739 final_state=PullRequest.STATE_CREATED) as state_obj:
734 740 MergeCheck.validate(
735 741 pull_request, auth_user=auth_user, translator=translator)
736 742
737 743 self.notify_reviewers(pull_request, reviewer_ids, created_by_user)
738 744 self.trigger_pull_request_hook(pull_request, created_by_user, 'create')
739 745
740 746 creation_data = pull_request.get_api_data(with_merge_state=False)
741 747 self._log_audit_action(
742 748 'repo.pull_request.create', {'data': creation_data},
743 749 auth_user, pull_request)
744 750
745 751 return pull_request
746 752
747 753 def trigger_pull_request_hook(self, pull_request, user, action, data=None):
748 754 pull_request = self.__get_pull_request(pull_request)
749 755 target_scm = pull_request.target_repo.scm_instance()
750 756 if action == 'create':
751 757 trigger_hook = hooks_utils.trigger_create_pull_request_hook
752 758 elif action == 'merge':
753 759 trigger_hook = hooks_utils.trigger_merge_pull_request_hook
754 760 elif action == 'close':
755 761 trigger_hook = hooks_utils.trigger_close_pull_request_hook
756 762 elif action == 'review_status_change':
757 763 trigger_hook = hooks_utils.trigger_review_pull_request_hook
758 764 elif action == 'update':
759 765 trigger_hook = hooks_utils.trigger_update_pull_request_hook
760 766 elif action == 'comment':
761 767 trigger_hook = hooks_utils.trigger_comment_pull_request_hook
762 768 elif action == 'comment_edit':
763 769 trigger_hook = hooks_utils.trigger_comment_pull_request_edit_hook
764 770 else:
765 771 return
766 772
767 773 log.debug('Handling pull_request %s trigger_pull_request_hook with action %s and hook: %s',
768 774 pull_request, action, trigger_hook)
769 775 trigger_hook(
770 776 username=user.username,
771 777 repo_name=pull_request.target_repo.repo_name,
772 778 repo_type=target_scm.alias,
773 779 pull_request=pull_request,
774 780 data=data)
775 781
776 782 def _get_commit_ids(self, pull_request):
777 783 """
778 784 Return the commit ids of the merged pull request.
779 785
780 786 This method is not dealing correctly yet with the lack of autoupdates
781 787 nor with the implicit target updates.
782 788 For example: if a commit in the source repo is already in the target it
783 789 will be reported anyways.
784 790 """
785 791 merge_rev = pull_request.merge_rev
786 792 if merge_rev is None:
787 793 raise ValueError('This pull request was not merged yet')
788 794
789 795 commit_ids = list(pull_request.revisions)
790 796 if merge_rev not in commit_ids:
791 797 commit_ids.append(merge_rev)
792 798
793 799 return commit_ids
794 800
795 801 def merge_repo(self, pull_request, user, extras):
796 802 log.debug("Merging pull request %s", pull_request.pull_request_id)
797 803 extras['user_agent'] = 'internal-merge'
798 804 merge_state = self._merge_pull_request(pull_request, user, extras)
799 805 if merge_state.executed:
800 806 log.debug("Merge was successful, updating the pull request comments.")
801 807 self._comment_and_close_pr(pull_request, user, merge_state)
802 808
803 809 self._log_audit_action(
804 810 'repo.pull_request.merge',
805 811 {'merge_state': merge_state.__dict__},
806 812 user, pull_request)
807 813
808 814 else:
809 815 log.warn("Merge failed, not updating the pull request.")
810 816 return merge_state
811 817
812 818 def _merge_pull_request(self, pull_request, user, extras, merge_msg=None):
813 819 target_vcs = pull_request.target_repo.scm_instance()
814 820 source_vcs = pull_request.source_repo.scm_instance()
815 821
816 822 message = safe_unicode(merge_msg or vcs_settings.MERGE_MESSAGE_TMPL).format(
817 823 pr_id=pull_request.pull_request_id,
818 824 pr_title=pull_request.title,
819 825 source_repo=source_vcs.name,
820 826 source_ref_name=pull_request.source_ref_parts.name,
821 827 target_repo=target_vcs.name,
822 828 target_ref_name=pull_request.target_ref_parts.name,
823 829 )
824 830
825 831 workspace_id = self._workspace_id(pull_request)
826 832 repo_id = pull_request.target_repo.repo_id
827 833 use_rebase = self._use_rebase_for_merging(pull_request)
828 834 close_branch = self._close_branch_before_merging(pull_request)
829 835 user_name = self._user_name_for_merging(pull_request, user)
830 836
831 837 target_ref = self._refresh_reference(
832 838 pull_request.target_ref_parts, target_vcs)
833 839
834 840 callback_daemon, extras = prepare_callback_daemon(
835 841 extras, protocol=vcs_settings.HOOKS_PROTOCOL,
836 842 host=vcs_settings.HOOKS_HOST,
837 843 use_direct_calls=vcs_settings.HOOKS_DIRECT_CALLS)
838 844
839 845 with callback_daemon:
840 846 # TODO: johbo: Implement a clean way to run a config_override
841 847 # for a single call.
842 848 target_vcs.config.set(
843 849 'rhodecode', 'RC_SCM_DATA', json.dumps(extras))
844 850
845 851 merge_state = target_vcs.merge(
846 852 repo_id, workspace_id, target_ref, source_vcs,
847 853 pull_request.source_ref_parts,
848 854 user_name=user_name, user_email=user.email,
849 855 message=message, use_rebase=use_rebase,
850 856 close_branch=close_branch)
851 857 return merge_state
852 858
853 859 def _comment_and_close_pr(self, pull_request, user, merge_state, close_msg=None):
854 860 pull_request.merge_rev = merge_state.merge_ref.commit_id
855 861 pull_request.updated_on = datetime.datetime.now()
856 862 close_msg = close_msg or 'Pull request merged and closed'
857 863
858 864 CommentsModel().create(
859 865 text=safe_unicode(close_msg),
860 866 repo=pull_request.target_repo.repo_id,
861 867 user=user.user_id,
862 868 pull_request=pull_request.pull_request_id,
863 869 f_path=None,
864 870 line_no=None,
865 871 closing_pr=True
866 872 )
867 873
868 874 Session().add(pull_request)
869 875 Session().flush()
870 876 # TODO: paris: replace invalidation with less radical solution
871 877 ScmModel().mark_for_invalidation(
872 878 pull_request.target_repo.repo_name)
873 879 self.trigger_pull_request_hook(pull_request, user, 'merge')
874 880
875 881 def has_valid_update_type(self, pull_request):
876 882 source_ref_type = pull_request.source_ref_parts.type
877 883 return source_ref_type in self.REF_TYPES
878 884
879 885 def get_flow_commits(self, pull_request):
880 886
881 887 # source repo
882 888 source_ref_name = pull_request.source_ref_parts.name
883 889 source_ref_type = pull_request.source_ref_parts.type
884 890 source_ref_id = pull_request.source_ref_parts.commit_id
885 891 source_repo = pull_request.source_repo.scm_instance()
886 892
887 893 try:
888 894 if source_ref_type in self.REF_TYPES:
889 895 source_commit = source_repo.get_commit(source_ref_name)
890 896 else:
891 897 source_commit = source_repo.get_commit(source_ref_id)
892 898 except CommitDoesNotExistError:
893 899 raise SourceRefMissing()
894 900
895 901 # target repo
896 902 target_ref_name = pull_request.target_ref_parts.name
897 903 target_ref_type = pull_request.target_ref_parts.type
898 904 target_ref_id = pull_request.target_ref_parts.commit_id
899 905 target_repo = pull_request.target_repo.scm_instance()
900 906
901 907 try:
902 908 if target_ref_type in self.REF_TYPES:
903 909 target_commit = target_repo.get_commit(target_ref_name)
904 910 else:
905 911 target_commit = target_repo.get_commit(target_ref_id)
906 912 except CommitDoesNotExistError:
907 913 raise TargetRefMissing()
908 914
909 915 return source_commit, target_commit
910 916
911 917 def update_commits(self, pull_request, updating_user):
912 918 """
913 919 Get the updated list of commits for the pull request
914 920 and return the new pull request version and the list
915 921 of commits processed by this update action
916 922
917 923 updating_user is the user_object who triggered the update
918 924 """
919 925 pull_request = self.__get_pull_request(pull_request)
920 926 source_ref_type = pull_request.source_ref_parts.type
921 927 source_ref_name = pull_request.source_ref_parts.name
922 928 source_ref_id = pull_request.source_ref_parts.commit_id
923 929
924 930 target_ref_type = pull_request.target_ref_parts.type
925 931 target_ref_name = pull_request.target_ref_parts.name
926 932 target_ref_id = pull_request.target_ref_parts.commit_id
927 933
928 934 if not self.has_valid_update_type(pull_request):
929 935 log.debug("Skipping update of pull request %s due to ref type: %s",
930 936 pull_request, source_ref_type)
931 937 return UpdateResponse(
932 938 executed=False,
933 939 reason=UpdateFailureReason.WRONG_REF_TYPE,
934 940 old=pull_request, new=None, common_ancestor_id=None, commit_changes=None,
935 941 source_changed=False, target_changed=False)
936 942
937 943 try:
938 944 source_commit, target_commit = self.get_flow_commits(pull_request)
939 945 except SourceRefMissing:
940 946 return UpdateResponse(
941 947 executed=False,
942 948 reason=UpdateFailureReason.MISSING_SOURCE_REF,
943 949 old=pull_request, new=None, common_ancestor_id=None, commit_changes=None,
944 950 source_changed=False, target_changed=False)
945 951 except TargetRefMissing:
946 952 return UpdateResponse(
947 953 executed=False,
948 954 reason=UpdateFailureReason.MISSING_TARGET_REF,
949 955 old=pull_request, new=None, common_ancestor_id=None, commit_changes=None,
950 956 source_changed=False, target_changed=False)
951 957
952 958 source_changed = source_ref_id != source_commit.raw_id
953 959 target_changed = target_ref_id != target_commit.raw_id
954 960
955 961 if not (source_changed or target_changed):
956 962 log.debug("Nothing changed in pull request %s", pull_request)
957 963 return UpdateResponse(
958 964 executed=False,
959 965 reason=UpdateFailureReason.NO_CHANGE,
960 966 old=pull_request, new=None, common_ancestor_id=None, commit_changes=None,
961 967 source_changed=target_changed, target_changed=source_changed)
962 968
963 969 change_in_found = 'target repo' if target_changed else 'source repo'
964 970 log.debug('Updating pull request because of change in %s detected',
965 971 change_in_found)
966 972
967 973 # Finally there is a need for an update, in case of source change
968 974 # we create a new version, else just an update
969 975 if source_changed:
970 976 pull_request_version = self._create_version_from_snapshot(pull_request)
971 977 self._link_comments_to_version(pull_request_version)
972 978 else:
973 979 try:
974 980 ver = pull_request.versions[-1]
975 981 except IndexError:
976 982 ver = None
977 983
978 984 pull_request.pull_request_version_id = \
979 985 ver.pull_request_version_id if ver else None
980 986 pull_request_version = pull_request
981 987
982 988 source_repo = pull_request.source_repo.scm_instance()
983 989 target_repo = pull_request.target_repo.scm_instance()
984 990
985 991 # re-compute commit ids
986 992 old_commit_ids = pull_request.revisions
987 993 pre_load = ["author", "date", "message", "branch"]
988 994 commit_ranges = target_repo.compare(
989 995 target_commit.raw_id, source_commit.raw_id, source_repo, merge=True,
990 996 pre_load=pre_load)
991 997
992 998 target_ref = target_commit.raw_id
993 999 source_ref = source_commit.raw_id
994 1000 ancestor_commit_id = target_repo.get_common_ancestor(
995 1001 target_ref, source_ref, source_repo)
996 1002
997 1003 if not ancestor_commit_id:
998 1004 raise ValueError(
999 1005 'cannot calculate diff info without a common ancestor. '
1000 1006 'Make sure both repositories are related, and have a common forking commit.')
1001 1007
1002 1008 pull_request.common_ancestor_id = ancestor_commit_id
1003 1009
1004 1010 pull_request.source_ref = '%s:%s:%s' % (
1005 1011 source_ref_type, source_ref_name, source_commit.raw_id)
1006 1012 pull_request.target_ref = '%s:%s:%s' % (
1007 1013 target_ref_type, target_ref_name, ancestor_commit_id)
1008 1014
1009 1015 pull_request.revisions = [
1010 1016 commit.raw_id for commit in reversed(commit_ranges)]
1011 1017 pull_request.updated_on = datetime.datetime.now()
1012 1018 Session().add(pull_request)
1013 1019 new_commit_ids = pull_request.revisions
1014 1020
1015 1021 old_diff_data, new_diff_data = self._generate_update_diffs(
1016 1022 pull_request, pull_request_version)
1017 1023
1018 1024 # calculate commit and file changes
1019 1025 commit_changes = self._calculate_commit_id_changes(
1020 1026 old_commit_ids, new_commit_ids)
1021 1027 file_changes = self._calculate_file_changes(
1022 1028 old_diff_data, new_diff_data)
1023 1029
1024 1030 # set comments as outdated if DIFFS changed
1025 1031 CommentsModel().outdate_comments(
1026 1032 pull_request, old_diff_data=old_diff_data,
1027 1033 new_diff_data=new_diff_data)
1028 1034
1029 1035 valid_commit_changes = (commit_changes.added or commit_changes.removed)
1030 1036 file_node_changes = (
1031 1037 file_changes.added or file_changes.modified or file_changes.removed)
1032 1038 pr_has_changes = valid_commit_changes or file_node_changes
1033 1039
1034 1040 # Add an automatic comment to the pull request, in case
1035 1041 # anything has changed
1036 1042 if pr_has_changes:
1037 1043 update_comment = CommentsModel().create(
1038 1044 text=self._render_update_message(ancestor_commit_id, commit_changes, file_changes),
1039 1045 repo=pull_request.target_repo,
1040 1046 user=pull_request.author,
1041 1047 pull_request=pull_request,
1042 1048 send_email=False, renderer=DEFAULT_COMMENTS_RENDERER)
1043 1049
1044 1050 # Update status to "Under Review" for added commits
1045 1051 for commit_id in commit_changes.added:
1046 1052 ChangesetStatusModel().set_status(
1047 1053 repo=pull_request.source_repo,
1048 1054 status=ChangesetStatus.STATUS_UNDER_REVIEW,
1049 1055 comment=update_comment,
1050 1056 user=pull_request.author,
1051 1057 pull_request=pull_request,
1052 1058 revision=commit_id)
1053 1059
1054 1060 # send update email to users
1055 1061 try:
1056 1062 self.notify_users(pull_request=pull_request, updating_user=updating_user,
1057 1063 ancestor_commit_id=ancestor_commit_id,
1058 1064 commit_changes=commit_changes,
1059 1065 file_changes=file_changes)
1060 1066 except Exception:
1061 1067 log.exception('Failed to send email notification to users')
1062 1068
1063 1069 log.debug(
1064 1070 'Updated pull request %s, added_ids: %s, common_ids: %s, '
1065 1071 'removed_ids: %s', pull_request.pull_request_id,
1066 1072 commit_changes.added, commit_changes.common, commit_changes.removed)
1067 1073 log.debug(
1068 1074 'Updated pull request with the following file changes: %s',
1069 1075 file_changes)
1070 1076
1071 1077 log.info(
1072 1078 "Updated pull request %s from commit %s to commit %s, "
1073 1079 "stored new version %s of this pull request.",
1074 1080 pull_request.pull_request_id, source_ref_id,
1075 1081 pull_request.source_ref_parts.commit_id,
1076 1082 pull_request_version.pull_request_version_id)
1077 1083 Session().commit()
1078 1084 self.trigger_pull_request_hook(pull_request, pull_request.author, 'update')
1079 1085
1080 1086 return UpdateResponse(
1081 1087 executed=True, reason=UpdateFailureReason.NONE,
1082 1088 old=pull_request, new=pull_request_version,
1083 1089 common_ancestor_id=ancestor_commit_id, commit_changes=commit_changes,
1084 1090 source_changed=source_changed, target_changed=target_changed)
1085 1091
1086 1092 def _create_version_from_snapshot(self, pull_request):
1087 1093 version = PullRequestVersion()
1088 1094 version.title = pull_request.title
1089 1095 version.description = pull_request.description
1090 1096 version.status = pull_request.status
1091 1097 version.pull_request_state = pull_request.pull_request_state
1092 1098 version.created_on = datetime.datetime.now()
1093 1099 version.updated_on = pull_request.updated_on
1094 1100 version.user_id = pull_request.user_id
1095 1101 version.source_repo = pull_request.source_repo
1096 1102 version.source_ref = pull_request.source_ref
1097 1103 version.target_repo = pull_request.target_repo
1098 1104 version.target_ref = pull_request.target_ref
1099 1105
1100 1106 version._last_merge_source_rev = pull_request._last_merge_source_rev
1101 1107 version._last_merge_target_rev = pull_request._last_merge_target_rev
1102 1108 version.last_merge_status = pull_request.last_merge_status
1103 1109 version.last_merge_metadata = pull_request.last_merge_metadata
1104 1110 version.shadow_merge_ref = pull_request.shadow_merge_ref
1105 1111 version.merge_rev = pull_request.merge_rev
1106 1112 version.reviewer_data = pull_request.reviewer_data
1107 1113
1108 1114 version.revisions = pull_request.revisions
1109 1115 version.common_ancestor_id = pull_request.common_ancestor_id
1110 1116 version.pull_request = pull_request
1111 1117 Session().add(version)
1112 1118 Session().flush()
1113 1119
1114 1120 return version
1115 1121
1116 1122 def _generate_update_diffs(self, pull_request, pull_request_version):
1117 1123
1118 1124 diff_context = (
1119 1125 self.DIFF_CONTEXT +
1120 1126 CommentsModel.needed_extra_diff_context())
1121 1127 hide_whitespace_changes = False
1122 1128 source_repo = pull_request_version.source_repo
1123 1129 source_ref_id = pull_request_version.source_ref_parts.commit_id
1124 1130 target_ref_id = pull_request_version.target_ref_parts.commit_id
1125 1131 old_diff = self._get_diff_from_pr_or_version(
1126 1132 source_repo, source_ref_id, target_ref_id,
1127 1133 hide_whitespace_changes=hide_whitespace_changes, diff_context=diff_context)
1128 1134
1129 1135 source_repo = pull_request.source_repo
1130 1136 source_ref_id = pull_request.source_ref_parts.commit_id
1131 1137 target_ref_id = pull_request.target_ref_parts.commit_id
1132 1138
1133 1139 new_diff = self._get_diff_from_pr_or_version(
1134 1140 source_repo, source_ref_id, target_ref_id,
1135 1141 hide_whitespace_changes=hide_whitespace_changes, diff_context=diff_context)
1136 1142
1137 1143 old_diff_data = diffs.DiffProcessor(old_diff)
1138 1144 old_diff_data.prepare()
1139 1145 new_diff_data = diffs.DiffProcessor(new_diff)
1140 1146 new_diff_data.prepare()
1141 1147
1142 1148 return old_diff_data, new_diff_data
1143 1149
1144 1150 def _link_comments_to_version(self, pull_request_version):
1145 1151 """
1146 1152 Link all unlinked comments of this pull request to the given version.
1147 1153
1148 1154 :param pull_request_version: The `PullRequestVersion` to which
1149 1155 the comments shall be linked.
1150 1156
1151 1157 """
1152 1158 pull_request = pull_request_version.pull_request
1153 1159 comments = ChangesetComment.query()\
1154 1160 .filter(
1155 1161 # TODO: johbo: Should we query for the repo at all here?
1156 1162 # Pending decision on how comments of PRs are to be related
1157 1163 # to either the source repo, the target repo or no repo at all.
1158 1164 ChangesetComment.repo_id == pull_request.target_repo.repo_id,
1159 1165 ChangesetComment.pull_request == pull_request,
1160 1166 ChangesetComment.pull_request_version == None)\
1161 1167 .order_by(ChangesetComment.comment_id.asc())
1162 1168
1163 1169 # TODO: johbo: Find out why this breaks if it is done in a bulk
1164 1170 # operation.
1165 1171 for comment in comments:
1166 1172 comment.pull_request_version_id = (
1167 1173 pull_request_version.pull_request_version_id)
1168 1174 Session().add(comment)
1169 1175
1170 1176 def _calculate_commit_id_changes(self, old_ids, new_ids):
1171 1177 added = [x for x in new_ids if x not in old_ids]
1172 1178 common = [x for x in new_ids if x in old_ids]
1173 1179 removed = [x for x in old_ids if x not in new_ids]
1174 1180 total = new_ids
1175 1181 return ChangeTuple(added, common, removed, total)
1176 1182
1177 1183 def _calculate_file_changes(self, old_diff_data, new_diff_data):
1178 1184
1179 1185 old_files = OrderedDict()
1180 1186 for diff_data in old_diff_data.parsed_diff:
1181 1187 old_files[diff_data['filename']] = md5_safe(diff_data['raw_diff'])
1182 1188
1183 1189 added_files = []
1184 1190 modified_files = []
1185 1191 removed_files = []
1186 1192 for diff_data in new_diff_data.parsed_diff:
1187 1193 new_filename = diff_data['filename']
1188 1194 new_hash = md5_safe(diff_data['raw_diff'])
1189 1195
1190 1196 old_hash = old_files.get(new_filename)
1191 1197 if not old_hash:
1192 1198 # file is not present in old diff, we have to figure out from parsed diff
1193 1199 # operation ADD/REMOVE
1194 1200 operations_dict = diff_data['stats']['ops']
1195 1201 if diffs.DEL_FILENODE in operations_dict:
1196 1202 removed_files.append(new_filename)
1197 1203 else:
1198 1204 added_files.append(new_filename)
1199 1205 else:
1200 1206 if new_hash != old_hash:
1201 1207 modified_files.append(new_filename)
1202 1208 # now remove a file from old, since we have seen it already
1203 1209 del old_files[new_filename]
1204 1210
1205 1211 # removed files is when there are present in old, but not in NEW,
1206 1212 # since we remove old files that are present in new diff, left-overs
1207 1213 # if any should be the removed files
1208 1214 removed_files.extend(old_files.keys())
1209 1215
1210 1216 return FileChangeTuple(added_files, modified_files, removed_files)
1211 1217
1212 1218 def _render_update_message(self, ancestor_commit_id, changes, file_changes):
1213 1219 """
1214 1220 render the message using DEFAULT_COMMENTS_RENDERER (RST renderer),
1215 1221 so it's always looking the same disregarding on which default
1216 1222 renderer system is using.
1217 1223
1218 1224 :param ancestor_commit_id: ancestor raw_id
1219 1225 :param changes: changes named tuple
1220 1226 :param file_changes: file changes named tuple
1221 1227
1222 1228 """
1223 1229 new_status = ChangesetStatus.get_status_lbl(
1224 1230 ChangesetStatus.STATUS_UNDER_REVIEW)
1225 1231
1226 1232 changed_files = (
1227 1233 file_changes.added + file_changes.modified + file_changes.removed)
1228 1234
1229 1235 params = {
1230 1236 'under_review_label': new_status,
1231 1237 'added_commits': changes.added,
1232 1238 'removed_commits': changes.removed,
1233 1239 'changed_files': changed_files,
1234 1240 'added_files': file_changes.added,
1235 1241 'modified_files': file_changes.modified,
1236 1242 'removed_files': file_changes.removed,
1237 1243 'ancestor_commit_id': ancestor_commit_id
1238 1244 }
1239 1245 renderer = RstTemplateRenderer()
1240 1246 return renderer.render('pull_request_update.mako', **params)
1241 1247
1242 1248 def edit(self, pull_request, title, description, description_renderer, user):
1243 1249 pull_request = self.__get_pull_request(pull_request)
1244 1250 old_data = pull_request.get_api_data(with_merge_state=False)
1245 1251 if pull_request.is_closed():
1246 1252 raise ValueError('This pull request is closed')
1247 1253 if title:
1248 1254 pull_request.title = title
1249 1255 pull_request.description = description
1250 1256 pull_request.updated_on = datetime.datetime.now()
1251 1257 pull_request.description_renderer = description_renderer
1252 1258 Session().add(pull_request)
1253 1259 self._log_audit_action(
1254 1260 'repo.pull_request.edit', {'old_data': old_data},
1255 1261 user, pull_request)
1256 1262
1257 1263 def update_reviewers(self, pull_request, reviewer_data, user):
1258 1264 """
1259 1265 Update the reviewers in the pull request
1260 1266
1261 1267 :param pull_request: the pr to update
1262 1268 :param reviewer_data: list of tuples
1263 1269 [(user, ['reason1', 'reason2'], mandatory_flag, role, [rules])]
1264 1270 :param user: current use who triggers this action
1265 1271 """
1266 1272
1267 1273 pull_request = self.__get_pull_request(pull_request)
1268 1274 if pull_request.is_closed():
1269 1275 raise ValueError('This pull request is closed')
1270 1276
1271 1277 reviewers = {}
1272 1278 for user_id, reasons, mandatory, role, rules in reviewer_data:
1273 1279 if isinstance(user_id, (int, compat.string_types)):
1274 1280 user_id = self._get_user(user_id).user_id
1275 1281 reviewers[user_id] = {
1276 1282 'reasons': reasons, 'mandatory': mandatory, 'role': role}
1277 1283
1278 1284 reviewers_ids = set(reviewers.keys())
1279 1285 current_reviewers = PullRequestReviewers.get_pull_request_reviewers(
1280 1286 pull_request.pull_request_id, role=PullRequestReviewers.ROLE_REVIEWER)
1281 1287
1282 1288 current_reviewers_ids = set([x.user.user_id for x in current_reviewers])
1283 1289
1284 1290 ids_to_add = reviewers_ids.difference(current_reviewers_ids)
1285 1291 ids_to_remove = current_reviewers_ids.difference(reviewers_ids)
1286 1292
1287 1293 log.debug("Adding %s reviewers", ids_to_add)
1288 1294 log.debug("Removing %s reviewers", ids_to_remove)
1289 1295 changed = False
1290 1296 added_audit_reviewers = []
1291 1297 removed_audit_reviewers = []
1292 1298
1293 1299 for uid in ids_to_add:
1294 1300 changed = True
1295 1301 _usr = self._get_user(uid)
1296 1302 reviewer = PullRequestReviewers()
1297 1303 reviewer.user = _usr
1298 1304 reviewer.pull_request = pull_request
1299 1305 reviewer.reasons = reviewers[uid]['reasons']
1300 1306 # NOTE(marcink): mandatory shouldn't be changed now
1301 1307 # reviewer.mandatory = reviewers[uid]['reasons']
1302 1308 # NOTE(marcink): role should be hardcoded, so we won't edit it.
1303 1309 reviewer.role = PullRequestReviewers.ROLE_REVIEWER
1304 1310 Session().add(reviewer)
1305 1311 added_audit_reviewers.append(reviewer.get_dict())
1306 1312
1307 1313 for uid in ids_to_remove:
1308 1314 changed = True
1309 1315 # NOTE(marcink): we fetch "ALL" reviewers objects using .all().
1310 1316 # This is an edge case that handles previous state of having the same reviewer twice.
1311 1317 # this CAN happen due to the lack of DB checks
1312 1318 reviewers = PullRequestReviewers.query()\
1313 1319 .filter(PullRequestReviewers.user_id == uid,
1314 1320 PullRequestReviewers.role == PullRequestReviewers.ROLE_REVIEWER,
1315 1321 PullRequestReviewers.pull_request == pull_request)\
1316 1322 .all()
1317 1323
1318 1324 for obj in reviewers:
1319 1325 added_audit_reviewers.append(obj.get_dict())
1320 1326 Session().delete(obj)
1321 1327
1322 1328 if changed:
1323 1329 Session().expire_all()
1324 1330 pull_request.updated_on = datetime.datetime.now()
1325 1331 Session().add(pull_request)
1326 1332
1327 1333 # finally store audit logs
1328 1334 for user_data in added_audit_reviewers:
1329 1335 self._log_audit_action(
1330 1336 'repo.pull_request.reviewer.add', {'data': user_data},
1331 1337 user, pull_request)
1332 1338 for user_data in removed_audit_reviewers:
1333 1339 self._log_audit_action(
1334 1340 'repo.pull_request.reviewer.delete', {'old_data': user_data},
1335 1341 user, pull_request)
1336 1342
1337 1343 self.notify_reviewers(pull_request, ids_to_add, user.get_instance())
1338 1344 return ids_to_add, ids_to_remove
1339 1345
1340 1346 def update_observers(self, pull_request, observer_data, user):
1341 1347 """
1342 1348 Update the observers in the pull request
1343 1349
1344 1350 :param pull_request: the pr to update
1345 1351 :param observer_data: list of tuples
1346 1352 [(user, ['reason1', 'reason2'], mandatory_flag, role, [rules])]
1347 1353 :param user: current use who triggers this action
1348 1354 """
1349 1355 pull_request = self.__get_pull_request(pull_request)
1350 1356 if pull_request.is_closed():
1351 1357 raise ValueError('This pull request is closed')
1352 1358
1353 1359 observers = {}
1354 1360 for user_id, reasons, mandatory, role, rules in observer_data:
1355 1361 if isinstance(user_id, (int, compat.string_types)):
1356 1362 user_id = self._get_user(user_id).user_id
1357 1363 observers[user_id] = {
1358 1364 'reasons': reasons, 'observers': mandatory, 'role': role}
1359 1365
1360 1366 observers_ids = set(observers.keys())
1361 1367 current_observers = PullRequestReviewers.get_pull_request_reviewers(
1362 1368 pull_request.pull_request_id, role=PullRequestReviewers.ROLE_OBSERVER)
1363 1369
1364 1370 current_observers_ids = set([x.user.user_id for x in current_observers])
1365 1371
1366 1372 ids_to_add = observers_ids.difference(current_observers_ids)
1367 1373 ids_to_remove = current_observers_ids.difference(observers_ids)
1368 1374
1369 1375 log.debug("Adding %s observer", ids_to_add)
1370 1376 log.debug("Removing %s observer", ids_to_remove)
1371 1377 changed = False
1372 1378 added_audit_observers = []
1373 1379 removed_audit_observers = []
1374 1380
1375 1381 for uid in ids_to_add:
1376 1382 changed = True
1377 1383 _usr = self._get_user(uid)
1378 1384 observer = PullRequestReviewers()
1379 1385 observer.user = _usr
1380 1386 observer.pull_request = pull_request
1381 1387 observer.reasons = observers[uid]['reasons']
1382 1388 # NOTE(marcink): mandatory shouldn't be changed now
1383 1389 # observer.mandatory = observer[uid]['reasons']
1384 1390
1385 1391 # NOTE(marcink): role should be hardcoded, so we won't edit it.
1386 1392 observer.role = PullRequestReviewers.ROLE_OBSERVER
1387 1393 Session().add(observer)
1388 1394 added_audit_observers.append(observer.get_dict())
1389 1395
1390 1396 for uid in ids_to_remove:
1391 1397 changed = True
1392 1398 # NOTE(marcink): we fetch "ALL" reviewers objects using .all().
1393 1399 # This is an edge case that handles previous state of having the same reviewer twice.
1394 1400 # this CAN happen due to the lack of DB checks
1395 1401 observers = PullRequestReviewers.query()\
1396 1402 .filter(PullRequestReviewers.user_id == uid,
1397 1403 PullRequestReviewers.role == PullRequestReviewers.ROLE_OBSERVER,
1398 1404 PullRequestReviewers.pull_request == pull_request)\
1399 1405 .all()
1400 1406
1401 1407 for obj in observers:
1402 1408 added_audit_observers.append(obj.get_dict())
1403 1409 Session().delete(obj)
1404 1410
1405 1411 if changed:
1406 1412 Session().expire_all()
1407 1413 pull_request.updated_on = datetime.datetime.now()
1408 1414 Session().add(pull_request)
1409 1415
1410 1416 # finally store audit logs
1411 1417 for user_data in added_audit_observers:
1412 1418 self._log_audit_action(
1413 1419 'repo.pull_request.observer.add', {'data': user_data},
1414 1420 user, pull_request)
1415 1421 for user_data in removed_audit_observers:
1416 1422 self._log_audit_action(
1417 1423 'repo.pull_request.observer.delete', {'old_data': user_data},
1418 1424 user, pull_request)
1419 1425
1420 1426 self.notify_observers(pull_request, ids_to_add, user.get_instance())
1421 1427 return ids_to_add, ids_to_remove
1422 1428
1423 1429 def get_url(self, pull_request, request=None, permalink=False):
1424 1430 if not request:
1425 1431 request = get_current_request()
1426 1432
1427 1433 if permalink:
1428 1434 return request.route_url(
1429 1435 'pull_requests_global',
1430 1436 pull_request_id=pull_request.pull_request_id,)
1431 1437 else:
1432 1438 return request.route_url('pullrequest_show',
1433 1439 repo_name=safe_str(pull_request.target_repo.repo_name),
1434 1440 pull_request_id=pull_request.pull_request_id,)
1435 1441
1436 1442 def get_shadow_clone_url(self, pull_request, request=None):
1437 1443 """
1438 1444 Returns qualified url pointing to the shadow repository. If this pull
1439 1445 request is closed there is no shadow repository and ``None`` will be
1440 1446 returned.
1441 1447 """
1442 1448 if pull_request.is_closed():
1443 1449 return None
1444 1450 else:
1445 1451 pr_url = urllib.unquote(self.get_url(pull_request, request=request))
1446 1452 return safe_unicode('{pr_url}/repository'.format(pr_url=pr_url))
1447 1453
1448 1454 def _notify_reviewers(self, pull_request, user_ids, role, user):
1449 1455 # notification to reviewers/observers
1450 1456 if not user_ids:
1451 1457 return
1452 1458
1453 1459 log.debug('Notify following %s users about pull-request %s', role, user_ids)
1454 1460
1455 1461 pull_request_obj = pull_request
1456 1462 # get the current participants of this pull request
1457 1463 recipients = user_ids
1458 1464 notification_type = EmailNotificationModel.TYPE_PULL_REQUEST
1459 1465
1460 1466 pr_source_repo = pull_request_obj.source_repo
1461 1467 pr_target_repo = pull_request_obj.target_repo
1462 1468
1463 1469 pr_url = h.route_url('pullrequest_show',
1464 1470 repo_name=pr_target_repo.repo_name,
1465 1471 pull_request_id=pull_request_obj.pull_request_id,)
1466 1472
1467 1473 # set some variables for email notification
1468 1474 pr_target_repo_url = h.route_url(
1469 1475 'repo_summary', repo_name=pr_target_repo.repo_name)
1470 1476
1471 1477 pr_source_repo_url = h.route_url(
1472 1478 'repo_summary', repo_name=pr_source_repo.repo_name)
1473 1479
1474 1480 # pull request specifics
1475 1481 pull_request_commits = [
1476 1482 (x.raw_id, x.message)
1477 1483 for x in map(pr_source_repo.get_commit, pull_request.revisions)]
1478 1484
1479 1485 current_rhodecode_user = user
1480 1486 kwargs = {
1481 1487 'user': current_rhodecode_user,
1482 1488 'pull_request_author': pull_request.author,
1483 1489 'pull_request': pull_request_obj,
1484 1490 'pull_request_commits': pull_request_commits,
1485 1491
1486 1492 'pull_request_target_repo': pr_target_repo,
1487 1493 'pull_request_target_repo_url': pr_target_repo_url,
1488 1494
1489 1495 'pull_request_source_repo': pr_source_repo,
1490 1496 'pull_request_source_repo_url': pr_source_repo_url,
1491 1497
1492 1498 'pull_request_url': pr_url,
1493 1499 'thread_ids': [pr_url],
1494 1500 'user_role': role
1495 1501 }
1496 1502
1497 1503 # pre-generate the subject for notification itself
1498 1504 (subject, _e, body_plaintext) = EmailNotificationModel().render_email(
1499 1505 notification_type, **kwargs)
1500 1506
1501 1507 # create notification objects, and emails
1502 1508 NotificationModel().create(
1503 1509 created_by=current_rhodecode_user,
1504 1510 notification_subject=subject,
1505 1511 notification_body=body_plaintext,
1506 1512 notification_type=notification_type,
1507 1513 recipients=recipients,
1508 1514 email_kwargs=kwargs,
1509 1515 )
1510 1516
1511 1517 def notify_reviewers(self, pull_request, reviewers_ids, user):
1512 1518 return self._notify_reviewers(pull_request, reviewers_ids,
1513 1519 PullRequestReviewers.ROLE_REVIEWER, user)
1514 1520
1515 1521 def notify_observers(self, pull_request, observers_ids, user):
1516 1522 return self._notify_reviewers(pull_request, observers_ids,
1517 1523 PullRequestReviewers.ROLE_OBSERVER, user)
1518 1524
1519 1525 def notify_users(self, pull_request, updating_user, ancestor_commit_id,
1520 1526 commit_changes, file_changes):
1521 1527
1522 1528 updating_user_id = updating_user.user_id
1523 1529 reviewers = set([x.user.user_id for x in pull_request.reviewers])
1524 1530 # NOTE(marcink): send notification to all other users except to
1525 1531 # person who updated the PR
1526 1532 recipients = reviewers.difference(set([updating_user_id]))
1527 1533
1528 1534 log.debug('Notify following recipients about pull-request update %s', recipients)
1529 1535
1530 1536 pull_request_obj = pull_request
1531 1537
1532 1538 # send email about the update
1533 1539 changed_files = (
1534 1540 file_changes.added + file_changes.modified + file_changes.removed)
1535 1541
1536 1542 pr_source_repo = pull_request_obj.source_repo
1537 1543 pr_target_repo = pull_request_obj.target_repo
1538 1544
1539 1545 pr_url = h.route_url('pullrequest_show',
1540 1546 repo_name=pr_target_repo.repo_name,
1541 1547 pull_request_id=pull_request_obj.pull_request_id,)
1542 1548
1543 1549 # set some variables for email notification
1544 1550 pr_target_repo_url = h.route_url(
1545 1551 'repo_summary', repo_name=pr_target_repo.repo_name)
1546 1552
1547 1553 pr_source_repo_url = h.route_url(
1548 1554 'repo_summary', repo_name=pr_source_repo.repo_name)
1549 1555
1550 1556 email_kwargs = {
1551 1557 'date': datetime.datetime.now(),
1552 1558 'updating_user': updating_user,
1553 1559
1554 1560 'pull_request': pull_request_obj,
1555 1561
1556 1562 'pull_request_target_repo': pr_target_repo,
1557 1563 'pull_request_target_repo_url': pr_target_repo_url,
1558 1564
1559 1565 'pull_request_source_repo': pr_source_repo,
1560 1566 'pull_request_source_repo_url': pr_source_repo_url,
1561 1567
1562 1568 'pull_request_url': pr_url,
1563 1569
1564 1570 'ancestor_commit_id': ancestor_commit_id,
1565 1571 'added_commits': commit_changes.added,
1566 1572 'removed_commits': commit_changes.removed,
1567 1573 'changed_files': changed_files,
1568 1574 'added_files': file_changes.added,
1569 1575 'modified_files': file_changes.modified,
1570 1576 'removed_files': file_changes.removed,
1571 1577 'thread_ids': [pr_url],
1572 1578 }
1573 1579
1574 1580 (subject, _e, body_plaintext) = EmailNotificationModel().render_email(
1575 1581 EmailNotificationModel.TYPE_PULL_REQUEST_UPDATE, **email_kwargs)
1576 1582
1577 1583 # create notification objects, and emails
1578 1584 NotificationModel().create(
1579 1585 created_by=updating_user,
1580 1586 notification_subject=subject,
1581 1587 notification_body=body_plaintext,
1582 1588 notification_type=EmailNotificationModel.TYPE_PULL_REQUEST_UPDATE,
1583 1589 recipients=recipients,
1584 1590 email_kwargs=email_kwargs,
1585 1591 )
1586 1592
1587 1593 def delete(self, pull_request, user=None):
1588 1594 if not user:
1589 1595 user = getattr(get_current_rhodecode_user(), 'username', None)
1590 1596
1591 1597 pull_request = self.__get_pull_request(pull_request)
1592 1598 old_data = pull_request.get_api_data(with_merge_state=False)
1593 1599 self._cleanup_merge_workspace(pull_request)
1594 1600 self._log_audit_action(
1595 1601 'repo.pull_request.delete', {'old_data': old_data},
1596 1602 user, pull_request)
1597 1603 Session().delete(pull_request)
1598 1604
1599 1605 def close_pull_request(self, pull_request, user):
1600 1606 pull_request = self.__get_pull_request(pull_request)
1601 1607 self._cleanup_merge_workspace(pull_request)
1602 1608 pull_request.status = PullRequest.STATUS_CLOSED
1603 1609 pull_request.updated_on = datetime.datetime.now()
1604 1610 Session().add(pull_request)
1605 1611 self.trigger_pull_request_hook(pull_request, pull_request.author, 'close')
1606 1612
1607 1613 pr_data = pull_request.get_api_data(with_merge_state=False)
1608 1614 self._log_audit_action(
1609 1615 'repo.pull_request.close', {'data': pr_data}, user, pull_request)
1610 1616
1611 1617 def close_pull_request_with_comment(
1612 1618 self, pull_request, user, repo, message=None, auth_user=None):
1613 1619
1614 1620 pull_request_review_status = pull_request.calculated_review_status()
1615 1621
1616 1622 if pull_request_review_status == ChangesetStatus.STATUS_APPROVED:
1617 1623 # approved only if we have voting consent
1618 1624 status = ChangesetStatus.STATUS_APPROVED
1619 1625 else:
1620 1626 status = ChangesetStatus.STATUS_REJECTED
1621 1627 status_lbl = ChangesetStatus.get_status_lbl(status)
1622 1628
1623 1629 default_message = (
1624 1630 'Closing with status change {transition_icon} {status}.'
1625 1631 ).format(transition_icon='>', status=status_lbl)
1626 1632 text = message or default_message
1627 1633
1628 1634 # create a comment, and link it to new status
1629 1635 comment = CommentsModel().create(
1630 1636 text=text,
1631 1637 repo=repo.repo_id,
1632 1638 user=user.user_id,
1633 1639 pull_request=pull_request.pull_request_id,
1634 1640 status_change=status_lbl,
1635 1641 status_change_type=status,
1636 1642 closing_pr=True,
1637 1643 auth_user=auth_user,
1638 1644 )
1639 1645
1640 1646 # calculate old status before we change it
1641 1647 old_calculated_status = pull_request.calculated_review_status()
1642 1648 ChangesetStatusModel().set_status(
1643 1649 repo.repo_id,
1644 1650 status,
1645 1651 user.user_id,
1646 1652 comment=comment,
1647 1653 pull_request=pull_request.pull_request_id
1648 1654 )
1649 1655
1650 1656 Session().flush()
1651 1657
1652 1658 self.trigger_pull_request_hook(pull_request, user, 'comment',
1653 1659 data={'comment': comment})
1654 1660
1655 1661 # we now calculate the status of pull request again, and based on that
1656 1662 # calculation trigger status change. This might happen in cases
1657 1663 # that non-reviewer admin closes a pr, which means his vote doesn't
1658 1664 # change the status, while if he's a reviewer this might change it.
1659 1665 calculated_status = pull_request.calculated_review_status()
1660 1666 if old_calculated_status != calculated_status:
1661 1667 self.trigger_pull_request_hook(pull_request, user, 'review_status_change',
1662 1668 data={'status': calculated_status})
1663 1669
1664 1670 # finally close the PR
1665 1671 PullRequestModel().close_pull_request(pull_request.pull_request_id, user)
1666 1672
1667 1673 return comment, status
1668 1674
1669 1675 def merge_status(self, pull_request, translator=None, force_shadow_repo_refresh=False):
1670 1676 _ = translator or get_current_request().translate
1671 1677
1672 1678 if not self._is_merge_enabled(pull_request):
1673 1679 return None, False, _('Server-side pull request merging is disabled.')
1674 1680
1675 1681 if pull_request.is_closed():
1676 1682 return None, False, _('This pull request is closed.')
1677 1683
1678 1684 merge_possible, msg = self._check_repo_requirements(
1679 1685 target=pull_request.target_repo, source=pull_request.source_repo,
1680 1686 translator=_)
1681 1687 if not merge_possible:
1682 1688 return None, merge_possible, msg
1683 1689
1684 1690 try:
1685 1691 merge_response = self._try_merge(
1686 1692 pull_request, force_shadow_repo_refresh=force_shadow_repo_refresh)
1687 1693 log.debug("Merge response: %s", merge_response)
1688 1694 return merge_response, merge_response.possible, merge_response.merge_status_message
1689 1695 except NotImplementedError:
1690 1696 return None, False, _('Pull request merging is not supported.')
1691 1697
1692 1698 def _check_repo_requirements(self, target, source, translator):
1693 1699 """
1694 1700 Check if `target` and `source` have compatible requirements.
1695 1701
1696 1702 Currently this is just checking for largefiles.
1697 1703 """
1698 1704 _ = translator
1699 1705 target_has_largefiles = self._has_largefiles(target)
1700 1706 source_has_largefiles = self._has_largefiles(source)
1701 1707 merge_possible = True
1702 1708 message = u''
1703 1709
1704 1710 if target_has_largefiles != source_has_largefiles:
1705 1711 merge_possible = False
1706 1712 if source_has_largefiles:
1707 1713 message = _(
1708 1714 'Target repository large files support is disabled.')
1709 1715 else:
1710 1716 message = _(
1711 1717 'Source repository large files support is disabled.')
1712 1718
1713 1719 return merge_possible, message
1714 1720
1715 1721 def _has_largefiles(self, repo):
1716 1722 largefiles_ui = VcsSettingsModel(repo=repo).get_ui_settings(
1717 1723 'extensions', 'largefiles')
1718 1724 return largefiles_ui and largefiles_ui[0].active
1719 1725
1720 1726 def _try_merge(self, pull_request, force_shadow_repo_refresh=False):
1721 1727 """
1722 1728 Try to merge the pull request and return the merge status.
1723 1729 """
1724 1730 log.debug(
1725 1731 "Trying out if the pull request %s can be merged. Force_refresh=%s",
1726 1732 pull_request.pull_request_id, force_shadow_repo_refresh)
1727 1733 target_vcs = pull_request.target_repo.scm_instance()
1728 1734 # Refresh the target reference.
1729 1735 try:
1730 1736 target_ref = self._refresh_reference(
1731 1737 pull_request.target_ref_parts, target_vcs)
1732 1738 except CommitDoesNotExistError:
1733 1739 merge_state = MergeResponse(
1734 1740 False, False, None, MergeFailureReason.MISSING_TARGET_REF,
1735 1741 metadata={'target_ref': pull_request.target_ref_parts})
1736 1742 return merge_state
1737 1743
1738 1744 target_locked = pull_request.target_repo.locked
1739 1745 if target_locked and target_locked[0]:
1740 1746 locked_by = 'user:{}'.format(target_locked[0])
1741 1747 log.debug("The target repository is locked by %s.", locked_by)
1742 1748 merge_state = MergeResponse(
1743 1749 False, False, None, MergeFailureReason.TARGET_IS_LOCKED,
1744 1750 metadata={'locked_by': locked_by})
1745 1751 elif force_shadow_repo_refresh or self._needs_merge_state_refresh(
1746 1752 pull_request, target_ref):
1747 1753 log.debug("Refreshing the merge status of the repository.")
1748 1754 merge_state = self._refresh_merge_state(
1749 1755 pull_request, target_vcs, target_ref)
1750 1756 else:
1751 1757 possible = pull_request.last_merge_status == MergeFailureReason.NONE
1752 1758 metadata = {
1753 1759 'unresolved_files': '',
1754 1760 'target_ref': pull_request.target_ref_parts,
1755 1761 'source_ref': pull_request.source_ref_parts,
1756 1762 }
1757 1763 if pull_request.last_merge_metadata:
1758 1764 metadata.update(pull_request.last_merge_metadata_parsed)
1759 1765
1760 1766 if not possible and target_ref.type == 'branch':
1761 1767 # NOTE(marcink): case for mercurial multiple heads on branch
1762 1768 heads = target_vcs._heads(target_ref.name)
1763 1769 if len(heads) != 1:
1764 1770 heads = '\n,'.join(target_vcs._heads(target_ref.name))
1765 1771 metadata.update({
1766 1772 'heads': heads
1767 1773 })
1768 1774
1769 1775 merge_state = MergeResponse(
1770 1776 possible, False, None, pull_request.last_merge_status, metadata=metadata)
1771 1777
1772 1778 return merge_state
1773 1779
1774 1780 def _refresh_reference(self, reference, vcs_repository):
1775 1781 if reference.type in self.UPDATABLE_REF_TYPES:
1776 1782 name_or_id = reference.name
1777 1783 else:
1778 1784 name_or_id = reference.commit_id
1779 1785
1780 1786 refreshed_commit = vcs_repository.get_commit(name_or_id)
1781 1787 refreshed_reference = Reference(
1782 1788 reference.type, reference.name, refreshed_commit.raw_id)
1783 1789 return refreshed_reference
1784 1790
1785 1791 def _needs_merge_state_refresh(self, pull_request, target_reference):
1786 1792 return not(
1787 1793 pull_request.revisions and
1788 1794 pull_request.revisions[0] == pull_request._last_merge_source_rev and
1789 1795 target_reference.commit_id == pull_request._last_merge_target_rev)
1790 1796
1791 1797 def _refresh_merge_state(self, pull_request, target_vcs, target_reference):
1792 1798 workspace_id = self._workspace_id(pull_request)
1793 1799 source_vcs = pull_request.source_repo.scm_instance()
1794 1800 repo_id = pull_request.target_repo.repo_id
1795 1801 use_rebase = self._use_rebase_for_merging(pull_request)
1796 1802 close_branch = self._close_branch_before_merging(pull_request)
1797 1803 merge_state = target_vcs.merge(
1798 1804 repo_id, workspace_id,
1799 1805 target_reference, source_vcs, pull_request.source_ref_parts,
1800 1806 dry_run=True, use_rebase=use_rebase,
1801 1807 close_branch=close_branch)
1802 1808
1803 1809 # Do not store the response if there was an unknown error.
1804 1810 if merge_state.failure_reason != MergeFailureReason.UNKNOWN:
1805 1811 pull_request._last_merge_source_rev = \
1806 1812 pull_request.source_ref_parts.commit_id
1807 1813 pull_request._last_merge_target_rev = target_reference.commit_id
1808 1814 pull_request.last_merge_status = merge_state.failure_reason
1809 1815 pull_request.last_merge_metadata = merge_state.metadata
1810 1816
1811 1817 pull_request.shadow_merge_ref = merge_state.merge_ref
1812 1818 Session().add(pull_request)
1813 1819 Session().commit()
1814 1820
1815 1821 return merge_state
1816 1822
1817 1823 def _workspace_id(self, pull_request):
1818 1824 workspace_id = 'pr-%s' % pull_request.pull_request_id
1819 1825 return workspace_id
1820 1826
1821 1827 def generate_repo_data(self, repo, commit_id=None, branch=None,
1822 1828 bookmark=None, translator=None):
1823 1829 from rhodecode.model.repo import RepoModel
1824 1830
1825 1831 all_refs, selected_ref = \
1826 1832 self._get_repo_pullrequest_sources(
1827 1833 repo.scm_instance(), commit_id=commit_id,
1828 1834 branch=branch, bookmark=bookmark, translator=translator)
1829 1835
1830 1836 refs_select2 = []
1831 1837 for element in all_refs:
1832 1838 children = [{'id': x[0], 'text': x[1]} for x in element[0]]
1833 1839 refs_select2.append({'text': element[1], 'children': children})
1834 1840
1835 1841 return {
1836 1842 'user': {
1837 1843 'user_id': repo.user.user_id,
1838 1844 'username': repo.user.username,
1839 1845 'firstname': repo.user.first_name,
1840 1846 'lastname': repo.user.last_name,
1841 1847 'gravatar_link': h.gravatar_url(repo.user.email, 14),
1842 1848 },
1843 1849 'name': repo.repo_name,
1844 1850 'link': RepoModel().get_url(repo),
1845 1851 'description': h.chop_at_smart(repo.description_safe, '\n'),
1846 1852 'refs': {
1847 1853 'all_refs': all_refs,
1848 1854 'selected_ref': selected_ref,
1849 1855 'select2_refs': refs_select2
1850 1856 }
1851 1857 }
1852 1858
1853 1859 def generate_pullrequest_title(self, source, source_ref, target):
1854 1860 return u'{source}#{at_ref} to {target}'.format(
1855 1861 source=source,
1856 1862 at_ref=source_ref,
1857 1863 target=target,
1858 1864 )
1859 1865
1860 1866 def _cleanup_merge_workspace(self, pull_request):
1861 1867 # Merging related cleanup
1862 1868 repo_id = pull_request.target_repo.repo_id
1863 1869 target_scm = pull_request.target_repo.scm_instance()
1864 1870 workspace_id = self._workspace_id(pull_request)
1865 1871
1866 1872 try:
1867 1873 target_scm.cleanup_merge_workspace(repo_id, workspace_id)
1868 1874 except NotImplementedError:
1869 1875 pass
1870 1876
1871 1877 def _get_repo_pullrequest_sources(
1872 1878 self, repo, commit_id=None, branch=None, bookmark=None,
1873 1879 translator=None):
1874 1880 """
1875 1881 Return a structure with repo's interesting commits, suitable for
1876 1882 the selectors in pullrequest controller
1877 1883
1878 1884 :param commit_id: a commit that must be in the list somehow
1879 1885 and selected by default
1880 1886 :param branch: a branch that must be in the list and selected
1881 1887 by default - even if closed
1882 1888 :param bookmark: a bookmark that must be in the list and selected
1883 1889 """
1884 1890 _ = translator or get_current_request().translate
1885 1891
1886 1892 commit_id = safe_str(commit_id) if commit_id else None
1887 1893 branch = safe_unicode(branch) if branch else None
1888 1894 bookmark = safe_unicode(bookmark) if bookmark else None
1889 1895
1890 1896 selected = None
1891 1897
1892 1898 # order matters: first source that has commit_id in it will be selected
1893 1899 sources = []
1894 1900 sources.append(('book', repo.bookmarks.items(), _('Bookmarks'), bookmark))
1895 1901 sources.append(('branch', repo.branches.items(), _('Branches'), branch))
1896 1902
1897 1903 if commit_id:
1898 1904 ref_commit = (h.short_id(commit_id), commit_id)
1899 1905 sources.append(('rev', [ref_commit], _('Commit IDs'), commit_id))
1900 1906
1901 1907 sources.append(
1902 1908 ('branch', repo.branches_closed.items(), _('Closed Branches'), branch),
1903 1909 )
1904 1910
1905 1911 groups = []
1906 1912
1907 1913 for group_key, ref_list, group_name, match in sources:
1908 1914 group_refs = []
1909 1915 for ref_name, ref_id in ref_list:
1910 1916 ref_key = u'{}:{}:{}'.format(group_key, ref_name, ref_id)
1911 1917 group_refs.append((ref_key, ref_name))
1912 1918
1913 1919 if not selected:
1914 1920 if set([commit_id, match]) & set([ref_id, ref_name]):
1915 1921 selected = ref_key
1916 1922
1917 1923 if group_refs:
1918 1924 groups.append((group_refs, group_name))
1919 1925
1920 1926 if not selected:
1921 1927 ref = commit_id or branch or bookmark
1922 1928 if ref:
1923 1929 raise CommitDoesNotExistError(
1924 1930 u'No commit refs could be found matching: {}'.format(ref))
1925 1931 elif repo.DEFAULT_BRANCH_NAME in repo.branches:
1926 1932 selected = u'branch:{}:{}'.format(
1927 1933 safe_unicode(repo.DEFAULT_BRANCH_NAME),
1928 1934 safe_unicode(repo.branches[repo.DEFAULT_BRANCH_NAME])
1929 1935 )
1930 1936 elif repo.commit_ids:
1931 1937 # make the user select in this case
1932 1938 selected = None
1933 1939 else:
1934 1940 raise EmptyRepositoryError()
1935 1941 return groups, selected
1936 1942
1937 1943 def get_diff(self, source_repo, source_ref_id, target_ref_id,
1938 1944 hide_whitespace_changes, diff_context):
1939 1945
1940 1946 return self._get_diff_from_pr_or_version(
1941 1947 source_repo, source_ref_id, target_ref_id,
1942 1948 hide_whitespace_changes=hide_whitespace_changes, diff_context=diff_context)
1943 1949
1944 1950 def _get_diff_from_pr_or_version(
1945 1951 self, source_repo, source_ref_id, target_ref_id,
1946 1952 hide_whitespace_changes, diff_context):
1947 1953
1948 1954 target_commit = source_repo.get_commit(
1949 1955 commit_id=safe_str(target_ref_id))
1950 1956 source_commit = source_repo.get_commit(
1951 1957 commit_id=safe_str(source_ref_id), maybe_unreachable=True)
1952 1958 if isinstance(source_repo, Repository):
1953 1959 vcs_repo = source_repo.scm_instance()
1954 1960 else:
1955 1961 vcs_repo = source_repo
1956 1962
1957 1963 # TODO: johbo: In the context of an update, we cannot reach
1958 1964 # the old commit anymore with our normal mechanisms. It needs
1959 1965 # some sort of special support in the vcs layer to avoid this
1960 1966 # workaround.
1961 1967 if (source_commit.raw_id == vcs_repo.EMPTY_COMMIT_ID and
1962 1968 vcs_repo.alias == 'git'):
1963 1969 source_commit.raw_id = safe_str(source_ref_id)
1964 1970
1965 1971 log.debug('calculating diff between '
1966 1972 'source_ref:%s and target_ref:%s for repo `%s`',
1967 1973 target_ref_id, source_ref_id,
1968 1974 safe_unicode(vcs_repo.path))
1969 1975
1970 1976 vcs_diff = vcs_repo.get_diff(
1971 1977 commit1=target_commit, commit2=source_commit,
1972 1978 ignore_whitespace=hide_whitespace_changes, context=diff_context)
1973 1979 return vcs_diff
1974 1980
1975 1981 def _is_merge_enabled(self, pull_request):
1976 1982 return self._get_general_setting(
1977 1983 pull_request, 'rhodecode_pr_merge_enabled')
1978 1984
1979 1985 def _use_rebase_for_merging(self, pull_request):
1980 1986 repo_type = pull_request.target_repo.repo_type
1981 1987 if repo_type == 'hg':
1982 1988 return self._get_general_setting(
1983 1989 pull_request, 'rhodecode_hg_use_rebase_for_merging')
1984 1990 elif repo_type == 'git':
1985 1991 return self._get_general_setting(
1986 1992 pull_request, 'rhodecode_git_use_rebase_for_merging')
1987 1993
1988 1994 return False
1989 1995
1990 1996 def _user_name_for_merging(self, pull_request, user):
1991 1997 env_user_name_attr = os.environ.get('RC_MERGE_USER_NAME_ATTR', '')
1992 1998 if env_user_name_attr and hasattr(user, env_user_name_attr):
1993 1999 user_name_attr = env_user_name_attr
1994 2000 else:
1995 2001 user_name_attr = 'short_contact'
1996 2002
1997 2003 user_name = getattr(user, user_name_attr)
1998 2004 return user_name
1999 2005
2000 2006 def _close_branch_before_merging(self, pull_request):
2001 2007 repo_type = pull_request.target_repo.repo_type
2002 2008 if repo_type == 'hg':
2003 2009 return self._get_general_setting(
2004 2010 pull_request, 'rhodecode_hg_close_branch_before_merging')
2005 2011 elif repo_type == 'git':
2006 2012 return self._get_general_setting(
2007 2013 pull_request, 'rhodecode_git_close_branch_before_merging')
2008 2014
2009 2015 return False
2010 2016
2011 2017 def _get_general_setting(self, pull_request, settings_key, default=False):
2012 2018 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
2013 2019 settings = settings_model.get_general_settings()
2014 2020 return settings.get(settings_key, default)
2015 2021
2016 2022 def _log_audit_action(self, action, action_data, user, pull_request):
2017 2023 audit_logger.store(
2018 2024 action=action,
2019 2025 action_data=action_data,
2020 2026 user=user,
2021 2027 repo=pull_request.target_repo)
2022 2028
2023 2029 def get_reviewer_functions(self):
2024 2030 """
2025 2031 Fetches functions for validation and fetching default reviewers.
2026 2032 If available we use the EE package, else we fallback to CE
2027 2033 package functions
2028 2034 """
2029 2035 try:
2030 2036 from rc_reviewers.utils import get_default_reviewers_data
2031 2037 from rc_reviewers.utils import validate_default_reviewers
2032 2038 from rc_reviewers.utils import validate_observers
2033 2039 except ImportError:
2034 2040 from rhodecode.apps.repository.utils import get_default_reviewers_data
2035 2041 from rhodecode.apps.repository.utils import validate_default_reviewers
2036 2042 from rhodecode.apps.repository.utils import validate_observers
2037 2043
2038 2044 return get_default_reviewers_data, validate_default_reviewers, validate_observers
2039 2045
2040 2046
2041 2047 class MergeCheck(object):
2042 2048 """
2043 2049 Perform Merge Checks and returns a check object which stores information
2044 2050 about merge errors, and merge conditions
2045 2051 """
2046 2052 TODO_CHECK = 'todo'
2047 2053 PERM_CHECK = 'perm'
2048 2054 REVIEW_CHECK = 'review'
2049 2055 MERGE_CHECK = 'merge'
2050 2056 WIP_CHECK = 'wip'
2051 2057
2052 2058 def __init__(self):
2053 2059 self.review_status = None
2054 2060 self.merge_possible = None
2055 2061 self.merge_msg = ''
2056 2062 self.merge_response = None
2057 2063 self.failed = None
2058 2064 self.errors = []
2059 2065 self.error_details = OrderedDict()
2060 2066 self.source_commit = AttributeDict()
2061 2067 self.target_commit = AttributeDict()
2062 2068
2063 2069 def __repr__(self):
2064 2070 return '<MergeCheck(possible:{}, failed:{}, errors:{})>'.format(
2065 2071 self.merge_possible, self.failed, self.errors)
2066 2072
2067 2073 def push_error(self, error_type, message, error_key, details):
2068 2074 self.failed = True
2069 2075 self.errors.append([error_type, message])
2070 2076 self.error_details[error_key] = dict(
2071 2077 details=details,
2072 2078 error_type=error_type,
2073 2079 message=message
2074 2080 )
2075 2081
2076 2082 @classmethod
2077 2083 def validate(cls, pull_request, auth_user, translator, fail_early=False,
2078 2084 force_shadow_repo_refresh=False):
2079 2085 _ = translator
2080 2086 merge_check = cls()
2081 2087
2082 2088 # title has WIP:
2083 2089 if pull_request.work_in_progress:
2084 2090 log.debug("MergeCheck: cannot merge, title has wip: marker.")
2085 2091
2086 2092 msg = _('WIP marker in title prevents from accidental merge.')
2087 2093 merge_check.push_error('error', msg, cls.WIP_CHECK, pull_request.title)
2088 2094 if fail_early:
2089 2095 return merge_check
2090 2096
2091 2097 # permissions to merge
2092 2098 user_allowed_to_merge = PullRequestModel().check_user_merge(pull_request, auth_user)
2093 2099 if not user_allowed_to_merge:
2094 2100 log.debug("MergeCheck: cannot merge, approval is pending.")
2095 2101
2096 2102 msg = _('User `{}` not allowed to perform merge.').format(auth_user.username)
2097 2103 merge_check.push_error('error', msg, cls.PERM_CHECK, auth_user.username)
2098 2104 if fail_early:
2099 2105 return merge_check
2100 2106
2101 2107 # permission to merge into the target branch
2102 2108 target_commit_id = pull_request.target_ref_parts.commit_id
2103 2109 if pull_request.target_ref_parts.type == 'branch':
2104 2110 branch_name = pull_request.target_ref_parts.name
2105 2111 else:
2106 2112 # for mercurial we can always figure out the branch from the commit
2107 2113 # in case of bookmark
2108 2114 target_commit = pull_request.target_repo.get_commit(target_commit_id)
2109 2115 branch_name = target_commit.branch
2110 2116
2111 2117 rule, branch_perm = auth_user.get_rule_and_branch_permission(
2112 2118 pull_request.target_repo.repo_name, branch_name)
2113 2119 if branch_perm and branch_perm == 'branch.none':
2114 2120 msg = _('Target branch `{}` changes rejected by rule {}.').format(
2115 2121 branch_name, rule)
2116 2122 merge_check.push_error('error', msg, cls.PERM_CHECK, auth_user.username)
2117 2123 if fail_early:
2118 2124 return merge_check
2119 2125
2120 2126 # review status, must be always present
2121 2127 review_status = pull_request.calculated_review_status()
2122 2128 merge_check.review_status = review_status
2123 2129
2124 2130 status_approved = review_status == ChangesetStatus.STATUS_APPROVED
2125 2131 if not status_approved:
2126 2132 log.debug("MergeCheck: cannot merge, approval is pending.")
2127 2133
2128 2134 msg = _('Pull request reviewer approval is pending.')
2129 2135
2130 2136 merge_check.push_error('warning', msg, cls.REVIEW_CHECK, review_status)
2131 2137
2132 2138 if fail_early:
2133 2139 return merge_check
2134 2140
2135 2141 # left over TODOs
2136 2142 todos = CommentsModel().get_pull_request_unresolved_todos(pull_request)
2137 2143 if todos:
2138 2144 log.debug("MergeCheck: cannot merge, {} "
2139 2145 "unresolved TODOs left.".format(len(todos)))
2140 2146
2141 2147 if len(todos) == 1:
2142 2148 msg = _('Cannot merge, {} TODO still not resolved.').format(
2143 2149 len(todos))
2144 2150 else:
2145 2151 msg = _('Cannot merge, {} TODOs still not resolved.').format(
2146 2152 len(todos))
2147 2153
2148 2154 merge_check.push_error('warning', msg, cls.TODO_CHECK, todos)
2149 2155
2150 2156 if fail_early:
2151 2157 return merge_check
2152 2158
2153 2159 # merge possible, here is the filesystem simulation + shadow repo
2154 2160 merge_response, merge_status, msg = PullRequestModel().merge_status(
2155 2161 pull_request, translator=translator,
2156 2162 force_shadow_repo_refresh=force_shadow_repo_refresh)
2157 2163
2158 2164 merge_check.merge_possible = merge_status
2159 2165 merge_check.merge_msg = msg
2160 2166 merge_check.merge_response = merge_response
2161 2167
2162 2168 source_ref_id = pull_request.source_ref_parts.commit_id
2163 2169 target_ref_id = pull_request.target_ref_parts.commit_id
2164 2170
2165 2171 try:
2166 2172 source_commit, target_commit = PullRequestModel().get_flow_commits(pull_request)
2167 2173 merge_check.source_commit.changed = source_ref_id != source_commit.raw_id
2168 2174 merge_check.source_commit.ref_spec = pull_request.source_ref_parts
2169 2175 merge_check.source_commit.current_raw_id = source_commit.raw_id
2170 2176 merge_check.source_commit.previous_raw_id = source_ref_id
2171 2177
2172 2178 merge_check.target_commit.changed = target_ref_id != target_commit.raw_id
2173 2179 merge_check.target_commit.ref_spec = pull_request.target_ref_parts
2174 2180 merge_check.target_commit.current_raw_id = target_commit.raw_id
2175 2181 merge_check.target_commit.previous_raw_id = target_ref_id
2176 2182 except (SourceRefMissing, TargetRefMissing):
2177 2183 pass
2178 2184
2179 2185 if not merge_status:
2180 2186 log.debug("MergeCheck: cannot merge, pull request merge not possible.")
2181 2187 merge_check.push_error('warning', msg, cls.MERGE_CHECK, None)
2182 2188
2183 2189 if fail_early:
2184 2190 return merge_check
2185 2191
2186 2192 log.debug('MergeCheck: is failed: %s', merge_check.failed)
2187 2193 return merge_check
2188 2194
2189 2195 @classmethod
2190 2196 def get_merge_conditions(cls, pull_request, translator):
2191 2197 _ = translator
2192 2198 merge_details = {}
2193 2199
2194 2200 model = PullRequestModel()
2195 2201 use_rebase = model._use_rebase_for_merging(pull_request)
2196 2202
2197 2203 if use_rebase:
2198 2204 merge_details['merge_strategy'] = dict(
2199 2205 details={},
2200 2206 message=_('Merge strategy: rebase')
2201 2207 )
2202 2208 else:
2203 2209 merge_details['merge_strategy'] = dict(
2204 2210 details={},
2205 2211 message=_('Merge strategy: explicit merge commit')
2206 2212 )
2207 2213
2208 2214 close_branch = model._close_branch_before_merging(pull_request)
2209 2215 if close_branch:
2210 2216 repo_type = pull_request.target_repo.repo_type
2211 2217 close_msg = ''
2212 2218 if repo_type == 'hg':
2213 2219 close_msg = _('Source branch will be closed before the merge.')
2214 2220 elif repo_type == 'git':
2215 2221 close_msg = _('Source branch will be deleted after the merge.')
2216 2222
2217 2223 merge_details['close_branch'] = dict(
2218 2224 details={},
2219 2225 message=close_msg
2220 2226 )
2221 2227
2222 2228 return merge_details
2223 2229
2224 2230
2225 2231 ChangeTuple = collections.namedtuple(
2226 2232 'ChangeTuple', ['added', 'common', 'removed', 'total'])
2227 2233
2228 2234 FileChangeTuple = collections.namedtuple(
2229 2235 'FileChangeTuple', ['added', 'modified', 'removed'])
General Comments 0
You need to be logged in to leave comments. Login now