lib/mix/tasks/openagents.promises.backfill.ex

436d37facc96 · 4 KB

defmodule Mix.Tasks.Openagents.Promises.Backfill do
  @moduledoc """
  Backfills the reviewed product promise set into a forge project.

  Run this task with `--owner`, `--repo`, and `--project-number`. The curated
  input lives in `priv/promises/curated.json`, and rerunning the task skips
  promises that already have a project item.
  """

  use Mix.Task

  alias OpenAgents.Issues
  alias OpenAgents.Projects
  alias OpenAgents.Projects.PromiseRegistry
  alias OpenAgents.Repositories
  alias OpenAgents.Issues.Issue

  @shortdoc "Backfill the curated product promise registry"

  @impl true
  def run(args) do
    Application.ensure_all_started(:openagents)

    {options, []} =
      OptionParser.parse!(args, strict: [owner: :string, repo: :string, project_number: :integer])

    owner = Keyword.fetch!(options, :owner)
    repo_name = Keyword.fetch!(options, :repo)
    project_number = Keyword.fetch!(options, :project_number)
    repository = Repositories.get_visible_by_path!(owner, repo_name, nil)
    project = Projects.get_project_by_number!(repository, project_number)

    promise_field_name = ensure_promise_field(project)
    promises = load_promises()

    {created, skipped} =
      Enum.reduce(promises, {0, 0}, fn promise, {created, skipped} ->
        case backfill_promise(repository, project, promise, promise_field_name) do
          :created -> {created + 1, skipped}
          :skipped -> {created, skipped + 1}
        end
      end)

    Mix.shell().info("Backfilled #{created} promise(s); skipped #{skipped}.")
  rescue
    KeyError -> Mix.raise("Pass --owner, --repo, and --project-number.")
    Ecto.NoResultsError -> Mix.raise("The repository or project does not exist.")
  end

  defp ensure_promise_field(project) do
    unless PromiseRegistry.registry?(project) do
      case Projects.create_project_field(%{
             project_id: project.id,
             name: "Promise state",
             data_type: "promise_state",
             options: %{"values" => PromiseRegistry.states()}
           }) do
        {:ok, _field} ->
          "Promise state"

        {:error, changeset} ->
          Mix.raise("Could not create promise state field: #{inspect(changeset.errors)}")
      end
    else
      PromiseRegistry.field(project).name
    end
  end

  defp backfill_promise(repository, project, promise, promise_field_name) do
    if existing_promise?(project, promise["id"]) do
      :skipped
    else
      issue = canonical_issue(repository, promise)
      values = promise_values(promise, promise_field_name)

      case Projects.create_project_item(
             %{"issue_number" => issue.number, "values" => values},
             project
           ) do
        {:ok, _item} ->
          :created

        {:error, changeset} ->
          if gate_failure?(changeset) do
            Mix.shell().info(
              "Skipped #{promise["id"]}: LIVE gate failed: #{format_errors(changeset.errors)}"
            )

            :skipped
          else
            Mix.raise("Could not create #{promise["id"]}: #{inspect(changeset.errors)}")
          end
      end
    end
  end

  defp existing_promise?(project, id) do
    Projects.list_project_items(project)
    |> Enum.any?(&(get_in(&1.values, ["promise", "id"]) == id))
  end

  defp canonical_issue(repository, promise) do
    marker = "Promise ID: #{promise["id"]}"

    case Issues.list_issues(repository, state: "all")
         |> Enum.find(&String.contains?(&1.body || "", marker)) do
      %Issue{} = issue ->
        issue

      nil ->
        attrs = %{
          title: "Promise: #{promise["id"]}",
          body: "#{marker}\n\n#{promise["claim"]}"
        }

        case Issues.create_issue(repository, attrs) do
          {:ok, issue} -> issue
          {:error, changeset} -> Mix.raise("Could not create issue: #{inspect(changeset.errors)}")
        end
    end
  end

  defp promise_values(promise, promise_field_name) do
    record =
      promise
      |> Map.drop(["state", "evidence"])
      |> Map.put("evidence", promise["evidence"])

    %{promise_field_name => promise["state"], "promise" => record}
  end

  defp gate_failure?(changeset) do
    Enum.any?(changeset.errors, fn
      {:values, {message, _opts}} -> String.contains?(message, "accepted-outcome evidence")
      _error -> false
    end)
  end

  defp format_errors(errors) do
    errors
    |> Enum.map_join(", ", fn {field, {message, _opts}} -> "#{field} #{message}" end)
  end

  defp load_promises do
    Application.get_env(
      :openagents,
      :promises_curated_path,
      Application.app_dir(:openagents, "priv/promises/curated.json")
    )
    |> File.read!()
    |> Jason.decode!()
  end
end