changeset.py
490 lines
| 18.1 KiB
| text/x-python
|
PythonLexer
r1 | # -*- coding: utf-8 -*- | |||
r1271 | # Copyright (C) 2010-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/ | ||||
""" | ||||
commit controller for RhodeCode showing changes between commits | ||||
""" | ||||
import logging | ||||
from collections import defaultdict | ||||
from webob.exc import HTTPForbidden, HTTPBadRequest, HTTPNotFound | ||||
from pylons import tmpl_context as c, request, response | ||||
from pylons.i18n.translation import _ | ||||
from pylons.controllers.util import redirect | ||||
from rhodecode.lib import auth | ||||
r1139 | from rhodecode.lib import diffs, codeblocks | |||
r1 | from rhodecode.lib.auth import ( | |||
LoginRequired, HasRepoPermissionAnyDecorator, NotAnonymous) | ||||
from rhodecode.lib.base import BaseRepoController, render | ||||
from rhodecode.lib.compat import OrderedDict | ||||
from rhodecode.lib.exceptions import StatusChangeOnClosedPullRequestError | ||||
import rhodecode.lib.helpers as h | ||||
r1796 | from rhodecode.lib.utils import jsonify | |||
r1818 | from rhodecode.lib.utils2 import safe_unicode, safe_int | |||
r1 | from rhodecode.lib.vcs.backends.base import EmptyCommit | |||
from rhodecode.lib.vcs.exceptions import ( | ||||
r1159 | RepositoryError, CommitDoesNotExistError, NodeDoesNotExistError) | |||
r1 | from rhodecode.model.db import ChangesetComment, ChangesetStatus | |||
from rhodecode.model.changeset_status import ChangesetStatusModel | ||||
r1323 | from rhodecode.model.comment import CommentsModel | |||
r1 | from rhodecode.model.meta import Session | |||
log = logging.getLogger(__name__) | ||||
def _update_with_GET(params, GET): | ||||
for k in ['diff1', 'diff2', 'diff']: | ||||
params[k] += GET.getall(k) | ||||
def get_ignore_ws(fid, GET): | ||||
ig_ws_global = GET.get('ignorews') | ||||
ig_ws = filter(lambda k: k.startswith('WS'), GET.getall(fid)) | ||||
if ig_ws: | ||||
try: | ||||
return int(ig_ws[0].split(':')[-1]) | ||||
except Exception: | ||||
pass | ||||
return ig_ws_global | ||||
def _ignorews_url(GET, fileid=None): | ||||
fileid = str(fileid) if fileid else None | ||||
params = defaultdict(list) | ||||
_update_with_GET(params, GET) | ||||
label = _('Show whitespace') | ||||
tooltiplbl = _('Show whitespace for all diffs') | ||||
ig_ws = get_ignore_ws(fileid, GET) | ||||
ln_ctx = get_line_ctx(fileid, GET) | ||||
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, h.url.current(**params), title=tooltiplbl, class_='tooltip') | ||||
def get_line_ctx(fid, GET): | ||||
ln_ctx_global = GET.get('context') | ||||
if fid: | ||||
ln_ctx = filter(lambda k: k.startswith('C'), GET.getall(fid)) | ||||
else: | ||||
_ln_ctx = filter(lambda k: k.startswith('C'), GET) | ||||
ln_ctx = 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: | ||||
return int(retval) | ||||
except Exception: | ||||
return 3 | ||||
def _context_url(GET, fileid=None): | ||||
""" | ||||
Generates a url for context lines. | ||||
:param fileid: | ||||
""" | ||||
fileid = str(fileid) if fileid else None | ||||
ig_ws = get_ignore_ws(fileid, GET) | ||||
ln_ctx = (get_line_ctx(fileid, GET) or 3) * 2 | ||||
params = defaultdict(list) | ||||
_update_with_GET(params, GET) | ||||
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, h.url.current(**params), title=tooltiplbl, class_='tooltip') | ||||
class ChangesetController(BaseRepoController): | ||||
def __before__(self): | ||||
super(ChangesetController, self).__before__() | ||||
c.affected_files_cut_off = 60 | ||||
def _index(self, commit_id_range, method): | ||||
c.ignorews_url = _ignorews_url | ||||
c.context_url = _context_url | ||||
c.fulldiff = fulldiff = request.GET.get('fulldiff') | ||||
r1143 | ||||
# fetch global flags of ignore ws or context lines | ||||
context_lcl = get_line_ctx('', request.GET) | ||||
ign_whitespace_lcl = get_ignore_ws('', request.GET) | ||||
# 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 = self.cut_off_limit_diff | ||||
file_limit = self.cut_off_limit_file | ||||
r1 | # get ranges of commit ids if preset | |||
commit_range = commit_id_range.split('...')[:2] | ||||
r1143 | ||||
r1 | try: | |||
pre_load = ['affected_files', 'author', 'branch', 'date', | ||||
'message', 'parents'] | ||||
if len(commit_range) == 2: | ||||
commits = c.rhodecode_repo.get_commits( | ||||
start_id=commit_range[0], end_id=commit_range[1], | ||||
pre_load=pre_load) | ||||
commits = list(commits) | ||||
else: | ||||
commits = [c.rhodecode_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 | ||||
r1268 | # 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 | ||||
r1 | c.commit_statuses = ChangesetStatus.STATUSES | |||
c.inline_comments = [] | ||||
c.files = [] | ||||
r1143 | c.statuses = [] | |||
c.comments = [] | ||||
r1385 | c.unresolved_comments = [] | |||
r1143 | if len(c.commit_ranges) == 1: | |||
commit = c.commit_ranges[0] | ||||
r1323 | c.comments = CommentsModel().get_comments( | |||
r1143 | c.rhodecode_db_repo.repo_id, | |||
revision=commit.raw_id) | ||||
c.statuses.append(ChangesetStatusModel().get_status( | ||||
c.rhodecode_db_repo.repo_id, commit.raw_id)) | ||||
# comments from PR | ||||
statuses = ChangesetStatusModel().get_statuses( | ||||
c.rhodecode_db_repo.repo_id, commit.raw_id, | ||||
with_revisions=True) | ||||
prs = set(st.pull_request for st in statuses | ||||
r1158 | if st.pull_request is not None) | |||
r1143 | # from associated statuses, check the pull requests, and | |||
# show comments from them | ||||
for pr in prs: | ||||
c.comments.extend(pr.comments) | ||||
r1385 | c.unresolved_comments = CommentsModel()\ | |||
.get_commit_unresolved_todos(commit.raw_id) | ||||
r1 | # 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() | ||||
_diff = c.rhodecode_repo.get_diff( | ||||
commit1, commit2, | ||||
ignore_whitespace=ign_whitespace_lcl, context=context_lcl) | ||||
diff_processor = diffs.DiffProcessor( | ||||
r1139 | _diff, format='newdiff', diff_limit=diff_limit, | |||
r1 | file_limit=file_limit, show_full_diff=fulldiff) | |||
r1143 | ||||
r1 | commit_changes = OrderedDict() | |||
if method == 'show': | ||||
_parsed = diff_processor.prepare() | ||||
c.limited_diff = isinstance(_parsed, diffs.LimitedDiffContainer) | ||||
r1139 | ||||
_parsed = diff_processor.prepare() | ||||
def _node_getter(commit): | ||||
def get_node(fname): | ||||
try: | ||||
return commit.get_node(fname) | ||||
except NodeDoesNotExistError: | ||||
return None | ||||
return get_node | ||||
r1323 | inline_comments = CommentsModel().get_inline_comments( | |||
r1143 | c.rhodecode_db_repo.repo_id, revision=commit.raw_id) | |||
r1323 | c.inline_cnt = CommentsModel().get_inline_comments_count( | |||
r1190 | inline_comments) | |||
r1143 | ||||
r1139 | diffset = codeblocks.DiffSet( | |||
r1142 | repo_name=c.repo_name, | |||
r1139 | source_node_getter=_node_getter(commit1), | |||
target_node_getter=_node_getter(commit2), | ||||
r1143 | comments=inline_comments | |||
r1139 | ).render_patchset(_parsed, commit1.raw_id, commit2.raw_id) | |||
c.changes[commit.raw_id] = diffset | ||||
r1 | else: | |||
# downloads/raw we only need RAW diff nothing else | ||||
diff = diff_processor.as_raw() | ||||
r1139 | c.changes[commit.raw_id] = [None, None, None, None, diff, None, None] | |||
r1 | ||||
# 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.content_type = 'text/plain' | ||||
response.content_disposition = ( | ||||
'attachment; filename=%s.diff' % commit_id_range[:12]) | ||||
return diff | ||||
elif method == 'patch': | ||||
response.content_type = 'text/plain' | ||||
c.diff = safe_unicode(diff) | ||||
r1282 | return render('changeset/patch_changeset.mako') | |||
r1 | elif method == 'raw': | |||
response.content_type = 'text/plain' | ||||
return diff | ||||
elif method == 'show': | ||||
if len(c.commit_ranges) == 1: | ||||
r1282 | return render('changeset/changeset.mako') | |||
r1 | else: | |||
c.ancestor = None | ||||
c.target_repo = c.rhodecode_db_repo | ||||
r1282 | return render('changeset/changeset_range.mako') | |||
r1 | ||||
@LoginRequired() | ||||
@HasRepoPermissionAnyDecorator('repository.read', 'repository.write', | ||||
'repository.admin') | ||||
def index(self, revision, method='show'): | ||||
return self._index(revision, method=method) | ||||
@LoginRequired() | ||||
@HasRepoPermissionAnyDecorator('repository.read', 'repository.write', | ||||
'repository.admin') | ||||
def changeset_raw(self, revision): | ||||
return self._index(revision, method='raw') | ||||
@LoginRequired() | ||||
@HasRepoPermissionAnyDecorator('repository.read', 'repository.write', | ||||
'repository.admin') | ||||
def changeset_patch(self, revision): | ||||
return self._index(revision, method='patch') | ||||
@LoginRequired() | ||||
@HasRepoPermissionAnyDecorator('repository.read', 'repository.write', | ||||
'repository.admin') | ||||
def changeset_download(self, revision): | ||||
return self._index(revision, method='download') | ||||
@LoginRequired() | ||||
@NotAnonymous() | ||||
@HasRepoPermissionAnyDecorator('repository.read', 'repository.write', | ||||
'repository.admin') | ||||
@auth.CSRFRequired() | ||||
@jsonify | ||||
def comment(self, repo_name, revision): | ||||
commit_id = revision | ||||
status = request.POST.get('changeset_status', None) | ||||
text = request.POST.get('text') | ||||
r1324 | comment_type = request.POST.get('comment_type') | |||
r1326 | resolves_comment_id = request.POST.get('resolves_comment_id', None) | |||
r1324 | ||||
r1 | if status: | |||
text = text or (_('Status change %(transition_icon)s %(status)s') | ||||
% {'transition_icon': '>', | ||||
'status': ChangesetStatus.get_status_lbl(status)}) | ||||
r1331 | multi_commit_ids = [] | |||
for _commit_id in 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) | ||||
r1 | ||||
commit_ids = multi_commit_ids or [commit_id] | ||||
r1331 | ||||
r1 | comment = None | |||
for current_id in filter(None, commit_ids): | ||||
r1323 | c.co = comment = CommentsModel().create( | |||
r1 | text=text, | |||
repo=c.rhodecode_db_repo.repo_id, | ||||
user=c.rhodecode_user.user_id, | ||||
r1322 | commit_id=current_id, | |||
r1 | f_path=request.POST.get('f_path'), | |||
line_no=request.POST.get('line'), | ||||
status_change=(ChangesetStatus.get_status_lbl(status) | ||||
r548 | if status else None), | |||
r1324 | status_change_type=status, | |||
r1326 | comment_type=comment_type, | |||
resolves_comment_id=resolves_comment_id | ||||
r1 | ) | |||
r1286 | ||||
r1 | # 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( | ||||
c.rhodecode_db_repo.repo_id, | ||||
status, | ||||
c.rhodecode_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') | ||||
return redirect(h.url( | ||||
'changeset_home', repo_name=repo_name, | ||||
revision=current_id)) | ||||
# finalize, commit and redirect | ||||
Session().commit() | ||||
data = { | ||||
'target_id': h.safeid(h.safe_unicode(request.POST.get('f_path'))), | ||||
} | ||||
if comment: | ||||
data.update(comment.get_dict()) | ||||
data.update({'rendered_text': | ||||
r1282 | render('changeset/changeset_comment_block.mako')}) | |||
r1 | ||||
return data | ||||
@LoginRequired() | ||||
@NotAnonymous() | ||||
@HasRepoPermissionAnyDecorator('repository.read', 'repository.write', | ||||
'repository.admin') | ||||
@auth.CSRFRequired() | ||||
def preview_comment(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. | ||||
if not request.environ.get('HTTP_X_PARTIAL_XHR'): | ||||
raise HTTPBadRequest() | ||||
text = request.POST.get('text') | ||||
renderer = 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') | ||||
@auth.CSRFRequired() | ||||
@jsonify | ||||
def delete_comment(self, repo_name, comment_id): | ||||
r1818 | comment = ChangesetComment.get_or_404(safe_int(comment_id)) | |||
r1330 | if not comment: | |||
log.debug('Comment with id:%s not found, skipping', comment_id) | ||||
# comment already deleted in another call probably | ||||
return True | ||||
r1 | is_repo_admin = h.HasRepoPermissionAny('repository.admin')(c.repo_name) | |||
r1818 | super_admin = h.HasPermissionAny('hg.admin')() | |||
comment_owner = (comment.author.user_id == c.rhodecode_user.user_id) | ||||
is_repo_comment = comment.repo.repo_name == c.repo_name | ||||
comment_repo_admin = is_repo_admin and is_repo_comment | ||||
if super_admin or comment_owner or comment_repo_admin: | ||||
r1807 | CommentsModel().delete(comment=comment, user=c.rhodecode_user) | |||
r1 | Session().commit() | |||
return True | ||||
else: | ||||
r1826 | log.warning('No permissions for user %s to delete comment_id: %s', | |||
c.rhodecode_user, comment_id) | ||||
raise HTTPNotFound() | ||||
r1 | ||||
@LoginRequired() | ||||
@HasRepoPermissionAnyDecorator('repository.read', 'repository.write', | ||||
'repository.admin') | ||||
@jsonify | ||||
def changeset_info(self, repo_name, revision): | ||||
if request.is_xhr: | ||||
try: | ||||
return c.rhodecode_repo.get_commit(commit_id=revision) | ||||
except CommitDoesNotExistError as e: | ||||
return EmptyCommit(message=str(e)) | ||||
else: | ||||
raise HTTPBadRequest() | ||||
@LoginRequired() | ||||
@HasRepoPermissionAnyDecorator('repository.read', 'repository.write', | ||||
'repository.admin') | ||||
@jsonify | ||||
def changeset_children(self, repo_name, revision): | ||||
if request.is_xhr: | ||||
commit = c.rhodecode_repo.get_commit(commit_id=revision) | ||||
result = {"results": commit.children} | ||||
return result | ||||
else: | ||||
raise HTTPBadRequest() | ||||
@LoginRequired() | ||||
@HasRepoPermissionAnyDecorator('repository.read', 'repository.write', | ||||
'repository.admin') | ||||
@jsonify | ||||
def changeset_parents(self, repo_name, revision): | ||||
if request.is_xhr: | ||||
commit = c.rhodecode_repo.get_commit(commit_id=revision) | ||||
result = {"results": commit.parents} | ||||
return result | ||||
else: | ||||
raise HTTPBadRequest() | ||||