##// END OF EJS Templates
events: properly refresh comment object to load it's relationship....
marcink -
r2470:4400cfcf default
parent child Browse files
Show More
@@ -1,1236 +1,1240 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
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 'rhodecode:templates/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 self.load_default_context()
177 177
178 178 # additional filters
179 179 req_get = self.request.GET
180 180 source = str2bool(req_get.get('source'))
181 181 closed = str2bool(req_get.get('closed'))
182 182 my = str2bool(req_get.get('my'))
183 183 awaiting_review = str2bool(req_get.get('awaiting_review'))
184 184 awaiting_my_review = str2bool(req_get.get('awaiting_my_review'))
185 185
186 186 filter_type = 'awaiting_review' if awaiting_review \
187 187 else 'awaiting_my_review' if awaiting_my_review \
188 188 else None
189 189
190 190 opened_by = None
191 191 if my:
192 192 opened_by = [self._rhodecode_user.user_id]
193 193
194 194 statuses = [PullRequest.STATUS_NEW, PullRequest.STATUS_OPEN]
195 195 if closed:
196 196 statuses = [PullRequest.STATUS_CLOSED]
197 197
198 198 data = self._get_pull_requests_list(
199 199 repo_name=self.db_repo_name, source=source,
200 200 filter_type=filter_type, opened_by=opened_by, statuses=statuses)
201 201
202 202 return data
203 203
204 204 def _get_diffset(self, source_repo_name, source_repo,
205 205 source_ref_id, target_ref_id,
206 206 target_commit, source_commit, diff_limit, fulldiff,
207 207 file_limit, display_inline_comments):
208 208
209 209 vcs_diff = PullRequestModel().get_diff(
210 210 source_repo, source_ref_id, target_ref_id)
211 211
212 212 diff_processor = diffs.DiffProcessor(
213 213 vcs_diff, format='newdiff', diff_limit=diff_limit,
214 214 file_limit=file_limit, show_full_diff=fulldiff)
215 215
216 216 _parsed = diff_processor.prepare()
217 217
218 218 def _node_getter(commit):
219 219 def get_node(fname):
220 220 try:
221 221 return commit.get_node(fname)
222 222 except NodeDoesNotExistError:
223 223 return None
224 224
225 225 return get_node
226 226
227 227 diffset = codeblocks.DiffSet(
228 228 repo_name=self.db_repo_name,
229 229 source_repo_name=source_repo_name,
230 230 source_node_getter=_node_getter(target_commit),
231 231 target_node_getter=_node_getter(source_commit),
232 232 comments=display_inline_comments
233 233 )
234 234 diffset = diffset.render_patchset(
235 235 _parsed, target_commit.raw_id, source_commit.raw_id)
236 236
237 237 return diffset
238 238
239 239 @LoginRequired()
240 240 @HasRepoPermissionAnyDecorator(
241 241 'repository.read', 'repository.write', 'repository.admin')
242 242 @view_config(
243 243 route_name='pullrequest_show', request_method='GET',
244 244 renderer='rhodecode:templates/pullrequests/pullrequest_show.mako')
245 245 def pull_request_show(self):
246 246 pull_request_id = self.request.matchdict['pull_request_id']
247 247
248 248 c = self.load_default_context()
249 249
250 250 version = self.request.GET.get('version')
251 251 from_version = self.request.GET.get('from_version') or version
252 252 merge_checks = self.request.GET.get('merge_checks')
253 253 c.fulldiff = str2bool(self.request.GET.get('fulldiff'))
254 254
255 255 (pull_request_latest,
256 256 pull_request_at_ver,
257 257 pull_request_display_obj,
258 258 at_version) = PullRequestModel().get_pr_version(
259 259 pull_request_id, version=version)
260 260 pr_closed = pull_request_latest.is_closed()
261 261
262 262 if pr_closed and (version or from_version):
263 263 # not allow to browse versions
264 264 raise HTTPFound(h.route_path(
265 265 'pullrequest_show', repo_name=self.db_repo_name,
266 266 pull_request_id=pull_request_id))
267 267
268 268 versions = pull_request_display_obj.versions()
269 269
270 270 c.at_version = at_version
271 271 c.at_version_num = (at_version
272 272 if at_version and at_version != 'latest'
273 273 else None)
274 274 c.at_version_pos = ChangesetComment.get_index_from_version(
275 275 c.at_version_num, versions)
276 276
277 277 (prev_pull_request_latest,
278 278 prev_pull_request_at_ver,
279 279 prev_pull_request_display_obj,
280 280 prev_at_version) = PullRequestModel().get_pr_version(
281 281 pull_request_id, version=from_version)
282 282
283 283 c.from_version = prev_at_version
284 284 c.from_version_num = (prev_at_version
285 285 if prev_at_version and prev_at_version != 'latest'
286 286 else None)
287 287 c.from_version_pos = ChangesetComment.get_index_from_version(
288 288 c.from_version_num, versions)
289 289
290 290 # define if we're in COMPARE mode or VIEW at version mode
291 291 compare = at_version != prev_at_version
292 292
293 293 # pull_requests repo_name we opened it against
294 294 # ie. target_repo must match
295 295 if self.db_repo_name != pull_request_at_ver.target_repo.repo_name:
296 296 raise HTTPNotFound()
297 297
298 298 c.shadow_clone_url = PullRequestModel().get_shadow_clone_url(
299 299 pull_request_at_ver)
300 300
301 301 c.pull_request = pull_request_display_obj
302 302 c.pull_request_latest = pull_request_latest
303 303
304 304 if compare or (at_version and not at_version == 'latest'):
305 305 c.allowed_to_change_status = False
306 306 c.allowed_to_update = False
307 307 c.allowed_to_merge = False
308 308 c.allowed_to_delete = False
309 309 c.allowed_to_comment = False
310 310 c.allowed_to_close = False
311 311 else:
312 312 can_change_status = PullRequestModel().check_user_change_status(
313 313 pull_request_at_ver, self._rhodecode_user)
314 314 c.allowed_to_change_status = can_change_status and not pr_closed
315 315
316 316 c.allowed_to_update = PullRequestModel().check_user_update(
317 317 pull_request_latest, self._rhodecode_user) and not pr_closed
318 318 c.allowed_to_merge = PullRequestModel().check_user_merge(
319 319 pull_request_latest, self._rhodecode_user) and not pr_closed
320 320 c.allowed_to_delete = PullRequestModel().check_user_delete(
321 321 pull_request_latest, self._rhodecode_user) and not pr_closed
322 322 c.allowed_to_comment = not pr_closed
323 323 c.allowed_to_close = c.allowed_to_merge and not pr_closed
324 324
325 325 c.forbid_adding_reviewers = False
326 326 c.forbid_author_to_review = False
327 327 c.forbid_commit_author_to_review = False
328 328
329 329 if pull_request_latest.reviewer_data and \
330 330 'rules' in pull_request_latest.reviewer_data:
331 331 rules = pull_request_latest.reviewer_data['rules'] or {}
332 332 try:
333 333 c.forbid_adding_reviewers = rules.get(
334 334 'forbid_adding_reviewers')
335 335 c.forbid_author_to_review = rules.get(
336 336 'forbid_author_to_review')
337 337 c.forbid_commit_author_to_review = rules.get(
338 338 'forbid_commit_author_to_review')
339 339 except Exception:
340 340 pass
341 341
342 342 # check merge capabilities
343 343 _merge_check = MergeCheck.validate(
344 344 pull_request_latest, user=self._rhodecode_user,
345 345 translator=self.request.translate)
346 346 c.pr_merge_errors = _merge_check.error_details
347 347 c.pr_merge_possible = not _merge_check.failed
348 348 c.pr_merge_message = _merge_check.merge_msg
349 349
350 350 c.pr_merge_info = MergeCheck.get_merge_conditions(
351 351 pull_request_latest, translator=self.request.translate)
352 352
353 353 c.pull_request_review_status = _merge_check.review_status
354 354 if merge_checks:
355 355 self.request.override_renderer = \
356 356 'rhodecode:templates/pullrequests/pullrequest_merge_checks.mako'
357 357 return self._get_template_context(c)
358 358
359 359 comments_model = CommentsModel()
360 360
361 361 # reviewers and statuses
362 362 c.pull_request_reviewers = pull_request_at_ver.reviewers_statuses()
363 363 allowed_reviewers = [x[0].user_id for x in c.pull_request_reviewers]
364 364
365 365 # GENERAL COMMENTS with versions #
366 366 q = comments_model._all_general_comments_of_pull_request(pull_request_latest)
367 367 q = q.order_by(ChangesetComment.comment_id.asc())
368 368 general_comments = q
369 369
370 370 # pick comments we want to render at current version
371 371 c.comment_versions = comments_model.aggregate_comments(
372 372 general_comments, versions, c.at_version_num)
373 373 c.comments = c.comment_versions[c.at_version_num]['until']
374 374
375 375 # INLINE COMMENTS with versions #
376 376 q = comments_model._all_inline_comments_of_pull_request(pull_request_latest)
377 377 q = q.order_by(ChangesetComment.comment_id.asc())
378 378 inline_comments = q
379 379
380 380 c.inline_versions = comments_model.aggregate_comments(
381 381 inline_comments, versions, c.at_version_num, inline=True)
382 382
383 383 # inject latest version
384 384 latest_ver = PullRequest.get_pr_display_object(
385 385 pull_request_latest, pull_request_latest)
386 386
387 387 c.versions = versions + [latest_ver]
388 388
389 389 # if we use version, then do not show later comments
390 390 # than current version
391 391 display_inline_comments = collections.defaultdict(
392 392 lambda: collections.defaultdict(list))
393 393 for co in inline_comments:
394 394 if c.at_version_num:
395 395 # pick comments that are at least UPTO given version, so we
396 396 # don't render comments for higher version
397 397 should_render = co.pull_request_version_id and \
398 398 co.pull_request_version_id <= c.at_version_num
399 399 else:
400 400 # showing all, for 'latest'
401 401 should_render = True
402 402
403 403 if should_render:
404 404 display_inline_comments[co.f_path][co.line_no].append(co)
405 405
406 406 # load diff data into template context, if we use compare mode then
407 407 # diff is calculated based on changes between versions of PR
408 408
409 409 source_repo = pull_request_at_ver.source_repo
410 410 source_ref_id = pull_request_at_ver.source_ref_parts.commit_id
411 411
412 412 target_repo = pull_request_at_ver.target_repo
413 413 target_ref_id = pull_request_at_ver.target_ref_parts.commit_id
414 414
415 415 if compare:
416 416 # in compare switch the diff base to latest commit from prev version
417 417 target_ref_id = prev_pull_request_display_obj.revisions[0]
418 418
419 419 # despite opening commits for bookmarks/branches/tags, we always
420 420 # convert this to rev to prevent changes after bookmark or branch change
421 421 c.source_ref_type = 'rev'
422 422 c.source_ref = source_ref_id
423 423
424 424 c.target_ref_type = 'rev'
425 425 c.target_ref = target_ref_id
426 426
427 427 c.source_repo = source_repo
428 428 c.target_repo = target_repo
429 429
430 430 c.commit_ranges = []
431 431 source_commit = EmptyCommit()
432 432 target_commit = EmptyCommit()
433 433 c.missing_requirements = False
434 434
435 435 source_scm = source_repo.scm_instance()
436 436 target_scm = target_repo.scm_instance()
437 437
438 438 # try first shadow repo, fallback to regular repo
439 439 try:
440 440 commits_source_repo = pull_request_latest.get_shadow_repo()
441 441 except Exception:
442 442 log.debug('Failed to get shadow repo', exc_info=True)
443 443 commits_source_repo = source_scm
444 444
445 445 c.commits_source_repo = commits_source_repo
446 446 commit_cache = {}
447 447 try:
448 448 pre_load = ["author", "branch", "date", "message"]
449 449 show_revs = pull_request_at_ver.revisions
450 450 for rev in show_revs:
451 451 comm = commits_source_repo.get_commit(
452 452 commit_id=rev, pre_load=pre_load)
453 453 c.commit_ranges.append(comm)
454 454 commit_cache[comm.raw_id] = comm
455 455
456 456 # Order here matters, we first need to get target, and then
457 457 # the source
458 458 target_commit = commits_source_repo.get_commit(
459 459 commit_id=safe_str(target_ref_id))
460 460
461 461 source_commit = commits_source_repo.get_commit(
462 462 commit_id=safe_str(source_ref_id))
463 463
464 464 except CommitDoesNotExistError:
465 465 log.warning(
466 466 'Failed to get commit from `{}` repo'.format(
467 467 commits_source_repo), exc_info=True)
468 468 except RepositoryRequirementError:
469 469 log.warning(
470 470 'Failed to get all required data from repo', exc_info=True)
471 471 c.missing_requirements = True
472 472
473 473 c.ancestor = None # set it to None, to hide it from PR view
474 474
475 475 try:
476 476 ancestor_id = source_scm.get_common_ancestor(
477 477 source_commit.raw_id, target_commit.raw_id, target_scm)
478 478 c.ancestor_commit = source_scm.get_commit(ancestor_id)
479 479 except Exception:
480 480 c.ancestor_commit = None
481 481
482 482 c.statuses = source_repo.statuses(
483 483 [x.raw_id for x in c.commit_ranges])
484 484
485 485 # auto collapse if we have more than limit
486 486 collapse_limit = diffs.DiffProcessor._collapse_commits_over
487 487 c.collapse_all_commits = len(c.commit_ranges) > collapse_limit
488 488 c.compare_mode = compare
489 489
490 490 # diff_limit is the old behavior, will cut off the whole diff
491 491 # if the limit is applied otherwise will just hide the
492 492 # big files from the front-end
493 493 diff_limit = c.visual.cut_off_limit_diff
494 494 file_limit = c.visual.cut_off_limit_file
495 495
496 496 c.missing_commits = False
497 497 if (c.missing_requirements
498 498 or isinstance(source_commit, EmptyCommit)
499 499 or source_commit == target_commit):
500 500
501 501 c.missing_commits = True
502 502 else:
503 503
504 504 c.diffset = self._get_diffset(
505 505 c.source_repo.repo_name, commits_source_repo,
506 506 source_ref_id, target_ref_id,
507 507 target_commit, source_commit,
508 508 diff_limit, c.fulldiff, file_limit, display_inline_comments)
509 509
510 510 c.limited_diff = c.diffset.limited_diff
511 511
512 512 # calculate removed files that are bound to comments
513 513 comment_deleted_files = [
514 514 fname for fname in display_inline_comments
515 515 if fname not in c.diffset.file_stats]
516 516
517 517 c.deleted_files_comments = collections.defaultdict(dict)
518 518 for fname, per_line_comments in display_inline_comments.items():
519 519 if fname in comment_deleted_files:
520 520 c.deleted_files_comments[fname]['stats'] = 0
521 521 c.deleted_files_comments[fname]['comments'] = list()
522 522 for lno, comments in per_line_comments.items():
523 523 c.deleted_files_comments[fname]['comments'].extend(
524 524 comments)
525 525
526 526 # this is a hack to properly display links, when creating PR, the
527 527 # compare view and others uses different notation, and
528 528 # compare_commits.mako renders links based on the target_repo.
529 529 # We need to swap that here to generate it properly on the html side
530 530 c.target_repo = c.source_repo
531 531
532 532 c.commit_statuses = ChangesetStatus.STATUSES
533 533
534 534 c.show_version_changes = not pr_closed
535 535 if c.show_version_changes:
536 536 cur_obj = pull_request_at_ver
537 537 prev_obj = prev_pull_request_at_ver
538 538
539 539 old_commit_ids = prev_obj.revisions
540 540 new_commit_ids = cur_obj.revisions
541 541 commit_changes = PullRequestModel()._calculate_commit_id_changes(
542 542 old_commit_ids, new_commit_ids)
543 543 c.commit_changes_summary = commit_changes
544 544
545 545 # calculate the diff for commits between versions
546 546 c.commit_changes = []
547 547 mark = lambda cs, fw: list(
548 548 h.itertools.izip_longest([], cs, fillvalue=fw))
549 549 for c_type, raw_id in mark(commit_changes.added, 'a') \
550 550 + mark(commit_changes.removed, 'r') \
551 551 + mark(commit_changes.common, 'c'):
552 552
553 553 if raw_id in commit_cache:
554 554 commit = commit_cache[raw_id]
555 555 else:
556 556 try:
557 557 commit = commits_source_repo.get_commit(raw_id)
558 558 except CommitDoesNotExistError:
559 559 # in case we fail extracting still use "dummy" commit
560 560 # for display in commit diff
561 561 commit = h.AttributeDict(
562 562 {'raw_id': raw_id,
563 563 'message': 'EMPTY or MISSING COMMIT'})
564 564 c.commit_changes.append([c_type, commit])
565 565
566 566 # current user review statuses for each version
567 567 c.review_versions = {}
568 568 if self._rhodecode_user.user_id in allowed_reviewers:
569 569 for co in general_comments:
570 570 if co.author.user_id == self._rhodecode_user.user_id:
571 571 # each comment has a status change
572 572 status = co.status_change
573 573 if status:
574 574 _ver_pr = status[0].comment.pull_request_version_id
575 575 c.review_versions[_ver_pr] = status[0]
576 576
577 577 return self._get_template_context(c)
578 578
579 579 def assure_not_empty_repo(self):
580 580 _ = self.request.translate
581 581
582 582 try:
583 583 self.db_repo.scm_instance().get_commit()
584 584 except EmptyRepositoryError:
585 585 h.flash(h.literal(_('There are no commits yet')),
586 586 category='warning')
587 587 raise HTTPFound(
588 588 h.route_path('repo_summary', repo_name=self.db_repo.repo_name))
589 589
590 590 @LoginRequired()
591 591 @NotAnonymous()
592 592 @HasRepoPermissionAnyDecorator(
593 593 'repository.read', 'repository.write', 'repository.admin')
594 594 @view_config(
595 595 route_name='pullrequest_new', request_method='GET',
596 596 renderer='rhodecode:templates/pullrequests/pullrequest.mako')
597 597 def pull_request_new(self):
598 598 _ = self.request.translate
599 599 c = self.load_default_context()
600 600
601 601 self.assure_not_empty_repo()
602 602 source_repo = self.db_repo
603 603
604 604 commit_id = self.request.GET.get('commit')
605 605 branch_ref = self.request.GET.get('branch')
606 606 bookmark_ref = self.request.GET.get('bookmark')
607 607
608 608 try:
609 609 source_repo_data = PullRequestModel().generate_repo_data(
610 610 source_repo, commit_id=commit_id,
611 611 branch=branch_ref, bookmark=bookmark_ref, translator=self.request.translate)
612 612 except CommitDoesNotExistError as e:
613 613 log.exception(e)
614 614 h.flash(_('Commit does not exist'), 'error')
615 615 raise HTTPFound(
616 616 h.route_path('pullrequest_new', repo_name=source_repo.repo_name))
617 617
618 618 default_target_repo = source_repo
619 619
620 620 if source_repo.parent:
621 621 parent_vcs_obj = source_repo.parent.scm_instance()
622 622 if parent_vcs_obj and not parent_vcs_obj.is_empty():
623 623 # change default if we have a parent repo
624 624 default_target_repo = source_repo.parent
625 625
626 626 target_repo_data = PullRequestModel().generate_repo_data(
627 627 default_target_repo, translator=self.request.translate)
628 628
629 629 selected_source_ref = source_repo_data['refs']['selected_ref']
630 630
631 631 title_source_ref = selected_source_ref.split(':', 2)[1]
632 632 c.default_title = PullRequestModel().generate_pullrequest_title(
633 633 source=source_repo.repo_name,
634 634 source_ref=title_source_ref,
635 635 target=default_target_repo.repo_name
636 636 )
637 637
638 638 c.default_repo_data = {
639 639 'source_repo_name': source_repo.repo_name,
640 640 'source_refs_json': json.dumps(source_repo_data),
641 641 'target_repo_name': default_target_repo.repo_name,
642 642 'target_refs_json': json.dumps(target_repo_data),
643 643 }
644 644 c.default_source_ref = selected_source_ref
645 645
646 646 return self._get_template_context(c)
647 647
648 648 @LoginRequired()
649 649 @NotAnonymous()
650 650 @HasRepoPermissionAnyDecorator(
651 651 'repository.read', 'repository.write', 'repository.admin')
652 652 @view_config(
653 653 route_name='pullrequest_repo_refs', request_method='GET',
654 654 renderer='json_ext', xhr=True)
655 655 def pull_request_repo_refs(self):
656 656 self.load_default_context()
657 657 target_repo_name = self.request.matchdict['target_repo_name']
658 658 repo = Repository.get_by_repo_name(target_repo_name)
659 659 if not repo:
660 660 raise HTTPNotFound()
661 661
662 662 target_perm = HasRepoPermissionAny(
663 663 'repository.read', 'repository.write', 'repository.admin')(
664 664 target_repo_name)
665 665 if not target_perm:
666 666 raise HTTPNotFound()
667 667
668 668 return PullRequestModel().generate_repo_data(
669 669 repo, translator=self.request.translate)
670 670
671 671 @LoginRequired()
672 672 @NotAnonymous()
673 673 @HasRepoPermissionAnyDecorator(
674 674 'repository.read', 'repository.write', 'repository.admin')
675 675 @view_config(
676 676 route_name='pullrequest_repo_destinations', request_method='GET',
677 677 renderer='json_ext', xhr=True)
678 678 def pull_request_repo_destinations(self):
679 679 _ = self.request.translate
680 680 filter_query = self.request.GET.get('query')
681 681
682 682 query = Repository.query() \
683 683 .order_by(func.length(Repository.repo_name)) \
684 684 .filter(
685 685 or_(Repository.repo_name == self.db_repo.repo_name,
686 686 Repository.fork_id == self.db_repo.repo_id))
687 687
688 688 if filter_query:
689 689 ilike_expression = u'%{}%'.format(safe_unicode(filter_query))
690 690 query = query.filter(
691 691 Repository.repo_name.ilike(ilike_expression))
692 692
693 693 add_parent = False
694 694 if self.db_repo.parent:
695 695 if filter_query in self.db_repo.parent.repo_name:
696 696 parent_vcs_obj = self.db_repo.parent.scm_instance()
697 697 if parent_vcs_obj and not parent_vcs_obj.is_empty():
698 698 add_parent = True
699 699
700 700 limit = 20 - 1 if add_parent else 20
701 701 all_repos = query.limit(limit).all()
702 702 if add_parent:
703 703 all_repos += [self.db_repo.parent]
704 704
705 705 repos = []
706 706 for obj in ScmModel().get_repos(all_repos):
707 707 repos.append({
708 708 'id': obj['name'],
709 709 'text': obj['name'],
710 710 'type': 'repo',
711 711 'obj': obj['dbrepo']
712 712 })
713 713
714 714 data = {
715 715 'more': False,
716 716 'results': [{
717 717 'text': _('Repositories'),
718 718 'children': repos
719 719 }] if repos else []
720 720 }
721 721 return data
722 722
723 723 @LoginRequired()
724 724 @NotAnonymous()
725 725 @HasRepoPermissionAnyDecorator(
726 726 'repository.read', 'repository.write', 'repository.admin')
727 727 @CSRFRequired()
728 728 @view_config(
729 729 route_name='pullrequest_create', request_method='POST',
730 730 renderer=None)
731 731 def pull_request_create(self):
732 732 _ = self.request.translate
733 733 self.assure_not_empty_repo()
734 734 self.load_default_context()
735 735
736 736 controls = peppercorn.parse(self.request.POST.items())
737 737
738 738 try:
739 739 form = PullRequestForm(
740 740 self.request.translate, self.db_repo.repo_id)()
741 741 _form = form.to_python(controls)
742 742 except formencode.Invalid as errors:
743 743 if errors.error_dict.get('revisions'):
744 744 msg = 'Revisions: %s' % errors.error_dict['revisions']
745 745 elif errors.error_dict.get('pullrequest_title'):
746 746 msg = _('Pull request requires a title with min. 3 chars')
747 747 else:
748 748 msg = _('Error creating pull request: {}').format(errors)
749 749 log.exception(msg)
750 750 h.flash(msg, 'error')
751 751
752 752 # would rather just go back to form ...
753 753 raise HTTPFound(
754 754 h.route_path('pullrequest_new', repo_name=self.db_repo_name))
755 755
756 756 source_repo = _form['source_repo']
757 757 source_ref = _form['source_ref']
758 758 target_repo = _form['target_repo']
759 759 target_ref = _form['target_ref']
760 760 commit_ids = _form['revisions'][::-1]
761 761
762 762 # find the ancestor for this pr
763 763 source_db_repo = Repository.get_by_repo_name(_form['source_repo'])
764 764 target_db_repo = Repository.get_by_repo_name(_form['target_repo'])
765 765
766 766 # re-check permissions again here
767 767 # source_repo we must have read permissions
768 768
769 769 source_perm = HasRepoPermissionAny(
770 770 'repository.read',
771 771 'repository.write', 'repository.admin')(source_db_repo.repo_name)
772 772 if not source_perm:
773 773 msg = _('Not Enough permissions to source repo `{}`.'.format(
774 774 source_db_repo.repo_name))
775 775 h.flash(msg, category='error')
776 776 # copy the args back to redirect
777 777 org_query = self.request.GET.mixed()
778 778 raise HTTPFound(
779 779 h.route_path('pullrequest_new', repo_name=self.db_repo_name,
780 780 _query=org_query))
781 781
782 782 # target repo we must have read permissions, and also later on
783 783 # we want to check branch permissions here
784 784 target_perm = HasRepoPermissionAny(
785 785 'repository.read',
786 786 'repository.write', 'repository.admin')(target_db_repo.repo_name)
787 787 if not target_perm:
788 788 msg = _('Not Enough permissions to target repo `{}`.'.format(
789 789 target_db_repo.repo_name))
790 790 h.flash(msg, category='error')
791 791 # copy the args back to redirect
792 792 org_query = self.request.GET.mixed()
793 793 raise HTTPFound(
794 794 h.route_path('pullrequest_new', repo_name=self.db_repo_name,
795 795 _query=org_query))
796 796
797 797 source_scm = source_db_repo.scm_instance()
798 798 target_scm = target_db_repo.scm_instance()
799 799
800 800 source_commit = source_scm.get_commit(source_ref.split(':')[-1])
801 801 target_commit = target_scm.get_commit(target_ref.split(':')[-1])
802 802
803 803 ancestor = source_scm.get_common_ancestor(
804 804 source_commit.raw_id, target_commit.raw_id, target_scm)
805 805
806 806 target_ref_type, target_ref_name, __ = _form['target_ref'].split(':')
807 807 target_ref = ':'.join((target_ref_type, target_ref_name, ancestor))
808 808
809 809 pullrequest_title = _form['pullrequest_title']
810 810 title_source_ref = source_ref.split(':', 2)[1]
811 811 if not pullrequest_title:
812 812 pullrequest_title = PullRequestModel().generate_pullrequest_title(
813 813 source=source_repo,
814 814 source_ref=title_source_ref,
815 815 target=target_repo
816 816 )
817 817
818 818 description = _form['pullrequest_desc']
819 819
820 820 get_default_reviewers_data, validate_default_reviewers = \
821 821 PullRequestModel().get_reviewer_functions()
822 822
823 823 # recalculate reviewers logic, to make sure we can validate this
824 824 reviewer_rules = get_default_reviewers_data(
825 825 self._rhodecode_db_user, source_db_repo,
826 826 source_commit, target_db_repo, target_commit)
827 827
828 828 given_reviewers = _form['review_members']
829 829 reviewers = validate_default_reviewers(given_reviewers, reviewer_rules)
830 830
831 831 try:
832 832 pull_request = PullRequestModel().create(
833 833 self._rhodecode_user.user_id, source_repo, source_ref,
834 834 target_repo, target_ref, commit_ids, reviewers,
835 835 pullrequest_title, description, reviewer_rules
836 836 )
837 837 Session().commit()
838 838
839 839 h.flash(_('Successfully opened new pull request'),
840 840 category='success')
841 841 except Exception:
842 842 msg = _('Error occurred during creation of this pull request.')
843 843 log.exception(msg)
844 844 h.flash(msg, category='error')
845 845
846 846 # copy the args back to redirect
847 847 org_query = self.request.GET.mixed()
848 848 raise HTTPFound(
849 849 h.route_path('pullrequest_new', repo_name=self.db_repo_name,
850 850 _query=org_query))
851 851
852 852 raise HTTPFound(
853 853 h.route_path('pullrequest_show', repo_name=target_repo,
854 854 pull_request_id=pull_request.pull_request_id))
855 855
856 856 @LoginRequired()
857 857 @NotAnonymous()
858 858 @HasRepoPermissionAnyDecorator(
859 859 'repository.read', 'repository.write', 'repository.admin')
860 860 @CSRFRequired()
861 861 @view_config(
862 862 route_name='pullrequest_update', request_method='POST',
863 863 renderer='json_ext')
864 864 def pull_request_update(self):
865 865 pull_request = PullRequest.get_or_404(
866 866 self.request.matchdict['pull_request_id'])
867 867 _ = self.request.translate
868 868
869 869 self.load_default_context()
870 870
871 871 if pull_request.is_closed():
872 872 log.debug('update: forbidden because pull request is closed')
873 873 msg = _(u'Cannot update closed pull requests.')
874 874 h.flash(msg, category='error')
875 875 return True
876 876
877 877 # only owner or admin can update it
878 878 allowed_to_update = PullRequestModel().check_user_update(
879 879 pull_request, self._rhodecode_user)
880 880 if allowed_to_update:
881 881 controls = peppercorn.parse(self.request.POST.items())
882 882
883 883 if 'review_members' in controls:
884 884 self._update_reviewers(
885 885 pull_request, controls['review_members'],
886 886 pull_request.reviewer_data)
887 887 elif str2bool(self.request.POST.get('update_commits', 'false')):
888 888 self._update_commits(pull_request)
889 889 elif str2bool(self.request.POST.get('edit_pull_request', 'false')):
890 890 self._edit_pull_request(pull_request)
891 891 else:
892 892 raise HTTPBadRequest()
893 893 return True
894 894 raise HTTPForbidden()
895 895
896 896 def _edit_pull_request(self, pull_request):
897 897 _ = self.request.translate
898 898 try:
899 899 PullRequestModel().edit(
900 900 pull_request, self.request.POST.get('title'),
901 901 self.request.POST.get('description'), self._rhodecode_user)
902 902 except ValueError:
903 903 msg = _(u'Cannot update closed pull requests.')
904 904 h.flash(msg, category='error')
905 905 return
906 906 else:
907 907 Session().commit()
908 908
909 909 msg = _(u'Pull request title & description updated.')
910 910 h.flash(msg, category='success')
911 911 return
912 912
913 913 def _update_commits(self, pull_request):
914 914 _ = self.request.translate
915 915 resp = PullRequestModel().update_commits(pull_request)
916 916
917 917 if resp.executed:
918 918
919 919 if resp.target_changed and resp.source_changed:
920 920 changed = 'target and source repositories'
921 921 elif resp.target_changed and not resp.source_changed:
922 922 changed = 'target repository'
923 923 elif not resp.target_changed and resp.source_changed:
924 924 changed = 'source repository'
925 925 else:
926 926 changed = 'nothing'
927 927
928 928 msg = _(
929 929 u'Pull request updated to "{source_commit_id}" with '
930 930 u'{count_added} added, {count_removed} removed commits. '
931 931 u'Source of changes: {change_source}')
932 932 msg = msg.format(
933 933 source_commit_id=pull_request.source_ref_parts.commit_id,
934 934 count_added=len(resp.changes.added),
935 935 count_removed=len(resp.changes.removed),
936 936 change_source=changed)
937 937 h.flash(msg, category='success')
938 938
939 939 channel = '/repo${}$/pr/{}'.format(
940 940 pull_request.target_repo.repo_name,
941 941 pull_request.pull_request_id)
942 942 message = msg + (
943 943 ' - <a onclick="window.location.reload()">'
944 944 '<strong>{}</strong></a>'.format(_('Reload page')))
945 945 channelstream.post_message(
946 946 channel, message, self._rhodecode_user.username,
947 947 registry=self.request.registry)
948 948 else:
949 949 msg = PullRequestModel.UPDATE_STATUS_MESSAGES[resp.reason]
950 950 warning_reasons = [
951 951 UpdateFailureReason.NO_CHANGE,
952 952 UpdateFailureReason.WRONG_REF_TYPE,
953 953 ]
954 954 category = 'warning' if resp.reason in warning_reasons else 'error'
955 955 h.flash(msg, category=category)
956 956
957 957 @LoginRequired()
958 958 @NotAnonymous()
959 959 @HasRepoPermissionAnyDecorator(
960 960 'repository.read', 'repository.write', 'repository.admin')
961 961 @CSRFRequired()
962 962 @view_config(
963 963 route_name='pullrequest_merge', request_method='POST',
964 964 renderer='json_ext')
965 965 def pull_request_merge(self):
966 966 """
967 967 Merge will perform a server-side merge of the specified
968 968 pull request, if the pull request is approved and mergeable.
969 969 After successful merging, the pull request is automatically
970 970 closed, with a relevant comment.
971 971 """
972 972 pull_request = PullRequest.get_or_404(
973 973 self.request.matchdict['pull_request_id'])
974 974
975 975 self.load_default_context()
976 976 check = MergeCheck.validate(pull_request, self._rhodecode_db_user,
977 977 translator=self.request.translate)
978 978 merge_possible = not check.failed
979 979
980 980 for err_type, error_msg in check.errors:
981 981 h.flash(error_msg, category=err_type)
982 982
983 983 if merge_possible:
984 984 log.debug("Pre-conditions checked, trying to merge.")
985 985 extras = vcs_operation_context(
986 986 self.request.environ, repo_name=pull_request.target_repo.repo_name,
987 987 username=self._rhodecode_db_user.username, action='push',
988 988 scm=pull_request.target_repo.repo_type)
989 989 self._merge_pull_request(
990 990 pull_request, self._rhodecode_db_user, extras)
991 991 else:
992 992 log.debug("Pre-conditions failed, NOT merging.")
993 993
994 994 raise HTTPFound(
995 995 h.route_path('pullrequest_show',
996 996 repo_name=pull_request.target_repo.repo_name,
997 997 pull_request_id=pull_request.pull_request_id))
998 998
999 999 def _merge_pull_request(self, pull_request, user, extras):
1000 1000 _ = self.request.translate
1001 1001 merge_resp = PullRequestModel().merge(pull_request, user, extras=extras)
1002 1002
1003 1003 if merge_resp.executed:
1004 1004 log.debug("The merge was successful, closing the pull request.")
1005 1005 PullRequestModel().close_pull_request(
1006 1006 pull_request.pull_request_id, user)
1007 1007 Session().commit()
1008 1008 msg = _('Pull request was successfully merged and closed.')
1009 1009 h.flash(msg, category='success')
1010 1010 else:
1011 1011 log.debug(
1012 1012 "The merge was not successful. Merge response: %s",
1013 1013 merge_resp)
1014 1014 msg = PullRequestModel().merge_status_message(
1015 1015 merge_resp.failure_reason)
1016 1016 h.flash(msg, category='error')
1017 1017
1018 1018 def _update_reviewers(self, pull_request, review_members, reviewer_rules):
1019 1019 _ = self.request.translate
1020 1020 get_default_reviewers_data, validate_default_reviewers = \
1021 1021 PullRequestModel().get_reviewer_functions()
1022 1022
1023 1023 try:
1024 1024 reviewers = validate_default_reviewers(review_members, reviewer_rules)
1025 1025 except ValueError as e:
1026 1026 log.error('Reviewers Validation: {}'.format(e))
1027 1027 h.flash(e, category='error')
1028 1028 return
1029 1029
1030 1030 PullRequestModel().update_reviewers(
1031 1031 pull_request, reviewers, self._rhodecode_user)
1032 1032 h.flash(_('Pull request reviewers updated.'), category='success')
1033 1033 Session().commit()
1034 1034
1035 1035 @LoginRequired()
1036 1036 @NotAnonymous()
1037 1037 @HasRepoPermissionAnyDecorator(
1038 1038 'repository.read', 'repository.write', 'repository.admin')
1039 1039 @CSRFRequired()
1040 1040 @view_config(
1041 1041 route_name='pullrequest_delete', request_method='POST',
1042 1042 renderer='json_ext')
1043 1043 def pull_request_delete(self):
1044 1044 _ = self.request.translate
1045 1045
1046 1046 pull_request = PullRequest.get_or_404(
1047 1047 self.request.matchdict['pull_request_id'])
1048 1048 self.load_default_context()
1049 1049
1050 1050 pr_closed = pull_request.is_closed()
1051 1051 allowed_to_delete = PullRequestModel().check_user_delete(
1052 1052 pull_request, self._rhodecode_user) and not pr_closed
1053 1053
1054 1054 # only owner can delete it !
1055 1055 if allowed_to_delete:
1056 1056 PullRequestModel().delete(pull_request, self._rhodecode_user)
1057 1057 Session().commit()
1058 1058 h.flash(_('Successfully deleted pull request'),
1059 1059 category='success')
1060 1060 raise HTTPFound(h.route_path('pullrequest_show_all',
1061 1061 repo_name=self.db_repo_name))
1062 1062
1063 1063 log.warning('user %s tried to delete pull request without access',
1064 1064 self._rhodecode_user)
1065 1065 raise HTTPNotFound()
1066 1066
1067 1067 @LoginRequired()
1068 1068 @NotAnonymous()
1069 1069 @HasRepoPermissionAnyDecorator(
1070 1070 'repository.read', 'repository.write', 'repository.admin')
1071 1071 @CSRFRequired()
1072 1072 @view_config(
1073 1073 route_name='pullrequest_comment_create', request_method='POST',
1074 1074 renderer='json_ext')
1075 1075 def pull_request_comment_create(self):
1076 1076 _ = self.request.translate
1077 1077
1078 1078 pull_request = PullRequest.get_or_404(
1079 1079 self.request.matchdict['pull_request_id'])
1080 1080 pull_request_id = pull_request.pull_request_id
1081 1081
1082 1082 if pull_request.is_closed():
1083 1083 log.debug('comment: forbidden because pull request is closed')
1084 1084 raise HTTPForbidden()
1085 1085
1086 1086 allowed_to_comment = PullRequestModel().check_user_comment(
1087 1087 pull_request, self._rhodecode_user)
1088 1088 if not allowed_to_comment:
1089 1089 log.debug(
1090 1090 'comment: forbidden because pull request is from forbidden repo')
1091 1091 raise HTTPForbidden()
1092 1092
1093 1093 c = self.load_default_context()
1094 1094
1095 1095 status = self.request.POST.get('changeset_status', None)
1096 1096 text = self.request.POST.get('text')
1097 1097 comment_type = self.request.POST.get('comment_type')
1098 1098 resolves_comment_id = self.request.POST.get('resolves_comment_id', None)
1099 1099 close_pull_request = self.request.POST.get('close_pull_request')
1100 1100
1101 1101 # the logic here should work like following, if we submit close
1102 1102 # pr comment, use `close_pull_request_with_comment` function
1103 1103 # else handle regular comment logic
1104 1104
1105 1105 if close_pull_request:
1106 1106 # only owner or admin or person with write permissions
1107 1107 allowed_to_close = PullRequestModel().check_user_update(
1108 1108 pull_request, self._rhodecode_user)
1109 1109 if not allowed_to_close:
1110 1110 log.debug('comment: forbidden because not allowed to close '
1111 1111 'pull request %s', pull_request_id)
1112 1112 raise HTTPForbidden()
1113 1113 comment, status = PullRequestModel().close_pull_request_with_comment(
1114 1114 pull_request, self._rhodecode_user, self.db_repo, message=text)
1115 1115 Session().flush()
1116 1116 events.trigger(
1117 1117 events.PullRequestCommentEvent(pull_request, comment))
1118 1118
1119 1119 else:
1120 1120 # regular comment case, could be inline, or one with status.
1121 1121 # for that one we check also permissions
1122 1122
1123 1123 allowed_to_change_status = PullRequestModel().check_user_change_status(
1124 1124 pull_request, self._rhodecode_user)
1125 1125
1126 1126 if status and allowed_to_change_status:
1127 1127 message = (_('Status change %(transition_icon)s %(status)s')
1128 1128 % {'transition_icon': '>',
1129 1129 'status': ChangesetStatus.get_status_lbl(status)})
1130 1130 text = text or message
1131 1131
1132 1132 comment = CommentsModel().create(
1133 1133 text=text,
1134 1134 repo=self.db_repo.repo_id,
1135 1135 user=self._rhodecode_user.user_id,
1136 1136 pull_request=pull_request,
1137 1137 f_path=self.request.POST.get('f_path'),
1138 1138 line_no=self.request.POST.get('line'),
1139 1139 status_change=(ChangesetStatus.get_status_lbl(status)
1140 1140 if status and allowed_to_change_status else None),
1141 1141 status_change_type=(status
1142 1142 if status and allowed_to_change_status else None),
1143 1143 comment_type=comment_type,
1144 1144 resolves_comment_id=resolves_comment_id
1145 1145 )
1146 1146
1147 1147 if allowed_to_change_status:
1148 1148 # calculate old status before we change it
1149 1149 old_calculated_status = pull_request.calculated_review_status()
1150 1150
1151 1151 # get status if set !
1152 1152 if status:
1153 1153 ChangesetStatusModel().set_status(
1154 1154 self.db_repo.repo_id,
1155 1155 status,
1156 1156 self._rhodecode_user.user_id,
1157 1157 comment,
1158 1158 pull_request=pull_request
1159 1159 )
1160 1160
1161 1161 Session().flush()
1162 # this is somehow required to get access to some relationship
1163 # loaded on comment
1164 Session().refresh(comment)
1165
1162 1166 events.trigger(
1163 1167 events.PullRequestCommentEvent(pull_request, comment))
1164 1168
1165 1169 # we now calculate the status of pull request, and based on that
1166 1170 # calculation we set the commits status
1167 1171 calculated_status = pull_request.calculated_review_status()
1168 1172 if old_calculated_status != calculated_status:
1169 1173 PullRequestModel()._trigger_pull_request_hook(
1170 1174 pull_request, self._rhodecode_user, 'review_status_change')
1171 1175
1172 1176 Session().commit()
1173 1177
1174 1178 data = {
1175 1179 'target_id': h.safeid(h.safe_unicode(
1176 1180 self.request.POST.get('f_path'))),
1177 1181 }
1178 1182 if comment:
1179 1183 c.co = comment
1180 1184 rendered_comment = render(
1181 1185 'rhodecode:templates/changeset/changeset_comment_block.mako',
1182 1186 self._get_template_context(c), self.request)
1183 1187
1184 1188 data.update(comment.get_dict())
1185 1189 data.update({'rendered_text': rendered_comment})
1186 1190
1187 1191 return data
1188 1192
1189 1193 @LoginRequired()
1190 1194 @NotAnonymous()
1191 1195 @HasRepoPermissionAnyDecorator(
1192 1196 'repository.read', 'repository.write', 'repository.admin')
1193 1197 @CSRFRequired()
1194 1198 @view_config(
1195 1199 route_name='pullrequest_comment_delete', request_method='POST',
1196 1200 renderer='json_ext')
1197 1201 def pull_request_comment_delete(self):
1198 1202 pull_request = PullRequest.get_or_404(
1199 1203 self.request.matchdict['pull_request_id'])
1200 1204
1201 1205 comment = ChangesetComment.get_or_404(
1202 1206 self.request.matchdict['comment_id'])
1203 1207 comment_id = comment.comment_id
1204 1208
1205 1209 if pull_request.is_closed():
1206 1210 log.debug('comment: forbidden because pull request is closed')
1207 1211 raise HTTPForbidden()
1208 1212
1209 1213 if not comment:
1210 1214 log.debug('Comment with id:%s not found, skipping', comment_id)
1211 1215 # comment already deleted in another call probably
1212 1216 return True
1213 1217
1214 1218 if comment.pull_request.is_closed():
1215 1219 # don't allow deleting comments on closed pull request
1216 1220 raise HTTPForbidden()
1217 1221
1218 1222 is_repo_admin = h.HasRepoPermissionAny('repository.admin')(self.db_repo_name)
1219 1223 super_admin = h.HasPermissionAny('hg.admin')()
1220 1224 comment_owner = comment.author.user_id == self._rhodecode_user.user_id
1221 1225 is_repo_comment = comment.repo.repo_name == self.db_repo_name
1222 1226 comment_repo_admin = is_repo_admin and is_repo_comment
1223 1227
1224 1228 if super_admin or comment_owner or comment_repo_admin:
1225 1229 old_calculated_status = comment.pull_request.calculated_review_status()
1226 1230 CommentsModel().delete(comment=comment, user=self._rhodecode_user)
1227 1231 Session().commit()
1228 1232 calculated_status = comment.pull_request.calculated_review_status()
1229 1233 if old_calculated_status != calculated_status:
1230 1234 PullRequestModel()._trigger_pull_request_hook(
1231 1235 comment.pull_request, self._rhodecode_user, 'review_status_change')
1232 1236 return True
1233 1237 else:
1234 1238 log.warning('No permissions for user %s to delete comment_id: %s',
1235 1239 self._rhodecode_db_user, comment_id)
1236 1240 raise HTTPNotFound()
@@ -1,269 +1,267 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-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 """
22 Changeset status conttroller
23 """
24 21
25 22 import itertools
26 23 import logging
27 24 from collections import defaultdict
28 25
29 26 from rhodecode.model import BaseModel
30 from rhodecode.model.db import ChangesetStatus, ChangesetComment, PullRequest
27 from rhodecode.model.db import (
28 ChangesetStatus, ChangesetComment, PullRequest, Session)
31 29 from rhodecode.lib.exceptions import StatusChangeOnClosedPullRequestError
32 30 from rhodecode.lib.markup_renderer import (
33 31 DEFAULT_COMMENTS_RENDERER, RstTemplateRenderer)
34 32
35 33 log = logging.getLogger(__name__)
36 34
37 35
38 36 class ChangesetStatusModel(BaseModel):
39 37
40 38 cls = ChangesetStatus
41 39
42 40 def __get_changeset_status(self, changeset_status):
43 41 return self._get_instance(ChangesetStatus, changeset_status)
44 42
45 43 def __get_pull_request(self, pull_request):
46 44 return self._get_instance(PullRequest, pull_request)
47 45
48 46 def _get_status_query(self, repo, revision, pull_request,
49 47 with_revisions=False):
50 48 repo = self._get_repo(repo)
51 49
52 50 q = ChangesetStatus.query()\
53 51 .filter(ChangesetStatus.repo == repo)
54 52 if not with_revisions:
55 53 q = q.filter(ChangesetStatus.version == 0)
56 54
57 55 if revision:
58 56 q = q.filter(ChangesetStatus.revision == revision)
59 57 elif pull_request:
60 58 pull_request = self.__get_pull_request(pull_request)
61 59 # TODO: johbo: Think about the impact of this join, there must
62 60 # be a reason why ChangesetStatus and ChanagesetComment is linked
63 61 # to the pull request. Might be that we want to do the same for
64 62 # the pull_request_version_id.
65 63 q = q.join(ChangesetComment).filter(
66 64 ChangesetStatus.pull_request == pull_request,
67 65 ChangesetComment.pull_request_version_id == None)
68 66 else:
69 67 raise Exception('Please specify revision or pull_request')
70 68 q = q.order_by(ChangesetStatus.version.asc())
71 69 return q
72 70
73 71 def calculate_status(self, statuses_by_reviewers):
74 72 """
75 73 Given the approval statuses from reviewers, calculates final approval
76 74 status. There can only be 3 results, all approved, all rejected. If
77 75 there is no consensus the PR is under review.
78 76
79 77 :param statuses_by_reviewers:
80 78 """
81 79 votes = defaultdict(int)
82 80 reviewers_number = len(statuses_by_reviewers)
83 81 for user, reasons, mandatory, statuses in statuses_by_reviewers:
84 82 if statuses:
85 83 ver, latest = statuses[0]
86 84 votes[latest.status] += 1
87 85 else:
88 86 votes[ChangesetStatus.DEFAULT] += 1
89 87
90 88 # all approved
91 89 if votes.get(ChangesetStatus.STATUS_APPROVED) == reviewers_number:
92 90 return ChangesetStatus.STATUS_APPROVED
93 91
94 92 # all rejected
95 93 if votes.get(ChangesetStatus.STATUS_REJECTED) == reviewers_number:
96 94 return ChangesetStatus.STATUS_REJECTED
97 95
98 96 return ChangesetStatus.STATUS_UNDER_REVIEW
99 97
100 98 def get_statuses(self, repo, revision=None, pull_request=None,
101 99 with_revisions=False):
102 100 q = self._get_status_query(repo, revision, pull_request,
103 101 with_revisions)
104 102 return q.all()
105 103
106 104 def get_status(self, repo, revision=None, pull_request=None, as_str=True):
107 105 """
108 106 Returns latest status of changeset for given revision or for given
109 107 pull request. Statuses are versioned inside a table itself and
110 108 version == 0 is always the current one
111 109
112 110 :param repo:
113 111 :param revision: 40char hash or None
114 112 :param pull_request: pull_request reference
115 113 :param as_str: return status as string not object
116 114 """
117 115 q = self._get_status_query(repo, revision, pull_request)
118 116
119 117 # need to use first here since there can be multiple statuses
120 118 # returned from pull_request
121 119 status = q.first()
122 120 if as_str:
123 121 status = status.status if status else status
124 122 st = status or ChangesetStatus.DEFAULT
125 123 return str(st)
126 124 return status
127 125
128 126 def _render_auto_status_message(
129 127 self, status, commit_id=None, pull_request=None):
130 128 """
131 129 render the message using DEFAULT_COMMENTS_RENDERER (RST renderer),
132 130 so it's always looking the same disregarding on which default
133 131 renderer system is using.
134 132
135 133 :param status: status text to change into
136 134 :param commit_id: the commit_id we change the status for
137 135 :param pull_request: the pull request we change the status for
138 136 """
139 137
140 138 new_status = ChangesetStatus.get_status_lbl(status)
141 139
142 140 params = {
143 141 'new_status_label': new_status,
144 142 'pull_request': pull_request,
145 143 'commit_id': commit_id,
146 144 }
147 145 renderer = RstTemplateRenderer()
148 146 return renderer.render('auto_status_change.mako', **params)
149 147
150 148 def set_status(self, repo, status, user, comment=None, revision=None,
151 149 pull_request=None, dont_allow_on_closed_pull_request=False):
152 150 """
153 151 Creates new status for changeset or updates the old ones bumping their
154 152 version, leaving the current status at
155 153
156 154 :param repo:
157 155 :param revision:
158 156 :param status:
159 157 :param user:
160 158 :param comment:
161 159 :param dont_allow_on_closed_pull_request: don't allow a status change
162 160 if last status was for pull request and it's closed. We shouldn't
163 161 mess around this manually
164 162 """
165 163 repo = self._get_repo(repo)
166 164
167 165 q = ChangesetStatus.query()
168 166
169 167 if revision:
170 168 q = q.filter(ChangesetStatus.repo == repo)
171 169 q = q.filter(ChangesetStatus.revision == revision)
172 170 elif pull_request:
173 171 pull_request = self.__get_pull_request(pull_request)
174 172 q = q.filter(ChangesetStatus.repo == pull_request.source_repo)
175 173 q = q.filter(ChangesetStatus.revision.in_(pull_request.revisions))
176 174 cur_statuses = q.all()
177 175
178 176 # if statuses exists and last is associated with a closed pull request
179 177 # we need to check if we can allow this status change
180 178 if (dont_allow_on_closed_pull_request and cur_statuses
181 179 and getattr(cur_statuses[0].pull_request, 'status', '')
182 180 == PullRequest.STATUS_CLOSED):
183 181 raise StatusChangeOnClosedPullRequestError(
184 182 'Changing status on closed pull request is not allowed'
185 183 )
186 184
187 185 # update all current statuses with older version
188 186 if cur_statuses:
189 187 for st in cur_statuses:
190 188 st.version += 1
191 self.sa.add(st)
189 Session().add(st)
192 190
193 191 def _create_status(user, repo, status, comment, revision, pull_request):
194 192 new_status = ChangesetStatus()
195 193 new_status.author = self._get_user(user)
196 194 new_status.repo = self._get_repo(repo)
197 195 new_status.status = status
198 196 new_status.comment = comment
199 197 new_status.revision = revision
200 198 new_status.pull_request = pull_request
201 199 return new_status
202 200
203 201 if not comment:
204 202 from rhodecode.model.comment import CommentsModel
205 203 comment = CommentsModel().create(
206 204 text=self._render_auto_status_message(
207 205 status, commit_id=revision, pull_request=pull_request),
208 206 repo=repo,
209 207 user=user,
210 208 pull_request=pull_request,
211 209 send_email=False, renderer=DEFAULT_COMMENTS_RENDERER
212 210 )
213 211
214 212 if revision:
215 213 new_status = _create_status(
216 214 user=user, repo=repo, status=status, comment=comment,
217 215 revision=revision, pull_request=pull_request)
218 self.sa.add(new_status)
216 Session().add(new_status)
219 217 return new_status
220 218 elif pull_request:
221 219 # pull request can have more than one revision associated to it
222 220 # we need to create new version for each one
223 221 new_statuses = []
224 222 repo = pull_request.source_repo
225 223 for rev in pull_request.revisions:
226 224 new_status = _create_status(
227 225 user=user, repo=repo, status=status, comment=comment,
228 226 revision=rev, pull_request=pull_request)
229 227 new_statuses.append(new_status)
230 self.sa.add(new_status)
228 Session().add(new_status)
231 229 return new_statuses
232 230
233 231 def reviewers_statuses(self, pull_request):
234 232 _commit_statuses = self.get_statuses(
235 233 pull_request.source_repo,
236 234 pull_request=pull_request,
237 235 with_revisions=True)
238 236
239 237 commit_statuses = defaultdict(list)
240 238 for st in _commit_statuses:
241 239 commit_statuses[st.author.username] += [st]
242 240
243 241 pull_request_reviewers = []
244 242
245 243 def version(commit_status):
246 244 return commit_status.version
247 245
248 246 for o in pull_request.reviewers:
249 247 if not o.user:
250 248 continue
251 249 statuses = commit_statuses.get(o.user.username, None)
252 250 if statuses:
253 251 statuses = [(x, list(y)[0])
254 252 for x, y in (itertools.groupby(
255 253 sorted(statuses, key=version),version))]
256 254
257 255 pull_request_reviewers.append(
258 256 (o.user, o.reasons, o.mandatory, statuses))
259 257 return pull_request_reviewers
260 258
261 259 def calculated_review_status(self, pull_request, reviewers_statuses=None):
262 260 """
263 261 calculate pull request status based on reviewers, it should be a list
264 262 of two element lists.
265 263
266 264 :param reviewers_statuses:
267 265 """
268 266 reviewers = reviewers_statuses or self.reviewers_statuses(pull_request)
269 267 return self.calculate_status(reviewers)
@@ -1,659 +1,660 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 """
22 22 comments model for RhodeCode
23 23 """
24 24
25 25 import logging
26 26 import traceback
27 27 import collections
28 28
29 29 from pyramid.threadlocal import get_current_registry, get_current_request
30 30 from sqlalchemy.sql.expression import null
31 31 from sqlalchemy.sql.functions import coalesce
32 32
33 33 from rhodecode.lib import helpers as h, diffs, channelstream
34 34 from rhodecode.lib import audit_logger
35 35 from rhodecode.lib.utils2 import extract_mentioned_users, safe_str
36 36 from rhodecode.model import BaseModel
37 37 from rhodecode.model.db import (
38 38 ChangesetComment, User, Notification, PullRequest, AttributeDict)
39 39 from rhodecode.model.notification import NotificationModel
40 40 from rhodecode.model.meta import Session
41 41 from rhodecode.model.settings import VcsSettingsModel
42 42 from rhodecode.model.notification import EmailNotificationModel
43 43 from rhodecode.model.validation_schema.schemas import comment_schema
44 44
45 45
46 46 log = logging.getLogger(__name__)
47 47
48 48
49 49 class CommentsModel(BaseModel):
50 50
51 51 cls = ChangesetComment
52 52
53 53 DIFF_CONTEXT_BEFORE = 3
54 54 DIFF_CONTEXT_AFTER = 3
55 55
56 56 def __get_commit_comment(self, changeset_comment):
57 57 return self._get_instance(ChangesetComment, changeset_comment)
58 58
59 59 def __get_pull_request(self, pull_request):
60 60 return self._get_instance(PullRequest, pull_request)
61 61
62 62 def _extract_mentions(self, s):
63 63 user_objects = []
64 64 for username in extract_mentioned_users(s):
65 65 user_obj = User.get_by_username(username, case_insensitive=True)
66 66 if user_obj:
67 67 user_objects.append(user_obj)
68 68 return user_objects
69 69
70 70 def _get_renderer(self, global_renderer='rst', request=None):
71 71 request = request or get_current_request()
72 72
73 73 try:
74 74 global_renderer = request.call_context.visual.default_renderer
75 75 except AttributeError:
76 76 log.debug("Renderer not set, falling back "
77 77 "to default renderer '%s'", global_renderer)
78 78 except Exception:
79 79 log.error(traceback.format_exc())
80 80 return global_renderer
81 81
82 82 def aggregate_comments(self, comments, versions, show_version, inline=False):
83 83 # group by versions, and count until, and display objects
84 84
85 85 comment_groups = collections.defaultdict(list)
86 86 [comment_groups[
87 87 _co.pull_request_version_id].append(_co) for _co in comments]
88 88
89 89 def yield_comments(pos):
90 90 for co in comment_groups[pos]:
91 91 yield co
92 92
93 93 comment_versions = collections.defaultdict(
94 94 lambda: collections.defaultdict(list))
95 95 prev_prvid = -1
96 96 # fake last entry with None, to aggregate on "latest" version which
97 97 # doesn't have an pull_request_version_id
98 98 for ver in versions + [AttributeDict({'pull_request_version_id': None})]:
99 99 prvid = ver.pull_request_version_id
100 100 if prev_prvid == -1:
101 101 prev_prvid = prvid
102 102
103 103 for co in yield_comments(prvid):
104 104 comment_versions[prvid]['at'].append(co)
105 105
106 106 # save until
107 107 current = comment_versions[prvid]['at']
108 108 prev_until = comment_versions[prev_prvid]['until']
109 109 cur_until = prev_until + current
110 110 comment_versions[prvid]['until'].extend(cur_until)
111 111
112 112 # save outdated
113 113 if inline:
114 114 outdated = [x for x in cur_until
115 115 if x.outdated_at_version(show_version)]
116 116 else:
117 117 outdated = [x for x in cur_until
118 118 if x.older_than_version(show_version)]
119 119 display = [x for x in cur_until if x not in outdated]
120 120
121 121 comment_versions[prvid]['outdated'] = outdated
122 122 comment_versions[prvid]['display'] = display
123 123
124 124 prev_prvid = prvid
125 125
126 126 return comment_versions
127 127
128 128 def get_unresolved_todos(self, pull_request, show_outdated=True):
129 129
130 130 todos = Session().query(ChangesetComment) \
131 131 .filter(ChangesetComment.pull_request == pull_request) \
132 132 .filter(ChangesetComment.resolved_by == None) \
133 133 .filter(ChangesetComment.comment_type
134 134 == ChangesetComment.COMMENT_TYPE_TODO)
135 135
136 136 if not show_outdated:
137 137 todos = todos.filter(
138 138 coalesce(ChangesetComment.display_state, '') !=
139 139 ChangesetComment.COMMENT_OUTDATED)
140 140
141 141 todos = todos.all()
142 142
143 143 return todos
144 144
145 145 def get_commit_unresolved_todos(self, commit_id, show_outdated=True):
146 146
147 147 todos = Session().query(ChangesetComment) \
148 148 .filter(ChangesetComment.revision == commit_id) \
149 149 .filter(ChangesetComment.resolved_by == None) \
150 150 .filter(ChangesetComment.comment_type
151 151 == ChangesetComment.COMMENT_TYPE_TODO)
152 152
153 153 if not show_outdated:
154 154 todos = todos.filter(
155 155 coalesce(ChangesetComment.display_state, '') !=
156 156 ChangesetComment.COMMENT_OUTDATED)
157 157
158 158 todos = todos.all()
159 159
160 160 return todos
161 161
162 162 def _log_audit_action(self, action, action_data, user, comment):
163 163 audit_logger.store(
164 164 action=action,
165 165 action_data=action_data,
166 166 user=user,
167 167 repo=comment.repo)
168 168
169 169 def create(self, text, repo, user, commit_id=None, pull_request=None,
170 170 f_path=None, line_no=None, status_change=None,
171 171 status_change_type=None, comment_type=None,
172 172 resolves_comment_id=None, closing_pr=False, send_email=True,
173 173 renderer=None):
174 174 """
175 175 Creates new comment for commit or pull request.
176 176 IF status_change is not none this comment is associated with a
177 177 status change of commit or commit associated with pull request
178 178
179 179 :param text:
180 180 :param repo:
181 181 :param user:
182 182 :param commit_id:
183 183 :param pull_request:
184 184 :param f_path:
185 185 :param line_no:
186 186 :param status_change: Label for status change
187 187 :param comment_type: Type of comment
188 188 :param status_change_type: type of status change
189 189 :param closing_pr:
190 190 :param send_email:
191 191 :param renderer: pick renderer for this comment
192 192 """
193 193 if not text:
194 194 log.warning('Missing text for comment, skipping...')
195 195 return
196 196 request = get_current_request()
197 197 _ = request.translate
198 198
199 199 if not renderer:
200 200 renderer = self._get_renderer(request=request)
201 201
202 202 repo = self._get_repo(repo)
203 203 user = self._get_user(user)
204 204
205 205 schema = comment_schema.CommentSchema()
206 206 validated_kwargs = schema.deserialize(dict(
207 207 comment_body=text,
208 208 comment_type=comment_type,
209 209 comment_file=f_path,
210 210 comment_line=line_no,
211 211 renderer_type=renderer,
212 212 status_change=status_change_type,
213 213 resolves_comment_id=resolves_comment_id,
214 214 repo=repo.repo_id,
215 215 user=user.user_id,
216 216 ))
217 217
218 218 comment = ChangesetComment()
219 219 comment.renderer = validated_kwargs['renderer_type']
220 220 comment.text = validated_kwargs['comment_body']
221 221 comment.f_path = validated_kwargs['comment_file']
222 222 comment.line_no = validated_kwargs['comment_line']
223 223 comment.comment_type = validated_kwargs['comment_type']
224 224
225 225 comment.repo = repo
226 226 comment.author = user
227 227 resolved_comment = self.__get_commit_comment(
228 228 validated_kwargs['resolves_comment_id'])
229 229 # check if the comment actually belongs to this PR
230 230 if resolved_comment and resolved_comment.pull_request and \
231 231 resolved_comment.pull_request != pull_request:
232 232 # comment not bound to this pull request, forbid
233 233 resolved_comment = None
234 234 comment.resolved_comment = resolved_comment
235 235
236 236 pull_request_id = pull_request
237 237
238 238 commit_obj = None
239 239 pull_request_obj = None
240 240
241 241 if commit_id:
242 242 notification_type = EmailNotificationModel.TYPE_COMMIT_COMMENT
243 243 # do a lookup, so we don't pass something bad here
244 244 commit_obj = repo.scm_instance().get_commit(commit_id=commit_id)
245 245 comment.revision = commit_obj.raw_id
246 246
247 247 elif pull_request_id:
248 248 notification_type = EmailNotificationModel.TYPE_PULL_REQUEST_COMMENT
249 249 pull_request_obj = self.__get_pull_request(pull_request_id)
250 250 comment.pull_request = pull_request_obj
251 251 else:
252 252 raise Exception('Please specify commit or pull_request_id')
253 253
254 254 Session().add(comment)
255 255 Session().flush()
256 256 kwargs = {
257 257 'user': user,
258 258 'renderer_type': renderer,
259 259 'repo_name': repo.repo_name,
260 260 'status_change': status_change,
261 261 'status_change_type': status_change_type,
262 262 'comment_body': text,
263 263 'comment_file': f_path,
264 264 'comment_line': line_no,
265 265 'comment_type': comment_type or 'note'
266 266 }
267 267
268 268 if commit_obj:
269 269 recipients = ChangesetComment.get_users(
270 270 revision=commit_obj.raw_id)
271 271 # add commit author if it's in RhodeCode system
272 272 cs_author = User.get_from_cs_author(commit_obj.author)
273 273 if not cs_author:
274 274 # use repo owner if we cannot extract the author correctly
275 275 cs_author = repo.user
276 276 recipients += [cs_author]
277 277
278 278 commit_comment_url = self.get_url(comment, request=request)
279 279
280 280 target_repo_url = h.link_to(
281 281 repo.repo_name,
282 282 h.route_url('repo_summary', repo_name=repo.repo_name))
283 283
284 284 # commit specifics
285 285 kwargs.update({
286 286 'commit': commit_obj,
287 287 'commit_message': commit_obj.message,
288 288 'commit_target_repo': target_repo_url,
289 289 'commit_comment_url': commit_comment_url,
290 290 })
291 291
292 292 elif pull_request_obj:
293 293 # get the current participants of this pull request
294 294 recipients = ChangesetComment.get_users(
295 295 pull_request_id=pull_request_obj.pull_request_id)
296 296 # add pull request author
297 297 recipients += [pull_request_obj.author]
298 298
299 299 # add the reviewers to notification
300 300 recipients += [x.user for x in pull_request_obj.reviewers]
301 301
302 302 pr_target_repo = pull_request_obj.target_repo
303 303 pr_source_repo = pull_request_obj.source_repo
304 304
305 305 pr_comment_url = h.route_url(
306 306 'pullrequest_show',
307 307 repo_name=pr_target_repo.repo_name,
308 308 pull_request_id=pull_request_obj.pull_request_id,
309 309 _anchor='comment-%s' % comment.comment_id)
310 310
311 311 # set some variables for email notification
312 312 pr_target_repo_url = h.route_url(
313 313 'repo_summary', repo_name=pr_target_repo.repo_name)
314 314
315 315 pr_source_repo_url = h.route_url(
316 316 'repo_summary', repo_name=pr_source_repo.repo_name)
317 317
318 318 # pull request specifics
319 319 kwargs.update({
320 320 'pull_request': pull_request_obj,
321 321 'pr_id': pull_request_obj.pull_request_id,
322 322 'pr_target_repo': pr_target_repo,
323 323 'pr_target_repo_url': pr_target_repo_url,
324 324 'pr_source_repo': pr_source_repo,
325 325 'pr_source_repo_url': pr_source_repo_url,
326 326 'pr_comment_url': pr_comment_url,
327 327 'pr_closing': closing_pr,
328 328 })
329 329 if send_email:
330 330 # pre-generate the subject for notification itself
331 331 (subject,
332 332 _h, _e, # we don't care about those
333 333 body_plaintext) = EmailNotificationModel().render_email(
334 334 notification_type, **kwargs)
335 335
336 336 mention_recipients = set(
337 337 self._extract_mentions(text)).difference(recipients)
338 338
339 339 # create notification objects, and emails
340 340 NotificationModel().create(
341 341 created_by=user,
342 342 notification_subject=subject,
343 343 notification_body=body_plaintext,
344 344 notification_type=notification_type,
345 345 recipients=recipients,
346 346 mention_recipients=mention_recipients,
347 347 email_kwargs=kwargs,
348 348 )
349 349
350 350 Session().flush()
351 351 if comment.pull_request:
352 352 action = 'repo.pull_request.comment.create'
353 353 else:
354 354 action = 'repo.commit.comment.create'
355 355
356 356 comment_data = comment.get_api_data()
357 357 self._log_audit_action(
358 358 action, {'data': comment_data}, user, comment)
359 359
360 360 msg_url = ''
361 361 channel = None
362 362 if commit_obj:
363 363 msg_url = commit_comment_url
364 364 repo_name = repo.repo_name
365 365 channel = u'/repo${}$/commit/{}'.format(
366 366 repo_name,
367 367 commit_obj.raw_id
368 368 )
369 369 elif pull_request_obj:
370 370 msg_url = pr_comment_url
371 371 repo_name = pr_target_repo.repo_name
372 372 channel = u'/repo${}$/pr/{}'.format(
373 373 repo_name,
374 374 pull_request_id
375 375 )
376 376
377 377 message = '<strong>{}</strong> {} - ' \
378 378 '<a onclick="window.location=\'{}\';' \
379 379 'window.location.reload()">' \
380 380 '<strong>{}</strong></a>'
381 381 message = message.format(
382 382 user.username, _('made a comment'), msg_url,
383 383 _('Show it now'))
384 384
385 385 channelstream.post_message(
386 386 channel, message, user.username,
387 387 registry=get_current_registry())
388 388
389 389 return comment
390 390
391 391 def delete(self, comment, user):
392 392 """
393 393 Deletes given comment
394 394 """
395 395 comment = self.__get_commit_comment(comment)
396 396 old_data = comment.get_api_data()
397 397 Session().delete(comment)
398 398
399 399 if comment.pull_request:
400 400 action = 'repo.pull_request.comment.delete'
401 401 else:
402 402 action = 'repo.commit.comment.delete'
403 403
404 404 self._log_audit_action(
405 405 action, {'old_data': old_data}, user, comment)
406 406
407 407 return comment
408 408
409 409 def get_all_comments(self, repo_id, revision=None, pull_request=None):
410 410 q = ChangesetComment.query()\
411 411 .filter(ChangesetComment.repo_id == repo_id)
412 412 if revision:
413 413 q = q.filter(ChangesetComment.revision == revision)
414 414 elif pull_request:
415 415 pull_request = self.__get_pull_request(pull_request)
416 416 q = q.filter(ChangesetComment.pull_request == pull_request)
417 417 else:
418 418 raise Exception('Please specify commit or pull_request')
419 419 q = q.order_by(ChangesetComment.created_on)
420 420 return q.all()
421 421
422 422 def get_url(self, comment, request=None, permalink=False):
423 423 if not request:
424 424 request = get_current_request()
425 425
426 426 comment = self.__get_commit_comment(comment)
427 427 if comment.pull_request:
428 428 pull_request = comment.pull_request
429 429 if permalink:
430 430 return request.route_url(
431 431 'pull_requests_global',
432 432 pull_request_id=pull_request.pull_request_id,
433 433 _anchor='comment-%s' % comment.comment_id)
434 434 else:
435 return request.route_url('pullrequest_show',
435 return request.route_url(
436 'pullrequest_show',
436 437 repo_name=safe_str(pull_request.target_repo.repo_name),
437 438 pull_request_id=pull_request.pull_request_id,
438 439 _anchor='comment-%s' % comment.comment_id)
439 440
440 441 else:
441 442 repo = comment.repo
442 443 commit_id = comment.revision
443 444
444 445 if permalink:
445 446 return request.route_url(
446 447 'repo_commit', repo_name=safe_str(repo.repo_id),
447 448 commit_id=commit_id,
448 449 _anchor='comment-%s' % comment.comment_id)
449 450
450 451 else:
451 452 return request.route_url(
452 453 'repo_commit', repo_name=safe_str(repo.repo_name),
453 454 commit_id=commit_id,
454 455 _anchor='comment-%s' % comment.comment_id)
455 456
456 457 def get_comments(self, repo_id, revision=None, pull_request=None):
457 458 """
458 459 Gets main comments based on revision or pull_request_id
459 460
460 461 :param repo_id:
461 462 :param revision:
462 463 :param pull_request:
463 464 """
464 465
465 466 q = ChangesetComment.query()\
466 467 .filter(ChangesetComment.repo_id == repo_id)\
467 468 .filter(ChangesetComment.line_no == None)\
468 469 .filter(ChangesetComment.f_path == None)
469 470 if revision:
470 471 q = q.filter(ChangesetComment.revision == revision)
471 472 elif pull_request:
472 473 pull_request = self.__get_pull_request(pull_request)
473 474 q = q.filter(ChangesetComment.pull_request == pull_request)
474 475 else:
475 476 raise Exception('Please specify commit or pull_request')
476 477 q = q.order_by(ChangesetComment.created_on)
477 478 return q.all()
478 479
479 480 def get_inline_comments(self, repo_id, revision=None, pull_request=None):
480 481 q = self._get_inline_comments_query(repo_id, revision, pull_request)
481 482 return self._group_comments_by_path_and_line_number(q)
482 483
483 484 def get_inline_comments_count(self, inline_comments, skip_outdated=True,
484 485 version=None):
485 486 inline_cnt = 0
486 487 for fname, per_line_comments in inline_comments.iteritems():
487 488 for lno, comments in per_line_comments.iteritems():
488 489 for comm in comments:
489 490 if not comm.outdated_at_version(version) and skip_outdated:
490 491 inline_cnt += 1
491 492
492 493 return inline_cnt
493 494
494 495 def get_outdated_comments(self, repo_id, pull_request):
495 496 # TODO: johbo: Remove `repo_id`, it is not needed to find the comments
496 497 # of a pull request.
497 498 q = self._all_inline_comments_of_pull_request(pull_request)
498 499 q = q.filter(
499 500 ChangesetComment.display_state ==
500 501 ChangesetComment.COMMENT_OUTDATED
501 502 ).order_by(ChangesetComment.comment_id.asc())
502 503
503 504 return self._group_comments_by_path_and_line_number(q)
504 505
505 506 def _get_inline_comments_query(self, repo_id, revision, pull_request):
506 507 # TODO: johbo: Split this into two methods: One for PR and one for
507 508 # commit.
508 509 if revision:
509 510 q = Session().query(ChangesetComment).filter(
510 511 ChangesetComment.repo_id == repo_id,
511 512 ChangesetComment.line_no != null(),
512 513 ChangesetComment.f_path != null(),
513 514 ChangesetComment.revision == revision)
514 515
515 516 elif pull_request:
516 517 pull_request = self.__get_pull_request(pull_request)
517 518 if not CommentsModel.use_outdated_comments(pull_request):
518 519 q = self._visible_inline_comments_of_pull_request(pull_request)
519 520 else:
520 521 q = self._all_inline_comments_of_pull_request(pull_request)
521 522
522 523 else:
523 524 raise Exception('Please specify commit or pull_request_id')
524 525 q = q.order_by(ChangesetComment.comment_id.asc())
525 526 return q
526 527
527 528 def _group_comments_by_path_and_line_number(self, q):
528 529 comments = q.all()
529 530 paths = collections.defaultdict(lambda: collections.defaultdict(list))
530 531 for co in comments:
531 532 paths[co.f_path][co.line_no].append(co)
532 533 return paths
533 534
534 535 @classmethod
535 536 def needed_extra_diff_context(cls):
536 537 return max(cls.DIFF_CONTEXT_BEFORE, cls.DIFF_CONTEXT_AFTER)
537 538
538 539 def outdate_comments(self, pull_request, old_diff_data, new_diff_data):
539 540 if not CommentsModel.use_outdated_comments(pull_request):
540 541 return
541 542
542 543 comments = self._visible_inline_comments_of_pull_request(pull_request)
543 544 comments_to_outdate = comments.all()
544 545
545 546 for comment in comments_to_outdate:
546 547 self._outdate_one_comment(comment, old_diff_data, new_diff_data)
547 548
548 549 def _outdate_one_comment(self, comment, old_diff_proc, new_diff_proc):
549 550 diff_line = _parse_comment_line_number(comment.line_no)
550 551
551 552 try:
552 553 old_context = old_diff_proc.get_context_of_line(
553 554 path=comment.f_path, diff_line=diff_line)
554 555 new_context = new_diff_proc.get_context_of_line(
555 556 path=comment.f_path, diff_line=diff_line)
556 557 except (diffs.LineNotInDiffException,
557 558 diffs.FileNotInDiffException):
558 559 comment.display_state = ChangesetComment.COMMENT_OUTDATED
559 560 return
560 561
561 562 if old_context == new_context:
562 563 return
563 564
564 565 if self._should_relocate_diff_line(diff_line):
565 566 new_diff_lines = new_diff_proc.find_context(
566 567 path=comment.f_path, context=old_context,
567 568 offset=self.DIFF_CONTEXT_BEFORE)
568 569 if not new_diff_lines:
569 570 comment.display_state = ChangesetComment.COMMENT_OUTDATED
570 571 else:
571 572 new_diff_line = self._choose_closest_diff_line(
572 573 diff_line, new_diff_lines)
573 574 comment.line_no = _diff_to_comment_line_number(new_diff_line)
574 575 else:
575 576 comment.display_state = ChangesetComment.COMMENT_OUTDATED
576 577
577 578 def _should_relocate_diff_line(self, diff_line):
578 579 """
579 580 Checks if relocation shall be tried for the given `diff_line`.
580 581
581 582 If a comment points into the first lines, then we can have a situation
582 583 that after an update another line has been added on top. In this case
583 584 we would find the context still and move the comment around. This
584 585 would be wrong.
585 586 """
586 587 should_relocate = (
587 588 (diff_line.new and diff_line.new > self.DIFF_CONTEXT_BEFORE) or
588 589 (diff_line.old and diff_line.old > self.DIFF_CONTEXT_BEFORE))
589 590 return should_relocate
590 591
591 592 def _choose_closest_diff_line(self, diff_line, new_diff_lines):
592 593 candidate = new_diff_lines[0]
593 594 best_delta = _diff_line_delta(diff_line, candidate)
594 595 for new_diff_line in new_diff_lines[1:]:
595 596 delta = _diff_line_delta(diff_line, new_diff_line)
596 597 if delta < best_delta:
597 598 candidate = new_diff_line
598 599 best_delta = delta
599 600 return candidate
600 601
601 602 def _visible_inline_comments_of_pull_request(self, pull_request):
602 603 comments = self._all_inline_comments_of_pull_request(pull_request)
603 604 comments = comments.filter(
604 605 coalesce(ChangesetComment.display_state, '') !=
605 606 ChangesetComment.COMMENT_OUTDATED)
606 607 return comments
607 608
608 609 def _all_inline_comments_of_pull_request(self, pull_request):
609 610 comments = Session().query(ChangesetComment)\
610 611 .filter(ChangesetComment.line_no != None)\
611 612 .filter(ChangesetComment.f_path != None)\
612 613 .filter(ChangesetComment.pull_request == pull_request)
613 614 return comments
614 615
615 616 def _all_general_comments_of_pull_request(self, pull_request):
616 617 comments = Session().query(ChangesetComment)\
617 618 .filter(ChangesetComment.line_no == None)\
618 619 .filter(ChangesetComment.f_path == None)\
619 620 .filter(ChangesetComment.pull_request == pull_request)
620 621 return comments
621 622
622 623 @staticmethod
623 624 def use_outdated_comments(pull_request):
624 625 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
625 626 settings = settings_model.get_general_settings()
626 627 return settings.get('rhodecode_use_outdated_comments', False)
627 628
628 629
629 630 def _parse_comment_line_number(line_no):
630 631 """
631 632 Parses line numbers of the form "(o|n)\d+" and returns them in a tuple.
632 633 """
633 634 old_line = None
634 635 new_line = None
635 636 if line_no.startswith('o'):
636 637 old_line = int(line_no[1:])
637 638 elif line_no.startswith('n'):
638 639 new_line = int(line_no[1:])
639 640 else:
640 641 raise ValueError("Comment lines have to start with either 'o' or 'n'.")
641 642 return diffs.DiffLineNumber(old_line, new_line)
642 643
643 644
644 645 def _diff_to_comment_line_number(diff_line):
645 646 if diff_line.new is not None:
646 647 return u'n{}'.format(diff_line.new)
647 648 elif diff_line.old is not None:
648 649 return u'o{}'.format(diff_line.old)
649 650 return u''
650 651
651 652
652 653 def _diff_line_delta(a, b):
653 654 if None not in (a.new, b.new):
654 655 return abs(a.new - b.new)
655 656 elif None not in (a.old, b.old):
656 657 return abs(a.old - b.old)
657 658 else:
658 659 raise ValueError(
659 660 "Cannot compute delta between {} and {}".format(a, b))
General Comments 0
You need to be logged in to leave comments. Login now