pullrequests.py
491 lines
| 20.0 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 | |||
Mads Kiilerich
|
r3515 | from mercurial import scmutil | ||
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 | ||||
Mads Kiilerich
|
r3515 | def _get_repo_refs(self, repo, rev=None, branch_rev=None): | ||
Mads Kiilerich
|
r3444 | """return a structure with repo's interesting changesets, suitable for | ||
the selectors in pullrequest.html""" | ||||
branches = [('branch:%s:%s' % (k, v), k) | ||||
for k, v in repo.branches.iteritems()] | ||||
bookmarks = [('book:%s:%s' % (k, v), k) | ||||
for k, v in repo.bookmarks.iteritems()] | ||||
tags = [('tag:%s:%s' % (k, v), k) | ||||
for k, v in repo.tags.iteritems() | ||||
if k != 'tip'] | ||||
Mads Kiilerich
|
r3329 | |||
tip = repo.tags['tip'] | ||||
colontip = ':' + tip | ||||
Mads Kiilerich
|
r3444 | tips = [x[1] for x in branches + bookmarks + tags | ||
Mads Kiilerich
|
r3329 | if x[0].endswith(colontip)] | ||
Mads Kiilerich
|
r3444 | selected = 'tag:tip:%s' % tip | ||
Mads Kiilerich
|
r3515 | special = [(selected, 'tip: %s' % ', '.join(tips))] | ||
r2244 | ||||
Mads Kiilerich
|
r3444 | if rev: | ||
selected = 'rev:%s:%s' % (rev, rev) | ||||
Mads Kiilerich
|
r3515 | special.append((selected, '%s: %s' % (_("Selected"), rev[:12]))) | ||
# list named branches that has been merged to this named branch - it should probably merge back | ||||
if branch_rev: | ||||
# not restricting to merge() would also get branch point and be better | ||||
# (especially because it would get the branch point) ... but is currently too expensive | ||||
revs = ["sort(parents(branch(id('%s')) and merge()) - branch(id('%s')))" % | ||||
(branch_rev, branch_rev)] | ||||
otherbranches = {} | ||||
for i in scmutil.revrange(repo._repo, revs): | ||||
cs = repo.get_changeset(i) | ||||
otherbranches[cs.branch] = cs.raw_id | ||||
for branch, node in otherbranches.iteritems(): | ||||
selected = 'branch:%s:%s' % (branch, node) | ||||
special.append((selected, '%s: %s' % (_('Peer'), branch))) | ||||
r2244 | ||||
Mads Kiilerich
|
r3444 | return [(special, _("Special")), | ||
(bookmarks, _("Bookmarks")), | ||||
(branches, _("Branches")), | ||||
(tags, _("Tags")), | ||||
], selected | ||||
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)) | ||||
Mads Kiilerich
|
r3485 | org_rev = request.GET.get('rev_end') | ||
# rev_start is not directly useful - its parent could however be used | ||||
# as default for other and thus give a simple compare view | ||||
#other_rev = request.POST.get('rev_start') | ||||
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 | ||
Mads Kiilerich
|
r3485 | c.org_refs, c.default_org_ref = self._get_repo_refs(org_repo.scm_instance, org_rev) | ||
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
|
r3515 | c.default_other_refs, c.default_other_ref = self._get_repo_refs(org_repo.scm_instance, branch_rev=org_rev) | ||
r3388 | usr_data = lambda usr: dict(user_id=usr.user_id, | |||
username=usr.username, | ||||
firstname=usr.firstname, | ||||
lastname=usr.lastname, | ||||
gravatar_link=h.gravatar_url(usr.email, 14)) | ||||
r2541 | other_repos_info[org_repo.repo_name] = { | |||
r3388 | 'user': usr_data(org_repo.user), | |||
r2720 | 'description': org_repo.description, | |||
r3388 | 'revs': h.select('other_ref', c.default_other_ref, | |||
c.default_other_refs, class_='refs') | ||||
r2541 | } | |||
r3394 | # gather forks and add to this list ... even though it is rare to | |||
r3388 | # 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] = { | |||
r3388 | 'user': usr_data(fork.user), | |||
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] = { | |||
r3388 | 'user': usr_data(org_repo.parent.user), | |||
r2720 | 'description': org_repo.parent.description, | |||
r3388 | '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) | |||
r3388 | # other repo owner | |||
c.review_members = [] | ||||
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'] | |||
Mads Kiilerich
|
r3486 | org_ref = 'rev:merge:%s' % _form['merge_rev'] | ||
r2711 | other_repo = _form['other_repo'] | |||
Mads Kiilerich
|
r3486 | other_ref = 'rev:ancestor:%s' % _form['ancestor_rev'] | ||
r2711 | revisions = _form['revisions'] | |||
reviewers = _form['review_members'] | ||||
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: | ||||
""" | ||||
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 | |||
Mads Kiilerich
|
r3484 | # convert this to rev to prevent changes after bookmark or branch change | ||
r2711 | 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] | ||||
r2803 | c.statuses = org_repo.statuses([x.raw_id for x in c.cs_ranges]) | |||
r2440 | ||||
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 | ||||
Mads Kiilerich
|
r3442 | c.as_form = False | ||
Mads Kiilerich
|
r3486 | c.ancestor = None # there is one - but right here we don't know which | ||
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') | |||
r3430 | close_pr = request.POST.get('save_close') | |||
r3104 | ||||
allowed_to_change_status = self._get_is_allowed_change_status(pull_request) | ||||
if status and change_status and allowed_to_change_status: | ||||
r3430 | _def = (_('status change -> %s') | |||
r2796 | % ChangesetStatus.get_status_lbl(status)) | |||
r3430 | if close_pr: | |||
_def = _('Closing with') + ' ' + _def | ||||
text = text or _def | ||||
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) | |||
r3430 | if status and change_status | |||
and allowed_to_change_status else None), | ||||
closing_pr=close_pr | ||||
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 | ||||
) | ||||
r3430 | if close_pr: | |||
r3104 | 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() | |||