lib/openagents_web/live/repository_import_live.ex

436d37facc96 · 7 KB

defmodule OpenAgentsWeb.RepositoryImportLive do
  @moduledoc "Imports one accepted GitHub repository snapshot."

  use OpenAgentsWeb, :live_view

  alias OpenAgents.Repositories
  alias OpenAgents.Repositories.GitHubProjection

  @impl true
  def mount(_params, _session, socket) do
    {repositories, next_page, error} = candidates(socket.assigns.current_user, 1)

    {:ok,
     socket
     |> assign(:page_title, "Import from GitHub")
     |> assign(:source_repositories, repositories)
     |> assign(:next_page, next_page)
     |> assign(:source_error, error)
     |> assign(:form, import_form(repositories))}
  end

  @impl true
  def handle_event("validate", %{"repository_import" => params}, socket) do
    repository =
      Enum.find(socket.assigns.source_repositories, &(&1["full_name"] == params["source"]))

    source_changed? = params["source"] != socket.assigns.form.params["source"]

    params =
      if is_map(repository) and source_changed? do
        params
        |> Map.put("name", repository["name"])
        |> Map.put("private", to_string(repository["private"]))
      else
        params
      end

    {:noreply, assign(socket, :form, to_form(params, as: :repository_import))}
  end

  def handle_event("load-more", _params, %{assigns: %{next_page: nil}} = socket),
    do: {:noreply, socket}

  def handle_event("load-more", _params, socket) do
    {repositories, next_page, error} =
      candidates(socket.assigns.current_user, socket.assigns.next_page)

    {:noreply,
     socket
     |> update(:source_repositories, &(&1 ++ repositories))
     |> assign(:next_page, next_page)
     |> assign(:source_error, error)}
  end

  def handle_event("save", %{"repository_import" => params}, socket) do
    with {:ok, source, _github_repository} <-
           GitHubProjection.import_source(socket.assigns.current_user, params["source"]),
         {:ok, result} <- create_import(socket.assigns.current_user, source, params) do
      _result = result

      {:noreply,
       socket
       |> put_flash(:info, "GitHub import accepted as a one-time copy.")
       |> push_navigate(to: ~p"/repositories")}
    else
      {:error, reason} -> {:noreply, put_flash(socket, :error, error_message(reason))}
    end
  end

  @impl true
  def render(assigns) do
    ~H"""
    <Layouts.app
      flash={@flash}
      current_scope={@current_scope}
      title="Import from GitHub"
      sidebar_sections={assigns[:sidebar_sections]}
    >
      <main id="repository-import" class="mx-auto w-full max-w-3xl space-y-8 px-4 py-10">
        <.header>
          Import from GitHub
          <:subtitle>
            Copy one accepted snapshot of branches, tags, and Git history into OpenAgents.
          </:subtitle>
        </.header>

        <.alert id="one-time-import" variant={:info} title="This import does not sync">
          GitHub remains unchanged. Later GitHub commits do not update the OpenAgents repository.
        </.alert>

        <.alert :if={@source_error} id="github-import-error" variant={:warning}>
          {@source_error}
        </.alert>

        <.empty
          :if={@source_repositories == [] and is_nil(@source_error)}
          id="github-repositories-empty"
          title="No eligible GitHub repositories"
        >
          You can import repositories from your GitHub user or an organization where you are an administrator.
        </.empty>

        <.card :if={@source_repositories != []}>
          <.form
            for={@form}
            id="repository-import-form"
            phx-change="validate"
            phx-submit="save"
            class="space-y-5"
          >
            <.input
              field={@form[:source]}
              type="select"
              label="GitHub repository"
              options={Enum.map(@source_repositories, &{&1["full_name"], &1["full_name"]})}
              required
            />
            <.input field={@form[:name]} label="Destination name" maxlength="64" required />
            <.input field={@form[:private]} type="checkbox" label="Make this repository private" />

            <.alert id="lfs-import-warning" variant={:warning}>
              Git LFS pointer files are copied, but Git LFS objects are not included in this release.
            </.alert>

            <div class="flex flex-wrap justify-between gap-3">
              <.button
                :if={@next_page}
                id="load-more-github-repositories"
                type="button"
                phx-click="load-more"
                variant={:secondary}
              >
                Load more
              </.button>
              <div class="ml-auto flex gap-3">
                <.button navigate={~p"/repositories"} variant={:secondary}>Cancel</.button>
                <.button id="import-github-repository" type="submit">Import repository</.button>
              </div>
            </div>
          </.form>
        </.card>
      </main>
    </Layouts.app>
    """
  end

  defp candidates(user, page) do
    case GitHubProjection.import_candidates(user, page) do
      {:ok, result} ->
        next_page = if result["has_next_page"], do: result["next_page"], else: nil
        {result["items"], next_page, nil}

      {:error, :github_connection_required} ->
        {[], nil, "Connect GitHub before importing a repository."}

      {:error, :github_scope_required} ->
        {[], nil, "Reconnect GitHub with repository and organization access before importing."}

      {:error, _reason} ->
        {[], nil, "GitHub repositories are unavailable right now."}
    end
  end

  defp create_import(user, source, params) do
    attrs = %{
      name: params["name"],
      visibility: if(params["private"] == "true", do: "private", else: "public"),
      default_branch: source.source_default_branch
    }

    result =
      if source.source_owner_id == user.github_id do
        Repositories.create_user_import(user, source, attrs, Ecto.UUID.generate())
      else
        [owner | _name] = String.split(source.source_full_name, "/", parts: 2)

        with {:ok, namespace} <- GitHubProjection.authorized_organization(user, owner) do
          Repositories.create_organization_import(
            user,
            namespace,
            source,
            attrs,
            Ecto.UUID.generate()
          )
        end
      end

    case result do
      {:ok, repository, repository_import, state} ->
        {:ok, {repository, repository_import, state}}

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

  defp import_form([]),
    do: to_form(%{"source" => "", "name" => "", "private" => "true"}, as: :repository_import)

  defp import_form([repository | _rest]) do
    to_form(
      %{
        "source" => repository["full_name"],
        "name" => repository["name"],
        "private" => to_string(repository["private"])
      },
      as: :repository_import
    )
  end

  defp error_message(:source_repository_not_accessible),
    do: "OpenAgents cannot read that GitHub repository."

  defp error_message(:github_scope_required),
    do: "Reconnect GitHub with repository and organization access."

  defp error_message(%Ecto.Changeset{}), do: "Check the destination name, then try again."
  defp error_message(_reason), do: "OpenAgents could not start this GitHub import."
end