lib/openagents/pull_requests.ex

main at 58e6347eeb72 · 14 KB

defmodule OpenAgents.PullRequests do
  @moduledoc "Repository-scoped pull requests backed by issues."
  import Ecto.Query, warn: false

  alias OpenAgents.Accounts.User
  alias OpenAgents.Forge.Browse
  alias OpenAgents.Forge.WAL
  alias OpenAgents.Issues
  alias OpenAgents.PullRequests.PullRequest
  alias OpenAgents.Repo
  alias OpenAgents.Repositories
  alias OpenAgents.Repositories.Repository
  alias OpenAgents.Repositories.RepositoryPublication
  alias OpenAgents.Stacks
  alias OpenAgents.Tools.Redaction

  def list(%Repository{id: id}) do
    Repo.all(
      from pr in PullRequest,
        where: pr.repository_id == ^id,
        preload: [:issue, :head_repository],
        order_by: [desc: pr.inserted_at]
    )
  end

  @doc """
  The four states GitHub gives a pull request, derived in one place.

  A pull request carries three independent facts — its issue's state, whether
  it is a draft, and whether it was merged — and every surface that draws a
  glyph or a badge has to collapse them into one word the same way. Merged
  wins over closed because a merged pull request is also closed and only one
  of those two readings is worth showing.
  """
  def state(%PullRequest{merged_at: merged_at}) when not is_nil(merged_at), do: "merged"
  def state(%PullRequest{state: "closed"}), do: "closed"
  def state(%PullRequest{draft: true}), do: "draft"
  def state(%PullRequest{}), do: "open"

  @doc """
  Which of `issues` are backed by a pull request, and what that pull request is.

  A pull request on this forge *is* an issue row — `pull_requests.issue_id`
  points at one, which is why the two share a number space — so a list of
  issues cannot tell which of its rows are pull requests without asking. The
  answer arrives as one query for the whole page, keyed by issue id, rather
  than one query per row.

  Returns `%{issue_id => %{state: state, draft: boolean, merged_at: at}}`. An
  absent key means a plain issue, which is the fact both the row glyph and
  GitHub's `pull_request` marker are built on.
  """
  def markers_by_issue_id(issues) when is_list(issues) do
    ids = issues |> Enum.map(& &1.id) |> Enum.reject(&is_nil/1)

    if ids == [] do
      %{}
    else
      from(pr in PullRequest, where: pr.issue_id in ^ids)
      |> Repo.all()
      |> Map.new(&{&1.issue_id, %{state: state(&1), draft: &1.draft, merged_at: &1.merged_at}})
    end
  end

  @doc """
  How many pull requests are open in `repository`.

  The repository nav states this beside the open-issue count. Both numbers were
  the same number until pull requests were filtered out of the issue lists, and
  a nav that says "Issues 12" while twelve means "issues and pull requests" is
  the confusion #120 is about.
  """
  def count_open(%Repository{id: id}) do
    Repo.aggregate(
      from(pr in PullRequest, where: pr.repository_id == ^id and pr.state == "open"),
      :count
    )
  end

  def get_by_number!(%Repository{id: id}, number) do
    Repo.one!(
      from pr in PullRequest,
        join: issue in assoc(pr, :issue),
        where: pr.repository_id == ^id and issue.number == ^number,
        preload: [:issue, :head_repository]
    )
  end

  def create(%Repository{pull_requests_enabled: false}, _attrs, _actor),
    do: {:error, :pull_requests_disabled}

  def create(%Repository{} = target, attrs, %User{} = actor) do
    Repo.transaction(fn ->
      with true <- Repositories.issue_participant?(target, actor),
           {:ok, source} <- source_repository(attrs, actor),
           {:ok, head_ref} <- required(attrs, "head"),
           {:ok, base_ref} <- optional(attrs, "base", target.default_branch),
           {:ok, head_sha} <- resolve(source, head_ref),
           {:ok, base_sha} <- resolve(target, base_ref),
           {:ok, issue} <- Issues.create_issue(target, attrs, actor),
           {:ok, pr} <-
             insert(target, source, issue, head_ref, head_sha, base_ref, base_sha, attrs) do
        Repo.preload(pr, [:issue, :head_repository])
      else
        false -> Repo.rollback(:forbidden)
        {:error, reason} -> Repo.rollback(reason)
      end
    end)
  end

  def update(%PullRequest{} = pr, attrs, %User{} = actor) do
    pr = Repo.preload(pr, [:issue, :repository])
    role = Repositories.membership_role(pr.repository, actor)

    if pr.issue.author_user_id == actor.id or role in ~w(owner maintainer) do
      Repo.transaction(fn ->
        with {:ok, base_attrs} <- base_update_attrs(pr, attrs),
             {:ok, issue} <-
               Issues.update_issue(
                 pr.issue,
                 Map.take(attrs, ["title", "body", "state"]),
                 actor
               ),
             {:ok, updated} <-
               pr
               |> PullRequest.changeset(
                 Map.merge(pull_request_update_attrs(attrs, issue.state), base_attrs)
               )
               |> Repo.update() do
          %{updated | issue: issue}
        else
          {:error, reason} -> Repo.rollback(reason)
        end
      end)
    else
      {:error, :forbidden}
    end
  end

  # While a pull request is an active stack member its base belongs to the
  # stack service, so a generic base edit fails with an explanation.
  defp base_update_attrs(pr, attrs) do
    case Map.get(attrs, "base") do
      nil ->
        {:ok, %{}}

      base_ref when is_binary(base_ref) and base_ref != "" ->
        with :ok <- Stacks.ensure_base_editable(pr),
             {:ok, base_sha} <- resolve(pr.repository, base_ref) do
          {:ok, %{base_ref: base_ref, base_sha: base_sha}}
        end

      _other ->
        {:error, :invalid_ref}
    end
  end

  @doc "Opens or refreshes the draft pull request for an accepted Forge publication."
  def open_from_publication(%RepositoryPublication{} = publication, attrs, %User{} = actor) do
    publication = Repo.preload(publication, :repository)
    repository = publication.repository

    with :ok <- validate_publication(publication, actor),
         :ok <- validate_pull_request_policy(repository, actor),
         {:ok, head_sha, base_sha} <- validate_wal_authority(publication) do
      Repo.transaction(fn ->
        lock_open_head(repository.id, publication.branch, repository.default_branch)

        case open_for_head(repository.id, publication.branch, repository.default_branch) do
          nil ->
            create_from_publication(publication, attrs, actor, head_sha, base_sha)

          %PullRequest{repository_publication_id: id} = pull_request
          when id == publication.id ->
            Repo.preload(pull_request, [
              :issue,
              :head_repository,
              :repository_publication,
              :repository
            ])

          %PullRequest{} = pull_request ->
            refresh_from_publication(pull_request, publication, attrs, actor, head_sha, base_sha)
        end
      end)
    end
  end

  defp validate_publication(%RepositoryPublication{} = publication, %User{} = actor) do
    cond do
      publication.owner_user_id != actor.id ->
        {:error, :publication_scope_mismatch}

      publication.state != "accepted" ->
        {:error, :publication_not_accepted}

      not is_binary(publication.published_oid) ->
        {:error, :publication_not_accepted}

      not is_integer(publication.wal_seq) or publication.wal_seq < 0 ->
        {:error, :publication_receipt_invalid}

      publication.branch == publication.repository.default_branch ->
        {:error, :publication_branch_refused}

      publication.branch != "openagents/chat/#{publication.conversation_id}" ->
        {:error, :publication_branch_refused}

      true ->
        :ok
    end
  end

  defp validate_pull_request_policy(%Repository{pull_requests_enabled: false}, _actor),
    do: {:error, :pull_requests_disabled}

  defp validate_pull_request_policy(repository, actor) do
    if Repositories.writable?(repository, actor), do: :ok, else: {:error, :forbidden}
  end

  defp validate_wal_authority(publication) do
    repository = publication.repository
    published_oid = publication.published_oid
    head_ref = "refs/heads/#{publication.branch}"
    base_ref = "refs/heads/#{repository.default_branch}"

    with {:ok, _generation, index} <- WAL.read_index(repository.storage_key),
         ^published_oid <- WAL.refs(index)[head_ref],
         base_sha when is_binary(base_sha) <- WAL.refs(index)[base_ref],
         %{"refs" => receipt_refs} <- Enum.at(WAL.entries(index), publication.wal_seq),
         ^published_oid <- receipt_refs[head_ref] do
      {:ok, published_oid, base_sha}
    else
      {:error, _reason} -> {:error, :forge_authority_unavailable}
      _ -> {:error, :publication_receipt_stale}
    end
  end

  defp open_for_head(repository_id, head_ref, base_ref) do
    Repo.one(
      from pr in PullRequest,
        where:
          pr.repository_id == ^repository_id and pr.head_repository_id == ^repository_id and
            pr.head_ref == ^head_ref and pr.base_ref == ^base_ref and pr.state == "open",
        lock: "FOR UPDATE"
    )
  end

  defp lock_open_head(repository_id, head_ref, base_ref) do
    key = Enum.join([repository_id, head_ref, base_ref], ":")
    Repo.query!("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [key])
    :ok
  end

  defp create_from_publication(publication, attrs, actor, head_sha, base_sha) do
    repository = publication.repository

    with {:ok, issue} <- Issues.create_issue(repository, issue_attrs(attrs, publication), actor),
         {:ok, pull_request} <-
           %PullRequest{}
           |> PullRequest.changeset(%{
             repository_id: repository.id,
             issue_id: issue.id,
             head_repository_id: repository.id,
             repository_publication_id: publication.id,
             opened_by_user_id: actor.id,
             conversation_id: publication.conversation_id,
             head_ref: publication.branch,
             head_sha: head_sha,
             base_ref: repository.default_branch,
             base_sha: base_sha,
             draft: Map.get(attrs, "draft", true)
           })
           |> Repo.insert() do
      Repo.preload(pull_request, [:issue, :head_repository, :repository_publication, :repository])
    else
      {:error, reason} -> Repo.rollback(reason)
    end
  end

  defp refresh_from_publication(pull_request, publication, attrs, actor, head_sha, base_sha) do
    pull_request = Repo.preload(pull_request, [:issue, :repository_publication])
    previous_publication = pull_request.repository_publication

    with :ok <- validate_existing_publication_scope(pull_request, publication, actor),
         {:ok, issue} <-
           Issues.update_issue(
             pull_request.issue,
             issue_attrs(attrs, publication, [previous_publication.id]),
             actor
           ),
         {:ok, updated} <-
           pull_request
           |> PullRequest.changeset(%{
             repository_publication_id: publication.id,
             head_sha: head_sha,
             base_sha: base_sha,
             draft: Map.get(attrs, "draft", true),
             conversation_id: publication.conversation_id
           })
           |> Repo.update() do
      %{
        Repo.preload(updated, [:head_repository, :repository_publication, :repository])
        | issue: issue
      }
    else
      {:error, reason} -> Repo.rollback(reason)
    end
  end

  defp validate_existing_publication_scope(pull_request, publication, actor) do
    previous_publication = pull_request.repository_publication

    cond do
      pull_request.opened_by_user_id != actor.id ->
        {:error, :publication_scope_mismatch}

      pull_request.conversation_id != publication.conversation_id ->
        {:error, :publication_scope_mismatch}

      is_nil(previous_publication) or
          previous_publication.workspace_ref != publication.workspace_ref ->
        {:error, :publication_workspace_mismatch}

      true ->
        :ok
    end
  end

  defp issue_attrs(attrs, publication, previous_publication_ids \\ []) do
    body =
      attrs
      |> Map.get("body", "")
      |> Redaction.redact_text()
      |> String.trim()

    publication_ids = Enum.uniq(previous_publication_ids ++ [publication.id])
    result = publication.result || %{}
    summary = result["summary"] || %{}
    compare_url = result["compare_url"]

    receipt_lines =
      Enum.map_join(publication_ids, "\n", fn id ->
        "- `repository-publication:#{id}`"
      end)

    trusted = """
    ## OpenAgents publication

    - Changed files: #{summary["files_changed"] || "unknown"}
    - Insertions: #{summary["insertions"] || "unknown"}
    - Deletions: #{summary["deletions"] || "unknown"}
    - Compare: #{compare_url || "unavailable"}

    Publication receipts:

    #{receipt_lines}
    """

    %{
      "title" => Map.fetch!(attrs, "title"),
      "body" => Enum.reject([body, String.trim(trusted)], &(&1 == "")) |> Enum.join("\n\n")
    }
  end

  defp pull_request_update_attrs(attrs, state) do
    %{state: state}
    |> maybe_put(:draft, Map.fetch(attrs, "draft"))
  end

  defp maybe_put(attrs, _key, :error), do: attrs
  defp maybe_put(attrs, key, {:ok, value}), do: Map.put(attrs, key, value)

  defp source_repository(attrs, actor) do
    case Map.get(attrs, "head_repository") do
      value when is_binary(value) ->
        case String.split(value, "/", parts: 2) do
          [owner, name] -> {:ok, Repositories.get_writable_by_path!(owner, name, actor)}
          _ -> {:error, :invalid_head_repository}
        end

      _ ->
        {:error, :invalid_head_repository}
    end
  rescue
    Ecto.NoResultsError -> {:error, :invalid_head_repository}
  end

  defp resolve(repository, ref) do
    case Browse.resolve_commit(repository, ref) do
      {:ok, sha} -> {:ok, sha}
      _ -> {:error, :invalid_ref}
    end
  end

  defp required(attrs, key), do: optional(attrs, key, nil)

  defp optional(attrs, key, default) do
    case Map.get(attrs, key, default) do
      value when is_binary(value) and value != "" -> {:ok, value}
      _ -> {:error, :invalid_ref}
    end
  end

  defp insert(target, source, issue, head_ref, head_sha, base_ref, base_sha, attrs) do
    %PullRequest{}
    |> PullRequest.changeset(%{
      repository_id: target.id,
      issue_id: issue.id,
      head_repository_id: source.id,
      head_ref: head_ref,
      head_sha: head_sha,
      base_ref: base_ref,
      base_sha: base_sha,
      draft: Map.get(attrs, "draft", true)
    })
    |> Repo.insert()
  end
end