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

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

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

"""

from __future__ import print_function, unicode_literals, absolute_import

import flask
import datetime
import logging

import arrow
from sqlalchemy.exc import SQLAlchemyError

import pagure.exceptions
import pagure.lib.query
from pagure.api import (
    API,
    api_method,
    api_login_required,
    api_login_optional,
    APIERROR,
    get_request_data,
    get_page,
    get_per_page,
)
from pagure.config import config as pagure_config
from pagure.utils import (
    api_authenticated,
    is_repo_committer,
    urlpattern,
    is_true,
)
from pagure.api.utils import (
    _get_repo,
    _check_token,
    _get_issue,
    _check_issue_tracker,
    _check_ticket_access,
    _check_private_issue_access,
)

_log = logging.getLogger(__name__)


def _check_link_custom_field(field, links):
    """Check if the value provided in the link custom field
    is a link.
    :param field : The issue custom field key object.
    :param links : Value of the custom field.
    :raises pagure.exceptions.APIERROR 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           |
    +-------------------+--------+-------------+---------------------------+
    | ``priority``      | string | Optional    | | The priority to set to  |
    |                   |        |             |   this ticket from the    |
    |                   |        |             |   list of priorities set  |
    |                   |        |             |   in the project          |
    +-------------------+--------+-------------+---------------------------+
    | ``milestone``     | string | Optional    | | The milestone to assign |
    |                   |        |             |   to this ticket from the |
    |                   |        |             |   list of milestones set  |
    |                   |        |             |   in the project          |
    +-------------------+--------+-------------+---------------------------+
    | ``tag``           | string | Optional    | | Comma separated list of |
    |                   |        |             |   tags to link to this    |
    |                   |        |             |   ticket from the list of |
    |                   |        |             |   tags in the project     |
    +-------------------+--------+-------------+---------------------------+
    | ``assignee``      | string | Optional    | | The username of the user|
    |                   |        |             |   to assign this ticket to|
    +-------------------+--------+-------------+---------------------------+

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

    ::

        {
          "issue": {
            "assignee": null,
            "blocks": [],
            "close_status": null,
            "closed_at": null,
            "closed_by": 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.query.get_user(
        flask.g.session, flask.g.fas_user.username
    )
    if not user_obj:
        raise pagure.exceptions.APIError(404, error_code=APIERROR.ENOUSER)

    form = pagure.forms.IssueFormSimplied(
        priorities=repo.priorities,
        milestones=repo.milestones,
        csrf_enabled=False,
    )
    if form.validate_on_submit():
        title = form.title.data
        content = form.issue_content.data
        milestone = form.milestone.data or None
        private = is_true(form.private.data)
        priority = form.priority.data or None
        assignee = get_request_data().get("assignee", "").strip() or None
        tags = [
            tag.strip()
            for tag in get_request_data().get("tag", "").split(",")
            if tag.strip()
        ]

        try:
            issue = pagure.lib.query.new_issue(
                flask.g.session,
                repo=repo,
                title=title,
                content=content,
                private=private,
                assignee=assignee,
                milestone=milestone,
                priority=priority,
                tags=tags,
                user=flask.g.fas_user.username,
            )
            flask.g.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.query.add_attachment(
                    repo=repo,
                    issue=issue,
                    attachmentfolder=pagure_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(
                    "ui_ns.view_issue_raw_file",
                    repo=repo.name,
                    username=username,
                    filename="files/%s" % new_filename,
                )
                new_filename = new_filename.split("-", 1)[1]
                url = "[![%s](%s)](%s)" % (
                    new_filename,
                    filelocation,
                    filelocation,
                )
                issue.content = issue.content.replace("<!!image>", url)
                flask.g.session.add(issue)
                flask.g.session.flush()

            flask.g.session.commit()
            output["message"] = "Issue created"
            output["issue"] = issue.to_json(public=True)
        except SQLAlchemyError as err:  # pragma: no cover
            flask.g.session.rollback()
            _log.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  |
    +---------------+---------+--------------+---------------------------+
    | ``order``     | string  | Optional     | | Set the ordering of the |
    |               |         |              |   issues. This can be     |
    |               |         |              |   ``asc`` or ``desc``.    |
    |               |         |              |   Default: ``desc``       |
    +---------------+---------+--------------+---------------------------+
    | ``page``      | int      | Optional    | | Specifies which         |
    |               |          |             |   page to return          |
    |               |          |             |   (defaults to: 1)        |
    +---------------+----------+-------------+---------------------------+
    | ``per_page``  | int      | Optional    | | The number of projects  |
    |               |          |             |   to return per page.     |
    |               |          |             |   The maximum is 100.     |
    +---------------+----------+-------------+---------------------------+

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

    ::

        {
          "args": {
            "assignee": null,
            "author": null,
            'milestones': [],
            'no_stones': null,
            'order': null,
            'priority': null,
            "since": null,
            "status": "Closed",
            "tags": [
              "0.1"
            ]
          },
          "total_issues": 1,
          "issues": [
            {
              "assignee": null,
              "blocks": ["1"],
              "close_status": null,
              "closed_at": null,
              "closed_by": null,
              "comments": [],
              "content": "asd",
              "custom_fields": [],
              "date_created": "1427442217",
              "depends": [],
              "id": 4,
              "last_updated": "1533815358",
              "milestone": null,
              "priority": null,
              "private": false,
              "status": "Fixed",
              "tags": [
                "0.1"
              ],
              "title": "bug",
              "user": {
                "fullname": "PY.C",
                "name": "pingou"
              }
            }
          ],
          'pagination': {
            'first': 'http://localhost/api/0/test/issues?per_page=20&page=1',
            'last': 'http://localhost/api/0/test/issues?per_page=20&page=1',
            'next': null,
            'page': 1,
            'pages': 1,
            'per_page': 20,
            'prev': null
          },
        }

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

    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:
        no_stones = is_true(no_stones)
    priority = flask.request.args.get("priority", None)
    since = flask.request.args.get("since", None)
    order = flask.request.args.get("order", None)
    status = flask.request.args.get("status", None)
    tags = flask.request.args.getlist("tags")
    tags = [tag.strip() for tag in tags if tag.strip()]
    search_id = flask.request.args.get("query_id", None)

    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": flask.g.session,
        "repo": repo,
        "tags": tags,
        "assignee": assignee,
        "author": author,
        "private": private,
        "milestones": milestone,
        "priority": priority_key,
        "order": order,
        "no_milestones": no_stones,
        "search_id": search_id,
    }

    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 = arrow.get(int(since)).datetime
            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})

    page = get_page()
    per_page = get_per_page()
    params["count"] = True
    issue_cnt = pagure.lib.query.search_issues(**params)
    pagination_metadata = pagure.lib.query.get_pagination_metadata(
        flask.request, page, per_page, issue_cnt
    )
    query_start = (page - 1) * per_page
    query_limit = per_page

    params["count"] = False
    params["limit"] = query_limit
    params["offset"] = query_start
    issues = pagure.lib.query.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,
                "order": order,
                "priority": priority,
                "since": since,
                "status": status,
                "tags": tags,
            },
            "pagination": pagination_metadata,
        }
    )
    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 = is_true(flask.request.args.get("comments", True))

    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.query.get_issue_comment(
        flask.g.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.query.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)
    open_access = repo.settings.get("open_metadata_access_to_all", False)
    _check_ticket_access(issue, assignee=True, open_access=open_access)

    status = pagure.lib.query.get_issue_statuses(flask.g.session)
    form = pagure.forms.StatusForm(
        status=status, close_status=repo.close_status, csrf_enabled=False
    )

    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.query.edit_issue(
                flask.g.session,
                issue=issue,
                status=new_status,
                close_status=close_status,
                user=flask.g.fas_user.username,
            )
            flask.g.session.commit()
            if message:
                output["message"] = message
            else:
                output["message"] = "No changes"

            if message:
                pagure.lib.query.add_metadata_update_notif(
                    session=flask.g.session,
                    obj=issue,
                    messages=message,
                    user=flask.g.fas_user.username,
                )
        except pagure.exceptions.PagureException as err:
            raise pagure.exceptions.APIError(
                400, error_code=APIERROR.ENOCODE, error=str(err)
            )
        except SQLAlchemyError:  # pragma: no cover
            flask.g.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)
    open_access = repo.settings.get("open_metadata_access_to_all", False)
    _check_ticket_access(issue, open_access=open_access)

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

    if form.validate_on_submit():
        new_milestone = form.milestone.data or None
        try:
            # Update status
            message = pagure.lib.query.edit_issue(
                flask.g.session,
                issue=issue,
                milestone=new_milestone,
                user=flask.g.fas_user.username,
            )
            flask.g.session.commit()
            if message:
                output["message"] = message
            else:
                output["message"] = "No changes"

            if message:
                pagure.lib.query.add_metadata_update_notif(
                    session=flask.g.session,
                    obj=issue,
                    messages=message,
                    user=flask.g.fas_user.username,
                )
        except pagure.exceptions.PagureException as err:
            raise pagure.exceptions.APIError(
                400, error_code=APIERROR.ENOCODE, error=str(err)
            )
        except SQLAlchemyError:  # pragma: no cover
            flask.g.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.query.add_issue_comment(
                flask.g.session,
                issue=issue,
                comment=comment,
                user=flask.g.fas_user.username,
            )
            flask.g.session.commit()
            output["message"] = message
        except SQLAlchemyError as err:  # pragma: no cover
            flask.g.session.rollback()
            _log.exception(err)
            raise pagure.exceptions.APIError(400, error_code=APIERROR.EDBERROR)

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

    output["avatar_url"] = pagure.lib.query.avatar_url_from_email(
        flask.g.fas_user.default_email, size=30
    )

    output["user"] = flask.g.fas_user.username

    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)
    open_access = repo.settings.get("open_metadata_access_to_all", False)
    _check_ticket_access(issue, assignee=True, open_access=open_access)

    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.query.add_issue_assignee(
                flask.g.session,
                issue=issue,
                assignee=assignee,
                user=flask.g.fas_user.username,
            )
            flask.g.session.commit()
            if message:
                pagure.lib.query.add_metadata_update_notif(
                    session=flask.g.session,
                    obj=issue,
                    messages=message,
                    user=flask.g.fas_user.username,
                )
                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
            flask.g.session.rollback()
            _log.exception(err)
            raise pagure.exceptions.APIError(400, error_code=APIERROR.EDBERROR)

    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",
          "avatar_url": "https://image.png",
          "user": "pingou"
        }

    """  # 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 = is_true(form.status.data)
        try:
            # Toggle subscribtion
            message = pagure.lib.query.set_watch_obj(
                flask.g.session,
                user=flask.g.fas_user.username,
                obj=issue,
                watch_status=status,
            )
            flask.g.session.commit()
            output["message"] = message
            user_obj = pagure.lib.query.get_user(
                flask.g.session, flask.g.fas_user.username
            )
            output["avatar_url"] = pagure.lib.query.avatar_url_from_email(
                user_obj.default_email, size=30
            )
            output["user"] = flask.g.fas_user.username
        except SQLAlchemyError as err:  # pragma: no cover
            flask.g.session.rollback()
            _log.exception(err)
            raise pagure.exceptions.APIError(400, error_code=APIERROR.EDBERROR)

    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)
    open_access = repo.settings.get("open_metadata_access_to_all", False)
    _check_ticket_access(issue, open_access=open_access)

    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 = get_request_data().get("value")
    if value:
        _check_link_custom_field(key, value)
    try:
        message = pagure.lib.query.set_custom_key_value(
            flask.g.session, issue, key, value
        )

        flask.g.session.commit()
        if message:
            output["message"] = message
            pagure.lib.query.add_metadata_update_notif(
                session=flask.g.session,
                obj=issue,
                messages=message,
                user=flask.g.fas_user.username,
            )
        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)
        flask.g.session.rollback()
        raise pagure.exceptions.APIError(400, error_code=APIERROR.EDBERROR)

    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                 |
    +==================+=========+==============+=============================+
    | ``myfields``     | dict    | Mandatory    | A dictionary with the fields|
    |                  |         |              | name as key and the value   |
    +------------------+---------+--------------+-----------------------------+

    Sample payload
    ^^^^^^^^^^^^^^

    ::

      {
         "myField": "to do",
         "myField_1": "test",
         "myField_2": "done",
      }

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

    ::

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

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

    issue = _get_issue(repo, issueid)
    open_access = repo.settings.get("open_metadata_access_to_all", False)
    _check_ticket_access(issue, open_access=open_access)

    fields = get_request_data()

    if not fields:
        raise pagure.exceptions.APIError(400, error_code=APIERROR.EINVALIDREQ)

    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.query.set_custom_key_value(
                flask.g.session, issue, key, value
            )

            flask.g.session.commit()
            if message:
                output["messages"].append({key.name: message})
                pagure.lib.query.add_metadata_update_notif(
                    session=flask.g.session,
                    obj=issue,
                    messages=message,
                    user=flask.g.fas_user.username,
                )
            else:
                output["messages"].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)
            flask.g.session.rollback()
            raise pagure.exceptions.APIError(400, error_code=APIERROR.EDBERROR)

    jsonout = flask.jsonify(output)
    return jsonout


@API.route("/<repo>/issues/history/stats")
@API.route("/<namespace>/<repo>/issues/history/stats")
@API.route("/fork/<username>/<repo>/issues/history/stats")
@API.route("/fork/<username>/<namespace>/<repo>/issues/history/stats")
@api_method
def api_view_issues_history_stats(repo, username=None, namespace=None):
    """
    List project's statistical issues history.
    ------------------------------------------
    Provides the number of opened issues over the last 6 months of the
    project.

    ::

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

    ::

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


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

    ::

        {
          "stats": {
            ...
            "2017-09-19T13:10:51.041345": 6,
            "2017-09-26T13:10:51.041345": 6,
            "2017-10-03T13:10:51.041345": 6,
            "2017-10-10T13:10:51.041345": 6,
            "2017-10-17T13:10:51.041345": 6
          }
        }

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

    stats = pagure.lib.query.issues_history_stats(flask.g.session, repo)
    jsonout = flask.jsonify({"stats": stats})
    return jsonout