pullrequests.py
467 lines
| 18.7 KiB
| text/x-python
|
PythonLexer
r2244 | # -*- coding: utf-8 -*- | |||
""" | ||||
rhodecode.controllers.pullrequests | ||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | ||||
pull requests controller for rhodecode for initializing pull requests | ||||
:created_on: May 7, 2012 | ||||
:author: marcink | ||||
:copyright: (C) 2010-2012 Marcin Kuzminski <marcin@python-works.com> | ||||
:license: GPLv3, see COPYING for more details. | ||||
""" | ||||
# This program is free software: you can redistribute it and/or modify | ||||
# it under the terms of the GNU General Public License as published by | ||||
# the Free Software Foundation, either version 3 of the License, or | ||||
# (at your option) any later version. | ||||
# | ||||
# 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 General Public License | ||||
# along with this program. If not, see <http://www.gnu.org/licenses/>. | ||||
import logging | ||||
import traceback | ||||
r2711 | import formencode | |||
r2440 | ||||
r2489 | from webob.exc import HTTPNotFound, HTTPForbidden | |||
r2481 | from collections import defaultdict | |||
from itertools import groupby | ||||
r2244 | ||||
from pylons import request, response, session, tmpl_context as c, url | ||||
from pylons.controllers.util import abort, redirect | ||||
from pylons.i18n.translation import _ | ||||
r2541 | from rhodecode.lib.compat import json | |||
r2244 | from rhodecode.lib.base import BaseRepoController, render | |||
r2612 | from rhodecode.lib.auth import LoginRequired, HasRepoPermissionAnyDecorator,\ | |||
NotAnonymous | ||||
r2434 | from rhodecode.lib import helpers as h | |||
r2440 | from rhodecode.lib import diffs | |||
r3061 | from rhodecode.lib.utils import action_logger, jsonify | |||
from rhodecode.lib.vcs.exceptions import EmptyRepositoryError | ||||
from rhodecode.lib.vcs.backends.base import EmptyChangeset | ||||
from rhodecode.lib.diffs import LimitedDiffContainer | ||||
r2489 | from rhodecode.model.db import User, PullRequest, ChangesetStatus,\ | |||
ChangesetComment | ||||
r2434 | from rhodecode.model.pull_request import PullRequestModel | |||
from rhodecode.model.meta import Session | ||||
r2440 | from rhodecode.model.repo import RepoModel | |||
from rhodecode.model.comment import ChangesetCommentsModel | ||||
from rhodecode.model.changeset_status import ChangesetStatusModel | ||||
r2711 | from rhodecode.model.forms import PullRequestForm | |||
r2244 | ||||
log = logging.getLogger(__name__) | ||||
class PullrequestsController(BaseRepoController): | ||||
@LoginRequired() | ||||
@HasRepoPermissionAnyDecorator('repository.read', 'repository.write', | ||||
'repository.admin') | ||||
def __before__(self): | ||||
super(PullrequestsController, self).__before__() | ||||
r2612 | repo_model = RepoModel() | |||
c.users_array = repo_model.get_users_js() | ||||
c.users_groups_array = repo_model.get_users_groups_js() | ||||
r2244 | ||||
r2395 | def _get_repo_refs(self, repo): | |||
r2244 | hist_l = [] | |||
r2440 | branches_group = ([('branch:%s:%s' % (k, v), k) for | |||
k, v in repo.branches.iteritems()], _("Branches")) | ||||
bookmarks_group = ([('book:%s:%s' % (k, v), k) for | ||||
k, v in repo.bookmarks.iteritems()], _("Bookmarks")) | ||||
r2478 | tags_group = ([('tag:%s:%s' % (k, v), k) for | |||
Mads Kiilerich
|
r3329 | k, v in repo.tags.iteritems() | ||
if k != 'tip'], _("Tags")) | ||||
tip = repo.tags['tip'] | ||||
tipref = 'tag:tip:%s' % tip | ||||
colontip = ':' + tip | ||||
tips = [x[1] for x in branches_group[0] + bookmarks_group[0] + tags_group[0] | ||||
if x[0].endswith(colontip)] | ||||
tags_group[0].append((tipref, 'tip (%s)' % ', '.join(tips))) | ||||
r2244 | ||||
hist_l.append(bookmarks_group) | ||||
hist_l.append(branches_group) | ||||
hist_l.append(tags_group) | ||||
Mads Kiilerich
|
r3329 | return hist_l, tipref | ||
r2849 | ||||
r3104 | def _get_is_allowed_change_status(self, pull_request): | |||
r3149 | owner = self.rhodecode_user.user_id == pull_request.user_id | |||
r3104 | reviewer = self.rhodecode_user.user_id in [x.user_id for x in | |||
pull_request.reviewers] | ||||
return (self.rhodecode_user.admin or owner or reviewer) | ||||
r2440 | def show_all(self, repo_name): | |||
c.pull_requests = PullRequestModel().get_all(repo_name) | ||||
c.repo_name = repo_name | ||||
return render('/pullrequests/pullrequest_show_all.html') | ||||
r2627 | @NotAnonymous() | |||
r2244 | def index(self): | |||
r2395 | org_repo = c.rhodecode_db_repo | |||
r2444 | ||||
if org_repo.scm_instance.alias != 'hg': | ||||
log.error('Review not available for GIT REPOS') | ||||
raise HTTPNotFound | ||||
r2874 | try: | |||
org_repo.scm_instance.get_changeset() | ||||
except EmptyRepositoryError, e: | ||||
h.flash(h.literal(_('There are no changesets yet')), | ||||
category='warning') | ||||
redirect(url('summary_home', repo_name=org_repo.repo_name)) | ||||
r2541 | other_repos_info = {} | |||
r2395 | c.org_repos = [] | |||
Mads Kiilerich
|
r3330 | c.org_repos.append((org_repo.repo_name, org_repo.repo_name)) | ||
Mads Kiilerich
|
r3329 | c.default_org_repo = org_repo.repo_name | ||
c.org_refs, c.default_org_ref = self._get_repo_refs(org_repo.scm_instance) | ||||
r2541 | ||||
Mads Kiilerich
|
r3327 | c.other_repos = [] | ||
# add org repo to other so we can open pull request against itself | ||||
c.other_repos.extend(c.org_repos) | ||||
c.default_other_repo = org_repo.repo_name | ||||
Mads Kiilerich
|
r3329 | c.default_other_refs, c.default_other_ref = self._get_repo_refs(org_repo.scm_instance) | ||
r2541 | other_repos_info[org_repo.repo_name] = { | |||
r2720 | 'description': org_repo.description, | |||
Mads Kiilerich
|
r3329 | 'revs': h.select('other_ref', c.default_other_ref, c.default_other_refs, class_='refs') | ||
r2541 | } | |||
Mads Kiilerich
|
r3327 | # gather forks and add to this list ... even though it is rare to request forks to pull their parent | ||
r2395 | for fork in org_repo.forks: | |||
Mads Kiilerich
|
r3330 | c.other_repos.append((fork.repo_name, fork.repo_name)) | ||
Mads Kiilerich
|
r3329 | refs, default_ref = self._get_repo_refs(fork.scm_instance) | ||
r2541 | other_repos_info[fork.repo_name] = { | |||
r2720 | 'description': fork.description, | |||
Mads Kiilerich
|
r3329 | 'revs': h.select('other_ref', default_ref, refs, class_='refs') | ||
r2541 | } | |||
Mads Kiilerich
|
r3327 | |||
# add parents of this fork also, but only if it's not empty | ||||
r2933 | if org_repo.parent and org_repo.parent.scm_instance.revisions: | |||
Mads Kiilerich
|
r3327 | c.default_other_repo = org_repo.parent.repo_name | ||
Mads Kiilerich
|
r3329 | c.default_other_refs, c.default_other_ref = self._get_repo_refs(org_repo.parent.scm_instance) | ||
Mads Kiilerich
|
r3330 | c.other_repos.append((org_repo.parent.repo_name, org_repo.parent.repo_name)) | ||
r2541 | other_repos_info[org_repo.parent.repo_name] = { | |||
r2720 | 'description': org_repo.parent.description, | |||
Mads Kiilerich
|
r3329 | 'revs': h.select('other_ref', c.default_other_ref, c.default_other_refs, class_='refs') | ||
r2541 | } | |||
r2395 | ||||
r2541 | c.other_repos_info = json.dumps(other_repos_info) | |||
r2612 | c.review_members = [org_repo.user] | |||
r2244 | return render('/pullrequests/pullrequest.html') | |||
r2434 | ||||
r2612 | @NotAnonymous() | |||
r2434 | def create(self, repo_name): | |||
r2893 | repo = RepoModel()._get_repo(repo_name) | |||
r2711 | try: | |||
r2893 | _form = PullRequestForm(repo.repo_id)().to_python(request.POST) | |||
r2711 | except formencode.Invalid, errors: | |||
log.error(traceback.format_exc()) | ||||
if errors.error_dict.get('revisions'): | ||||
r2720 | msg = 'Revisions: %s' % errors.error_dict['revisions'] | |||
r2711 | elif errors.error_dict.get('pullrequest_title'): | |||
msg = _('Pull request requires a title with min. 3 chars') | ||||
else: | ||||
msg = _('error during creation of pull request') | ||||
r2612 | ||||
r2711 | h.flash(msg, 'error') | |||
return redirect(url('pullrequest_home', repo_name=repo_name)) | ||||
r2434 | ||||
r2711 | org_repo = _form['org_repo'] | |||
org_ref = _form['org_ref'] | ||||
other_repo = _form['other_repo'] | ||||
other_ref = _form['other_ref'] | ||||
revisions = _form['revisions'] | ||||
reviewers = _form['review_members'] | ||||
r3023 | # if we have cherry picked pull request we don't care what is in | |||
# org_ref/other_ref | ||||
rev_start = request.POST.get('rev_start') | ||||
rev_end = request.POST.get('rev_end') | ||||
if rev_start and rev_end: | ||||
# this is swapped to simulate that rev_end is a revision from | ||||
# parent of the fork | ||||
org_ref = 'rev:%s:%s' % (rev_end, rev_end) | ||||
other_ref = 'rev:%s:%s' % (rev_start, rev_start) | ||||
r2711 | title = _form['pullrequest_title'] | |||
description = _form['pullrequest_desc'] | ||||
r2434 | ||||
try: | ||||
r2541 | pull_request = PullRequestModel().create( | |||
self.rhodecode_user.user_id, org_repo, org_ref, other_repo, | ||||
other_ref, revisions, reviewers, title, description | ||||
) | ||||
Session().commit() | ||||
r2533 | h.flash(_('Successfully opened new pull request'), | |||
category='success') | ||||
r2434 | except Exception: | |||
r2533 | h.flash(_('Error occurred during sending pull request'), | |||
r2434 | category='error') | |||
log.error(traceback.format_exc()) | ||||
r2711 | return redirect(url('pullrequest_home', repo_name=repo_name)) | |||
r2434 | ||||
r2533 | return redirect(url('pullrequest_show', repo_name=other_repo, | |||
pull_request_id=pull_request.pull_request_id)) | ||||
r2434 | ||||
r2614 | @NotAnonymous() | |||
@jsonify | ||||
def update(self, repo_name, pull_request_id): | ||||
pull_request = PullRequest.get_or_404(pull_request_id) | ||||
if pull_request.is_closed(): | ||||
raise HTTPForbidden() | ||||
r2769 | #only owner or admin can update it | |||
owner = pull_request.author.user_id == c.rhodecode_user.user_id | ||||
if h.HasPermissionAny('hg.admin', 'repository.admin')() or owner: | ||||
reviewers_ids = map(int, filter(lambda v: v not in [None, ''], | ||||
request.POST.get('reviewers_ids', '').split(','))) | ||||
r2712 | ||||
r2769 | PullRequestModel().update_reviewers(pull_request_id, reviewers_ids) | |||
r3023 | Session().commit() | |||
r2769 | return True | |||
raise HTTPForbidden() | ||||
r2614 | ||||
r2746 | @NotAnonymous() | |||
@jsonify | ||||
def delete(self, repo_name, pull_request_id): | ||||
pull_request = PullRequest.get_or_404(pull_request_id) | ||||
#only owner can delete it ! | ||||
if pull_request.author.user_id == c.rhodecode_user.user_id: | ||||
PullRequestModel().delete(pull_request) | ||||
Session().commit() | ||||
h.flash(_('Successfully deleted pull request'), | ||||
category='success') | ||||
r3023 | return redirect(url('admin_settings_my_account', anchor='pullrequests')) | |||
r2769 | raise HTTPForbidden() | |||
r2746 | ||||
r2608 | def _load_compare_data(self, pull_request, enable_comments=True): | |||
r2442 | """ | |||
Load context data needed for generating compare diff | ||||
r2440 | ||||
r2442 | :param pull_request: | |||
:type pull_request: | ||||
""" | ||||
r3023 | rev_start = request.GET.get('rev_start') | |||
rev_end = request.GET.get('rev_end') | ||||
r2440 | ||||
org_repo = pull_request.org_repo | ||||
r2711 | (org_ref_type, | |||
org_ref_name, | ||||
org_ref_rev) = pull_request.org_ref.split(':') | ||||
r3023 | other_repo = org_repo | |||
r2711 | (other_ref_type, | |||
other_ref_name, | ||||
other_ref_rev) = pull_request.other_ref.split(':') | ||||
r2440 | ||||
r2720 | # despite opening revisions for bookmarks/branches/tags, we always | |||
r2711 | # convert this to rev to prevent changes after book or branch change | |||
org_ref = ('rev', org_ref_rev) | ||||
other_ref = ('rev', other_ref_rev) | ||||
r2440 | ||||
c.org_repo = org_repo | ||||
c.other_repo = other_repo | ||||
r3023 | c.fulldiff = fulldiff = request.GET.get('fulldiff') | |||
c.cs_ranges = [org_repo.get_changeset(x) for x in pull_request.revisions] | ||||
other_ref = ('rev', getattr(c.cs_ranges[0].parents[0] | ||||
if c.cs_ranges[0].parents | ||||
else EmptyChangeset(), 'raw_id')) | ||||
r2440 | ||||
r2803 | c.statuses = org_repo.statuses([x.raw_id for x in c.cs_ranges]) | |||
r2440 | # defines that we need hidden inputs with changesets | |||
c.as_form = request.GET.get('as_form', False) | ||||
c.org_ref = org_ref[1] | ||||
r3357 | c.org_ref_type = org_ref[0] | |||
r2440 | c.other_ref = other_ref[1] | |||
r3357 | c.other_ref_type = other_ref[0] | |||
r3023 | ||||
diff_limit = self.cut_off_limit if not fulldiff else None | ||||
#we swap org/other ref since we run a simple diff on one repo | ||||
_diff = diffs.differ(org_repo, other_ref, other_repo, org_ref) | ||||
r3015 | ||||
r3023 | diff_processor = diffs.DiffProcessor(_diff or '', format='gitdiff', | |||
diff_limit=diff_limit) | ||||
r2440 | _parsed = diff_processor.prepare() | |||
r3023 | c.limited_diff = False | |||
if isinstance(_parsed, LimitedDiffContainer): | ||||
c.limited_diff = True | ||||
r2440 | c.files = [] | |||
c.changes = {} | ||||
r3023 | c.lines_added = 0 | |||
c.lines_deleted = 0 | ||||
r2440 | for f in _parsed: | |||
r3023 | st = f['stats'] | |||
if st[0] != 'b': | ||||
c.lines_added += st[0] | ||||
c.lines_deleted += st[1] | ||||
r2440 | fid = h.FID('', f['filename']) | |||
c.files.append([fid, f['operation'], f['filename'], f['stats']]) | ||||
r2608 | diff = diff_processor.as_html(enable_comments=enable_comments, | |||
r2995 | parsed_lines=[f]) | |||
r2440 | c.changes[fid] = [f['operation'], f['filename'], diff] | |||
r2434 | def show(self, repo_name, pull_request_id): | |||
r2440 | repo_model = RepoModel() | |||
c.users_array = repo_model.get_users_js() | ||||
c.users_groups_array = repo_model.get_users_groups_js() | ||||
r2496 | c.pull_request = PullRequest.get_or_404(pull_request_id) | |||
r3104 | c.allowed_to_change_status = self._get_is_allowed_change_status(c.pull_request) | |||
r2481 | cc_model = ChangesetCommentsModel() | |||
cs_model = ChangesetStatusModel() | ||||
_cs_statuses = cs_model.get_statuses(c.pull_request.org_repo, | ||||
pull_request=c.pull_request, | ||||
with_revisions=True) | ||||
cs_statuses = defaultdict(list) | ||||
for st in _cs_statuses: | ||||
cs_statuses[st.author.username] += [st] | ||||
c.pull_request_reviewers = [] | ||||
r2712 | c.pull_request_pending_reviewers = [] | |||
r2481 | for o in c.pull_request.reviewers: | |||
st = cs_statuses.get(o.user.username, None) | ||||
if st: | ||||
sorter = lambda k: k.version | ||||
st = [(x, list(y)[0]) | ||||
for x, y in (groupby(sorted(st, key=sorter), sorter))] | ||||
r2712 | else: | |||
c.pull_request_pending_reviewers.append(o.user) | ||||
r2481 | c.pull_request_reviewers.append([o.user, st]) | |||
r2444 | ||||
# pull_requests repo_name we opened it against | ||||
# ie. other_repo must match | ||||
if repo_name != c.pull_request.other_repo.repo_name: | ||||
raise HTTPNotFound | ||||
r2442 | # load compare data into template context | |||
r2608 | enable_comments = not c.pull_request.is_closed() | |||
self._load_compare_data(c.pull_request, enable_comments=enable_comments) | ||||
r2440 | ||||
# inline comments | ||||
c.inline_cnt = 0 | ||||
r2481 | c.inline_comments = cc_model.get_inline_comments( | |||
c.rhodecode_db_repo.repo_id, | ||||
pull_request=pull_request_id) | ||||
r2440 | # count inline comments | |||
for __, lines in c.inline_comments: | ||||
for comments in lines.values(): | ||||
c.inline_cnt += len(comments) | ||||
# comments | ||||
r2481 | c.comments = cc_model.get_comments(c.rhodecode_db_repo.repo_id, | |||
pull_request=pull_request_id) | ||||
r2440 | ||||
r2803 | try: | |||
cur_status = c.statuses[c.pull_request.revisions[0]][0] | ||||
except: | ||||
log.error(traceback.format_exc()) | ||||
cur_status = 'undefined' | ||||
if c.pull_request.is_closed() and 0: | ||||
c.current_changeset_status = cur_status | ||||
else: | ||||
# changeset(pull-request) status calulation based on reviewers | ||||
c.current_changeset_status = cs_model.calculate_status( | ||||
c.pull_request_reviewers, | ||||
) | ||||
r2440 | c.changeset_statuses = ChangesetStatus.STATUSES | |||
r2803 | ||||
r2434 | return render('/pullrequests/pullrequest_show.html') | |||
r2443 | ||||
r2627 | @NotAnonymous() | |||
r2443 | @jsonify | |||
def comment(self, repo_name, pull_request_id): | ||||
r2608 | pull_request = PullRequest.get_or_404(pull_request_id) | |||
if pull_request.is_closed(): | ||||
raise HTTPForbidden() | ||||
r2443 | ||||
status = request.POST.get('changeset_status') | ||||
change_status = request.POST.get('change_changeset_status') | ||||
r2796 | text = request.POST.get('text') | |||
r3104 | ||||
allowed_to_change_status = self._get_is_allowed_change_status(pull_request) | ||||
if status and change_status and allowed_to_change_status: | ||||
r2796 | text = text or (_('Status change -> %s') | |||
% ChangesetStatus.get_status_lbl(status)) | ||||
r2443 | comm = ChangesetCommentsModel().create( | |||
r2796 | text=text, | |||
r2541 | repo=c.rhodecode_db_repo.repo_id, | |||
user=c.rhodecode_user.user_id, | ||||
r2443 | pull_request=pull_request_id, | |||
f_path=request.POST.get('f_path'), | ||||
line_no=request.POST.get('line'), | ||||
r2478 | status_change=(ChangesetStatus.get_status_lbl(status) | |||
r3104 | if status and change_status and allowed_to_change_status else None) | |||
r2443 | ) | |||
action_logger(self.rhodecode_user, | ||||
'user_commented_pull_request:%s' % pull_request_id, | ||||
c.rhodecode_db_repo, self.ip_addr, self.sa) | ||||
r3104 | if allowed_to_change_status: | |||
# get status if set ! | ||||
if status and change_status: | ||||
ChangesetStatusModel().set_status( | ||||
c.rhodecode_db_repo.repo_id, | ||||
status, | ||||
c.rhodecode_user.user_id, | ||||
comm, | ||||
pull_request=pull_request_id | ||||
) | ||||
if request.POST.get('save_close'): | ||||
if status in ['rejected', 'approved']: | ||||
PullRequestModel().close_pull_request(pull_request_id) | ||||
action_logger(self.rhodecode_user, | ||||
'user_closed_pull_request:%s' % pull_request_id, | ||||
c.rhodecode_db_repo, self.ip_addr, self.sa) | ||||
else: | ||||
h.flash(_('Closing pull request on other statuses than ' | ||||
'rejected or approved forbidden'), | ||||
category='warning') | ||||
r2608 | ||||
r2541 | Session().commit() | |||
r2443 | ||||
if not request.environ.get('HTTP_X_PARTIAL_XHR'): | ||||
return redirect(h.url('pullrequest_show', repo_name=repo_name, | ||||
pull_request_id=pull_request_id)) | ||||
data = { | ||||
'target_id': h.safeid(h.safe_unicode(request.POST.get('f_path'))), | ||||
} | ||||
if comm: | ||||
c.co = comm | ||||
data.update(comm.get_dict()) | ||||
data.update({'rendered_text': | ||||
render('changeset/changeset_comment_block.html')}) | ||||
r2444 | return data | |||
r2489 | ||||
r2627 | @NotAnonymous() | |||
r2489 | @jsonify | |||
def delete_comment(self, repo_name, comment_id): | ||||
co = ChangesetComment.get(comment_id) | ||||
r2608 | if co.pull_request.is_closed(): | |||
#don't allow deleting comments on closed pull request | ||||
raise HTTPForbidden() | ||||
Mads Kiilerich
|
r3141 | owner = co.author.user_id == c.rhodecode_user.user_id | ||
r2489 | if h.HasPermissionAny('hg.admin', 'repository.admin')() or owner: | |||
ChangesetCommentsModel().delete(comment=co) | ||||
r2541 | Session().commit() | |||
r2489 | return True | |||
else: | ||||
r2614 | raise HTTPForbidden() | |||