lib/openagents/tools/open_pull_request.ex

58e6347eeb72 · 6 KB

defmodule OpenAgents.Tools.OpenPullRequest do
  @moduledoc "Opens an approved draft pull request from a Forge publication receipt."

  @behaviour OpenAgents.Tools.Tool

  alias OpenAgents.Accounts.User
  alias OpenAgents.Modules.Metadata
  alias OpenAgents.PullRequests
  alias OpenAgents.Repositories.RepositoryPublication
  alias OpenAgents.Repo
  alias OpenAgents.Tools.{ExecutionResult, Tool}

  @impl true
  def specification do
    %Tool{
      module_id: "openagents.tool.open_pull_request.v1",
      name: "open_pull_request",
      version: 1,
      description:
        "Open or refresh a draft pull request from an accepted repository publication receipt. " <>
          "The server loads and validates the receipt, repository policy, branch, commit, and Forge WAL authority.",
      input_schema: %{
        "type" => "object",
        "properties" => %{
          "publication_receipt_ref" => %{
            "type" => "string",
            "pattern" => "^repository-publication:[0-9a-f-]{36}$"
          },
          "title" => %{"type" => "string", "minLength" => 1, "maxLength" => 256},
          "body" => %{"type" => "string", "maxLength" => 65_536},
          "draft" => %{"type" => "boolean", "default" => true}
        },
        "required" => ["publication_receipt_ref", "title", "body"],
        "additionalProperties" => false
      },
      output_schema: %{"type" => "object", "properties" => %{}, "additionalProperties" => true},
      side_effect: :external_effect,
      required_scope: "browser_conversation",
      required_authority: "pull_request.write",
      executor: %{
        id: "openagents.forge.pull_requests",
        disclosure: "the OpenAgents pull request service, using an accepted Forge publication"
      },
      maintainer: "OpenAgents",
      attribution: ["OpenAgentsInc/openagents.com"],
      policy_facets: %{
        "privacy" => "browser_conversation",
        "residency" => "host",
        "consent" => "approved_publication_pull_request"
      },
      module_metadata:
        Metadata.first_party("pull_request.write", "browser_conversation",
          effect: :external_effect,
          privacy: "browser_conversation",
          residency: "host",
          surfaces: ["text", "voice"],
          approval_class: "external_confirmation",
          approval_enforcement: "host_receipt"
        ),
      timeout_ms: 30_000,
      maximum_input_bytes: 72_000,
      maximum_output_bytes: 16_384,
      implementation: __MODULE__
    }
  end

  @doc "Builds the separate person approval required to open a pull request."
  def approval_receipt(scope_ref, approval_ref)
      when is_binary(scope_ref) and is_binary(approval_ref) do
    %{
      "schema" => "sarah.module_approval.v1",
      "approval_class" => "external_confirmation",
      "module_id" => specification().module_id,
      "version" => specification().version,
      "scope_ref" => scope_ref,
      "explicit" => true,
      "actor_type" => "person",
      "receipt_ref" => approval_ref
    }
  end

  @impl true
  def execute(%{"publication_receipt_ref" => receipt_ref} = arguments, context) do
    with {:ok, publication_id} <- parse_receipt_ref(receipt_ref),
         %RepositoryPublication{} = publication <- Repo.get(RepositoryPublication, publication_id) do
      execute_publication(publication, arguments, context, receipt_ref)
    else
      nil -> {:error, :publication_receipt_not_found}
      {:error, reason} -> {:error, reason}
    end
  end

  defp execute_publication(publication, arguments, context, receipt_ref) do
    with :ok <- validate_context(publication, context),
         %User{} = actor <- Repo.get(User, context.owner_user_id),
         {:ok, pull_request} <- PullRequests.open_from_publication(publication, arguments, actor) do
      result = result(pull_request)

      {:ok,
       %ExecutionResult{
         result: result,
         target_receipt_refs: [
           receipt_ref,
           "pull-request:#{pull_request.id}",
           "issue:#{pull_request.repository_id}:#{pull_request.issue.number}"
         ]
       }}
    else
      {:error, :pull_requests_disabled} ->
        disabled_result(publication)

      {:error, reason} ->
        {:error, reason}

      _ ->
        {:error, :publication_scope_mismatch}
    end
  end

  defp parse_receipt_ref("repository-publication:" <> publication_id) do
    case Ecto.UUID.cast(publication_id) do
      {:ok, id} -> {:ok, id}
      :error -> {:error, :publication_receipt_invalid}
    end
  end

  defp parse_receipt_ref(_receipt_ref), do: {:error, :publication_receipt_invalid}

  defp validate_context(publication, context) do
    workspace = context.workspace || %{}

    cond do
      publication.owner_user_id != context.owner_user_id ->
        {:error, :publication_scope_mismatch}

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

      workspace["repository_id"] != publication.repository_id ->
        {:error, :publication_workspace_mismatch}

      workspace["workspace_ref"] != publication.workspace_ref ->
        {:error, :publication_workspace_mismatch}

      true ->
        :ok
    end
  end

  defp result(pull_request) do
    repository = pull_request.repository

    %{
      "schema" => "openagents.pull_request_opened.v1",
      "id" => pull_request.id,
      "number" => pull_request.issue.number,
      "state" => pull_request.state,
      "draft" => pull_request.draft,
      "title" => pull_request.issue.title,
      "repository" => "#{repository.owner}/#{repository.name}",
      "url" => "/#{repository.owner}/#{repository.name}/pulls/#{pull_request.issue.number}",
      "head" => %{"ref" => pull_request.head_ref, "oid" => pull_request.head_sha},
      "base" => %{"ref" => pull_request.base_ref, "oid" => pull_request.base_sha},
      "checks" => %{"state" => "unknown"},
      "mergeability" => %{"state" => "unknown"},
      "publication_receipt_ref" =>
        "repository-publication:#{pull_request.repository_publication_id}",
      "receipt" => %{
        "schema" => "openagents.pull_request_receipt.v1",
        "receipt_ref" => "pull-request:#{pull_request.id}"
      }
    }
  end

  defp disabled_result(publication) do
    repository = Repo.get!(OpenAgents.Repositories.Repository, publication.repository_id)

    compare_url =
      get_in(publication.result || %{}, ["compare_url"]) ||
        "/#{repository.owner}/#{repository.name}/compare/#{repository.default_branch}...#{publication.branch}"

    {:ok,
     %ExecutionResult{
       status: "refused",
       error: %{
         "code" => "pull_requests_disabled",
         "message" => "Pull requests are disabled for this repository."
       },
       result: %{
         "schema" => "openagents.pull_request_disabled.v1",
         "repository" => "#{repository.owner}/#{repository.name}",
         "protected_branch" => publication.branch,
         "compare_url" => compare_url
       },
       target_receipt_refs: ["repository-publication:#{publication.id}"]
     }}
  end
end