comment.py
849 lines
| 31.8 KiB
| text/x-python
|
PythonLexer
r5608 | # Copyright (C) 2011-2024 RhodeCode GmbH | |||
r1 | # | |||
# This program is free software: you can redistribute it and/or modify | ||||
# it under the terms of the GNU Affero General Public License, version 3 | ||||
# (only), as published by the Free Software Foundation. | ||||
# | ||||
# This program is distributed in the hope that it will be useful, | ||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||||
# GNU General Public License for more details. | ||||
# | ||||
# You should have received a copy of the GNU Affero General Public License | ||||
# along with this program. If not, see <http://www.gnu.org/licenses/>. | ||||
# | ||||
# This program is dual-licensed. If you wish to learn more about the | ||||
# RhodeCode Enterprise Edition, including its added features, Support services, | ||||
# and proprietary license terms, please see https://rhodecode.com/licenses/ | ||||
""" | ||||
comments model for RhodeCode | ||||
""" | ||||
r4407 | import datetime | |||
r1 | ||||
import logging | ||||
import traceback | ||||
import collections | ||||
r5659 | from pyramid.threadlocal import get_current_registry | |||
r1 | from sqlalchemy.sql.expression import null | |||
from sqlalchemy.sql.functions import coalesce | ||||
r4305 | from rhodecode.lib import helpers as h, diffs, channelstream, hooks_utils | |||
r1807 | from rhodecode.lib import audit_logger | |||
r5659 | from rhodecode.lib.pyramid_utils import get_current_request | |||
r4408 | from rhodecode.lib.exceptions import CommentVersionMismatch | |||
r4443 | from rhodecode.lib.utils2 import extract_mentioned_users, safe_str, safe_int | |||
r1 | from rhodecode.model import BaseModel | |||
from rhodecode.model.db import ( | ||||
r4562 | false, true, | |||
r4401 | ChangesetComment, | |||
User, | ||||
Notification, | ||||
PullRequest, | ||||
AttributeDict, | ||||
ChangesetCommentHistory, | ||||
) | ||||
r1 | from rhodecode.model.notification import NotificationModel | |||
from rhodecode.model.meta import Session | ||||
from rhodecode.model.settings import VcsSettingsModel | ||||
from rhodecode.model.notification import EmailNotificationModel | ||||
r1324 | from rhodecode.model.validation_schema.schemas import comment_schema | |||
r1 | ||||
log = logging.getLogger(__name__) | ||||
r1323 | class CommentsModel(BaseModel): | |||
r1 | ||||
cls = ChangesetComment | ||||
DIFF_CONTEXT_BEFORE = 3 | ||||
DIFF_CONTEXT_AFTER = 3 | ||||
def __get_commit_comment(self, changeset_comment): | ||||
return self._get_instance(ChangesetComment, changeset_comment) | ||||
def __get_pull_request(self, pull_request): | ||||
return self._get_instance(PullRequest, pull_request) | ||||
def _extract_mentions(self, s): | ||||
user_objects = [] | ||||
for username in extract_mentioned_users(s): | ||||
user_obj = User.get_by_username(username, case_insensitive=True) | ||||
if user_obj: | ||||
user_objects.append(user_obj) | ||||
return user_objects | ||||
r2351 | def _get_renderer(self, global_renderer='rst', request=None): | |||
request = request or get_current_request() | ||||
r2005 | ||||
r1 | try: | |||
r2005 | global_renderer = request.call_context.visual.default_renderer | |||
r1 | except AttributeError: | |||
log.debug("Renderer not set, falling back " | ||||
"to default renderer '%s'", global_renderer) | ||||
except Exception: | ||||
log.error(traceback.format_exc()) | ||||
return global_renderer | ||||
r1332 | def aggregate_comments(self, comments, versions, show_version, inline=False): | |||
# group by versions, and count until, and display objects | ||||
comment_groups = collections.defaultdict(list) | ||||
r4482 | [comment_groups[_co.pull_request_version_id].append(_co) for _co in comments] | |||
r1332 | ||||
def yield_comments(pos): | ||||
r5096 | yield from comment_groups[pos] | |||
r1332 | ||||
comment_versions = collections.defaultdict( | ||||
lambda: collections.defaultdict(list)) | ||||
prev_prvid = -1 | ||||
# fake last entry with None, to aggregate on "latest" version which | ||||
# doesn't have an pull_request_version_id | ||||
for ver in versions + [AttributeDict({'pull_request_version_id': None})]: | ||||
prvid = ver.pull_request_version_id | ||||
if prev_prvid == -1: | ||||
prev_prvid = prvid | ||||
for co in yield_comments(prvid): | ||||
comment_versions[prvid]['at'].append(co) | ||||
# save until | ||||
current = comment_versions[prvid]['at'] | ||||
prev_until = comment_versions[prev_prvid]['until'] | ||||
cur_until = prev_until + current | ||||
comment_versions[prvid]['until'].extend(cur_until) | ||||
# save outdated | ||||
if inline: | ||||
outdated = [x for x in cur_until | ||||
if x.outdated_at_version(show_version)] | ||||
else: | ||||
outdated = [x for x in cur_until | ||||
if x.older_than_version(show_version)] | ||||
display = [x for x in cur_until if x not in outdated] | ||||
comment_versions[prvid]['outdated'] = outdated | ||||
comment_versions[prvid]['display'] = display | ||||
prev_prvid = prvid | ||||
return comment_versions | ||||
r3435 | def get_repository_comments(self, repo, comment_type=None, user=None, commit_id=None): | |||
qry = Session().query(ChangesetComment) \ | ||||
.filter(ChangesetComment.repo == repo) | ||||
if comment_type and comment_type in ChangesetComment.COMMENT_TYPES: | ||||
qry = qry.filter(ChangesetComment.comment_type == comment_type) | ||||
if user: | ||||
user = self._get_user(user) | ||||
if user: | ||||
qry = qry.filter(ChangesetComment.user_id == user.user_id) | ||||
if commit_id: | ||||
qry = qry.filter(ChangesetComment.revision == commit_id) | ||||
qry = qry.order_by(ChangesetComment.created_on) | ||||
return qry.all() | ||||
r3433 | def get_repository_unresolved_todos(self, repo): | |||
todos = Session().query(ChangesetComment) \ | ||||
.filter(ChangesetComment.repo == repo) \ | ||||
r5180 | .filter(ChangesetComment.resolved_by == null()) \ | |||
r3433 | .filter(ChangesetComment.comment_type | |||
== ChangesetComment.COMMENT_TYPE_TODO) | ||||
todos = todos.all() | ||||
return todos | ||||
r4540 | def get_pull_request_unresolved_todos(self, pull_request, show_outdated=True, include_drafts=True): | |||
r1334 | ||||
todos = Session().query(ChangesetComment) \ | ||||
.filter(ChangesetComment.pull_request == pull_request) \ | ||||
r5180 | .filter(ChangesetComment.resolved_by == null()) \ | |||
r1334 | .filter(ChangesetComment.comment_type | |||
r1342 | == ChangesetComment.COMMENT_TYPE_TODO) | |||
r4540 | if not include_drafts: | |||
todos = todos.filter(ChangesetComment.draft == false()) | ||||
r1342 | if not show_outdated: | |||
todos = todos.filter( | ||||
coalesce(ChangesetComment.display_state, '') != | ||||
ChangesetComment.COMMENT_OUTDATED) | ||||
todos = todos.all() | ||||
r1334 | ||||
return todos | ||||
r4540 | def get_pull_request_resolved_todos(self, pull_request, show_outdated=True, include_drafts=True): | |||
r3884 | ||||
todos = Session().query(ChangesetComment) \ | ||||
.filter(ChangesetComment.pull_request == pull_request) \ | ||||
.filter(ChangesetComment.resolved_by != None) \ | ||||
.filter(ChangesetComment.comment_type | ||||
== ChangesetComment.COMMENT_TYPE_TODO) | ||||
r4540 | if not include_drafts: | |||
todos = todos.filter(ChangesetComment.draft == false()) | ||||
r3884 | if not show_outdated: | |||
todos = todos.filter( | ||||
coalesce(ChangesetComment.display_state, '') != | ||||
ChangesetComment.COMMENT_OUTDATED) | ||||
todos = todos.all() | ||||
return todos | ||||
r4562 | def get_pull_request_drafts(self, user_id, pull_request): | |||
drafts = Session().query(ChangesetComment) \ | ||||
.filter(ChangesetComment.pull_request == pull_request) \ | ||||
.filter(ChangesetComment.user_id == user_id) \ | ||||
.filter(ChangesetComment.draft == true()) | ||||
return drafts.all() | ||||
r4540 | def get_commit_unresolved_todos(self, commit_id, show_outdated=True, include_drafts=True): | |||
r1385 | ||||
todos = Session().query(ChangesetComment) \ | ||||
.filter(ChangesetComment.revision == commit_id) \ | ||||
r5180 | .filter(ChangesetComment.resolved_by == null()) \ | |||
r1385 | .filter(ChangesetComment.comment_type | |||
== ChangesetComment.COMMENT_TYPE_TODO) | ||||
r4540 | if not include_drafts: | |||
todos = todos.filter(ChangesetComment.draft == false()) | ||||
r1385 | if not show_outdated: | |||
todos = todos.filter( | ||||
coalesce(ChangesetComment.display_state, '') != | ||||
ChangesetComment.COMMENT_OUTDATED) | ||||
todos = todos.all() | ||||
return todos | ||||
r4540 | def get_commit_resolved_todos(self, commit_id, show_outdated=True, include_drafts=True): | |||
r3882 | ||||
todos = Session().query(ChangesetComment) \ | ||||
.filter(ChangesetComment.revision == commit_id) \ | ||||
.filter(ChangesetComment.resolved_by != None) \ | ||||
.filter(ChangesetComment.comment_type | ||||
== ChangesetComment.COMMENT_TYPE_TODO) | ||||
r4540 | if not include_drafts: | |||
todos = todos.filter(ChangesetComment.draft == false()) | ||||
r3882 | if not show_outdated: | |||
todos = todos.filter( | ||||
coalesce(ChangesetComment.display_state, '') != | ||||
ChangesetComment.COMMENT_OUTDATED) | ||||
todos = todos.all() | ||||
return todos | ||||
r4540 | def get_commit_inline_comments(self, commit_id, include_drafts=True): | |||
r4485 | inline_comments = Session().query(ChangesetComment) \ | |||
.filter(ChangesetComment.line_no != None) \ | ||||
.filter(ChangesetComment.f_path != None) \ | ||||
.filter(ChangesetComment.revision == commit_id) | ||||
r4540 | ||||
if not include_drafts: | ||||
inline_comments = inline_comments.filter(ChangesetComment.draft == false()) | ||||
r4485 | inline_comments = inline_comments.all() | |||
return inline_comments | ||||
r2728 | def _log_audit_action(self, action, action_data, auth_user, comment): | |||
r1807 | audit_logger.store( | |||
action=action, | ||||
action_data=action_data, | ||||
r2728 | user=auth_user, | |||
r1807 | repo=comment.repo) | |||
r1322 | def create(self, text, repo, user, commit_id=None, pull_request=None, | |||
r1325 | f_path=None, line_no=None, status_change=None, | |||
r4540 | status_change_type=None, comment_type=None, is_draft=False, | |||
r1325 | resolves_comment_id=None, closing_pr=False, send_email=True, | |||
r4049 | renderer=None, auth_user=None, extra_recipients=None): | |||
r1 | """ | |||
Creates new comment for commit or pull request. | ||||
IF status_change is not none this comment is associated with a | ||||
status change of commit or commit associated with pull request | ||||
:param text: | ||||
:param repo: | ||||
:param user: | ||||
r1322 | :param commit_id: | |||
r1 | :param pull_request: | |||
:param f_path: | ||||
:param line_no: | ||||
r548 | :param status_change: Label for status change | |||
r1322 | :param comment_type: Type of comment | |||
r4540 | :param is_draft: is comment a draft only | |||
r4049 | :param resolves_comment_id: id of comment which this one will resolve | |||
r548 | :param status_change_type: type of status change | |||
r1 | :param closing_pr: | |||
:param send_email: | ||||
r1322 | :param renderer: pick renderer for this comment | |||
r4049 | :param auth_user: current authenticated user calling this method | |||
:param extra_recipients: list of extra users to be added to recipients | ||||
r1 | """ | |||
r2728 | ||||
r2351 | request = get_current_request() | |||
_ = request.translate | ||||
r1 | ||||
if not renderer: | ||||
r2351 | renderer = self._get_renderer(request=request) | |||
r1 | ||||
r1325 | repo = self._get_repo(repo) | |||
user = self._get_user(user) | ||||
r3026 | auth_user = auth_user or user | |||
r1324 | ||||
schema = comment_schema.CommentSchema() | ||||
validated_kwargs = schema.deserialize(dict( | ||||
comment_body=text, | ||||
comment_type=comment_type, | ||||
r4540 | is_draft=is_draft, | |||
r1324 | comment_file=f_path, | |||
comment_line=line_no, | ||||
renderer_type=renderer, | ||||
r1325 | status_change=status_change_type, | |||
resolves_comment_id=resolves_comment_id, | ||||
repo=repo.repo_id, | ||||
user=user.user_id, | ||||
r1324 | )) | |||
r5071 | ||||
r4540 | is_draft = validated_kwargs['is_draft'] | |||
r1324 | ||||
r1 | comment = ChangesetComment() | |||
r1324 | comment.renderer = validated_kwargs['renderer_type'] | |||
comment.text = validated_kwargs['comment_body'] | ||||
comment.f_path = validated_kwargs['comment_file'] | ||||
comment.line_no = validated_kwargs['comment_line'] | ||||
comment.comment_type = validated_kwargs['comment_type'] | ||||
r4540 | comment.draft = is_draft | |||
r1324 | ||||
r1 | comment.repo = repo | |||
comment.author = user | ||||
r2441 | resolved_comment = self.__get_commit_comment( | |||
r1325 | validated_kwargs['resolves_comment_id']) | |||
r4633 | ||||
r2441 | # check if the comment actually belongs to this PR | |||
if resolved_comment and resolved_comment.pull_request and \ | ||||
resolved_comment.pull_request != pull_request: | ||||
r3546 | log.warning('Comment tried to resolved unrelated todo comment: %s', | |||
resolved_comment) | ||||
r2441 | # comment not bound to this pull request, forbid | |||
resolved_comment = None | ||||
r3546 | ||||
elif resolved_comment and resolved_comment.repo and \ | ||||
resolved_comment.repo != repo: | ||||
log.warning('Comment tried to resolved unrelated todo comment: %s', | ||||
resolved_comment) | ||||
# comment not bound to this repo, forbid | ||||
resolved_comment = None | ||||
r4633 | if resolved_comment and resolved_comment.resolved_by: | |||
# if this comment is already resolved, don't mark it again! | ||||
resolved_comment = None | ||||
r2441 | comment.resolved_comment = resolved_comment | |||
r1 | ||||
pull_request_id = pull_request | ||||
commit_obj = None | ||||
pull_request_obj = None | ||||
if commit_id: | ||||
notification_type = EmailNotificationModel.TYPE_COMMIT_COMMENT | ||||
# do a lookup, so we don't pass something bad here | ||||
commit_obj = repo.scm_instance().get_commit(commit_id=commit_id) | ||||
comment.revision = commit_obj.raw_id | ||||
elif pull_request_id: | ||||
notification_type = EmailNotificationModel.TYPE_PULL_REQUEST_COMMENT | ||||
pull_request_obj = self.__get_pull_request(pull_request_id) | ||||
comment.pull_request = pull_request_obj | ||||
else: | ||||
raise Exception('Please specify commit or pull_request_id') | ||||
Session().add(comment) | ||||
Session().flush() | ||||
r5607 | ||||
r526 | kwargs = { | |||
'user': user, | ||||
'renderer_type': renderer, | ||||
'repo_name': repo.repo_name, | ||||
'status_change': status_change, | ||||
r548 | 'status_change_type': status_change_type, | |||
r526 | 'comment_body': text, | |||
'comment_file': f_path, | ||||
'comment_line': line_no, | ||||
r4050 | 'comment_type': comment_type or 'note', | |||
'comment_id': comment.comment_id | ||||
r526 | } | |||
r1 | ||||
r526 | if commit_obj: | |||
r5607 | recipients = ChangesetComment.get_users(revision=commit_obj.raw_id) | |||
r526 | # add commit author if it's in RhodeCode system | |||
cs_author = User.get_from_cs_author(commit_obj.author) | ||||
if not cs_author: | ||||
# use repo owner if we cannot extract the author correctly | ||||
cs_author = repo.user | ||||
recipients += [cs_author] | ||||
r1 | ||||
r2351 | commit_comment_url = self.get_url(comment, request=request) | |||
r5607 | commit_comment_reply_url = self.get_url(comment, request=request, anchor=f'comment-{comment.comment_id}/?/ReplyToComment') | |||
r1 | ||||
r526 | target_repo_url = h.link_to( | |||
repo.repo_name, | ||||
r1785 | h.route_url('repo_summary', repo_name=repo.repo_name)) | |||
r1 | ||||
r5607 | commit_url = h.route_url('repo_commit', repo_name=repo.repo_name, commit_id=commit_id) | |||
r4447 | ||||
r526 | # commit specifics | |||
kwargs.update({ | ||||
'commit': commit_obj, | ||||
'commit_message': commit_obj.message, | ||||
r4038 | 'commit_target_repo_url': target_repo_url, | |||
r526 | 'commit_comment_url': commit_comment_url, | |||
r4447 | 'commit_comment_reply_url': commit_comment_reply_url, | |||
'commit_url': commit_url, | ||||
'thread_ids': [commit_url, commit_comment_url], | ||||
r526 | }) | |||
r1 | ||||
r526 | elif pull_request_obj: | |||
# get the current participants of this pull request | ||||
recipients = ChangesetComment.get_users( | ||||
pull_request_id=pull_request_obj.pull_request_id) | ||||
# add pull request author | ||||
recipients += [pull_request_obj.author] | ||||
r1 | ||||
r526 | # add the reviewers to notification | |||
r4514 | recipients += [x.user for x in pull_request_obj.get_pull_request_reviewers()] | |||
r1 | ||||
r526 | pr_target_repo = pull_request_obj.target_repo | |||
pr_source_repo = pull_request_obj.source_repo | ||||
r1 | ||||
r4050 | pr_comment_url = self.get_url(comment, request=request) | |||
pr_comment_reply_url = self.get_url( | ||||
comment, request=request, | ||||
r5096 | anchor=f'comment-{comment.comment_id}/?/ReplyToComment') | |||
r1 | ||||
r4038 | pr_url = h.route_url( | |||
'pullrequest_show', | ||||
repo_name=pr_target_repo.repo_name, | ||||
pull_request_id=pull_request_obj.pull_request_id, ) | ||||
r526 | # set some variables for email notification | |||
r1785 | pr_target_repo_url = h.route_url( | |||
'repo_summary', repo_name=pr_target_repo.repo_name) | ||||
r1 | ||||
r1785 | pr_source_repo_url = h.route_url( | |||
'repo_summary', repo_name=pr_source_repo.repo_name) | ||||
r1 | ||||
r526 | # pull request specifics | |||
kwargs.update({ | ||||
'pull_request': pull_request_obj, | ||||
'pr_id': pull_request_obj.pull_request_id, | ||||
r4038 | 'pull_request_url': pr_url, | |||
'pull_request_target_repo': pr_target_repo, | ||||
'pull_request_target_repo_url': pr_target_repo_url, | ||||
'pull_request_source_repo': pr_source_repo, | ||||
'pull_request_source_repo_url': pr_source_repo_url, | ||||
r526 | 'pr_comment_url': pr_comment_url, | |||
r4050 | 'pr_comment_reply_url': pr_comment_reply_url, | |||
r526 | 'pr_closing': closing_pr, | |||
r4447 | 'thread_ids': [pr_url, pr_comment_url], | |||
r526 | }) | |||
r4049 | ||||
r526 | if send_email: | |||
r4500 | recipients += [self._get_user(u) for u in (extra_recipients or [])] | |||
r1 | ||||
mention_recipients = set( | ||||
self._extract_mentions(text)).difference(recipients) | ||||
# create notification objects, and emails | ||||
NotificationModel().create( | ||||
created_by=user, | ||||
r4560 | notification_subject='', # Filled in based on the notification_type | |||
notification_body='', # Filled in based on the notification_type | ||||
r1 | notification_type=notification_type, | |||
recipients=recipients, | ||||
mention_recipients=mention_recipients, | ||||
email_kwargs=kwargs, | ||||
) | ||||
r1807 | Session().flush() | |||
if comment.pull_request: | ||||
action = 'repo.pull_request.comment.create' | ||||
else: | ||||
action = 'repo.commit.comment.create' | ||||
r4540 | if not is_draft: | |||
comment_data = comment.get_api_data() | ||||
self._log_audit_action( | ||||
action, {'data': comment_data}, auth_user, comment) | ||||
r1 | ||||
return comment | ||||
r4401 | def edit(self, comment_id, text, auth_user, version): | |||
""" | ||||
Change existing comment for commit or pull request. | ||||
:param comment_id: | ||||
:param text: | ||||
:param auth_user: current authenticated user calling this method | ||||
:param version: last comment version | ||||
""" | ||||
if not text: | ||||
log.warning('Missing text for comment, skipping...') | ||||
return | ||||
comment = ChangesetComment.get(comment_id) | ||||
old_comment_text = comment.text | ||||
comment.text = text | ||||
r4407 | comment.modified_at = datetime.datetime.now() | |||
r4443 | version = safe_int(version) | |||
r4407 | ||||
r4443 | # NOTE(marcink): this returns initial comment + edits, so v2 from ui | |||
# would return 3 here | ||||
r4401 | comment_version = ChangesetCommentHistory.get_version(comment_id) | |||
r4443 | ||||
r4935 | if isinstance(version, int) and (comment_version - version) != 1: | |||
r4401 | log.warning( | |||
r4408 | 'Version mismatch comment_version {} submitted {}, skipping'.format( | |||
r4443 | comment_version-1, # -1 since note above | |||
r4408 | version | |||
r4401 | ) | |||
) | ||||
r4408 | raise CommentVersionMismatch() | |||
r4401 | comment_history = ChangesetCommentHistory() | |||
comment_history.comment_id = comment_id | ||||
comment_history.version = comment_version | ||||
comment_history.created_by_user_id = auth_user.user_id | ||||
comment_history.text = old_comment_text | ||||
# TODO add email notification | ||||
Session().add(comment_history) | ||||
Session().add(comment) | ||||
Session().flush() | ||||
if comment.pull_request: | ||||
action = 'repo.pull_request.comment.edit' | ||||
else: | ||||
action = 'repo.commit.comment.edit' | ||||
comment_data = comment.get_api_data() | ||||
comment_data['old_comment_text'] = old_comment_text | ||||
self._log_audit_action( | ||||
action, {'data': comment_data}, auth_user, comment) | ||||
return comment_history | ||||
r2728 | def delete(self, comment, auth_user): | |||
r1 | """ | |||
Deletes given comment | ||||
""" | ||||
comment = self.__get_commit_comment(comment) | ||||
r1807 | old_data = comment.get_api_data() | |||
r1 | Session().delete(comment) | |||
r1807 | if comment.pull_request: | |||
action = 'repo.pull_request.comment.delete' | ||||
else: | ||||
action = 'repo.commit.comment.delete' | ||||
self._log_audit_action( | ||||
r2728 | action, {'old_data': old_data}, auth_user, comment) | |||
r1807 | ||||
r1 | return comment | |||
r4553 | def get_all_comments(self, repo_id, revision=None, pull_request=None, | |||
include_drafts=True, count_only=False): | ||||
r1 | q = ChangesetComment.query()\ | |||
.filter(ChangesetComment.repo_id == repo_id) | ||||
if revision: | ||||
q = q.filter(ChangesetComment.revision == revision) | ||||
elif pull_request: | ||||
pull_request = self.__get_pull_request(pull_request) | ||||
r4506 | q = q.filter(ChangesetComment.pull_request_id == pull_request.pull_request_id) | |||
r1 | else: | |||
raise Exception('Please specify commit or pull_request') | ||||
r4553 | if not include_drafts: | |||
q = q.filter(ChangesetComment.draft == false()) | ||||
r1 | q = q.order_by(ChangesetComment.created_on) | |||
r4506 | if count_only: | |||
return q.count() | ||||
r1 | return q.all() | |||
r4050 | def get_url(self, comment, request=None, permalink=False, anchor=None): | |||
r1788 | if not request: | |||
request = get_current_request() | ||||
r443 | comment = self.__get_commit_comment(comment) | |||
r4050 | if anchor is None: | |||
r5096 | anchor = f'comment-{comment.comment_id}' | |||
r4050 | ||||
r443 | if comment.pull_request: | |||
r1788 | pull_request = comment.pull_request | |||
if permalink: | ||||
return request.route_url( | ||||
'pull_requests_global', | ||||
pull_request_id=pull_request.pull_request_id, | ||||
r4050 | _anchor=anchor) | |||
r1788 | else: | |||
r2470 | return request.route_url( | |||
'pullrequest_show', | ||||
r1788 | repo_name=safe_str(pull_request.target_repo.repo_name), | |||
pull_request_id=pull_request.pull_request_id, | ||||
r4050 | _anchor=anchor) | |||
r1788 | ||||
r443 | else: | |||
r1788 | repo = comment.repo | |||
commit_id = comment.revision | ||||
if permalink: | ||||
return request.route_url( | ||||
'repo_commit', repo_name=safe_str(repo.repo_id), | ||||
commit_id=commit_id, | ||||
r4050 | _anchor=anchor) | |||
r1788 | ||||
else: | ||||
return request.route_url( | ||||
'repo_commit', repo_name=safe_str(repo.repo_name), | ||||
commit_id=commit_id, | ||||
r4050 | _anchor=anchor) | |||
r443 | ||||
r1 | def get_comments(self, repo_id, revision=None, pull_request=None): | |||
""" | ||||
Gets main comments based on revision or pull_request_id | ||||
:param repo_id: | ||||
:param revision: | ||||
:param pull_request: | ||||
""" | ||||
q = ChangesetComment.query()\ | ||||
.filter(ChangesetComment.repo_id == repo_id)\ | ||||
r5180 | .filter(ChangesetComment.line_no == null())\ | |||
.filter(ChangesetComment.f_path == null()) | ||||
r1 | if revision: | |||
q = q.filter(ChangesetComment.revision == revision) | ||||
elif pull_request: | ||||
pull_request = self.__get_pull_request(pull_request) | ||||
q = q.filter(ChangesetComment.pull_request == pull_request) | ||||
else: | ||||
raise Exception('Please specify commit or pull_request') | ||||
q = q.order_by(ChangesetComment.created_on) | ||||
return q.all() | ||||
def get_inline_comments(self, repo_id, revision=None, pull_request=None): | ||||
q = self._get_inline_comments_query(repo_id, revision, pull_request) | ||||
return self._group_comments_by_path_and_line_number(q) | ||||
r4481 | def get_inline_comments_as_list(self, inline_comments, skip_outdated=True, | |||
r4482 | version=None): | |||
inline_comms = [] | ||||
r4932 | for fname, per_line_comments in inline_comments.items(): | |||
for lno, comments in per_line_comments.items(): | ||||
r1268 | for comm in comments: | |||
if not comm.outdated_at_version(version) and skip_outdated: | ||||
r4482 | inline_comms.append(comm) | |||
r1268 | ||||
r4482 | return inline_comms | |||
r1206 | ||||
r1 | def get_outdated_comments(self, repo_id, pull_request): | |||
# TODO: johbo: Remove `repo_id`, it is not needed to find the comments | ||||
# of a pull request. | ||||
q = self._all_inline_comments_of_pull_request(pull_request) | ||||
q = q.filter( | ||||
ChangesetComment.display_state == | ||||
ChangesetComment.COMMENT_OUTDATED | ||||
).order_by(ChangesetComment.comment_id.asc()) | ||||
return self._group_comments_by_path_and_line_number(q) | ||||
def _get_inline_comments_query(self, repo_id, revision, pull_request): | ||||
# TODO: johbo: Split this into two methods: One for PR and one for | ||||
# commit. | ||||
if revision: | ||||
q = Session().query(ChangesetComment).filter( | ||||
ChangesetComment.repo_id == repo_id, | ||||
ChangesetComment.line_no != null(), | ||||
ChangesetComment.f_path != null(), | ||||
ChangesetComment.revision == revision) | ||||
elif pull_request: | ||||
pull_request = self.__get_pull_request(pull_request) | ||||
r1323 | if not CommentsModel.use_outdated_comments(pull_request): | |||
r1 | q = self._visible_inline_comments_of_pull_request(pull_request) | |||
else: | ||||
q = self._all_inline_comments_of_pull_request(pull_request) | ||||
else: | ||||
raise Exception('Please specify commit or pull_request_id') | ||||
q = q.order_by(ChangesetComment.comment_id.asc()) | ||||
return q | ||||
def _group_comments_by_path_and_line_number(self, q): | ||||
comments = q.all() | ||||
paths = collections.defaultdict(lambda: collections.defaultdict(list)) | ||||
for co in comments: | ||||
paths[co.f_path][co.line_no].append(co) | ||||
return paths | ||||
@classmethod | ||||
def needed_extra_diff_context(cls): | ||||
return max(cls.DIFF_CONTEXT_BEFORE, cls.DIFF_CONTEXT_AFTER) | ||||
def outdate_comments(self, pull_request, old_diff_data, new_diff_data): | ||||
r1323 | if not CommentsModel.use_outdated_comments(pull_request): | |||
r1 | return | |||
comments = self._visible_inline_comments_of_pull_request(pull_request) | ||||
comments_to_outdate = comments.all() | ||||
for comment in comments_to_outdate: | ||||
self._outdate_one_comment(comment, old_diff_data, new_diff_data) | ||||
def _outdate_one_comment(self, comment, old_diff_proc, new_diff_proc): | ||||
diff_line = _parse_comment_line_number(comment.line_no) | ||||
try: | ||||
old_context = old_diff_proc.get_context_of_line( | ||||
path=comment.f_path, diff_line=diff_line) | ||||
new_context = new_diff_proc.get_context_of_line( | ||||
path=comment.f_path, diff_line=diff_line) | ||||
except (diffs.LineNotInDiffException, | ||||
diffs.FileNotInDiffException): | ||||
r4556 | if not comment.draft: | |||
comment.display_state = ChangesetComment.COMMENT_OUTDATED | ||||
r1 | return | |||
if old_context == new_context: | ||||
return | ||||
if self._should_relocate_diff_line(diff_line): | ||||
new_diff_lines = new_diff_proc.find_context( | ||||
path=comment.f_path, context=old_context, | ||||
offset=self.DIFF_CONTEXT_BEFORE) | ||||
r4556 | if not new_diff_lines and not comment.draft: | |||
r1 | comment.display_state = ChangesetComment.COMMENT_OUTDATED | |||
else: | ||||
new_diff_line = self._choose_closest_diff_line( | ||||
diff_line, new_diff_lines) | ||||
comment.line_no = _diff_to_comment_line_number(new_diff_line) | ||||
else: | ||||
r4556 | if not comment.draft: | |||
comment.display_state = ChangesetComment.COMMENT_OUTDATED | ||||
r1 | ||||
def _should_relocate_diff_line(self, diff_line): | ||||
""" | ||||
Checks if relocation shall be tried for the given `diff_line`. | ||||
If a comment points into the first lines, then we can have a situation | ||||
that after an update another line has been added on top. In this case | ||||
we would find the context still and move the comment around. This | ||||
would be wrong. | ||||
""" | ||||
should_relocate = ( | ||||
(diff_line.new and diff_line.new > self.DIFF_CONTEXT_BEFORE) or | ||||
(diff_line.old and diff_line.old > self.DIFF_CONTEXT_BEFORE)) | ||||
return should_relocate | ||||
def _choose_closest_diff_line(self, diff_line, new_diff_lines): | ||||
candidate = new_diff_lines[0] | ||||
best_delta = _diff_line_delta(diff_line, candidate) | ||||
for new_diff_line in new_diff_lines[1:]: | ||||
delta = _diff_line_delta(diff_line, new_diff_line) | ||||
if delta < best_delta: | ||||
candidate = new_diff_line | ||||
best_delta = delta | ||||
return candidate | ||||
def _visible_inline_comments_of_pull_request(self, pull_request): | ||||
comments = self._all_inline_comments_of_pull_request(pull_request) | ||||
comments = comments.filter( | ||||
coalesce(ChangesetComment.display_state, '') != | ||||
ChangesetComment.COMMENT_OUTDATED) | ||||
return comments | ||||
def _all_inline_comments_of_pull_request(self, pull_request): | ||||
comments = Session().query(ChangesetComment)\ | ||||
r5180 | .filter(ChangesetComment.line_no != null())\ | |||
.filter(ChangesetComment.f_path != null())\ | ||||
r1 | .filter(ChangesetComment.pull_request == pull_request) | |||
return comments | ||||
r1332 | def _all_general_comments_of_pull_request(self, pull_request): | |||
comments = Session().query(ChangesetComment)\ | ||||
r5180 | .filter(ChangesetComment.line_no == null())\ | |||
.filter(ChangesetComment.f_path == null())\ | ||||
r1332 | .filter(ChangesetComment.pull_request == pull_request) | |||
r4401 | ||||
r1332 | return comments | |||
r1 | @staticmethod | |||
def use_outdated_comments(pull_request): | ||||
settings_model = VcsSettingsModel(repo=pull_request.target_repo) | ||||
settings = settings_model.get_general_settings() | ||||
return settings.get('rhodecode_use_outdated_comments', False) | ||||
r4305 | def trigger_commit_comment_hook(self, repo, user, action, data=None): | |||
repo = self._get_repo(repo) | ||||
target_scm = repo.scm_instance() | ||||
if action == 'create': | ||||
trigger_hook = hooks_utils.trigger_comment_commit_hooks | ||||
elif action == 'edit': | ||||
r4444 | trigger_hook = hooks_utils.trigger_comment_commit_edit_hooks | |||
r4305 | else: | |||
return | ||||
log.debug('Handling repo %s trigger_commit_comment_hook with action %s: %s', | ||||
repo, action, trigger_hook) | ||||
trigger_hook( | ||||
username=user.username, | ||||
repo_name=repo.repo_name, | ||||
repo_type=target_scm.alias, | ||||
repo=repo, | ||||
data=data) | ||||
r1 | ||||
def _parse_comment_line_number(line_no): | ||||
r5096 | r""" | |||
r1 | Parses line numbers of the form "(o|n)\d+" and returns them in a tuple. | |||
""" | ||||
old_line = None | ||||
new_line = None | ||||
if line_no.startswith('o'): | ||||
old_line = int(line_no[1:]) | ||||
elif line_no.startswith('n'): | ||||
new_line = int(line_no[1:]) | ||||
else: | ||||
raise ValueError("Comment lines have to start with either 'o' or 'n'.") | ||||
return diffs.DiffLineNumber(old_line, new_line) | ||||
def _diff_to_comment_line_number(diff_line): | ||||
if diff_line.new is not None: | ||||
r5096 | return f'n{diff_line.new}' | |||
r1 | elif diff_line.old is not None: | |||
r5096 | return f'o{diff_line.old}' | |||
return '' | ||||
r1 | ||||
def _diff_line_delta(a, b): | ||||
if None not in (a.new, b.new): | ||||
return abs(a.new - b.new) | ||||
elif None not in (a.old, b.old): | ||||
return abs(a.old - b.old) | ||||
else: | ||||
raise ValueError( | ||||
r5096 | f"Cannot compute delta between {a} and {b}") | |||