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

"""
 (c) 2015-2017 - Copyright Red Hat Inc

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

"""

import flask
import datetime

from sqlalchemy.exc import SQLAlchemyError

import pagure
import pagure.exceptions
import pagure.lib

from pagure import (
    APP, SESSION, is_repo_committer, api_authenticated,
    urlpattern, is_repo_user
)
from pagure.api import (
    API, api_method, api_login_required, api_login_optional, APIERROR,
    get_authorized_api_project
)


def _get_repo(repo_name, username=None, namespace=None):
    """Check if repository exists and get repository name
    :param repo_name: name of repository
    :param username:
    :param namespace:
    :raises pagure.exceptions.APIError: when repository doesn't exists or
        is disabled
    :return: repository name
    """
    repo = get_authorized_api_project(
        SESSION, repo_name, user=username, namespace=namespace)

    if repo is None:
        raise pagure.exceptions.APIError(
            404, error_code=APIERROR.ENOPROJECT)

    return repo


def _check_issue_tracker(repo):
    """Check if issue tracker is enabled for repository
    :param repo: repository
    :raises pagure.exceptions.APIError: when issue tracker is disabled
    """
    if not repo.settings.get('issue_tracker', True):
        raise pagure.exceptions.APIError(
            404, error_code=APIERROR.ETRACKERDISABLED)


def _check_token(repo, project_token=True):
    """Check if token is valid for the repo
    :param repo: repository name
    :param project_token: set True when project token is required,
        otherwise any token can be used
    :raises pagure.exceptions.APIError: when token is not valid for repo
    """
    if api_authenticated():
        # if there is a project associated with the token, check it
        # if there is no project associated, check if it is required
        if (flask.g.token.project is not None
                and repo != flask.g.token.project) \
                or (flask.g.token.project is None and project_token):
            raise pagure.exceptions.APIError(
                401, error_code=APIERROR.EINVALIDTOK)


def _get_issue(repo, issueid, issueuid=None):
    """Get issue and check permissions
    :param repo: repository name
    :param issueid: issue ID
    :param issueuid: issue Unique ID
    :raises pagure.exceptions.APIError: when issues doesn't exists
    :return: issue
    """
    issue = pagure.lib.search_issues(
        SESSION, repo, issueid=issueid, issueuid=issueuid)

    if issue is None or issue.project != repo:
        raise pagure.exceptions.APIError(404, error_code=APIERROR.ENOISSUE)

    return issue


def _check_private_issue_access(issue):
    """Check if user can access issue. Must be repo commiter
    or author to see private issues.
    :param issue: issue object
    :raises pagure.exceptions.APIError: when access denied
    """
    if (
        issue.private
        and not is_repo_committer(issue.project)
        and (
            not api_authenticated()
            or not issue.user.user == flask.g.fas_user.username
        )
    ):
        raise pagure.exceptions.APIError(
            403, error_code=APIERROR.EISSUENOTALLOWED)


def _check_ticket_access(issue):
    """Check if user can access issue. Must be repo commiter
    or author to see private issues.
    :param issue: issue object
    :raises pagure.exceptions.APIError: when access denied
    """
    # Private tickets require commit access
    _check_private_issue_access(issue)
    # Public tickets require ticket access
    if not is_repo_user(issue.project):
        raise pagure.exceptions.APIError(
            403, error_code=APIERROR.EISSUENOTALLOWED)


def _check_link_custom_field(field, links):
    """Check if the value provided in the link custom field
    is a link.
    ::param field (pagure.lib.model.IssueKeys) : The issue custom field key object.
    ::param links (str): Value of the custom field.
    ::raises pagure.exceptions.APIERROR.EINVALIDISSUEFIELD_LINK when invalid.
    """
    if field.key_type == 'link':
                links = links.split(',')
                for link in links:
                    link = link.replace(' ', '')
                    if not urlpattern.match(link):
                        raise pagure.exceptions.APIError(
                            400, error_code=APIERROR.EINVALIDISSUEFIELD_LINK)

@API.route('/<repo>/new_issue', methods=['POST'])
@API.route('/<namespace>/<repo>/new_issue', methods=['POST'])
@API.route('/fork/<username>/<repo>/new_issue', methods=['POST'])
@API.route('/fork/<username>/<namespace>/<repo>/new_issue', methods=['POST'])
@api_login_required(acls=['issue_create'])
@api_method
def api_new_issue(repo, username=None, namespace=None):
    """
    Create a new issue
    ------------------
    Open a new issue on a project.

    ::

        POST /api/0/<repo>/new_issue
        POST /api/0/<namespace>/<repo>/new_issue

    ::

        POST /api/0/fork/<username>/<repo>/new_issue
        POST /api/0/fork/<username>/<namespace>/<repo>/new_issue

    Input
    ^^^^^

    +-------------------+--------+-------------+---------------------------+
    | Key               | Type   | Optionality | Description               |
    +===================+========+=============+===========================+
    | ``title``         | string | Mandatory   | The title of the issue    |
    +-------------------+--------+-------------+---------------------------+
    | ``issue_content`` | string | Mandatory   | | The description of the  |
    |                   |        |             |   issue                   |
    +-------------------+--------+-------------+---------------------------+
    | ``private``       | boolean| Optional    | | Include this key if     |
    |                   |        |             |   you want a private issue|
    |                   |        |             |   to be created           |
    +-------------------+--------+-------------+---------------------------+

    Sample response
    ^^^^^^^^^^^^^^^

    ::

        {
          "issue": {
            "assignee": null,
            "blocks": [],
            "close_status": null,
            "closed_at": null,
            "comments": [],
            "content": "This issue needs attention",
            "custom_fields": [],
            "date_created": "1479458613",
            "depends": [],
            "id": 1,
            "milestone": null,
            "priority": null,
            "private": false,
            "status": "Open",
            "tags": [],
            "title": "test issue",
            "user": {
              "fullname": "PY C",
              "name": "pingou"
            }
          },
          "message": "Issue created"
        }

    """
    output = {}
    repo = _get_repo(repo, username, namespace)
    _check_issue_tracker(repo)
    _check_token(repo, project_token=False)

    user_obj = pagure.lib.get_user(
        SESSION, flask.g.fas_user.username)
    if not user_obj:
        raise pagure.exceptions.APIError(404, error_code=APIERROR.ENOUSER)

    form = pagure.forms.IssueFormSimplied(csrf_enabled=False)
    if form.validate_on_submit():
        title = form.title.data
        content = form.issue_content.data
        milestone = form.milestone.data
        private = str(form.private.data).lower() in ['true', '1']

        try:
            issue = pagure.lib.new_issue(
                SESSION,
                repo=repo,
                title=title,
                content=content,
                private=private,
                milestone=milestone,
                user=flask.g.fas_user.username,
                ticketfolder=APP.config['TICKETS_FOLDER'],
            )
            SESSION.flush()
            # If there is a file attached, attach it.
            filestream = flask.request.files.get('filestream')
            if filestream and '<!!image>' in issue.content:
                new_filename = pagure.lib.add_attachment(
                    repo=repo,
                    issue=issue,
                    attachmentfolder=APP.config['ATTACHMENTS_FOLDER'],
                    user=user_obj,
                    filename=filestream.filename,
                    filestream=filestream.stream,
                )
                # Replace the <!!image> tag in the comment with the link
                # to the actual image
                filelocation = flask.url_for(
                    'view_issue_raw_file',
                    repo=repo.name,
                    username=username,
                    filename=new_filename,
                )
                new_filename = new_filename.split('-', 1)[1]
                url = '[![%s](%s)](%s)' % (
                    new_filename, filelocation, filelocation)
                issue.content = issue.content.replace('<!!image>', url)
                SESSION.add(issue)
                SESSION.flush()

            SESSION.commit()
            output['message'] = 'Issue created'
            output['issue'] = issue.to_json(public=True)
        except SQLAlchemyError as err:  # pragma: no cover
            SESSION.rollback()
            APP.logger.exception(err)
            raise pagure.exceptions.APIError(400, error_code=APIERROR.EDBERROR)

    else:
        raise pagure.exceptions.APIError(
            400, error_code=APIERROR.EINVALIDREQ, errors=form.errors)

    jsonout = flask.jsonify(output)
    return jsonout


@API.route('/<namespace>/<repo>/issues')
@API.route('/fork/<username>/<repo>/issues')
@API.route('/<repo>/issues')
@API.route('/fork/<username>/<namespace>/<repo>/issues')
@api_login_optional()
@api_method
def api_view_issues(repo, username=None, namespace=None):
    """
    List project's issues
    ---------------------
    List issues of a project.

    ::

        GET /api/0/<repo>/issues
        GET /api/0/<namespace>/<repo>/issues

    ::

        GET /api/0/fork/<username>/<repo>/issues
        GET /api/0/fork/<username>/<namespace>/<repo>/issues

    Parameters
    ^^^^^^^^^^

    +---------------+---------+--------------+---------------------------+
    | Key           | Type    | Optionality  | Description               |
    +===============+=========+==============+===========================+
    | ``status``    | string  | Optional     | | Filters the status of   |
    |               |         |              |   issues. Fetches all the |
    |               |         |              |   issues if status is     |
    |               |         |              |   ``all``. Default:       |
    |               |         |              |   ``Open``                |
    +---------------+---------+--------------+---------------------------+
    | ``tags``      | string  | Optional     | | A list of tags you      |
    |               |         |              |   wish to filter. If      |
    |               |         |              |   you want to filter      |
    |               |         |              |   for issues not having   |
    |               |         |              |   a tag, add an           |
    |               |         |              |   exclamation mark in     |
    |               |         |              |   front of it             |
    +---------------+---------+--------------+---------------------------+
    | ``assignee``  | string  | Optional     | | Filter the issues       |
    |               |         |              |   by assignee             |
    +---------------+---------+--------------+---------------------------+
    | ``author``    | string  | Optional     | | Filter the issues       |
    |               |         |              |   by creator              |
    +---------------+---------+--------------+---------------------------+
    | ``milestones``| list of | Optional     | | Filter the issues       |
    |               | strings |              |   by milestone            |
    +---------------+---------+--------------+---------------------------+
    | ``priority``  | string  | Optional     | | Filter the issues       |
    |               |         |              |   by priority             |
    +---------------+---------+--------------+---------------------------+
    | ``no_stones`` | boolean | Optional     | | If true returns only the|
    |               |         |              |   issues having no        |
    |               |         |              |   milestone, if false     |
    |               |         |              |   returns only the issues |
    |               |         |              |   having a milestone      |
    +---------------+---------+--------------+---------------------------+
    | ``since``     | string  | Optional     | | Filter the issues       |
    |               |         |              |   updated after this date.|
    |               |         |              |   The date can either be  |
    |               |         |              |   provided as an unix date|
    |               |         |              |   or in the format Y-M-D  |
    +---------------+---------+--------------+---------------------------+

    Sample response
    ^^^^^^^^^^^^^^^

    ::

        {
          "args": {
            "assignee": null,
            "author": null,
            'milestones': [],
            'no_stones': null,
            'priority': null,
            "since": null,
            "status": "Closed",
            "tags": [
              "0.1"
            ]
          },
          "total_issues": 1,
          "issues": [
            {
              "assignee": null,
              "blocks": ["1"],
              "comments": [],
              "content": "asd",
              "date_created": "1427442217",
              "depends": [],
              "id": 4,
              "private": false,
              "status": "Fixed",
              "tags": [
                "0.1"
              ],
              "title": "bug",
              "user": {
                "fullname": "PY.C",
                "name": "pingou"
              }
            }
          ]
        }

    """
    repo = _get_repo(repo, username, namespace)
    _check_issue_tracker(repo)
    _check_token(repo)

    assignee = flask.request.args.get('assignee', None)
    author = flask.request.args.get('author', None)
    milestone = flask.request.args.getlist('milestones', None)
    no_stones = flask.request.args.get('no_stones', None)
    if no_stones is not None:
        if str(no_stones).lower() in ['1', 'true', 't']:
            no_stones = True
        else:
            no_stones = False
    priority = flask.request.args.get('priority', None)
    since = flask.request.args.get('since', None)
    status = flask.request.args.get('status', None)
    tags = flask.request.args.getlist('tags')
    tags = [tag.strip() for tag in tags if tag.strip()]

    priority_key = None
    if priority:
        found = False
        if priority in repo.priorities:
            found = True
            priority_key = int(priority)
        else:
            for key, val in repo.priorities.items():
                if val.lower() == priority.lower():
                    priority_key = key
                    found = True
                    break

        if not found:
            raise pagure.exceptions.APIError(
                400, error_code=APIERROR.EINVALIDPRIORITY)

    # Hide private tickets
    private = False
    # If user is authenticated, show him/her his/her private tickets
    if api_authenticated():
        private = flask.g.fas_user.username
    # If user is repo committer, show all tickets included the private ones
    if is_repo_committer(repo):
        private = None

    params = {
        'session': SESSION,
        'repo': repo,
        'tags': tags,
        'assignee': assignee,
        'author': author,
        'private': private,
        'milestones': milestone,
        'priority': priority_key,
        'no_milestones': no_stones,
    }

    if status is not None:
        if status.lower() == 'all':
            params.update({'status': None})
        elif status.lower() == 'closed':
            params.update({'closed': True})
        else:
            params.update({'status': status})
    else:
        params.update({'status': 'Open'})

    updated_after = None
    if since:
        # Validate and convert the time
        if since.isdigit():
            # We assume its a timestamp, so convert it to datetime
            try:
                updated_after = datetime.datetime.fromtimestamp(int(since))
            except ValueError:
                raise pagure.exceptions.APIError(
                    400, error_code=APIERROR.ETIMESTAMP)
        else:
            # We assume datetime format, so validate it
            try:
                updated_after = datetime.datetime.strptime(since, '%Y-%m-%d')
            except ValueError:
                raise pagure.exceptions.APIError(
                    400, error_code=APIERROR.EDATETIME)

    params.update({'updated_after': updated_after})
    issues = pagure.lib.search_issues(**params)
    jsonout = flask.jsonify({
        'total_issues': len(issues),
        'issues': [issue.to_json(public=True) for issue in issues],
        'args': {
            'assignee': assignee,
            'author': author,
            'milestones': milestone,
            'no_stones': no_stones,
            'priority': priority,
            'since': since,
            'status': status,
            'tags': tags,
        }
    })
    return jsonout


@API.route('/<repo>/issue/<issueid>')
@API.route('/<namespace>/<repo>/issue/<issueid>')
@API.route('/fork/<username>/<repo>/issue/<issueid>')
@API.route('/fork/<username>/<namespace>/<repo>/issue/<issueid>')
@api_login_optional()
@api_method
def api_view_issue(repo, issueid, username=None, namespace=None):
    """
    Issue information
    -----------------
    Retrieve information of a specific issue.

    ::

        GET /api/0/<repo>/issue/<issue id>
        GET /api/0/<namespace>/<repo>/issue/<issue id>

    ::

        GET /api/0/fork/<username>/<repo>/issue/<issue id>
        GET /api/0/fork/<username>/<namespace>/<repo>/issue/<issue id>

    The identifier provided can be either the unique identifier or the
    regular identifier used in the UI (for example ``24`` in
    ``/forks/user/test/issue/24``)

    Sample response
    ^^^^^^^^^^^^^^^

    ::

        {
          "assignee": null,
          "blocks": [],
          "comments": [],
          "content": "This issue needs attention",
          "date_created": "1431414800",
          "depends": ["4"],
          "id": 1,
          "private": false,
          "status": "Open",
          "tags": [],
          "title": "test issue",
          "user": {
            "fullname": "PY C",
            "name": "pingou"
          }
        }

    """
    comments = flask.request.args.get('comments', True)
    if str(comments).lower() in ['0', 'False']:
        comments = False

    repo = _get_repo(repo, username, namespace)
    _check_issue_tracker(repo)
    _check_token(repo)

    issue_id = issue_uid = None
    try:
        issue_id = int(issueid)
    except (ValueError, TypeError):
        issue_uid = issueid

    issue = _get_issue(repo, issue_id, issueuid=issue_uid)
    _check_private_issue_access(issue)

    jsonout = flask.jsonify(
        issue.to_json(public=True, with_comments=comments))
    return jsonout


@API.route('/<repo>/issue/<issueid>/comment/<int:commentid>')
@API.route('/<namespace>/<repo>/issue/<issueid>/comment/<int:commentid>')
@API.route('/fork/<username>/<repo>/issue/<issueid>/comment/<int:commentid>')
@API.route(
    '/fork/<username>/<namespace>/<repo>/issue/<issueid>/'
    'comment/<int:commentid>')
@api_login_optional()
@api_method
def api_view_issue_comment(
        repo, issueid, commentid, username=None, namespace=None):
    """
    Comment of an issue
    --------------------
    Retrieve a specific comment of an issue.

    ::

        GET /api/0/<repo>/issue/<issue id>/comment/<comment id>
        GET /api/0/<namespace>/<repo>/issue/<issue id>/comment/<comment id>

    ::

        GET /api/0/fork/<username>/<repo>/issue/<issue id>/comment/<comment id>
        GET /api/0/fork/<username>/<namespace>/<repo>/issue/<issue id>/comment/<comment id>

    The identifier provided can be either the unique identifier or the
    regular identifier used in the UI (for example ``24`` in
    ``/forks/user/test/issue/24``)

    Sample response
    ^^^^^^^^^^^^^^^

    ::

        {
          "avatar_url": "https://seccdn.libravatar.org/avatar/...",
          "comment": "9",
          "comment_date": "2015-07-01 15:08",
          "date_created": "1435756127",
          "id": 464,
          "parent": null,
          "user": {
            "fullname": "P.-Y.C.",
            "name": "pingou"
          }
        }

    """  # noqa: E501

    repo = _get_repo(repo, username, namespace)
    _check_issue_tracker(repo)
    _check_token(repo)

    issue_id = issue_uid = None
    try:
        issue_id = int(issueid)
    except (ValueError, TypeError):
        issue_uid = issueid

    issue = _get_issue(repo, issue_id, issueuid=issue_uid)
    _check_private_issue_access(issue)

    comment = pagure.lib.get_issue_comment(SESSION, issue.uid, commentid)
    if not comment:
        raise pagure.exceptions.APIError(
            404, error_code=APIERROR.ENOCOMMENT)

    output = comment.to_json(public=True)
    output['avatar_url'] = pagure.lib.avatar_url_from_email(
        comment.user.default_email, size=16)
    output['comment_date'] = comment.date_created.strftime(
        '%Y-%m-%d %H:%M:%S')
    jsonout = flask.jsonify(output)
    return jsonout


@API.route('/<repo>/issue/<int:issueid>/status', methods=['POST'])
@API.route('/<namespace>/<repo>/issue/<int:issueid>/status', methods=['POST'])
@API.route(
    '/fork/<username>/<repo>/issue/<int:issueid>/status', methods=['POST'])
@API.route(
    '/fork/<username>/<namespace>/<repo>/issue/<int:issueid>/status',
    methods=['POST'])
@api_login_required(acls=['issue_change_status', 'issue_update'])
@api_method
def api_change_status_issue(repo, issueid, username=None, namespace=None):
    """
    Change issue status
    -------------------
    Change the status of an issue.

    ::

        POST /api/0/<repo>/issue/<issue id>/status
        POST /api/0/<namespace>/<repo>/issue/<issue id>/status

    ::

        POST /api/0/fork/<username>/<repo>/issue/<issue id>/status
        POST /api/0/fork/<username>/<namespace>/<repo>/issue/<issue id>/status

    Input
    ^^^^^

    +------------------+---------+--------------+------------------------+
    | Key              | Type    | Optionality  | Description            |
    +==================+=========+==============+========================+
    | ``close_status`` | string  | Optional     | The close status of    |
    |                  |         |              | the issue              |
    +------------------+---------+--------------+------------------------+
    | ``status``       | string  | Mandatory    | The new status of the  |
    |                  |         |              | issue, can be 'Open' or|
    |                  |         |              | 'Closed'               |
    +------------------+---------+--------------+------------------------+

    Sample response
    ^^^^^^^^^^^^^^^

    ::

        {
          "message": "Successfully edited issue #1"
        }

    """
    output = {}

    repo = _get_repo(repo, username, namespace)
    _check_issue_tracker(repo)
    _check_token(repo, project_token=False)

    issue = _get_issue(repo, issueid)
    _check_ticket_access(issue)

    status = pagure.lib.get_issue_statuses(SESSION)
    form = pagure.forms.StatusForm(
        status=status,
        close_status=repo.close_status,
        csrf_enabled=False)

    if not pagure.is_repo_user(repo) \
            and flask.g.fas_user.username != issue.user.user:
        raise pagure.exceptions.APIError(
            403, error_code=APIERROR.EISSUENOTALLOWED)

    close_status = None
    if form.close_status.raw_data:
        close_status = form.close_status.data
    new_status = form.status.data.strip()
    if new_status in repo.close_status and not close_status:
        close_status = new_status
        new_status = 'Closed'
        form.status.data = new_status

    if form.validate_on_submit():
        try:
            # Update status
            message = pagure.lib.edit_issue(
                SESSION,
                issue=issue,
                status=new_status,
                close_status=close_status,
                user=flask.g.fas_user.username,
                ticketfolder=APP.config['TICKETS_FOLDER'],
            )
            SESSION.commit()
            if message:
                output['message'] = message
            else:
                output['message'] = 'No changes'

            if message:
                pagure.lib.add_metadata_update_notif(
                    session=SESSION,
                    issue=issue,
                    messages=message,
                    user=flask.g.fas_user.username,
                    ticketfolder=APP.config['TICKETS_FOLDER']
                )
        except pagure.exceptions.PagureException as err:
            raise pagure.exceptions.APIError(
                400, error_code=APIERROR.ENOCODE, error=str(err))
        except SQLAlchemyError as err:  # pragma: no cover
            SESSION.rollback()
            raise pagure.exceptions.APIError(400, error_code=APIERROR.EDBERROR)

    else:
        raise pagure.exceptions.APIError(
            400, error_code=APIERROR.EINVALIDREQ, errors=form.errors)

    jsonout = flask.jsonify(output)
    return jsonout


@API.route('/<repo>/issue/<int:issueid>/milestone', methods=['POST'])
@API.route(
    '/<namespace>/<repo>/issue/<int:issueid>/milestone', methods=['POST'])
@API.route(
    '/fork/<username>/<repo>/issue/<int:issueid>/milestone',
    methods=['POST'])
@API.route(
    '/fork/<username>/<namespace>/<repo>/issue/<int:issueid>/milestone',
    methods=['POST'])
@api_login_required(acls=['issue_update_milestone', 'issue_update'])
@api_method
def api_change_milestone_issue(repo, issueid, username=None, namespace=None):
    """
    Change issue milestone
    ----------------------
    Change the milestone of an issue.

    ::

        POST /api/0/<repo>/issue/<issue id>/milestone
        POST /api/0/<namespace>/<repo>/issue/<issue id>/milestone

    ::

        POST /api/0/fork/<username>/<repo>/issue/<issue id>/milestone
        POST /api/0/fork/<username>/<namespace>/<repo>/issue/<issue id>/milestone

    Input
    ^^^^^

    +------------------+---------+--------------+------------------------+
    | Key              | Type    | Optionality  | Description            |
    +==================+=========+==============+========================+
    | ``milestone``    | string  | Optional     | The new milestone of   |
    |                  |         |              | the issue, can be any  |
    |                  |         |              | of defined milestones  |
    |                  |         |              | or empty to unset the  |
    |                  |         |              | milestone              |
    +------------------+---------+--------------+------------------------+

    Sample response
    ^^^^^^^^^^^^^^^

    ::

        {
          "message": "Successfully edited issue #1"
        }

    """  # noqa
    output = {}
    repo = _get_repo(repo, username, namespace)
    _check_issue_tracker(repo)
    _check_token(repo)

    issue = _get_issue(repo, issueid)
    _check_ticket_access(issue)

    form = pagure.forms.MilestoneForm(
        milestones=repo.milestones.keys(),
        csrf_enabled=False)

    if form.validate_on_submit():
        new_milestone = form.milestone.data
        if new_milestone == '':
            new_milestone = None  # unset milestone
        try:
            # Update status
            message = pagure.lib.edit_issue(
                SESSION,
                issue=issue,
                milestone=new_milestone,
                user=flask.g.fas_user.username,
                ticketfolder=APP.config['TICKETS_FOLDER'],
            )
            SESSION.commit()
            if message:
                output['message'] = message
            else:
                output['message'] = 'No changes'

            if message:
                pagure.lib.add_metadata_update_notif(
                    session=SESSION,
                    issue=issue,
                    messages=message,
                    user=flask.g.fas_user.username,
                    ticketfolder=APP.config['TICKETS_FOLDER']
                )
        except pagure.exceptions.PagureException as err:
            raise pagure.exceptions.APIError(
                400, error_code=APIERROR.ENOCODE, error=str(err))
        except SQLAlchemyError as err:  # pragma: no cover
            SESSION.rollback()
            raise pagure.exceptions.APIError(400, error_code=APIERROR.EDBERROR)

    else:
        raise pagure.exceptions.APIError(
            400, error_code=APIERROR.EINVALIDREQ, errors=form.errors)

    jsonout = flask.jsonify(output)
    return jsonout


@API.route('/<repo>/issue/<int:issueid>/comment', methods=['POST'])
@API.route('/<namespace>/<repo>/issue/<int:issueid>/comment', methods=['POST'])
@API.route(
    '/fork/<username>/<repo>/issue/<int:issueid>/comment', methods=['POST'])
@API.route(
    '/fork/<username>/<namespace>/<repo>/issue/<int:issueid>/comment',
    methods=['POST'])
@api_login_required(acls=['issue_comment', 'issue_update'])
@api_method
def api_comment_issue(repo, issueid, username=None, namespace=None):
    """
    Comment to an issue
    -------------------
    Add a comment to an issue.

    ::

        POST /api/0/<repo>/issue/<issue id>/comment
        POST /api/0/<namespace>/<repo>/issue/<issue id>/comment

    ::

        POST /api/0/fork/<username>/<repo>/issue/<issue id>/comment
        POST /api/0/fork/<username>/<namespace>/<repo>/issue/<issue id>/comment

    Input
    ^^^^^

    +--------------+----------+---------------+---------------------------+
    | Key          | Type     | Optionality   | Description               |
    +==============+==========+===============+===========================+
    | ``comment``  | string   | Mandatory     | | The comment to add to   |
    |              |          |               |   the issue               |
    +--------------+----------+---------------+---------------------------+

    Sample response
    ^^^^^^^^^^^^^^^

    ::

        {
          "message": "Comment added"
        }

    """
    output = {}
    repo = _get_repo(repo, username, namespace)
    _check_issue_tracker(repo)
    _check_token(repo, project_token=False)

    issue = _get_issue(repo, issueid)
    _check_private_issue_access(issue)

    form = pagure.forms.CommentForm(csrf_enabled=False)
    if form.validate_on_submit():
        comment = form.comment.data
        try:
            # New comment
            message = pagure.lib.add_issue_comment(
                SESSION,
                issue=issue,
                comment=comment,
                user=flask.g.fas_user.username,
                ticketfolder=APP.config['TICKETS_FOLDER'],
            )
            SESSION.commit()
            output['message'] = message
        except SQLAlchemyError as err:  # pragma: no cover
            SESSION.rollback()
            APP.logger.exception(err)
            raise pagure.exceptions.APIError(400, error_code=APIERROR.EDBERROR)

    else:
        raise pagure.exceptions.APIError(
            400, error_code=APIERROR.EINVALIDREQ, errors=form.errors)

    jsonout = flask.jsonify(output)
    return jsonout


@API.route('/<repo>/issue/<int:issueid>/assign', methods=['POST'])
@API.route('/<namespace>/<repo>/issue/<int:issueid>/assign', methods=['POST'])
@API.route(
    '/fork/<username>/<repo>/issue/<int:issueid>/assign', methods=['POST'])
@API.route(
    '/fork/<username>/<namespace>/<repo>/issue/<int:issueid>/assign',
    methods=['POST'])
@api_login_required(acls=['issue_assign', 'issue_update'])
@api_method
def api_assign_issue(repo, issueid, username=None, namespace=None):
    """
    Assign an issue
    ---------------
    Assign an issue to someone.

    ::

        POST /api/0/<repo>/issue/<issue id>/assign
        POST /api/0/<namespace>/<repo>/issue/<issue id>/assign

    ::

        POST /api/0/fork/<username>/<repo>/issue/<issue id>/assign
        POST /api/0/fork/<username>/<namespace>/<repo>/issue/<issue id>/assign

    Input
    ^^^^^

    +--------------+----------+---------------+---------------------------+
    | Key          | Type     | Optionality   | Description               |
    +==============+==========+===============+===========================+
    | ``assignee`` | string   | Mandatory     | | The username of the user|
    |              |          |               |   to assign the issue to. |
    +--------------+----------+---------------+---------------------------+

    Sample response
    ^^^^^^^^^^^^^^^

    ::

        {
          "message": "Issue assigned"
        }

    """
    output = {}
    repo = _get_repo(repo, username, namespace)
    _check_issue_tracker(repo)
    _check_token(repo)

    issue = _get_issue(repo, issueid)
    _check_ticket_access(issue)

    form = pagure.forms.AssignIssueForm(csrf_enabled=False)
    if form.validate_on_submit():
        assignee = form.assignee.data or None
        # Create our metadata comment object
        try:
            # New comment
            message = pagure.lib.add_issue_assignee(
                SESSION,
                issue=issue,
                assignee=assignee,
                user=flask.g.fas_user.username,
                ticketfolder=APP.config['TICKETS_FOLDER'],
            )
            SESSION.commit()
            if message:
                pagure.lib.add_metadata_update_notif(
                    session=SESSION,
                    issue=issue,
                    messages=message,
                    user=flask.g.fas_user.username,
                    ticketfolder=APP.config['TICKETS_FOLDER']
                )
                output['message'] = message
            else:
                output['message'] = 'Nothing to change'
        except pagure.exceptions.PagureException as err:  # pragma: no cover
            raise pagure.exceptions.APIError(
                400, error_code=APIERROR.ENOCODE, error=str(err))
        except SQLAlchemyError as err:  # pragma: no cover
            SESSION.rollback()
            APP.logger.exception(err)
            raise pagure.exceptions.APIError(400, error_code=APIERROR.EDBERROR)

    else:
        raise pagure.exceptions.APIError(
            400, error_code=APIERROR.EINVALIDREQ, errors=form.errors)

    jsonout = flask.jsonify(output)
    return jsonout


@API.route('/<repo>/issue/<int:issueid>/subscribe', methods=['POST'])
@API.route(
    '/<namespace>/<repo>/issue/<int:issueid>/subscribe', methods=['POST'])
@API.route(
    '/fork/<username>/<repo>/issue/<int:issueid>/subscribe', methods=['POST'])
@API.route(
    '/fork/<username>/<namespace>/<repo>/issue/<int:issueid>/subscribe',
    methods=['POST'])
@api_login_required(acls=['issue_subscribe'])
@api_method
def api_subscribe_issue(repo, issueid, username=None, namespace=None):
    """
    Subscribe to an issue
    ---------------------
    Allows someone to subscribe to or unsubscribe from the notifications
    related to an issue.

    ::

        POST /api/0/<repo>/issue/<issue id>/subscribe
        POST /api/0/<namespace>/<repo>/issue/<issue id>/subscribe

    ::

        POST /api/0/fork/<username>/<repo>/issue/<issue id>/subscribe
        POST /api/0/fork/<username>/<namespace>/<repo>/issue/<issue id>/subscribe

    Input
    ^^^^^

    +--------------+----------+---------------+---------------------------+
    | Key          | Type     | Optionality   | Description               |
    +==============+==========+===============+===========================+
    | ``status``   | boolean  | Mandatory     | The intended subscription |
    |              |          |               | status. ``true`` for      |
    |              |          |               | subscribing, ``false``    |
    |              |          |               | for unsubscribing.        |
    +--------------+----------+---------------+---------------------------+

    Sample response
    ^^^^^^^^^^^^^^^

    ::

        {
          "message": "User subscribed"
        }

    """  # noqa
    output = {}
    repo = _get_repo(repo, username, namespace)
    _check_issue_tracker(repo)
    _check_token(repo)

    issue = _get_issue(repo, issueid)
    _check_private_issue_access(issue)

    form = pagure.forms.SubscribtionForm(csrf_enabled=False)
    if form.validate_on_submit():
        status = str(form.status.data).strip().lower() in ['1', 'true']
        try:
            # Toggle subscribtion
            message = pagure.lib.set_watch_obj(
                SESSION,
                user=flask.g.fas_user.username,
                obj=issue,
                watch_status=status
            )
            SESSION.commit()
            output['message'] = message
        except SQLAlchemyError as err:  # pragma: no cover
            SESSION.rollback()
            APP.logger.exception(err)
            raise pagure.exceptions.APIError(400, error_code=APIERROR.EDBERROR)

    else:
        raise pagure.exceptions.APIError(
            400, error_code=APIERROR.EINVALIDREQ, errors=form.errors)

    jsonout = flask.jsonify(output)
    return jsonout


@API.route('/<repo>/issue/<int:issueid>/custom/<field>', methods=['POST'])
@API.route(
    '/<namespace>/<repo>/issue/<int:issueid>/custom/<field>',
    methods=['POST'])
@API.route(
    '/fork/<username>/<repo>/issue/<int:issueid>/custom/<field>',
    methods=['POST'])
@API.route(
    '/fork/<username>/<namespace>/<repo>/issue/<int:issueid>/custom/<field>',
    methods=['POST'])
@api_login_required(acls=['issue_update_custom_fields', 'issue_update'])
@api_method
def api_update_custom_field(
        repo, issueid, field, username=None, namespace=None):
    """
    Update custom field
    -------------------
    Update or reset the content of a custom field associated to an issue.

    ::

        POST /api/0/<repo>/issue/<issue id>/custom/<field>
        POST /api/0/<namespace>/<repo>/issue/<issue id>/custom/<field>

    ::

        POST /api/0/fork/<username>/<repo>/issue/<issue id>/custom/<field>
        POST /api/0/fork/<username>/<namespace>/<repo>/issue/<issue id>/custom/<field>

    Input
    ^^^^^

    +------------------+---------+--------------+-------------------------+
    | Key              | Type    | Optionality  | Description             |
    +==================+=========+==============+=========================+
    | ``value``        | string  | Optional     | The new value of the    |
    |                  |         |              | custom field of interest|
    +------------------+---------+--------------+-------------------------+

    Sample response
    ^^^^^^^^^^^^^^^

    ::

        {
          "message": "Custom field adjusted"
        }

    """  # noqa
    output = {}
    repo = _get_repo(repo, username, namespace)
    _check_issue_tracker(repo)
    _check_token(repo)

    issue = _get_issue(repo, issueid)
    _check_ticket_access(issue)

    fields = {k.name: k for k in repo.issue_keys}
    if field not in fields:
        raise pagure.exceptions.APIError(
            400, error_code=APIERROR.EINVALIDISSUEFIELD)

    key = fields[field]
    value = flask.request.form.get('value')
    if value:
        if key.key_type == 'link':
            links = value.split(',')
            for link in links:
                link = link.replace(' ', '')
                if not urlpattern.match(link):
                    raise pagure.exceptions.APIError(
                        400, error_code=APIERROR.EINVALIDISSUEFIELD_LINK)
    try:
        message = pagure.lib.set_custom_key_value(
            SESSION, issue, key, value)

        SESSION.commit()
        if message:
            output['message'] = message
            pagure.lib.add_metadata_update_notif(
                session=SESSION,
                issue=issue,
                messages=message,
                user=flask.g.fas_user.username,
                ticketfolder=APP.config['TICKETS_FOLDER']
            )
        else:
            output['message'] = 'No changes'
    except pagure.exceptions.PagureException as err:
        raise pagure.exceptions.APIError(
            400, error_code=APIERROR.ENOCODE, error=str(err))
    except SQLAlchemyError as err:  # pragma: no cover
        print err
        SESSION.rollback()
        raise pagure.exceptions.APIError(400, error_code=APIERROR.EDBERROR)

    if message:
        pagure.lib.add_metadata_update_notif(
            session=SESSION,
            issue=issue,
            messages=message,
            user=flask.g.fas_user.username,
            ticketfolder=APP.config['TICKETS_FOLDER']
        )

    jsonout = flask.jsonify(output)
    return jsonout


@API.route('/<repo>/issue/<int:issueid>/custom', methods=['POST'])
@API.route(
    '/<namespace>/<repo>/issue/<int:issueid>/custom',
    methods=['POST'])
@API.route(
    '/fork/<username>/<repo>/issue/<int:issueid>/custom',
    methods=['POST'])
@API.route(
    '/fork/<username>/<namespace>/<repo>/issue/<int:issueid>/custom',
    methods=['POST'])
@api_login_required(acls=['issue_update_custom_fields', 'issue_update'])
@api_method
def api_update_custom_fields(
        repo, issueid, username=None, namespace=None):
    """
    Update custom fields
    --------------------
    Update or reset the content of a collection of custom fields
    associated to an issue.

    ::

        POST /api/0/<repo>/issue/<issue id>/custom
        POST /api/0/<namespace>/<repo>/issue/<issue id>/custom

    ::

        POST /api/0/fork/<username>/<repo>/issue/<issue id>/custom
        POST /api/0/fork/<username>/<namespace>/<repo>/issue/<issue id>/custom

    Input
    ^^^^^

    +------------------+---------+--------------+-----------------------------+
    | Key              | Type    | Optionality  | Description                 |
    +==================+=========+==============+=============================+
    | ``fields``       | dict    | Mandatory    | A dictionary with the field |
    |                  |         |              | name as key and the value   |
    +------------------+---------+--------------+-----------------------------+

    Sample response
    ^^^^^^^^^^^^^^^

    ::

        {
          "fields": [
            {
              "myField" : "Custom field myField adjusted to test (was: to do)"
            },
            {
              "myField_1": "Custom field myField_1 adjusted to done (was: test)"
            }
          ]
        }

    """  # noqa
    output = {'fields': []}
    repo = _get_repo(repo, username, namespace)
    _check_issue_tracker(repo)
    _check_token(repo)

    issue = _get_issue(repo, issueid)
    _check_ticket_access(issue)

    fields = flask.request.get_json(force=True, silent=True)

    if fields is None or fields.get('fields') is None:
        raise pagure.exceptions.APIError(
            400, error_code=APIERROR.EINVALIDCUSTOMFIELDS)

    fields = fields.get('fields')

    repo_fields = {k.name: k for k in repo.issue_keys}

    if not all(key in repo_fields.keys() for key in fields.keys()):
        raise pagure.exceptions.APIError(
            400, error_code=APIERROR.EINVALIDISSUEFIELD)

    for field in fields:
        key = repo_fields[field]
        value = fields.get(key.name)
        if value:
            _check_link_custom_field(key, value)
        try:
            message = pagure.lib.set_custom_key_value(
                SESSION, issue, key, value)

            SESSION.commit()
            if message:
                output['fields'].append({key.name: message})
                pagure.lib.add_metadata_update_notif(
                    session=SESSION,
                    issue=issue,
                    messages=message,
                    user=flask.g.fas_user.username,
                    ticketfolder=APP.config['TICKETS_FOLDER']
                )
            else:
                output['fields'].append({key.name: 'No changes'})
        except pagure.exceptions.PagureException as err:
            raise pagure.exceptions.APIError(
                400, error_code=APIERROR.ENOCODE, error=str(err))
        except SQLAlchemyError as err:  # pragma: no cover
            print err
            SESSION.rollback()
            raise pagure.exceptions.APIError(400, error_code=APIERROR.EDBERROR)

    jsonout = flask.jsonify(output)
    return jsonout