comment.py
551 lines
| 20.6 KiB
| text/x-python
|
PythonLexer
r1 | # -*- coding: utf-8 -*- | |||
r1271 | # Copyright (C) 2011-2017 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 | ||||
""" | ||||
import logging | ||||
import traceback | ||||
import collections | ||||
r526 | from datetime import datetime | |||
from pylons.i18n.translation import _ | ||||
from pyramid.threadlocal import get_current_registry | ||||
r1 | from sqlalchemy.sql.expression import null | |||
from sqlalchemy.sql.functions import coalesce | ||||
from rhodecode.lib import helpers as h, diffs | ||||
r526 | from rhodecode.lib.channelstream import channelstream_request | |||
r1 | from rhodecode.lib.utils import action_logger | |||
from rhodecode.lib.utils2 import extract_mentioned_users | ||||
from rhodecode.model import BaseModel | ||||
from rhodecode.model.db import ( | ||||
ChangesetComment, User, Notification, PullRequest) | ||||
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 | ||||
def _get_renderer(self, global_renderer='rst'): | ||||
try: | ||||
# try reading from visual context | ||||
from pylons import tmpl_context | ||||
global_renderer = tmpl_context.visual.default_renderer | ||||
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 | ||||
r1322 | def create(self, text, repo, user, commit_id=None, pull_request=None, | |||
r1325 | f_path=None, line_no=None, status_change=None, | |||
status_change_type=None, comment_type=None, | ||||
resolves_comment_id=None, closing_pr=False, send_email=True, | ||||
renderer=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 | |||
r548 | :param status_change_type: type of status change | |||
r1 | :param closing_pr: | |||
:param send_email: | ||||
r1322 | :param renderer: pick renderer for this comment | |||
r1 | """ | |||
if not text: | ||||
log.warning('Missing text for comment, skipping...') | ||||
return | ||||
if not renderer: | ||||
renderer = self._get_renderer() | ||||
r1325 | repo = self._get_repo(repo) | |||
user = self._get_user(user) | ||||
r1324 | ||||
schema = comment_schema.CommentSchema() | ||||
validated_kwargs = schema.deserialize(dict( | ||||
comment_body=text, | ||||
comment_type=comment_type, | ||||
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 | )) | |||
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'] | ||||
r1 | comment.repo = repo | |||
comment.author = user | ||||
r1325 | comment.resolved_comment = self.__get_commit_comment( | |||
validated_kwargs['resolves_comment_id']) | ||||
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() | ||||
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, | ||||
} | ||||
r1 | ||||
r526 | if commit_obj: | |||
recipients = ChangesetComment.get_users( | ||||
revision=commit_obj.raw_id) | ||||
# 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 | ||||
r526 | commit_comment_url = self.get_url(comment) | |||
r1 | ||||
r526 | target_repo_url = h.link_to( | |||
repo.repo_name, | ||||
h.url('summary_home', | ||||
repo_name=repo.repo_name, qualified=True)) | ||||
r1 | ||||
r526 | # commit specifics | |||
kwargs.update({ | ||||
'commit': commit_obj, | ||||
'commit_message': commit_obj.message, | ||||
'commit_target_repo': target_repo_url, | ||||
'commit_comment_url': commit_comment_url, | ||||
}) | ||||
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 | |||
recipients += [x.user for x in pull_request_obj.reviewers] | ||||
r1 | ||||
r526 | pr_target_repo = pull_request_obj.target_repo | |||
pr_source_repo = pull_request_obj.source_repo | ||||
r1 | ||||
r526 | pr_comment_url = h.url( | |||
'pullrequest_show', | ||||
repo_name=pr_target_repo.repo_name, | ||||
pull_request_id=pull_request_obj.pull_request_id, | ||||
anchor='comment-%s' % comment.comment_id, | ||||
qualified=True,) | ||||
r1 | ||||
r526 | # set some variables for email notification | |||
pr_target_repo_url = h.url( | ||||
'summary_home', repo_name=pr_target_repo.repo_name, | ||||
qualified=True) | ||||
r1 | ||||
r526 | pr_source_repo_url = h.url( | |||
'summary_home', repo_name=pr_source_repo.repo_name, | ||||
qualified=True) | ||||
r1 | ||||
r526 | # pull request specifics | |||
kwargs.update({ | ||||
'pull_request': pull_request_obj, | ||||
'pr_id': pull_request_obj.pull_request_id, | ||||
'pr_target_repo': pr_target_repo, | ||||
'pr_target_repo_url': pr_target_repo_url, | ||||
'pr_source_repo': pr_source_repo, | ||||
'pr_source_repo_url': pr_source_repo_url, | ||||
'pr_comment_url': pr_comment_url, | ||||
'pr_closing': closing_pr, | ||||
}) | ||||
if send_email: | ||||
r1 | # pre-generate the subject for notification itself | |||
(subject, | ||||
_h, _e, # we don't care about those | ||||
body_plaintext) = EmailNotificationModel().render_email( | ||||
notification_type, **kwargs) | ||||
mention_recipients = set( | ||||
self._extract_mentions(text)).difference(recipients) | ||||
# create notification objects, and emails | ||||
NotificationModel().create( | ||||
created_by=user, | ||||
notification_subject=subject, | ||||
notification_body=body_plaintext, | ||||
notification_type=notification_type, | ||||
recipients=recipients, | ||||
mention_recipients=mention_recipients, | ||||
email_kwargs=kwargs, | ||||
) | ||||
action = ( | ||||
'user_commented_pull_request:{}'.format( | ||||
comment.pull_request.pull_request_id) | ||||
if comment.pull_request | ||||
else 'user_commented_revision:{}'.format(comment.revision) | ||||
) | ||||
action_logger(user, action, comment.repo) | ||||
r526 | registry = get_current_registry() | |||
rhodecode_plugins = getattr(registry, 'rhodecode_plugins', {}) | ||||
r546 | channelstream_config = rhodecode_plugins.get('channelstream', {}) | |||
r526 | msg_url = '' | |||
if commit_obj: | ||||
msg_url = commit_comment_url | ||||
repo_name = repo.repo_name | ||||
elif pull_request_obj: | ||||
msg_url = pr_comment_url | ||||
repo_name = pr_target_repo.repo_name | ||||
r546 | if channelstream_config.get('enabled'): | |||
r526 | message = '<strong>{}</strong> {} - ' \ | |||
'<a onclick="window.location=\'{}\';' \ | ||||
'window.location.reload()">' \ | ||||
'<strong>{}</strong></a>' | ||||
message = message.format( | ||||
user.username, _('made a comment'), msg_url, | ||||
r551 | _('Show it now')) | |||
r546 | channel = '/repo${}$/pr/{}'.format( | |||
repo_name, | ||||
pull_request_id | ||||
) | ||||
payload = { | ||||
'type': 'message', | ||||
'timestamp': datetime.utcnow(), | ||||
'user': 'system', | ||||
'exclude_users': [user.username], | ||||
'channel': channel, | ||||
'message': { | ||||
'message': message, | ||||
'level': 'info', | ||||
'topic': '/notifications' | ||||
r526 | } | |||
r546 | } | |||
channelstream_request(channelstream_config, [payload], | ||||
'/message', raise_exc=False) | ||||
r526 | ||||
r1 | return comment | |||
def delete(self, comment): | ||||
""" | ||||
Deletes given comment | ||||
:param comment_id: | ||||
""" | ||||
comment = self.__get_commit_comment(comment) | ||||
Session().delete(comment) | ||||
return comment | ||||
def get_all_comments(self, repo_id, revision=None, pull_request=None): | ||||
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) | ||||
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() | ||||
r443 | def get_url(self, comment): | |||
comment = self.__get_commit_comment(comment) | ||||
if comment.pull_request: | ||||
return h.url( | ||||
'pullrequest_show', | ||||
repo_name=comment.pull_request.target_repo.repo_name, | ||||
pull_request_id=comment.pull_request.pull_request_id, | ||||
anchor='comment-%s' % comment.comment_id, | ||||
qualified=True,) | ||||
else: | ||||
return h.url( | ||||
'changeset_home', | ||||
repo_name=comment.repo.repo_name, | ||||
revision=comment.revision, | ||||
anchor='comment-%s' % comment.comment_id, | ||||
qualified=True,) | ||||
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)\ | ||||
.filter(ChangesetComment.line_no == None)\ | ||||
.filter(ChangesetComment.f_path == None) | ||||
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) | ||||
r1206 | def get_inline_comments_count(self, inline_comments, skip_outdated=True, | |||
r1268 | version=None, include_aggregates=False): | |||
version_aggregates = collections.defaultdict(list) | ||||
r1206 | inline_cnt = 0 | |||
for fname, per_line_comments in inline_comments.iteritems(): | ||||
for lno, comments in per_line_comments.iteritems(): | ||||
r1268 | for comm in comments: | |||
version_aggregates[comm.pull_request_version_id].append(comm) | ||||
if not comm.outdated_at_version(version) and skip_outdated: | ||||
inline_cnt += 1 | ||||
if include_aggregates: | ||||
return inline_cnt, version_aggregates | ||||
r1206 | return inline_cnt | |||
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): | ||||
comment.display_state = ChangesetComment.COMMENT_OUTDATED | ||||
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) | ||||
if not new_diff_lines: | ||||
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: | ||||
comment.display_state = ChangesetComment.COMMENT_OUTDATED | ||||
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)\ | ||||
.filter(ChangesetComment.line_no != None)\ | ||||
.filter(ChangesetComment.f_path != None)\ | ||||
.filter(ChangesetComment.pull_request == pull_request) | ||||
return comments | ||||
@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) | ||||
def _parse_comment_line_number(line_no): | ||||
""" | ||||
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: | ||||
return u'n{}'.format(diff_line.new) | ||||
elif diff_line.old is not None: | ||||
return u'o{}'.format(diff_line.old) | ||||
return u'' | ||||
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( | ||||
"Cannot compute delta between {} and {}".format(a, b)) | ||||