6.9 KiB · text History b868907
defmodule GitGudWeb.Api.Mobile.IssueController do
  @moduledoc """
  Issue viewer, opener and commenter.

  Bodies ship twice — `body` (the markdown source, so the phone's
  quote-reply can prefix it with `> `) and `body_html` (rendered here,
  because reimplementing GFM plus cross-references plus syntax
  highlighting in Kotlin is not a thing anyone should do).
  """
  use GitGudWeb, :controller

  import GitGudWeb.Api.Mobile.Helpers

  alias GitGud.Issues
  alias GitGud.Labels
  alias GitGudWeb.Api.Mobile.Auth
  alias GitGudWeb.Api.Mobile.JSON

  def index(conn, %{"owner" => owner, "name" => name} = params) do
    with {:ok, user} <- Auth.require_scope(conn, "issues:read"),
         {:ok, repo} <- fetch_repo(conn, user, owner, name) do
      issues =
        Issues.list_issues(repo,
          state: state_param(params),
          search: params["search"],
          limit: int_param(params, "limit", 50, 100),
          offset: int_param(params, "offset", 0, 10_000)
        )

      json(conn, %{"issues" => Enum.map(issues, &JSON.issue_summary/1)})
    else
      {:error, conn} -> conn
    end
  end

  def show(conn, %{"owner" => owner, "name" => name, "number" => number}) do
    with {:ok, user} <- Auth.require_scope(conn, "issues:read"),
         {:ok, repo} <- fetch_repo(conn, user, owner, name),
         {:ok, n} <- number_param(conn, number),
         {:ok, issue} <- get_issue(conn, repo, n) do
      json(conn, %{
        "issue" => JSON.issue(issue, repo),
        "comments" => Enum.map(issue.comments, &JSON.comment(&1, repo)),
        "can_comment" => GitGud.Organizations.can?(user, repo, :read),
        "can_close" => can_close?(user, repo, issue)
      })
    else
      {:error, conn} -> conn
    end
  end

  @doc """
  Open an issue: `title`, and optionally `body` and `labels` (names of
  the repository's labels, its own or inherited). Labels are applied
  for someone with triage access, as on the web — and for a job, whose
  `issues: write` is the say-so — and quietly left off otherwise; a
  name the repository has no label for is an error.
  """
  def create(conn, %{"owner" => owner, "name" => name} = params) do
    with {:ok, user} <- Auth.require_scope(conn, "issues:write"),
         {:ok, repo} <- fetch_repo(conn, user, owner, name),
         {:ok, title} <- require_title(conn, params),
         {:ok, labels} <- find_labels(conn, repo, params["labels"]),
         :ok <- job_quota(conn, :job_issues) do
      attrs = %{"title" => title, "body" => params["body"]}

      case Issues.create_issue(repo, user, attrs, job_opts(conn)) do
        {:ok, issue} ->
          if conn.assigns[:api_job] || GitGud.Organizations.can?(user, repo, :triage),
            do: Enum.each(labels, &Issues.attach_label(issue, &1))

          :ok = GitGud.Events.record_cross_references(issue, repo, nil, issue.body, user)

          conn
          |> put_status(201)
          |> json(%{"issue" => JSON.issue(Issues.get_issue!(repo, issue.number), repo)})

        {:error, :banned} ->
          send_error(conn, 403, "you are banned from this repository")

        {:error, %Ecto.Changeset{} = cs} ->
          send_error(conn, 422, changeset_message(cs))
      end
    else
      {:error, conn} -> conn
    end
  end

  defp require_title(conn, params) do
    case String.trim(to_string(params["title"] || "")) do
      "" -> {:error, send_error(conn, 422, "title can't be blank")}
      title -> {:ok, title}
    end
  end

  defp find_labels(_conn, _repo, nil), do: {:ok, []}

  defp find_labels(conn, repo, names) when is_list(names) do
    pickable = Labels.list_labels(repo)

    Enum.reduce_while(names, {:ok, []}, fn name, {:ok, acc} ->
      case Enum.find(pickable, &(&1.name == name)) do
        nil -> {:halt, {:error, send_error(conn, 422, "no label named #{name} here")}}
        label -> {:cont, {:ok, [label | acc]}}
      end
    end)
    |> case do
      {:ok, found} -> {:ok, Enum.reverse(found)}
      error -> error
    end
  end

  defp find_labels(conn, _repo, _),
    do: {:error, send_error(conn, 422, "labels must be a list of names")}

  def comment(conn, %{"owner" => owner, "name" => name, "number" => number} = params) do
    with {:ok, user} <- Auth.require_scope(conn, "issues:write"),
         {:ok, repo} <- fetch_repo(conn, user, owner, name),
         {:ok, n} <- number_param(conn, number),
         {:ok, issue} <- get_issue(conn, repo, n),
         {:ok, body} <- require_body(conn, params) do
      case Issues.add_comment(issue, user, %{"body" => body}) do
        {:ok, comment} ->
          comment = GitGud.Repo.preload(comment, :author)
          conn |> put_status(201) |> json(%{"comment" => JSON.comment(comment, repo)})

        {:error, :banned} ->
          send_error(conn, 403, "you are banned from this repository")

        {:error, %Ecto.Changeset{} = cs} ->
          send_error(conn, 422, changeset_message(cs))
      end
    else
      {:error, conn} -> conn
    end
  end

  @doc """
  Close or reopen. Gated on `:triage` rather than the issue's author,
  plus an explicit author exception — the same rule the web UI applies,
  restated here because the context doesn't enforce it.
  """
  def set_state(conn, %{"owner" => owner, "name" => name, "number" => number} = params) do
    with {:ok, user} <- Auth.require_scope(conn, "issues:write"),
         {:ok, repo} <- fetch_repo(conn, user, owner, name),
         {:ok, n} <- number_param(conn, number),
         {:ok, issue} <- get_issue(conn, repo, n),
         {:ok, state} <- require_state(conn, params),
         :ok <- authorize_close(conn, user, repo, issue) do
      case Issues.set_state(issue, state) do
        {:ok, updated} -> json(conn, %{"issue" => JSON.issue(updated, repo)})
        {:error, cs} -> send_error(conn, 422, changeset_message(cs))
      end
    else
      {:error, conn} -> conn
    end
  end

  # ── internals ────────────────────────────────────────────────────────

  defp get_issue(conn, repo, number) do
    {:ok, Issues.get_issue!(repo, number)}
  rescue
    Ecto.NoResultsError -> {:error, send_error(conn, 404, "no issue ##{number}")}
  end

  defp authorize_close(conn, user, repo, issue) do
    if can_close?(user, repo, issue) do
      :ok
    else
      {:error, send_error(conn, 403, "requires triage access or issue authorship")}
    end
  end

  defp can_close?(user, repo, issue) do
    # `can?/3` is nil, not false, for someone with no access at all.
    GitGud.Organizations.can?(user, repo, :triage) == true or issue.author_id == user.id
  end

  defp require_body(conn, params) do
    case String.trim(to_string(params["body"] || "")) do
      "" -> {:error, send_error(conn, 422, "body can't be blank")}
      body -> {:ok, body}
    end
  end

  defp require_state(conn, params) do
    case params["state"] do
      s when s in ["open", "closed"] -> {:ok, s}
      _ -> {:error, send_error(conn, 422, "state must be 'open' or 'closed'")}
    end
  end
end