18.4 KiB · text History 6280797
defmodule GitGudWeb.ImportLive.New do
@moduledoc """
Two-step importer: describe the source, then pick from what we found.
Discovery runs inline (it's a handful of API calls) so the operator
sees the real repo list — names, visibility, forks, archived state —
before anything is written. The provider token lives in socket assigns
for the duration of the form and is never rendered back into the page.
"""
use GitGudWeb, :live_view
alias GitGud.Imports
alias GitGud.Imports.Provider
alias GitGud.Organizations
alias GitGud.Repositories.Storage
@impl true
def mount(_params, _session, socket) do
user = socket.assigns.current_scope.user
orgs =
Organizations.list_organizations_for(user)
|> Enum.filter(&Organizations.org_admin?(&1, user))
{:ok,
socket
|> assign(:page_title, "Import repositories")
|> assign(:user, user)
|> assign(:orgs, orgs)
|> assign(:step, :configure)
|> assign(:token, nil)
|> assign(:discovering, false)
|> assign(:error, nil)
|> assign(:discovered, [])
|> assign(:selected, MapSet.new())
|> assign(:targets, %{})
|> assign(:params, default_params())}
end
defp default_params do
%{
"provider" => "github",
"remote_owner" => "",
"remote_username" => "",
"base_url" => Provider.default_base_url("github"),
"namespace" => "user",
"visibility_mode" => "mirror",
"default_visibility" => "private",
"include_forks" => "false",
"include_archived" => "false"
}
end
# ── configure step ───────────────────────────────────────────────────
@impl true
def handle_event("validate", params, socket) do
previous = socket.assigns.params
merged = Map.merge(previous, Map.take(params, Map.keys(default_params())))
# Switching provider swaps in that provider's default API root, but
# only when the operator hasn't typed their own.
merged =
if merged["provider"] != previous["provider"] and
previous["base_url"] in [Provider.default_base_url(previous["provider"]), "", nil] do
Map.put(merged, "base_url", Provider.default_base_url(merged["provider"]))
else
merged
end
{:noreply,
socket
|> assign(:params, merged)
|> assign(:token, blank_to_nil(params["token"]) || socket.assigns.token)}
end
def handle_event("discover", params, socket) do
socket = handle_validate_side_effects(socket, params)
p = socket.assigns.params
case Imports.discover(p, socket.assigns.token) do
{:ok, repos} ->
filtered = Imports.filter_discovered(repos, p)
{:noreply,
socket
|> assign(:error, nil)
|> assign(:discovered, filtered)
|> assign(:selected, MapSet.new(Enum.map(filtered, & &1.full_name)))
|> assign(:targets, Map.new(filtered, &{&1.full_name, Imports.target_name(&1.name)}))
|> assign(:step, :select)}
{:error, reason} ->
{:noreply, socket |> assign(:error, discovery_error(reason)) |> assign(:step, :configure)}
end
end
# ── select step ──────────────────────────────────────────────────────
def handle_event("back", _params, socket),
do: {:noreply, assign(socket, :step, :configure)}
def handle_event("toggle", %{"full-name" => full_name}, socket) do
selected = socket.assigns.selected
selected =
if MapSet.member?(selected, full_name),
do: MapSet.delete(selected, full_name),
else: MapSet.put(selected, full_name)
{:noreply, assign(socket, :selected, selected)}
end
def handle_event("select_all", _params, socket),
do:
{:noreply,
assign(socket, :selected, MapSet.new(Enum.map(socket.assigns.discovered, & &1.full_name)))}
def handle_event("select_none", _params, socket),
do: {:noreply, assign(socket, :selected, MapSet.new())}
def handle_event("rename", %{"full-name" => full_name, "value" => value}, socket) do
{:noreply, assign(socket, :targets, Map.put(socket.assigns.targets, full_name, value))}
end
def handle_event("start", _params, socket) do
%{user: user, params: p, selected: selected, targets: targets} = socket.assigns
repos =
socket.assigns.discovered
|> Enum.filter(&MapSet.member?(selected, &1.full_name))
|> Enum.map(fn r ->
Map.put(r, :target_name, Imports.target_name(Map.get(targets, r.full_name) || r.name))
end)
with {:ok, destination} <- resolve_destination(p["namespace"], user),
{:ok, import_row} <-
Imports.start_import(user, destination, p, repos, socket.assigns.token) do
{:noreply,
socket
|> put_flash(:info, "Import started — #{length(repos)} repositories queued.")
|> push_navigate(to: ~p"/imports/#{import_row.id}")}
else
{:error, :forbidden} ->
{:noreply, assign(socket, :error, "You need to be an admin of that organization.")}
{:error, %Ecto.Changeset{} = cs} ->
{:noreply, assign(socket, :error, changeset_message(cs))}
{:error, reason} ->
{:noreply, assign(socket, :error, "Could not start import: #{inspect(reason)}")}
end
end
defp resolve_destination("user", user), do: {:ok, user}
defp resolve_destination("org:" <> handle, _user) do
case Organizations.get_organization_by_handle(handle) do
nil -> {:error, :invalid_namespace}
org -> {:ok, org}
end
end
defp resolve_destination(_, _), do: {:error, :invalid_namespace}
defp handle_validate_side_effects(socket, params) do
merged = Map.merge(socket.assigns.params, Map.take(params, Map.keys(default_params())))
socket
|> assign(:params, merged)
|> assign(:token, blank_to_nil(params["token"]) || socket.assigns.token)
end
# ── error copy ───────────────────────────────────────────────────────
defp discovery_error({:missing, :remote_owner}),
do: "Enter an organization, user, or repository URL to import from."
defp discovery_error(:credentials_in_url),
do:
"Don't put credentials in the URL — they'd be stored with the repository. " <>
"Use the plain URL and put the token in the access token field."
defp discovery_error(:invalid_clone_url),
do:
"That doesn't look like an http(s) git URL. SSH remotes aren't supported — use the https URL with a token."
defp discovery_error(:not_found),
do: "No such organization or user on that host — check the name and the base URL."
defp discovery_error({:unauthorized, 401}),
do: "The host rejected the token (401). Check that it's valid and unexpired."
defp discovery_error({:unauthorized, 403}),
do: "The token lacks permission to list those repositories (403), or you're rate limited."
defp discovery_error({:http_error, status, nil}), do: "The host returned HTTP #{status}."
defp discovery_error({:http_error, status, msg}), do: "The host returned HTTP #{status}: #{msg}"
defp discovery_error({:transport, reason}),
do: "Could not reach the host: #{inspect(reason)}"
defp discovery_error(:unknown_provider), do: "Unknown provider."
defp discovery_error(other), do: "Discovery failed: #{inspect(other)}"
defp changeset_message(cs) do
cs
|> Ecto.Changeset.traverse_errors(fn {msg, _} -> msg end)
|> Enum.map_join("; ", fn {field, msgs} -> "#{field} #{Enum.join(msgs, ", ")}" end)
end
defp blank_to_nil(nil), do: nil
defp blank_to_nil(""), do: nil
defp blank_to_nil(s) when is_binary(s), do: if(String.trim(s) == "", do: nil, else: s)
defp provider_hint("github"), do: "An org or user on github.com or Enterprise"
defp provider_hint("gitlab"), do: "A group (with subgroups) or user on any instance"
defp provider_hint("git"), do: "One repository, by https URL"
defp provider_hint(_), do: nil
defp git_url?(%{"provider" => "git"}), do: true
defp git_url?(_), do: false
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope}>
<div class="space-y-6 max-w-4xl">
<div>
<h1 class="text-2xl font-semibold">Import repositories</h1>
<p class="text-sm opacity-70 mt-1">
Mirror repositories from GitHub or GitLab, or pull in a single git URL.
Branches, tags and notes come across; issues and pull requests don't.
</p>
</div>
<div :if={@error} class="alert alert-error text-sm">
<.icon name="hero-exclamation-triangle" class="w-5 h-5" />
<span>{@error}</span>
</div>
<.configure_step
:if={@step == :configure}
params={@params}
orgs={@orgs}
user={@user}
has_token={not is_nil(@token)}
/>
<.select_step
:if={@step == :select}
discovered={@discovered}
selected={@selected}
targets={@targets}
params={@params}
/>
</div>
</Layouts.app>
"""
end
attr :params, :map, required: true
attr :orgs, :list, required: true
attr :user, :map, required: true
attr :has_token, :boolean, default: false
defp configure_step(assigns) do
~H"""
<form phx-change="validate" phx-submit="discover" class="space-y-4">
<fieldset class="flex flex-col gap-2">
<legend class="text-sm opacity-70 mb-2">Source</legend>
<div class="grid gap-2 sm:grid-cols-3">
<label
:for={{label, slug} <- Provider.options()}
class={[
"flex flex-col gap-1 rounded-lg border p-3 cursor-pointer transition-colors",
(@params["provider"] == slug && "border-primary bg-primary/5 ring-1 ring-primary") ||
"border-base-300 hover:border-base-content/30"
]}
>
<span class="flex items-center gap-2">
<input
type="radio"
name="provider"
value={slug}
checked={@params["provider"] == slug}
class="radio radio-sm radio-primary"
/>
<span class="text-sm font-medium">{label}</span>
</span>
<span class="text-xs opacity-60">{provider_hint(slug)}</span>
</label>
</div>
</fieldset>
<label class="flex flex-col gap-1">
<span class="text-sm opacity-70">
{if git_url?(@params), do: "Repository URL", else: "Organization, group or user"}
</span>
<input
type="text"
name="remote_owner"
value={@params["remote_owner"]}
class="input input-bordered"
placeholder={if git_url?(@params), do: "https://example.com/team/project.git", else: "acme"}
autocomplete="off"
/>
<span :if={not git_url?(@params)} class="text-xs opacity-60">
Organizations and users both work. GitLab group paths may be nested (<code>acme/platform</code>);
subgroups are included.
</span>
</label>
<label :if={not git_url?(@params)} class="flex flex-col gap-1">
<span class="text-sm opacity-70">API base URL</span>
<input
type="text"
name="base_url"
value={@params["base_url"]}
class="input input-bordered"
autocomplete="off"
/>
<span class="text-xs opacity-60">
Change this for self-hosted instances — GitHub Enterprise uses <code>https://host/api/v3</code>, GitLab uses the instance root.
</span>
</label>
<label class="flex flex-col gap-1">
<span class="text-sm opacity-70">
Access token <span class="opacity-60">(optional)</span>
</span>
<input
type="password"
name="token"
value=""
class="input input-bordered"
autocomplete="new-password"
placeholder={
if @has_token,
do: "•••••••• (stored for this import)",
else: "Needed for private repositories"
}
/>
<span class="text-xs opacity-60">
Encrypted at rest and erased as soon as the import finishes. It's passed to <code>git</code>
out-of-band, never written into a clone URL.
</span>
</label>
<label :if={git_url?(@params)} class="flex flex-col gap-1">
<span class="text-sm opacity-70">Username <span class="opacity-60">(optional)</span></span>
<input
type="text"
name="remote_username"
value={@params["remote_username"]}
class="input input-bordered"
autocomplete="off"
placeholder="git"
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-sm opacity-70">Import into</span>
<select name="namespace" class="select select-bordered">
<option value="user" selected={@params["namespace"] == "user"}>
{Storage.owner_handle(@user)} (you)
</option>
<option
:for={org <- @orgs}
value={"org:" <> org.handle}
selected={@params["namespace"] == "org:" <> org.handle}
>
{org.handle}
</option>
</select>
</label>
<div class="grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1">
<span class="text-sm opacity-70">Visibility</span>
<select name="visibility_mode" class="select select-bordered">
<option value="mirror" selected={@params["visibility_mode"] == "mirror"}>
Match the source
</option>
<option value="force" selected={@params["visibility_mode"] == "force"}>
Set them all to…
</option>
</select>
</label>
<label :if={@params["visibility_mode"] == "force"} class="flex flex-col gap-1">
<span class="text-sm opacity-70">&nbsp;</span>
<select name="default_visibility" class="select select-bordered">
<option
:for={v <- ~w(private internal public)}
value={v}
selected={@params["default_visibility"] == v}
>
{String.capitalize(v)}
</option>
</select>
</label>
</div>
<div :if={not git_url?(@params)} class="flex flex-col gap-2">
<label class="flex items-center gap-2 text-sm">
<input type="hidden" name="include_forks" value="false" />
<input
type="checkbox"
name="include_forks"
value="true"
checked={@params["include_forks"] == "true"}
class="checkbox checkbox-sm"
/> Include forks
</label>
<label class="flex items-center gap-2 text-sm">
<input type="hidden" name="include_archived" value="false" />
<input
type="checkbox"
name="include_archived"
value="true"
checked={@params["include_archived"] == "true"}
class="checkbox checkbox-sm"
/> Include archived repositories
</label>
</div>
<button type="submit" class="btn btn-primary">
{if git_url?(@params), do: "Check URL", else: "Find repositories"}
</button>
</form>
"""
end
attr :discovered, :list, required: true
attr :selected, :any, required: true
attr :targets, :map, required: true
attr :params, :map, required: true
defp select_step(assigns) do
~H"""
<div class="space-y-4">
<div class="flex items-center justify-between gap-3 flex-wrap">
<p class="text-sm">
<span class="font-medium">{MapSet.size(@selected)}</span> of {length(@discovered)} selected
</p>
<div class="flex gap-2">
<button type="button" phx-click="select_all" class="btn btn-sm btn-ghost">
Select all
</button>
<button type="button" phx-click="select_none" class="btn btn-sm btn-ghost">
Select none
</button>
<button type="button" phx-click="back" class="btn btn-sm btn-ghost">Back</button>
</div>
</div>
<p :if={@discovered == []} class="text-sm opacity-70">
Nothing to import — no repositories matched. Forks and archived repositories are
excluded unless you tick them on the previous step.
</p>
<div :if={@discovered != []} class="overflow-x-auto border border-base-300 rounded-lg">
<table class="table table-sm">
<thead>
<tr>
<th class="w-8"></th>
<th>Source</th>
<th>Imports as</th>
<th class="w-32">Flags</th>
</tr>
</thead>
<tbody>
<tr
:for={repo <- @discovered}
class={not MapSet.member?(@selected, repo.full_name) && "opacity-40"}
>
<td>
<input
type="checkbox"
class="checkbox checkbox-sm"
checked={MapSet.member?(@selected, repo.full_name)}
phx-click="toggle"
phx-value-full-name={repo.full_name}
/>
</td>
<td>
<div class="font-medium break-all">{repo.full_name}</div>
<div :if={repo.description} class="text-xs opacity-60 line-clamp-1">
{repo.description}
</div>
</td>
<td>
<input
type="text"
class="input input-bordered input-sm w-48"
value={Map.get(@targets, repo.full_name)}
phx-blur="rename"
phx-value-full-name={repo.full_name}
/>
</td>
<td class="text-xs">
<span :if={repo.private} class="badge badge-sm badge-ghost">private</span>
<span :if={repo.fork} class="badge badge-sm badge-ghost">fork</span>
<span :if={repo.archived} class="badge badge-sm badge-ghost">archived</span>
</td>
</tr>
</tbody>
</table>
</div>
<button
type="button"
phx-click="start"
disabled={MapSet.size(@selected) == 0}
class="btn btn-primary"
>
Import {MapSet.size(@selected)} {if MapSet.size(@selected) == 1,
do: "repository",
else: "repositories"}
</button>
</div>
"""
end
end