lib/openagents_web/controllers/project_controller.ex

main at 58e6347eeb72 · 20 KB

defmodule OpenAgentsWeb.ProjectController do
  use OpenAgentsWeb, :controller

  alias OpenAgents.Issues.Issue
  alias OpenAgents.Projects
  alias OpenAgents.Projects.Project
  alias OpenAgents.Repo
  alias OpenAgents.Repositories
  alias OpenAgentsWeb.ApiError

  @doc """
  The repository's projects.

  Archived projects are out of the working set, so the list leaves them out
  until `archived=true` asks for them.
  """
  def index(conn, %{"owner" => owner, "repo" => repo} = params) do
    repository = visible_repository!(conn, owner, repo)
    projects = Projects.list_projects(repository, archived: params["archived"] == "true")
    render(conn, :index, projects: projects)
  rescue
    Ecto.NoResultsError -> not_found(conn)
  end

  def create(conn, %{"owner" => owner, "repo" => repo} = params) do
    user = conn.assigns.current_user
    repository = Repositories.get_writable_by_path!(owner, repo, user)

    case Projects.create_project(repository, params, user) do
      {:ok, %Project{} = project} ->
        conn
        |> put_status(:created)
        |> render(:show, project: project)

      {:error, %Ecto.Changeset{} = changeset} ->
        ApiError.changeset(conn, changeset)
    end
  rescue
    Ecto.NoResultsError -> not_found(conn)
  end

  def show(conn, %{
        "owner" => owner,
        "repo" => repo,
        "project_number" => project_number
      }) do
    repository = visible_repository!(conn, owner, repo)
    project = Projects.get_project_by_number!(repository, parse_id!(project_number))
    render(conn, :show, project: project)
  rescue
    Ecto.NoResultsError -> not_found(conn)
  end

  @doc """
  Updates the title, description, state, or archive standing of one project.

  Authority is a writable membership in the repository the path names, the same
  boundary every other project write reads. `description` is Markdown, `state`
  is `open` or `closed`, and `archived` is a boolean.

  Closing and reopening move `state`. Archiving is a separate axis: a closed
  project says the work reached an end, an archived project says the board left
  the working set, whatever became of the work.
  """
  def update(
        conn,
        %{
          "owner" => owner,
          "repo" => repo,
          "project_number" => project_number
        } = params
      ) do
    repository = writable_repository!(conn, owner, repo)
    project = Projects.get_project_by_number!(repository, parse_id!(project_number))
    attrs = params |> Map.take(["title", "description", "state", "archived"]) |> cast_archived()

    cond do
      attrs == %{} ->
        unprocessable(conn, %{base: ["no updatable field was given"]})

      not valid_state?(attrs) ->
        unprocessable(conn, %{state: ["is invalid"]})

      true ->
        case Projects.update_project(project, attrs, conn.assigns.current_user) do
          {:ok, %Project{} = project} ->
            render(conn, :show, project: project)

          {:error, %Ecto.Changeset{} = changeset} ->
            ApiError.changeset(conn, changeset)
        end
    end
  rescue
    Ecto.NoResultsError -> not_found(conn)
  end

  @doc """
  Deletes one project, its fields, its items, and its item events.

  The policy is two keys, not one: a writable membership in the repository, and
  a project already in the archive. The board surface pairs its delete control
  with a confirmation prompt; an API caller has no prompt, so archiving is the
  deliberate step that stands in for one. The referenced issues are untouched —
  an item points at canonical work rather than owning it.
  """
  def delete(conn, %{
        "owner" => owner,
        "repo" => repo,
        "project_number" => project_number
      }) do
    repository = writable_repository!(conn, owner, repo)
    project = Projects.get_project_by_number!(repository, parse_id!(project_number))

    if Project.archived?(project) do
      case Projects.delete_project(project) do
        {:ok, %Project{}} ->
          send_resp(conn, :no_content, "")

        {:error, %Ecto.Changeset{} = changeset} ->
          unprocessable_changeset(conn, changeset)
      end
    else
      unprocessable(conn, %{archived: ["the project must be archived before it deletes"]})
    end
  rescue
    Ecto.NoResultsError -> not_found(conn)
  end

  @doc """
  One page of a project's notes, newest first.

  Reads follow the repository's visibility, so a private project's notes stay
  invisible to a non-member: the request 404s at the repository before a note
  is read. Query parameters are `page` and `kind`, where `kind` is `note`,
  `activity`, or `all`.
  """
  def notes(
        conn,
        %{
          "owner" => owner,
          "repo" => repo,
          "project_number" => project_number
        } = params
      ) do
    repository = visible_repository!(conn, owner, repo)
    project = Projects.get_project_by_number!(repository, parse_id!(project_number))
    page = Projects.parse_page(params["page"])

    {notes, total_count} =
      Projects.list_project_notes_page(project, page: page, kind: params["kind"])

    render(conn, :notes, notes: notes, page: page, total_count: total_count)
  rescue
    Ecto.NoResultsError -> not_found(conn)
  end

  @doc "Writes one discussion note on a project."
  def create_note(
        conn,
        %{
          "owner" => owner,
          "repo" => repo,
          "project_number" => project_number
        } = params
      ) do
    repository = writable_repository!(conn, owner, repo)
    project = Projects.get_project_by_number!(repository, parse_id!(project_number))

    case Projects.create_project_note(project, params, conn.assigns.current_user) do
      {:ok, note} ->
        conn
        |> put_status(:created)
        |> render(:note, note: note)

      {:error, %Ecto.Changeset{} = changeset} ->
        ApiError.changeset(conn, changeset)
    end
  rescue
    Ecto.NoResultsError -> not_found(conn)
  end

  @doc """
  Edits one discussion note.

  Authority is the note's author. Repository write access is necessary but not
  sufficient: another member adds a note of their own rather than rewriting
  this one, and an activity entry is never editable.
  """
  def update_note(
        conn,
        %{
          "owner" => owner,
          "repo" => repo,
          "project_number" => project_number,
          "note_id" => note_id
        } = params
      ) do
    with {:ok, note} <- authored_note(conn, owner, repo, project_number, note_id),
         {:ok, note} <- Projects.update_project_note(note, params) do
      render(conn, :note, note: note)
    else
      {:error, :forbidden} -> forbidden(conn)
      {:error, :immutable} -> forbidden(conn)
      {:error, %Ecto.Changeset{} = changeset} -> unprocessable_changeset(conn, changeset)
    end
  rescue
    Ecto.NoResultsError -> not_found(conn)
  end

  @doc "Deletes one discussion note. Authority is the note's author."
  def delete_note(conn, %{
        "owner" => owner,
        "repo" => repo,
        "project_number" => project_number,
        "note_id" => note_id
      }) do
    with {:ok, note} <- authored_note(conn, owner, repo, project_number, note_id),
         {:ok, _note} <- Projects.delete_project_note(note) do
      send_resp(conn, :no_content, "")
    else
      {:error, :forbidden} -> forbidden(conn)
      {:error, :immutable} -> forbidden(conn)
      {:error, %Ecto.Changeset{} = changeset} -> unprocessable_changeset(conn, changeset)
    end
  rescue
    Ecto.NoResultsError -> not_found(conn)
  end

  defp authored_note(conn, owner, repo, project_number, note_id) do
    repository = writable_repository!(conn, owner, repo)
    project = Projects.get_project_by_number!(repository, parse_id!(project_number))
    note = Projects.get_project_note!(project, parse_id!(note_id))

    if Projects.authored_by?(note, conn.assigns.current_user) do
      {:ok, note}
    else
      {:error, :forbidden}
    end
  end

  defp valid_state?(%{"state" => state}), do: state in Project.states()
  defp valid_state?(_attrs), do: true

  # A JSON client sends a boolean; a form-encoded client sends the word. Both
  # mean the same thing, and anything else stays invalid so the context can say
  # so.
  defp cast_archived(%{"archived" => "true"} = attrs), do: %{attrs | "archived" => true}
  defp cast_archived(%{"archived" => "false"} = attrs), do: %{attrs | "archived" => false}
  defp cast_archived(attrs), do: attrs

  def items(
        conn,
        %{
          "owner" => owner,
          "repo" => repo,
          "project_number" => project_number
        } = params
      ) do
    repository = visible_repository!(conn, owner, repo)
    project = Projects.get_project_by_number!(repository, parse_id!(project_number))

    with {:ok, opts} <- item_filters(params) do
      {items, item_projections} =
        Projects.list_visible_project_items_with_promises(
          project,
          conn.assigns[:current_user],
          opts
        )

      render(conn, :items,
        items: items,
        viewer: conn.assigns[:current_user],
        item_projections: item_projections
      )
    else
      {:error, field, message} -> unprocessable(conn, %{field => [message]})
    end
  rescue
    Ecto.NoResultsError -> not_found(conn)
  end

  @doc """
  Adds one issue to a project.

  Membership is a set: an issue is on a board once. A repeated add is answered
  with `200` and the membership the board already has, rather than `422`,
  because a client that retries a request it never saw the answer to has asked
  for a state that already holds. The same issue can sit on several boards,
  because an item is a board's view of canonical work rather than the work.
  """
  def create_item(
        conn,
        %{
          "owner" => owner,
          "repo" => repo,
          "project_number" => project_number
        } = params
      ) do
    repository = writable_repository!(conn, owner, repo)
    project = Projects.get_project_by_number!(repository, parse_id!(project_number))

    case issue_reference(conn, repository, params) do
      :error ->
        unprocessable(conn, %{issue_number: ["is invalid"]})

      {:ok, source_repository, issue_number} ->
        params =
          params
          |> Map.put("issue_number", issue_number)
          |> Map.put("issue_repository_id", source_repository.id)

        existing =
          case Repo.get_by(Issue, repository_id: source_repository.id, number: issue_number) do
            nil -> nil
            issue -> Projects.project_item_for_issue(project, issue.id)
          end

        case Projects.create_project_item(params, project, conn.assigns.current_user) do
          {:ok, item} ->
            conn
            |> put_status(if(existing, do: :ok, else: :created))
            |> render_items([item], project)

          {:error, %Ecto.Changeset{} = changeset} ->
            ApiError.changeset(conn, changeset)
        end
    end
  rescue
    Ecto.NoResultsError -> not_found(conn)
  end

  @doc """
  Removes one item from a project.

  The item goes, the issue stays. A second `DELETE` on the same item returns
  `404`, because the item is gone and the path names nothing: the board's state
  after both requests is the same one.
  """
  def delete_item(conn, %{
        "owner" => owner,
        "repo" => repo,
        "project_number" => project_number,
        "item_id" => item_id
      }) do
    repository = writable_repository!(conn, owner, repo)
    project = Projects.get_project_by_number!(repository, parse_id!(project_number))

    item =
      Projects.get_visible_project_item!(
        project,
        parse_id!(item_id),
        conn.assigns.current_user
      )

    case Projects.delete_project_item(item, conn.assigns.current_user) do
      {:ok, _item} -> send_resp(conn, :no_content, "")
      {:error, %Ecto.Changeset{} = changeset} -> unprocessable_changeset(conn, changeset)
    end
  rescue
    Ecto.NoResultsError -> not_found(conn)
  end

  @doc """
  Moves one item to another column, another rank, or both.

  The body carries `values`, merged the way `PATCH` merges them, and
  `position`, a one-based rank within the destination column. A body with only
  `position` is a reorder. A body with only `values` lands the card at the end
  of the column it names. A stale option identifier the field no longer offers
  is refused with `422` and changes nothing.
  """
  def move_item(
        conn,
        %{
          "owner" => owner,
          "repo" => repo,
          "project_number" => project_number,
          "item_id" => item_id
        } = params
      ) do
    repository = writable_repository!(conn, owner, repo)
    project = Projects.get_project_by_number!(repository, parse_id!(project_number))

    item =
      Projects.get_visible_project_item!(
        project,
        parse_id!(item_id),
        conn.assigns.current_user
      )

    attrs = Map.take(params, ["values", "position"])

    if is_map(Map.get(attrs, "values", %{})) do
      case Projects.move_project_item(item, attrs, conn.assigns.current_user) do
        {:ok, item} ->
          render_items(conn, [item], project)

        {:error, %Ecto.Changeset{} = changeset} ->
          unprocessable_changeset(conn, changeset)
      end
    else
      unprocessable(conn, %{values: ["is invalid"]})
    end
  rescue
    Ecto.NoResultsError -> not_found(conn)
  end

  def update_item(
        conn,
        %{
          "owner" => owner,
          "repo" => repo,
          "project_number" => project_number,
          "item_id" => item_id
        } = params
      ) do
    repository = writable_repository!(conn, owner, repo)
    project = Projects.get_project_by_number!(repository, parse_id!(project_number))

    item =
      Projects.get_visible_project_item!(
        project,
        parse_id!(item_id),
        conn.assigns.current_user
      )

    if is_map(Map.get(params, "values", %{})) do
      case Projects.update_project_item(item, params, conn.assigns.current_user) do
        {:ok, item} ->
          render_items(conn, [item], project)

        {:error, %Ecto.Changeset{} = changeset} ->
          ApiError.changeset(conn, changeset)
      end
    else
      unprocessable(conn, %{values: ["is invalid"]})
    end
  rescue
    Ecto.NoResultsError -> not_found(conn)
  end

  def events(
        conn,
        %{
          "owner" => owner,
          "repo" => repo,
          "project_number" => project_number,
          "item_id" => item_id
        } = params
      ) do
    repository = visible_repository!(conn, owner, repo)
    project = Projects.get_project_by_number!(repository, parse_id!(project_number))

    item =
      Projects.get_visible_project_item!(
        project,
        parse_id!(item_id),
        conn.assigns[:current_user]
      )

    {events, total, page, per_page} =
      Projects.list_project_item_events(item, page: params["page"])

    render(conn, :events,
      events: Projects.project_item_events(events, conn.assigns[:current_user]),
      pagination: %{page: page, per_page: per_page, total: total},
      viewer: conn.assigns[:current_user]
    )
  rescue
    Ecto.NoResultsError -> not_found(conn)
  end

  def fields(conn, %{
        "owner" => owner,
        "repo" => repo,
        "project_number" => project_number
      }) do
    repository = visible_repository!(conn, owner, repo)
    project = Projects.get_project_by_number!(repository, parse_id!(project_number))
    render(conn, :fields, fields: Projects.list_project_fields(project))
  rescue
    Ecto.NoResultsError -> not_found(conn)
  end

  def create_field(
        conn,
        %{
          "owner" => owner,
          "repo" => repo,
          "project_number" => project_number
        } = params
      ) do
    repository = writable_repository!(conn, owner, repo)
    project = Projects.get_project_by_number!(repository, parse_id!(project_number))

    attrs =
      params
      |> Map.take(["name", "data_type", "options"])
      |> Map.put("project_id", project.id)

    case Projects.create_project_field(attrs, conn.assigns.current_user) do
      {:ok, field} ->
        conn
        |> put_status(:created)
        |> render(:fields, fields: [field])

      {:error, %Ecto.Changeset{} = changeset} ->
        ApiError.changeset(conn, changeset)
    end
  rescue
    Ecto.NoResultsError -> not_found(conn)
  end

  @doc """
  Updates one field of a project.

  A rename carries the values with it: the field name is the key an item stores
  its value under, so the rename rewrites that key on every item of the project
  in the same transaction. The data type never changes, and an option items
  still carry cannot be dropped.
  """
  def update_field(
        conn,
        %{
          "owner" => owner,
          "repo" => repo,
          "project_number" => project_number,
          "field_id" => field_id
        } = params
      ) do
    repository = writable_repository!(conn, owner, repo)
    project = Projects.get_project_by_number!(repository, parse_id!(project_number))
    field = Projects.get_project_field!(project, parse_id!(field_id))
    attrs = Map.take(params, ["name", "data_type", "options"])

    if attrs == %{} do
      unprocessable(conn, %{base: ["no updatable field was given"]})
    else
      case Projects.update_project_field(project, field, attrs, conn.assigns.current_user) do
        {:ok, field} ->
          render(conn, :fields, fields: [field])

        {:error, %Ecto.Changeset{} = changeset} ->
          unprocessable_changeset(conn, changeset)
      end
    end
  rescue
    Ecto.NoResultsError -> not_found(conn)
  end

  @doc """
  Removes one field of a project.

  A field whose values items still carry is preserved and the request returns
  `422`: deleting it would leave those values keyed to a column nothing
  declares, which is data loss reported as success.
  """
  def delete_field(conn, %{
        "owner" => owner,
        "repo" => repo,
        "project_number" => project_number,
        "field_id" => field_id
      }) do
    repository = writable_repository!(conn, owner, repo)
    project = Projects.get_project_by_number!(repository, parse_id!(project_number))
    field = Projects.get_project_field!(project, parse_id!(field_id))

    case Projects.delete_project_field(project, field, conn.assigns.current_user) do
      {:ok, _field} ->
        send_resp(conn, :no_content, "")

      {:error, %Ecto.Changeset{} = changeset} ->
        unprocessable_changeset(conn, changeset)
    end
  rescue
    Ecto.NoResultsError -> not_found(conn)
  end

  defp visible_repository!(conn, owner, repo) do
    Repositories.get_visible_by_path!(owner, repo, conn.assigns[:current_user])
  end

  defp writable_repository!(conn, owner, repo) do
    Repositories.get_writable_by_path!(owner, repo, conn.assigns.current_user)
  end

  defp parse_id!(value) do
    case cast_number(value) do
      {:ok, number} -> number
      :error -> raise Ecto.NoResultsError, queryable: Project
    end
  end

  defp cast_number(number) when is_integer(number), do: {:ok, number}

  defp cast_number(number) when is_binary(number) do
    case Integer.parse(number) do
      {parsed, ""} -> {:ok, parsed}
      _ -> :error
    end
  end

  defp cast_number(_other), do: :error

  defp issue_reference(conn, _project_repository, %{"issue" => issue}) when is_map(issue) do
    with owner when is_binary(owner) <- issue["owner"],
         repo when is_binary(repo) <- issue["repo"],
         {:ok, number} <- cast_number(issue["number"]) do
      {:ok, visible_repository!(conn, owner, repo), number}
    else
      _invalid -> :error
    end
  end

  defp issue_reference(_conn, project_repository, params) do
    case cast_number(params["issue_number"]) do
      {:ok, number} -> {:ok, project_repository, number}
      :error -> :error
    end
  end

  defp unprocessable(conn, errors), do: ApiError.validation_failed(conn, errors)

  defp unprocessable_changeset(conn, changeset), do: ApiError.changeset(conn, changeset)

  defp forbidden(conn), do: ApiError.forbidden(conn)

  defp item_filters(params) do
    state = params["promise_state"]
    bounty = params["bounty_candidate"]

    cond do
      state && state not in ~w(LIVE GATED WITHDRAWN) ->
        {:error, :promise_state, "must be one of: LIVE, GATED, WITHDRAWN"}

      bounty && bounty not in ~w(true false) ->
        {:error, :bounty_candidate, "must be true or false"}

      true ->
        {:ok,
         [
           promise_state: state,
           bounty_candidate: if(bounty, do: bounty == "true")
         ]
         |> Enum.reject(fn {_key, value} -> is_nil(value) end)}
    end
  end

  defp render_items(conn, items, project) do
    render(conn, :items,
      items: items,
      viewer: conn.assigns[:current_user],
      item_projections:
        Projects.project_item_projections(
          items,
          OpenAgents.Projects.PromiseRegistry.context(project),
          conn.assigns[:current_user]
        )
    )
  end

  defp not_found(conn), do: ApiError.not_found(conn)
end