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