# -*- coding: utf-8 -*- # Copyright (C) 2010-2017 RhodeCode GmbH # # 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 . # # 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 from rhodecode.lib import diffs, codeblocks 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 from rhodecode.lib.utils import jsonify from rhodecode.lib.utils2 import safe_unicode from rhodecode.lib.vcs.backends.base import EmptyCommit from rhodecode.lib.vcs.exceptions import ( RepositoryError, CommitDoesNotExistError, NodeDoesNotExistError) 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 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') # 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 # 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 = 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 # 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( 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 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) # 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( _diff, format='newdiff', diff_limit=diff_limit, file_limit=file_limit, show_full_diff=fulldiff) commit_changes = OrderedDict() if method == 'show': _parsed = diff_processor.prepare() c.limited_diff = isinstance(_parsed, diffs.LimitedDiffContainer) _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 inline_comments = CommentsModel().get_inline_comments( c.rhodecode_db_repo.repo_id, revision=commit.raw_id) c.inline_cnt = CommentsModel().get_inline_comments_count( inline_comments) diffset = codeblocks.DiffSet( repo_name=c.repo_name, source_node_getter=_node_getter(commit1), target_node_getter=_node_getter(commit2), comments=inline_comments ).render_patchset(_parsed, commit1.raw_id, commit2.raw_id) c.changes[commit.raw_id] = diffset else: # downloads/raw we only need RAW diff nothing else diff = diff_processor.as_raw() 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.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) return render('changeset/patch_changeset.mako') elif method == 'raw': response.content_type = 'text/plain' return diff elif method == 'show': if len(c.commit_ranges) == 1: return render('changeset/changeset.mako') else: c.ancestor = None c.target_repo = c.rhodecode_db_repo return render('changeset/changeset_range.mako') @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') comment_type = request.POST.get('comment_type') resolves_comment_id = 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 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): c.co = comment = CommentsModel().create( text=text, repo=c.rhodecode_db_repo.repo_id, user=c.rhodecode_user.user_id, commit_id=current_id, f_path=request.POST.get('f_path'), line_no=request.POST.get('line'), status_change=(ChangesetStatus.get_status_lbl(status) if status else None), status_change_type=status, comment_type=comment_type, resolves_comment_id=resolves_comment_id ) # 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': render('changeset/changeset_comment_block.mako')}) 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): comment = ChangesetComment.get(comment_id) if not comment: log.debug('Comment with id:%s not found, skipping', comment_id) # comment already deleted in another call probably return True owner = (comment.author.user_id == c.rhodecode_user.user_id) is_repo_admin = h.HasRepoPermissionAny('repository.admin')(c.repo_name) if h.HasPermissionAny('hg.admin')() or is_repo_admin or owner: CommentsModel().delete(comment=comment, user=c.rhodecode_user) Session().commit() return True else: raise HTTPForbidden() @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()