repo_commits.py
590 lines
| 22.0 KiB
| text/x-python
|
PythonLexer
r1951 | # -*- coding: utf-8 -*- | |||
r2487 | # Copyright (C) 2010-2018 RhodeCode GmbH | |||
r1951 | # | |||
# 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/ | ||||
import logging | ||||
import collections | ||||
from pyramid.httpexceptions import HTTPNotFound, HTTPBadRequest, HTTPFound | ||||
from pyramid.view import view_config | ||||
from pyramid.renderers import render | ||||
from pyramid.response import Response | ||||
from rhodecode.apps._base import RepoAppView | ||||
from rhodecode.lib import diffs, codeblocks | ||||
from rhodecode.lib.auth import ( | ||||
LoginRequired, HasRepoPermissionAnyDecorator, NotAnonymous, CSRFRequired) | ||||
from rhodecode.lib.compat import OrderedDict | ||||
Bartłomiej Wołyńczyk
|
r2685 | from rhodecode.lib.diffs import cache_diff, load_cached_diff, diff_cache_exist | ||
r1951 | from rhodecode.lib.exceptions import StatusChangeOnClosedPullRequestError | |||
import rhodecode.lib.helpers as h | ||||
Bartłomiej Wołyńczyk
|
r2685 | from rhodecode.lib.utils2 import safe_unicode, str2bool | ||
r1951 | from rhodecode.lib.vcs.backends.base import EmptyCommit | |||
from rhodecode.lib.vcs.exceptions import ( | ||||
Bartłomiej Wołyńczyk
|
r2685 | RepositoryError, CommitDoesNotExistError) | ||
r1951 | from rhodecode.model.db import ChangesetComment, ChangesetStatus | |||
from rhodecode.model.changeset_status import ChangesetStatusModel | ||||
from rhodecode.model.comment import CommentsModel | ||||
from rhodecode.model.meta import Session | ||||
Bartłomiej Wołyńczyk
|
r2685 | from rhodecode.model.settings import VcsSettingsModel | ||
r1951 | ||||
log = logging.getLogger(__name__) | ||||
def _update_with_GET(params, request): | ||||
for k in ['diff1', 'diff2', 'diff']: | ||||
params[k] += request.GET.getall(k) | ||||
def get_ignore_ws(fid, request): | ||||
ig_ws_global = request.GET.get('ignorews') | ||||
ig_ws = filter(lambda k: k.startswith('WS'), request.GET.getall(fid)) | ||||
if ig_ws: | ||||
try: | ||||
return int(ig_ws[0].split(':')[-1]) | ||||
except Exception: | ||||
pass | ||||
return ig_ws_global | ||||
def _ignorews_url(request, fileid=None): | ||||
_ = request.translate | ||||
fileid = str(fileid) if fileid else None | ||||
params = collections.defaultdict(list) | ||||
_update_with_GET(params, request) | ||||
label = _('Show whitespace') | ||||
tooltiplbl = _('Show whitespace for all diffs') | ||||
ig_ws = get_ignore_ws(fileid, request) | ||||
ln_ctx = get_line_ctx(fileid, request) | ||||
if ig_ws is None: | ||||
params['ignorews'] += [1] | ||||
label = _('Ignore whitespace') | ||||
tooltiplbl = _('Ignore whitespace for all diffs') | ||||
ctx_key = 'context' | ||||
ctx_val = ln_ctx | ||||
# if we have passed in ln_ctx pass it along to our params | ||||
if ln_ctx: | ||||
params[ctx_key] += [ctx_val] | ||||
if fileid: | ||||
params['anchor'] = 'a_' + fileid | ||||
return h.link_to(label, request.current_route_path(_query=params), | ||||
title=tooltiplbl, class_='tooltip') | ||||
def get_line_ctx(fid, request): | ||||
ln_ctx_global = request.GET.get('context') | ||||
if fid: | ||||
ln_ctx = filter(lambda k: k.startswith('C'), request.GET.getall(fid)) | ||||
else: | ||||
_ln_ctx = filter(lambda k: k.startswith('C'), request.GET) | ||||
ln_ctx = request.GET.get(_ln_ctx[0]) if _ln_ctx else ln_ctx_global | ||||
if ln_ctx: | ||||
ln_ctx = [ln_ctx] | ||||
if ln_ctx: | ||||
retval = ln_ctx[0].split(':')[-1] | ||||
else: | ||||
retval = ln_ctx_global | ||||
try: | ||||
r3092 | return min(diffs.MAX_CONTEXT, int(retval)) | |||
r1951 | except Exception: | |||
return 3 | ||||
def _context_url(request, fileid=None): | ||||
""" | ||||
Generates a url for context lines. | ||||
:param fileid: | ||||
""" | ||||
_ = request.translate | ||||
fileid = str(fileid) if fileid else None | ||||
ig_ws = get_ignore_ws(fileid, request) | ||||
ln_ctx = (get_line_ctx(fileid, request) or 3) * 2 | ||||
params = collections.defaultdict(list) | ||||
_update_with_GET(params, request) | ||||
if ln_ctx > 0: | ||||
params['context'] += [ln_ctx] | ||||
if ig_ws: | ||||
ig_ws_key = 'ignorews' | ||||
ig_ws_val = 1 | ||||
params[ig_ws_key] += [ig_ws_val] | ||||
lbl = _('Increase context') | ||||
tooltiplbl = _('Increase context for all diffs') | ||||
if fileid: | ||||
params['anchor'] = 'a_' + fileid | ||||
return h.link_to(lbl, request.current_route_path(_query=params), | ||||
title=tooltiplbl, class_='tooltip') | ||||
class RepoCommitsView(RepoAppView): | ||||
def load_default_context(self): | ||||
c = self._get_local_tmpl_context(include_app_defaults=True) | ||||
c.rhodecode_repo = self.rhodecode_vcs_repo | ||||
return c | ||||
Bartłomiej Wołyńczyk
|
r2685 | def _is_diff_cache_enabled(self, target_repo): | ||
caching_enabled = self._get_general_setting( | ||||
target_repo, 'rhodecode_diff_cache') | ||||
log.debug('Diff caching enabled: %s', caching_enabled) | ||||
return caching_enabled | ||||
r1951 | def _commit(self, commit_id_range, method): | |||
_ = self.request.translate | ||||
c = self.load_default_context() | ||||
c.ignorews_url = _ignorews_url | ||||
c.context_url = _context_url | ||||
c.fulldiff = self.request.GET.get('fulldiff') | ||||
# fetch global flags of ignore ws or context lines | ||||
context_lcl = get_line_ctx('', self.request) | ||||
ign_whitespace_lcl = get_ignore_ws('', self.request) | ||||
# diff_limit will cut off the whole diff if the limit is applied | ||||
# otherwise it will just hide the big files from the front-end | ||||
diff_limit = c.visual.cut_off_limit_diff | ||||
file_limit = c.visual.cut_off_limit_file | ||||
# get ranges of commit ids if preset | ||||
commit_range = commit_id_range.split('...')[:2] | ||||
try: | ||||
pre_load = ['affected_files', 'author', 'branch', 'date', | ||||
'message', 'parents'] | ||||
if len(commit_range) == 2: | ||||
commits = self.rhodecode_vcs_repo.get_commits( | ||||
start_id=commit_range[0], end_id=commit_range[1], | ||||
pre_load=pre_load) | ||||
commits = list(commits) | ||||
else: | ||||
commits = [self.rhodecode_vcs_repo.get_commit( | ||||
commit_id=commit_id_range, pre_load=pre_load)] | ||||
c.commit_ranges = commits | ||||
if not c.commit_ranges: | ||||
raise RepositoryError( | ||||
'The commit range returned an empty result') | ||||
except CommitDoesNotExistError: | ||||
msg = _('No such commit exists for this repository') | ||||
h.flash(msg, category='error') | ||||
raise HTTPNotFound() | ||||
except Exception: | ||||
log.exception("General failure") | ||||
raise HTTPNotFound() | ||||
c.changes = OrderedDict() | ||||
c.lines_added = 0 | ||||
c.lines_deleted = 0 | ||||
# auto collapse if we have more than limit | ||||
collapse_limit = diffs.DiffProcessor._collapse_commits_over | ||||
c.collapse_all_commits = len(c.commit_ranges) > collapse_limit | ||||
c.commit_statuses = ChangesetStatus.STATUSES | ||||
c.inline_comments = [] | ||||
c.files = [] | ||||
c.statuses = [] | ||||
c.comments = [] | ||||
c.unresolved_comments = [] | ||||
if len(c.commit_ranges) == 1: | ||||
commit = c.commit_ranges[0] | ||||
c.comments = CommentsModel().get_comments( | ||||
self.db_repo.repo_id, | ||||
revision=commit.raw_id) | ||||
c.statuses.append(ChangesetStatusModel().get_status( | ||||
self.db_repo.repo_id, commit.raw_id)) | ||||
# comments from PR | ||||
statuses = ChangesetStatusModel().get_statuses( | ||||
self.db_repo.repo_id, commit.raw_id, | ||||
with_revisions=True) | ||||
prs = set(st.pull_request for st in statuses | ||||
if st.pull_request is not None) | ||||
# from associated statuses, check the pull requests, and | ||||
# show comments from them | ||||
for pr in prs: | ||||
c.comments.extend(pr.comments) | ||||
c.unresolved_comments = CommentsModel()\ | ||||
.get_commit_unresolved_todos(commit.raw_id) | ||||
diff = None | ||||
# Iterate over ranges (default commit view is always one commit) | ||||
for commit in c.commit_ranges: | ||||
c.changes[commit.raw_id] = [] | ||||
commit2 = commit | ||||
commit1 = commit.parents[0] if commit.parents else EmptyCommit() | ||||
if method == 'show': | ||||
inline_comments = CommentsModel().get_inline_comments( | ||||
self.db_repo.repo_id, revision=commit.raw_id) | ||||
c.inline_cnt = CommentsModel().get_inline_comments_count( | ||||
inline_comments) | ||||
Bartłomiej Wołyńczyk
|
r2685 | c.inline_comments = inline_comments | ||
r1951 | ||||
Bartłomiej Wołyńczyk
|
r2685 | cache_path = self.rhodecode_vcs_repo.get_create_shadow_cache_pr_path( | ||
self.db_repo) | ||||
cache_file_path = diff_cache_exist( | ||||
cache_path, 'diff', commit.raw_id, | ||||
ign_whitespace_lcl, context_lcl, c.fulldiff) | ||||
caching_enabled = self._is_diff_cache_enabled(self.db_repo) | ||||
force_recache = str2bool(self.request.GET.get('force_recache')) | ||||
cached_diff = None | ||||
if caching_enabled: | ||||
cached_diff = load_cached_diff(cache_file_path) | ||||
r1951 | ||||
Bartłomiej Wołyńczyk
|
r2685 | has_proper_diff_cache = cached_diff and cached_diff.get('diff') | ||
if not force_recache and has_proper_diff_cache: | ||||
diffset = cached_diff['diff'] | ||||
else: | ||||
vcs_diff = self.rhodecode_vcs_repo.get_diff( | ||||
commit1, commit2, | ||||
ignore_whitespace=ign_whitespace_lcl, | ||||
context=context_lcl) | ||||
diff_processor = diffs.DiffProcessor( | ||||
vcs_diff, format='newdiff', diff_limit=diff_limit, | ||||
file_limit=file_limit, show_full_diff=c.fulldiff) | ||||
_parsed = diff_processor.prepare() | ||||
diffset = codeblocks.DiffSet( | ||||
repo_name=self.db_repo_name, | ||||
source_node_getter=codeblocks.diffset_node_getter(commit1), | ||||
target_node_getter=codeblocks.diffset_node_getter(commit2)) | ||||
diffset = self.path_filter.render_patchset_filtered( | ||||
diffset, _parsed, commit1.raw_id, commit2.raw_id) | ||||
# save cached diff | ||||
if caching_enabled: | ||||
cache_diff(cache_file_path, diffset, None) | ||||
c.limited_diff = diffset.limited_diff | ||||
r1951 | c.changes[commit.raw_id] = diffset | |||
else: | ||||
Bartłomiej Wołyńczyk
|
r2685 | # TODO(marcink): no cache usage here... | ||
_diff = self.rhodecode_vcs_repo.get_diff( | ||||
commit1, commit2, | ||||
ignore_whitespace=ign_whitespace_lcl, context=context_lcl) | ||||
diff_processor = diffs.DiffProcessor( | ||||
_diff, format='newdiff', diff_limit=diff_limit, | ||||
file_limit=file_limit, show_full_diff=c.fulldiff) | ||||
r1951 | # downloads/raw we only need RAW diff nothing else | |||
r2618 | diff = self.path_filter.get_raw_patch(diff_processor) | |||
r1951 | c.changes[commit.raw_id] = [None, None, None, None, diff, None, None] | |||
# sort comments by how they were generated | ||||
c.comments = sorted(c.comments, key=lambda x: x.comment_id) | ||||
if len(c.commit_ranges) == 1: | ||||
c.commit = c.commit_ranges[0] | ||||
c.parent_tmpl = ''.join( | ||||
'# Parent %s\n' % x.raw_id for x in c.commit.parents) | ||||
if method == 'download': | ||||
response = Response(diff) | ||||
response.content_type = 'text/plain' | ||||
response.content_disposition = ( | ||||
'attachment; filename=%s.diff' % commit_id_range[:12]) | ||||
return response | ||||
elif method == 'patch': | ||||
c.diff = safe_unicode(diff) | ||||
patch = render( | ||||
'rhodecode:templates/changeset/patch_changeset.mako', | ||||
self._get_template_context(c), self.request) | ||||
response = Response(patch) | ||||
response.content_type = 'text/plain' | ||||
return response | ||||
elif method == 'raw': | ||||
response = Response(diff) | ||||
response.content_type = 'text/plain' | ||||
return response | ||||
elif method == 'show': | ||||
if len(c.commit_ranges) == 1: | ||||
html = render( | ||||
'rhodecode:templates/changeset/changeset.mako', | ||||
self._get_template_context(c), self.request) | ||||
return Response(html) | ||||
else: | ||||
c.ancestor = None | ||||
c.target_repo = self.db_repo | ||||
html = render( | ||||
'rhodecode:templates/changeset/changeset_range.mako', | ||||
self._get_template_context(c), self.request) | ||||
return Response(html) | ||||
raise HTTPBadRequest() | ||||
@LoginRequired() | ||||
@HasRepoPermissionAnyDecorator( | ||||
'repository.read', 'repository.write', 'repository.admin') | ||||
@view_config( | ||||
route_name='repo_commit', request_method='GET', | ||||
renderer=None) | ||||
def repo_commit_show(self): | ||||
commit_id = self.request.matchdict['commit_id'] | ||||
return self._commit(commit_id, method='show') | ||||
@LoginRequired() | ||||
@HasRepoPermissionAnyDecorator( | ||||
'repository.read', 'repository.write', 'repository.admin') | ||||
@view_config( | ||||
route_name='repo_commit_raw', request_method='GET', | ||||
renderer=None) | ||||
@view_config( | ||||
route_name='repo_commit_raw_deprecated', request_method='GET', | ||||
renderer=None) | ||||
def repo_commit_raw(self): | ||||
commit_id = self.request.matchdict['commit_id'] | ||||
return self._commit(commit_id, method='raw') | ||||
@LoginRequired() | ||||
@HasRepoPermissionAnyDecorator( | ||||
'repository.read', 'repository.write', 'repository.admin') | ||||
@view_config( | ||||
route_name='repo_commit_patch', request_method='GET', | ||||
renderer=None) | ||||
def repo_commit_patch(self): | ||||
commit_id = self.request.matchdict['commit_id'] | ||||
return self._commit(commit_id, method='patch') | ||||
@LoginRequired() | ||||
@HasRepoPermissionAnyDecorator( | ||||
'repository.read', 'repository.write', 'repository.admin') | ||||
@view_config( | ||||
route_name='repo_commit_download', request_method='GET', | ||||
renderer=None) | ||||
def repo_commit_download(self): | ||||
commit_id = self.request.matchdict['commit_id'] | ||||
return self._commit(commit_id, method='download') | ||||
@LoginRequired() | ||||
@NotAnonymous() | ||||
@HasRepoPermissionAnyDecorator( | ||||
'repository.read', 'repository.write', 'repository.admin') | ||||
@CSRFRequired() | ||||
@view_config( | ||||
route_name='repo_commit_comment_create', request_method='POST', | ||||
renderer='json_ext') | ||||
def repo_commit_comment_create(self): | ||||
_ = self.request.translate | ||||
commit_id = self.request.matchdict['commit_id'] | ||||
c = self.load_default_context() | ||||
status = self.request.POST.get('changeset_status', None) | ||||
text = self.request.POST.get('text') | ||||
comment_type = self.request.POST.get('comment_type') | ||||
resolves_comment_id = self.request.POST.get('resolves_comment_id', None) | ||||
if status: | ||||
text = text or (_('Status change %(transition_icon)s %(status)s') | ||||
% {'transition_icon': '>', | ||||
'status': ChangesetStatus.get_status_lbl(status)}) | ||||
multi_commit_ids = [] | ||||
for _commit_id in self.request.POST.get('commit_ids', '').split(','): | ||||
if _commit_id not in ['', None, EmptyCommit.raw_id]: | ||||
if _commit_id not in multi_commit_ids: | ||||
multi_commit_ids.append(_commit_id) | ||||
commit_ids = multi_commit_ids or [commit_id] | ||||
comment = None | ||||
for current_id in filter(None, commit_ids): | ||||
comment = CommentsModel().create( | ||||
text=text, | ||||
repo=self.db_repo.repo_id, | ||||
user=self._rhodecode_db_user.user_id, | ||||
commit_id=current_id, | ||||
f_path=self.request.POST.get('f_path'), | ||||
line_no=self.request.POST.get('line'), | ||||
status_change=(ChangesetStatus.get_status_lbl(status) | ||||
if status else None), | ||||
status_change_type=status, | ||||
comment_type=comment_type, | ||||
r2728 | resolves_comment_id=resolves_comment_id, | |||
auth_user=self._rhodecode_user | ||||
r1951 | ) | |||
# get status if set ! | ||||
if status: | ||||
# if latest status was from pull request and it's closed | ||||
# disallow changing status ! | ||||
# dont_allow_on_closed_pull_request = True ! | ||||
try: | ||||
ChangesetStatusModel().set_status( | ||||
self.db_repo.repo_id, | ||||
status, | ||||
self._rhodecode_db_user.user_id, | ||||
comment, | ||||
revision=current_id, | ||||
dont_allow_on_closed_pull_request=True | ||||
) | ||||
except StatusChangeOnClosedPullRequestError: | ||||
msg = _('Changing the status of a commit associated with ' | ||||
'a closed pull request is not allowed') | ||||
log.exception(msg) | ||||
h.flash(msg, category='warning') | ||||
raise HTTPFound(h.route_path( | ||||
'repo_commit', repo_name=self.db_repo_name, | ||||
commit_id=current_id)) | ||||
# finalize, commit and redirect | ||||
Session().commit() | ||||
data = { | ||||
'target_id': h.safeid(h.safe_unicode( | ||||
self.request.POST.get('f_path'))), | ||||
} | ||||
if comment: | ||||
c.co = comment | ||||
rendered_comment = render( | ||||
'rhodecode:templates/changeset/changeset_comment_block.mako', | ||||
self._get_template_context(c), self.request) | ||||
data.update(comment.get_dict()) | ||||
data.update({'rendered_text': rendered_comment}) | ||||
return data | ||||
@LoginRequired() | ||||
@NotAnonymous() | ||||
@HasRepoPermissionAnyDecorator( | ||||
'repository.read', 'repository.write', 'repository.admin') | ||||
@CSRFRequired() | ||||
@view_config( | ||||
route_name='repo_commit_comment_preview', request_method='POST', | ||||
renderer='string', xhr=True) | ||||
def repo_commit_comment_preview(self): | ||||
# Technically a CSRF token is not needed as no state changes with this | ||||
# call. However, as this is a POST is better to have it, so automated | ||||
# tools don't flag it as potential CSRF. | ||||
# Post is required because the payload could be bigger than the maximum | ||||
# allowed by GET. | ||||
text = self.request.POST.get('text') | ||||
renderer = self.request.POST.get('renderer') or 'rst' | ||||
if text: | ||||
return h.render(text, renderer=renderer, mentions=True) | ||||
return '' | ||||
@LoginRequired() | ||||
@NotAnonymous() | ||||
@HasRepoPermissionAnyDecorator( | ||||
'repository.read', 'repository.write', 'repository.admin') | ||||
@CSRFRequired() | ||||
@view_config( | ||||
route_name='repo_commit_comment_delete', request_method='POST', | ||||
renderer='json_ext') | ||||
def repo_commit_comment_delete(self): | ||||
commit_id = self.request.matchdict['commit_id'] | ||||
comment_id = self.request.matchdict['comment_id'] | ||||
r1956 | comment = ChangesetComment.get_or_404(comment_id) | |||
r1951 | if not comment: | |||
log.debug('Comment with id:%s not found, skipping', comment_id) | ||||
# comment already deleted in another call probably | ||||
return True | ||||
is_repo_admin = h.HasRepoPermissionAny('repository.admin')(self.db_repo_name) | ||||
super_admin = h.HasPermissionAny('hg.admin')() | ||||
comment_owner = (comment.author.user_id == self._rhodecode_db_user.user_id) | ||||
is_repo_comment = comment.repo.repo_name == self.db_repo_name | ||||
comment_repo_admin = is_repo_admin and is_repo_comment | ||||
if super_admin or comment_owner or comment_repo_admin: | ||||
r2728 | CommentsModel().delete(comment=comment, auth_user=self._rhodecode_user) | |||
r1951 | Session().commit() | |||
return True | ||||
else: | ||||
log.warning('No permissions for user %s to delete comment_id: %s', | ||||
self._rhodecode_db_user, comment_id) | ||||
raise HTTPNotFound() | ||||
@LoginRequired() | ||||
@HasRepoPermissionAnyDecorator( | ||||
'repository.read', 'repository.write', 'repository.admin') | ||||
@view_config( | ||||
route_name='repo_commit_data', request_method='GET', | ||||
renderer='json_ext', xhr=True) | ||||
def repo_commit_data(self): | ||||
commit_id = self.request.matchdict['commit_id'] | ||||
self.load_default_context() | ||||
try: | ||||
return self.rhodecode_vcs_repo.get_commit(commit_id=commit_id) | ||||
except CommitDoesNotExistError as e: | ||||
return EmptyCommit(message=str(e)) | ||||
@LoginRequired() | ||||
@HasRepoPermissionAnyDecorator( | ||||
'repository.read', 'repository.write', 'repository.admin') | ||||
@view_config( | ||||
route_name='repo_commit_children', request_method='GET', | ||||
renderer='json_ext', xhr=True) | ||||
def repo_commit_children(self): | ||||
commit_id = self.request.matchdict['commit_id'] | ||||
self.load_default_context() | ||||
r2150 | try: | |||
commit = self.rhodecode_vcs_repo.get_commit(commit_id=commit_id) | ||||
children = commit.children | ||||
except CommitDoesNotExistError: | ||||
children = [] | ||||
result = {"results": children} | ||||
r1951 | return result | |||
@LoginRequired() | ||||
@HasRepoPermissionAnyDecorator( | ||||
'repository.read', 'repository.write', 'repository.admin') | ||||
@view_config( | ||||
route_name='repo_commit_parents', request_method='GET', | ||||
renderer='json_ext') | ||||
def repo_commit_parents(self): | ||||
commit_id = self.request.matchdict['commit_id'] | ||||
self.load_default_context() | ||||
r2150 | try: | |||
commit = self.rhodecode_vcs_repo.get_commit(commit_id=commit_id) | ||||
parents = commit.parents | ||||
except CommitDoesNotExistError: | ||||
parents = [] | ||||
result = {"results": parents} | ||||
r1951 | return result | |||