Blob Blame Raw
# -*- coding: utf-8 -*-

"""
 (c) 2014 - Copyright Red Hat Inc

 Authors:
   Pierre-Yves Chibon <pingou@pingoured.fr>

"""

import flask
import os
import shutil
import tempfile
from math import ceil

import pygit2
from sqlalchemy.exc import SQLAlchemyError
from pygments import highlight
from pygments.lexers import guess_lexer
from pygments.lexers.text import DiffLexer
from pygments.formatters import HtmlFormatter


import progit.doc_utils
import progit.lib
import progit.lib.git
import progit.forms
from progit import (APP, SESSION, LOG, __get_file_in_tree, cla_required,
                    is_repo_admin, generate_gitolite_acls)


def _get_repo_path(repo):
    """ Return the pat of the git repository corresponding to the provided
    Repository object from the DB.
    """
    if repo.is_fork:
        repopath = os.path.join(APP.config['FORK_FOLDER'], repo.path)
    else:
        repopath = os.path.join(APP.config['GIT_FOLDER'], repo.path)
    return repopath


def _get_parent_repo_path(repo):
    """ Return the path of the parent git repository corresponding to the
    provided Repository object from the DB.
    """
    if repo.parent:
        parentpath = os.path.join(APP.config['GIT_FOLDER'], repo.parent.path)
    else:
        parentpath = os.path.join(APP.config['GIT_FOLDER'], repo.path)
    return parentpath


@APP.route('/<repo>/request-pulls')
@APP.route('/fork/<username>/<repo>/request-pulls')
def request_pulls(repo, username=None):
    """ Request pulling the changes from the fork into the project.
    """
    status = flask.request.args.get('status', True)

    repo = progit.lib.get_project(SESSION, repo, user=username)

    if not repo:
        flask.abort(404, 'Project not found')

    if status is False or str(status).lower() == 'closed':
        requests = progit.lib.search_pull_requests(
            SESSION, project_id=repo.id, status=False)
    else:
        requests = progit.lib.search_pull_requests(
            SESSION, project_id=repo.id, status=status)

    return flask.render_template(
        'requests.html',
        select='requests',
        repo=repo,
        username=username,
        requests=requests,
        status=status,
    )


@APP.route('/<repo>/request-pull/<int:requestid>')
@APP.route('/fork/<username>/<repo>/request-pull/<int:requestid>')
def request_pull(repo, requestid, username=None):
    """ Request pulling the changes from the fork into the project.
    """

    repo = progit.lib.get_project(SESSION, repo, user=username)

    if not repo:
        flask.abort(404, 'Project not found')

    request = progit.lib.search_pull_requests(
        SESSION, project_id=repo.id, requestid=requestid)

    if not request:
        flask.abort(404, 'Pull-request not found')

    repo_from = request.repo_from
    repopath = _get_repo_path(repo_from)
    repo_obj = pygit2.Repository(repopath)

    parentpath = _get_parent_repo_path(repo_from)
    orig_repo = pygit2.Repository(parentpath)

    branch = repo_obj.lookup_branch(request.branch_from)
    commitid = branch.get_object().hex

    diff_commits = []
    diff = None
    if not repo_obj.is_empty and not orig_repo.is_empty:
        orig_commit = orig_repo[
            orig_repo.lookup_branch(request.branch).get_object().hex]

        # Closed pull-request
        if request.status is False:
            commitid = request.commit_stop
            for commit in repo_obj.walk(commitid, pygit2.GIT_SORT_TIME):
                diff_commits.append(commit)
                if commit.oid.hex == request.commit_start:
                    break
        # Pull-request open
        else:
            master_commits = [
                commit.oid.hex
                for commit in orig_repo.walk(
                    orig_repo.lookup_branch(request.branch).get_object().hex,
                    pygit2.GIT_SORT_TIME)
            ]
            for commit in repo_obj.walk(commitid, pygit2.GIT_SORT_TIME):
                if request.status and commit.oid.hex in master_commits:
                    break
                diff_commits.append(commit)

            if request.status:
                first_commit = repo_obj[diff_commits[-1].oid.hex]
                request.commit_start = first_commit.oid.hex
                request.commit_stop = diff_commits[0].oid.hex
                SESSION.add(request)
                try:
                    SESSION.commit()
                except SQLAlchemyError as err:
                    SESSION.rollback()
                    APP.logger.exception(err)
                    flask.flash(
                        'Could not update this pull-request in the database',
                        'error')

        if diff_commits:
            first_commit = repo_obj[diff_commits[-1].oid.hex]
            diff = repo_obj.diff(
                repo_obj.revparse_single(first_commit.parents[0].oid.hex),
                repo_obj.revparse_single(diff_commits[0].oid.hex)
            )

    elif orig_repo.is_empty:
        orig_commit = None
        repo_commit = repo_obj[request.stop_id]
        diff = repo_commit.tree.diff_to_tree(swap=True)
    else:
        flask.flash(
            'Fork is empty, there are no commits to request pulling',
            'error')
        return flask.redirect(flask.url_for(
            'view_repo', username=username, repo=repo.name))

    form = progit.forms.ConfirmationForm()

    return flask.render_template(
        'pull_request.html',
        select='requests',
        requestid=requestid,
        repo=repo,
        username=username,
        pull_request=request,
        repo_admin=is_repo_admin(request.repo),
        diff_commits=diff_commits,
        diff=diff,
        mergeform=form,
    )


@APP.route('/<repo>/request-pull/<int:requestid>.patch')
@APP.route('/fork/<username>/<repo>/request-pull/<int:requestid>.patch')
def request_pull_patch(repo, requestid, username=None):
    """ Returns the commits from the specified pull-request as patches.
    """

    repo = progit.lib.get_project(SESSION, repo, user=username)

    if not repo:
        flask.abort(404, 'Project not found')

    request = progit.lib.search_pull_requests(
        SESSION, project_id=repo.id, requestid=requestid)

    if not request:
        flask.abort(404, 'Pull-request not found')

    repo_from = request.repo_from
    repopath = _get_repo_path(repo_from)
    repo_obj = pygit2.Repository(repopath)

    parentpath = _get_parent_repo_path(repo_from)
    orig_repo = pygit2.Repository(parentpath)

    branch = repo_obj.lookup_branch(request.branch_from)
    commitid = branch.get_object().hex

    diff_commits = []
    if not repo_obj.is_empty and not orig_repo.is_empty:
        orig_commit = orig_repo[
            orig_repo.lookup_branch(request.branch).get_object().hex]

        # Closed pull-request
        if request.status is False:
            commitid = request.commit_stop
            for commit in repo_obj.walk(commitid, pygit2.GIT_SORT_TIME):
                diff_commits.append(commit)
                if commit.oid.hex == request.commit_start:
                    break
        # Pull-request open
        else:
            master_commits = [
                commit.oid.hex
                for commit in orig_repo.walk(
                    orig_repo.lookup_branch(request.branch).get_object().hex,
                    pygit2.GIT_SORT_TIME)
            ]
            for commit in repo_obj.walk(commitid, pygit2.GIT_SORT_TIME):
                if request.status and commit.oid.hex in master_commits:
                    break
                diff_commits.append(commit)

    elif orig_repo.is_empty:
        orig_commit = None
        repo_commit = repo_obj[request.stop_id]
        diff = repo_commit.tree.diff_to_tree(swap=True)
    else:
        flask.flash(
            'Fork is empty, there are no commits to request pulling',
            'error')
        return flask.redirect(flask.url_for(
            'view_repo', username=username, repo=repo.name))

    diff_commits.reverse()
    patch = progit.lib.git.commit_to_patch(repo_obj, diff_commits)

    return flask.Response(patch, content_type="text/plain;charset=UTF-8")


@APP.route('/<repo>/request-pull/<int:requestid>/comment/<commit>/'
           '<filename>/<row>', methods=('GET', 'POST'))
@APP.route('/fork/<username>/<repo>/request-pull/<int:requestid>/comment/'
           '<commit>/<filename>/<row>', methods=('GET', 'POST'))
def pull_request_add_comment(repo, requestid, commit, filename, row,
                             username=None):
    """ Add a comment to a commit in a pull-request.
    """
    repo = progit.lib.get_project(SESSION, repo, user=username)

    if not repo:
        flask.abort(404, 'Project not found')

    request = progit.lib.search_pull_requests(
        SESSION, project_id=repo.id, requestid=requestid)
    repo = request.repo_from

    if not request:
        flask.abort(404, 'Pull-request not found')

    form = progit.forms.AddPullRequestCommentForm()
    form.commit.data = commit
    form.filename.data = filename
    form.requestid.data = requestid
    form.row.data = row

    if form.validate_on_submit():
        comment = form.comment.data

        try:
            message = progit.lib.add_pull_request_comment(
                SESSION,
                request=request,
                commit=commit,
                filename=filename,
                row=row,
                comment=comment,
                user=flask.g.fas_user.username,
            )
            SESSION.commit()
            flask.flash(message)
        except SQLAlchemyError, err:  # pragma: no cover
            SESSION.rollback()
            APP.logger.exception(err)
            flask.flash(str(err), 'error')

        return flask.redirect(flask.url_for(
            'request_pull', username=username,
            repo=repo.name, requestid=requestid))

    return flask.render_template(
        'pull_request_comment.html',
        select='requests',
        requestid=requestid,
        repo=repo,
        username=username,
        commit=commit,
        filename=filename,
        row=row,
        form=form,
    )


@APP.route('/<repo>/request-pull/<int:requestid>/merge', methods=['POST'])
@APP.route('/fork/<username>/<repo>/request-pull/<int:requestid>/merge',
           methods=['POST'])
def merge_request_pull(repo, requestid, username=None):
    """ Request pulling the changes from the fork into the project.
    """

    form = progit.forms.ConfirmationForm()
    if not form.validate_on_submit():
        flask.flash('Invalid input submitted', 'error')
        return flask.redirect(flask.url_for('view_repo', repo=repo.name))

    repo = progit.lib.get_project(SESSION, repo, user=username)

    if not repo:
        flask.abort(404, 'Project not found')

    request = progit.lib.search_pull_requests(
        SESSION, project_id=repo.id, requestid=requestid)

    if not request:
        flask.abort(404, 'Pull-request not found')

    if not is_repo_admin(repo):
        flask.abort(
            403,
            'You are not allowed to merge pull-request for this project')

    error_output = flask.url_for(
        'request_pull', repo=repo.name, requestid=requestid)
    if username:
        error_output = flask.url_for(
            'fork_request_pull',
            repo=repo.name,
            requestid=requestid,
            username=username)

    # Get the fork
    if request.repo_from.is_fork:
        repopath = os.path.join(
            APP.config['FORK_FOLDER'], request.repo_from.path)
    else:
        repopath = os.path.join(
            APP.config['GIT_FOLDER'], request.repo_from.path)
    fork_obj = pygit2.Repository(repopath)

    # Get the original repo
    parentpath = os.path.join(APP.config['GIT_FOLDER'], request.repo.path)
    orig_repo = pygit2.Repository(parentpath)

    # Clone the original repo into a temp folder
    newpath = tempfile.mkdtemp()
    new_repo = pygit2.clone_repository(parentpath, newpath)

    repo_commit = fork_obj[
        fork_obj.lookup_branch(request.branch_from).get_object().hex]

    ori_remote = new_repo.remotes[0]
    # Add the fork as remote repo
    reponame = '%s_%s' % (request.user.user, repo.name)
    remote = new_repo.create_remote(reponame, repopath)

    # Fetch the commits
    remote.fetch()

    merge = new_repo.merge(repo_commit.oid)
    if merge is None:
        mergecode, prefcode = new_repo.merge_analysis(repo_commit.oid)

    try:
        branch_ref = new_repo.lookup_reference(
            request.branch).resolve()
    except ValueError:
        branch_ref = new_repo.lookup_reference(
            'refs/heads/%s' % request.branch).resolve()

    refname = '%s:%s' % (branch_ref.name, branch_ref.name)
    if (
            (merge is not None and merge.is_uptodate)
            or
            (merge is None and
             mergecode & pygit2.GIT_MERGE_ANALYSIS_UP_TO_DATE
             )):
        flask.flash('Nothing to do, changes were already merged', 'error')
        progit.lib.close_pull_request(SESSION, request)
        try:
            SESSION.commit()
        except SQLAlchemyError as err:
            SESSION.rollback()
            APP.logger.exception(err)
            flask.flash('Could not close this pull-request', 'error')
        return flask.redirect(error_output)
    elif (
            (merge is not None and merge.is_fastforward)
            or
            (merge is None and
             mergecode & pygit2.GIT_MERGE_ANALYSIS_FASTFORWARD
             )):
        if merge is not None:
            branch_ref.target = merge.fastforward_oid
            sha = merge.fastforward_oid
        elif merge is None and mergecode is not None:
            print repo_commit.oid
            branch_ref.set_target(repo_commit.oid.hex)
            sha = branch_ref.target
        ori_remote.push(refname)
        flask.flash('Changes merged!')

    else:
        new_repo.index.write()
        try:
            tree = new_repo.index.write_tree()
        except pygit2.GitError:
            shutil.rmtree(newpath)
            flask.flash('Merge conflicts!', 'error')
            return flask.redirect(flask.url_for(
                'request_pull',
                repo=repo.name,
                username=username,
                requestid=requestid))
        head = new_repo.lookup_reference('HEAD').get_object()
        commit = new_repo[head.oid]
        sha = new_repo.create_commit(
            'refs/heads/master',
            repo_commit.author,
            repo_commit.committer,
            'Merge #%s `%s`' % (request.id, request.title),
            tree,
            [head.hex, repo_commit.oid.hex])
        ori_remote.push(refname)
        flask.flash('Changes merged!')

    # Update status
    progit.lib.close_pull_request(SESSION, request, flask.g.fas_user)
    try:
        SESSION.commit()
    except SQLAlchemyError as err:
        SESSION.rollback()
        APP.logger.exception(err)
        flask.flash(
            'Could not update this pull-request in the database',
            'error')
    shutil.rmtree(newpath)

    return flask.redirect(flask.url_for('view_repo', repo=repo.name))


@APP.route('/<repo>/request-pull/cancel/<int:requestid>',
           methods=['POST'])
@APP.route('/fork/<username>/<repo>/request-pull/cancel/<int:requestid>',
           methods=['POST'])
def cancel_request_pull(repo, requestid, username=None):
    """ Cancel request pulling request.
    """

    form = progit.forms.ConfirmationForm()
    if form.validate_on_submit():

        repo = progit.lib.get_project(SESSION, repo, user=username)

        if not repo:
            flask.abort(404, 'Project not found')

        request = progit.lib.search_pull_requests(
            SESSION, project_id=repo.id, requestid=requestid)

        if not request:
            flask.abort(404, 'Pull-request not found')

        if not is_repo_admin(repo):
            flask.abort(
                403,
                'You are not allowed to cancel pull-request for this project')

        progit.lib.close_pull_request(
            SESSION, request, flask.g.fas_user, merged=False)
        try:
            SESSION.commit()
            flask.flash('Request pull canceled!')
        except SQLAlchemyError as err:
            SESSION.rollback()
            APP.logger.exception(err)
            flask.flash(
                'Could not update this pull-request in the database',
                'error')
    else:
        flask.flash('Invalid input submitted', 'error')

    return flask.redirect(flask.url_for('view_repo', repo=repo.name))


# Specific actions


@APP.route('/do_fork/<repo>')
@APP.route('/do_fork/<username>/<repo>')
@cla_required
def fork_project(repo, username=None):
    """ Fork the project specified into the user's namespace
    """
    repo = progit.lib.get_project(SESSION, repo, user=username)

    if repo is None:
        flask.abort(404)

    try:
        message = progit.lib.fork_project(
            session=SESSION,
            repo=repo,
            gitfolder=APP.config['GIT_FOLDER'],
            forkfolder=APP.config['FORK_FOLDER'],
            docfolder=APP.config['DOCS_FOLDER'],
            ticketfolder=APP.config['TICKETS_FOLDER'],
            user=flask.g.fas_user.username)

        SESSION.commit()
        generate_gitolite_acls()
        flask.flash(message)
        return flask.redirect(
            flask.url_for(
                'view_repo',
                username=flask.g.fas_user.username,
                repo=repo.name)
        )
    except progit.exceptions.ProgitException, err:
        flask.flash(str(err), 'error')
    except SQLAlchemyError, err:  # pragma: no cover
        SESSION.rollback()
        flask.flash(str(err), 'error')

    return flask.redirect(flask.url_for('view_repo', repo=repo.name))


@APP.route('/<repo>/diff/<branch_to>..<branch_from>',
           methods=('GET', 'POST'))
@APP.route('/fork/<username>/<repo>/diff/<branch_to>..<branch_from>',
           methods=('GET', 'POST'))
@cla_required
def new_request_pull(repo,  branch_to, branch_from, username=None):
    """ Request pulling the changes from the fork into the project.
    """
    repo = progit.lib.get_project(SESSION, repo, user=username)

    if not repo:
        flask.abort(404)

    if not is_repo_admin(repo):
        flask.abort(
            403,
            'You are not allowed to create pull-requests for this project')

    repopath = _get_repo_path(repo)
    repo_obj = pygit2.Repository(repopath)

    parentpath = _get_parent_repo_path(repo)
    orig_repo = pygit2.Repository(parentpath)

    frombranch = repo_obj.lookup_branch(branch_from)
    if not frombranch:
        flask.abort(
            400,
            'Branch %s does not exist' % branch_from)

    branch = orig_repo.lookup_branch(branch_to)
    if not branch:
        flask.abort(
            400,
            'Branch %s could not be found in the target repo' % branch_to)

    branch = repo_obj.lookup_branch(branch_from)
    commitid = branch.get_object().hex

    diff_commits = []
    if not repo_obj.is_empty and not orig_repo.is_empty:
        orig_commit = orig_repo[
            orig_repo.lookup_branch(branch_to).get_object().hex]

        master_commits = [
            commit.oid.hex
            for commit in orig_repo.walk(
                orig_commit.oid.hex, pygit2.GIT_SORT_TIME)
        ]

        repo_commit = repo_obj[commitid]

        for commit in repo_obj.walk(
                repo_commit.oid.hex, pygit2.GIT_SORT_TIME):
            if commit.oid.hex in master_commits:
                break
            diff_commits.append(commit)

        first_commit = repo_obj[diff_commits[-1].oid.hex]
        diff = repo_obj.diff(
            repo_obj.revparse_single(first_commit.parents[0].oid.hex),
            repo_obj.revparse_single(diff_commits[0].oid.hex)
        )

    elif orig_repo.is_empty:
        orig_commit = None
        repo_commit = repo_obj[repo_obj.head.target]
        diff = repo_commit.tree.diff_to_tree(swap=True)
    else:
        flask.flash(
            'Fork is empty, there are no commits to request pulling',
            'error')
        return flask.redirect(flask.url_for(
            'view_repo', username=username, repo=repo.name))

    form = progit.forms.RequestPullForm()
    if form.validate_on_submit():
        try:
            if orig_commit:
                orig_commit = orig_commit.oid.hex

            parent = repo
            if repo.parent:
                parent = repo.parent

            message = progit.lib.new_pull_request(
                SESSION,
                repo_to=parent,
                branch_to=branch_to,
                branch_from=branch_from,
                repo_from=repo,
                title=form.title.data,
                user=flask.g.fas_user.username,
            )
            try:
                SESSION.commit()
                flask.flash(message)
            except SQLAlchemyError as err:
                SESSION.rollback()
                APP.logger.exception(err)
                flask.flash(
                    'Could not register this pull-request in the database',
                    'error')

            if not parent.is_fork:
                url = flask.url_for(
                    'request_pulls', username=None, repo=parent.name)
            else:
                url = flask.url_for(
                    'request_pulls', username=parent.user, repo=parent.name)

            return flask.redirect(url)
        except progit.exceptions.ProgitException, err:
            flask.flash(str(err), 'error')
        except SQLAlchemyError, err:  # pragma: no cover
            SESSION.rollback()
            flask.flash(str(err), 'error')

    return flask.render_template(
        'pull_request.html',
        select='requests',
        repo=repo,
        username=username,
        repo_obj=repo_obj,
        orig_repo=orig_repo,
        diff_commits=diff_commits,
        diff=diff,
        form=form,
        branches=[
            branch.replace('refs/heads/', '')
            for branch in sorted(orig_repo.listall_references())
        ],
        branch_to=branch_to,
        branch_from=branch_from,
    )