15.9 KiB · text History 69753e1
defmodule GitGud.Imports do
@moduledoc """
Bulk import of repositories from another forge.
Three shapes, one pipeline:
* a GitHub organization or user
* a GitLab group (including subgroups) or user
* a single arbitrary git URL
A batch is an `Import` row plus one `ImportItem` per remote repo.
Discovery happens up front and synchronously — it is a couple of API
calls, and the operator wants to see the list before committing to it.
Cloning is one Oban job per item so a single unreachable repo fails
alone instead of taking the batch down with it.
## Credentials
The provider token is needed after the request finishes (the clone
workers use it), so it is stored AES-256-GCM encrypted with the
`GitGud.Secrets` master key and wiped the moment the batch reaches a
terminal state. It never reaches a clone URL or the repo's git config —
see `GitGud.Repositories.git_credential_env/1`.
"""
import Ecto.Query, warn: false
alias GitGud.Accounts.User
alias GitGud.Imports.Import
alias GitGud.Imports.ImportItem
alias GitGud.Imports.Provider
alias GitGud.Imports.Workers.CloneRepo
alias GitGud.Organizations
alias GitGud.Organizations.Organization
alias GitGud.Repo
alias GitGud.Repositories
alias GitGud.Repositories.Repository
alias GitGud.Secrets
@doc "PubSub topic carrying progress for one import batch."
def topic(%Import{id: id}), do: topic(id)
def topic(id) when is_integer(id), do: "import:#{id}"
# ── discovery ────────────────────────────────────────────────────────
@doc """
Ask the provider what lives under `remote_owner`, without writing
anything. Returns normalized `t:GitGud.Imports.Provider.remote_repo/0`
maps, sorted by name.
This is the "preview" step behind the import form — the operator picks
from the result before anything is created.
"""
def discover(attrs, token \\ nil) do
provider = attrs["provider"] || attrs[:provider]
owner = trim(attrs["remote_owner"] || attrs[:remote_owner])
base_url = trim(attrs["base_url"] || attrs[:base_url]) || Provider.default_base_url(provider)
with {:ok, adapter} <- Provider.adapter(provider),
:ok <- require_present(owner, :remote_owner),
{:ok, repos} <- adapter.list_repositories(owner, base_url: base_url, token: token) do
{:ok, Enum.sort_by(repos, &String.downcase(&1.full_name || &1.name || ""))}
end
end
@doc """
Filter a discovered list by the batch's fork/archive preferences.
"""
def filter_discovered(repos, opts) do
include_forks = truthy(opts["include_forks"] || opts[:include_forks])
include_archived = truthy(opts["include_archived"] || opts[:include_archived])
Enum.filter(repos, fn r ->
(include_forks or not r.fork) and (include_archived or not r.archived)
end)
end
# ── starting a batch ─────────────────────────────────────────────────
@doc """
Create an import batch for `repos` (a list of discovered repo maps)
and enqueue a clone job per item.
`destination` is the `%User{}` or `%Organization{}` the repos land in;
for an org, `actor` must be an org admin. `token` is the provider
credential, or `nil` for a public import.
Returns `{:ok, import}` with items preloaded.
"""
def start_import(%User{} = actor, destination, attrs, repos, token \\ nil) do
with :ok <- authorize_destination(destination, actor),
{:ok, base} <- build_import(actor, destination, attrs, token) do
Repo.transaction(fn ->
import_row = Repo.insert!(base)
items =
repos
|> Enum.uniq_by(& &1.full_name)
|> Enum.map(fn repo ->
case insert_item(import_row, repo) do
{:ok, item} -> item
{:error, changeset} -> Repo.rollback(changeset)
end
end)
import_row =
import_row
|> Import.status_changeset(%{status: if(items == [], do: "completed", else: "running")})
|> Repo.update!()
Enum.each(items, fn item ->
{:ok, _} = %{item_id: item.id} |> CloneRepo.new_job() |> Oban.insert()
end)
# An empty selection is a no-op batch, but it still holds a token.
if items == [], do: clear_token!(import_row)
%{import_row | items: items}
end)
|> case do
{:ok, import_row} ->
broadcast(import_row, {:import_updated, import_row.id})
{:ok, import_row}
{:error, reason} ->
{:error, reason}
end
end
end
defp build_import(%User{} = actor, destination, attrs, token) do
attrs = stringify(attrs)
provider = attrs["provider"]
attrs = Map.put(attrs, "base_url", resolved_base_url(attrs, provider))
changeset =
%Import{
owner_id: actor.id,
organization_id: org_id(destination)
}
|> Import.create_changeset(attrs)
|> put_token(token)
if changeset.valid?, do: {:ok, changeset}, else: {:error, changeset}
end
# A one-off `git` import has no API root; use the clone URL's own origin
# so the column still holds something meaningful (and valid).
defp resolved_base_url(attrs, "git") do
case URI.new(attrs["remote_owner"] || "") do
{:ok, %URI{scheme: s, host: h, port: p}} when s in ["http", "https"] and is_binary(h) ->
URI.to_string(%URI{scheme: s, host: h, port: p})
_ ->
# Leave it invalid on purpose — the changeset reports the bad URL
# against `remote_owner`, which is the field the operator typed.
""
end
end
defp resolved_base_url(attrs, provider) do
case attrs["base_url"] do
v when is_binary(v) -> if blank?(v), do: Provider.default_base_url(provider) || "", else: v
_ -> Provider.default_base_url(provider) || ""
end
end
defp put_token(changeset, nil), do: changeset
defp put_token(changeset, token) when is_binary(token) do
if blank?(token) do
changeset
else
{:ok, enc} = Secrets.encrypt(token)
Ecto.Changeset.change(changeset,
token_ciphertext: enc.ciphertext,
token_iv: enc.iv,
token_tag: enc.tag,
token_key_id: enc.key_id
)
end
end
defp insert_item(%Import{} = import_row, repo) do
%ImportItem{repository_import_id: import_row.id}
|> ImportItem.changeset(%{
remote_name: repo.name,
remote_full_name: repo.full_name || repo.name,
clone_url: repo.clone_url,
remote_description: repo.description,
remote_default_branch: repo.default_branch,
remote_private: !!repo.private,
remote_fork: !!repo.fork,
remote_archived: !!repo.archived,
target_name: Map.get(repo, :target_name) || target_name(repo.name),
status: "pending"
})
|> Repo.insert()
end
# GitGud repo names are stricter than GitLab project paths (which allow
# a leading dot, for instance). Coerce rather than reject so a
# 200-repo import doesn't stall on one oddly-named project.
@doc false
def target_name(nil), do: "repository"
def target_name(name) do
name
|> String.replace(~r/[^a-zA-Z0-9._-]/, "-")
|> String.replace(~r/\A[^a-zA-Z0-9]+/, "")
|> String.slice(0, 100)
|> case do
"" -> "repository"
n -> n
end
end
defp authorize_destination(%User{id: id}, %User{id: id}), do: :ok
defp authorize_destination(%User{}, %User{}), do: {:error, :forbidden}
defp authorize_destination(%Organization{} = org, %User{} = actor) do
if Organizations.org_admin?(org, actor), do: :ok, else: {:error, :forbidden}
end
defp org_id(%Organization{id: id}), do: id
defp org_id(%User{}), do: nil
# ── reads ────────────────────────────────────────────────────────────
@doc "Imports started by `user`, newest first."
def list_imports(%User{id: uid}) do
from(i in Import, where: i.owner_id == ^uid, order_by: [desc: i.id])
|> Repo.all()
|> Repo.preload(:organization)
end
@doc "Fetch an import owned by `user`, with items. Raises if not theirs."
def get_import!(%User{id: uid}, id) do
from(i in Import, where: i.id == ^id and i.owner_id == ^uid)
|> Repo.one!()
|> Repo.preload([:organization, items: from(it in ImportItem, order_by: [asc: it.id])])
end
def get_import(id), do: Repo.get(Import, id)
def get_item!(id), do: Repo.get!(ImportItem, id) |> Repo.preload(:import)
@doc "Counts by item status, e.g. `%{\"imported\" => 12, \"failed\" => 1}`."
def progress(%Import{id: id}) do
from(it in ImportItem,
where: it.repository_import_id == ^id,
group_by: it.status,
select: {it.status, count(it.id)}
)
|> Repo.all()
|> Map.new()
end
@doc "Decrypt the batch token, or `nil` if there isn't one (or it was wiped)."
def token(%Import{token_ciphertext: nil}), do: nil
def token(%Import{} = import_row) do
case Secrets.decrypt(%{
ciphertext: import_row.token_ciphertext,
iv: import_row.token_iv,
tag: import_row.token_tag,
key_id: import_row.token_key_id
}) do
{:ok, plaintext} -> plaintext
{:error, _} -> nil
end
end
@doc """
Credentials for cloning this batch's repos, or `nil` when the import
is anonymous. Shaped for `GitGud.Repositories.git_credential_env/1`.
"""
def credentials(%Import{} = import_row) do
case token(import_row) do
nil -> nil
token -> %{username: username(import_row), token: token}
end
end
defp username(%Import{remote_username: u}) when is_binary(u) and u != "", do: u
# Both providers authenticate on the token alone, but git still asks
# for a username over http basic; these are the conventional answers.
defp username(%Import{provider: "github"}), do: "x-access-token"
defp username(%Import{provider: "gitlab"}), do: "oauth2"
defp username(%Import{}), do: "git"
# ── item lifecycle (called by the worker) ────────────────────────────
@doc false
def mark_item(%ImportItem{} = item, status, attrs \\ %{}) do
{:ok, item} =
item
|> ImportItem.status_changeset(Map.merge(attrs, %{status: status}))
|> Repo.update()
broadcast(item.repository_import_id, {:import_item_updated, item.id})
item
end
@doc """
Recompute the batch's status from its items. Once nothing is pending
or in flight the batch is terminal and the stored token is wiped.
"""
def settle(%Import{} = import_row) do
counts = progress(import_row)
outstanding = Map.get(counts, "pending", 0) + Map.get(counts, "cloning", 0)
if outstanding == 0 do
status = if Map.get(counts, "failed", 0) > 0, do: "failed", else: "completed"
import_row =
import_row
|> Import.status_changeset(%{status: status})
|> Repo.update!()
|> clear_token!()
broadcast(import_row, {:import_updated, import_row.id})
import_row
else
import_row
end
end
@doc """
Cancel a batch: pending items are marked skipped and the token is
wiped. Items already cloning are left to finish — killing a clone
mid-flight would leave a half-written bare repo on disk.
"""
def cancel(%Import{} = import_row) do
{_n, _} =
from(it in ImportItem,
where: it.repository_import_id == ^import_row.id and it.status == "pending"
)
|> Repo.update_all(
set: [status: "skipped", error: "canceled", updated_at: DateTime.utc_now(:second)]
)
import_row =
import_row
|> Import.status_changeset(%{status: "canceled"})
|> Repo.update!()
|> clear_token!()
broadcast(import_row, {:import_updated, import_row.id})
{:ok, import_row}
end
defp clear_token!(%Import{} = import_row) do
import_row
|> Ecto.Changeset.change(
token_ciphertext: nil,
token_iv: nil,
token_tag: nil,
token_key_id: nil,
token_cleared_at: DateTime.utc_now(:second)
)
|> Repo.update!()
end
# ── the actual import of one item ────────────────────────────────────
@doc """
Create the local repository for `item` by mirror-cloning its remote.
Returns `{:ok, repository}`, `{:skip, reason}` when the name is already
taken locally (the operator can rename and retry rather than us
guessing a suffix), or `{:error, reason}`.
"""
def import_item(%ImportItem{} = item, %Import{} = import_row) do
destination = destination(import_row)
attrs = %{
"name" => item.target_name,
"description" => truncate(item.remote_description, 1000),
"visibility" => visibility_for(item, import_row, destination),
"clone_addr" => item.clone_url,
"clone_username" => username(import_row),
"clone_token" => token(import_row)
}
attrs =
case item.remote_default_branch do
b when is_binary(b) and b != "" -> Map.put(attrs, "default_branch", b)
_ -> attrs
end
cond do
is_nil(destination) ->
{:error, :destination_missing}
name_taken?(destination, item.target_name) ->
{:skip, "a repository named #{item.target_name} already exists here"}
true ->
create(destination, import_row, attrs)
end
end
defp create(%Organization{} = org, %Import{} = import_row, attrs) do
creator = Repo.get!(User, import_row.owner_id)
Repositories.create_repository_for_org(org, creator, attrs)
end
defp create(%User{} = user, _import_row, attrs) do
Repositories.create_repository(user, attrs)
end
@doc "The `%User{}` or `%Organization{}` this batch lands in."
def destination(%Import{organization_id: nil, owner_id: uid}), do: Repo.get(User, uid)
def destination(%Import{organization_id: oid}), do: Repo.get(Organization, oid)
defp name_taken?(destination, name) do
query =
case destination do
%Organization{id: oid} ->
from(r in Repository, where: r.organization_id == ^oid and r.name == ^name)
%User{id: uid} ->
from(r in Repository,
where: r.owner_id == ^uid and is_nil(r.organization_id) and r.name == ^name
)
end
Repo.exists?(query)
end
# `mirror` maps the remote's own public/private flag; `force` pins every
# repo to one visibility. Either way the result is clamped so it can
# never be more open than the destination org.
defp visibility_for(%ImportItem{} = item, %Import{} = import_row, destination) do
base =
case import_row.visibility_mode do
"force" -> import_row.default_visibility
_ -> if item.remote_private, do: "private", else: "public"
end
clamp_visibility(base, destination)
end
defp clamp_visibility(vis, %Organization{visibility: org_vis}) do
if Organization.visibility_rank(vis) >= Organization.visibility_rank(org_vis),
do: vis,
else: org_vis
end
defp clamp_visibility(vis, _), do: vis
# ── misc ─────────────────────────────────────────────────────────────
defp broadcast(%Import{id: id}, msg), do: broadcast(id, msg)
defp broadcast(id, msg) when is_integer(id) do
Phoenix.PubSub.broadcast(GitGud.PubSub, topic(id), msg)
end
defp require_present(value, field) do
if blank?(value), do: {:error, {:missing, field}}, else: :ok
end
defp trim(nil), do: nil
defp trim(s) when is_binary(s), do: String.trim(s)
defp blank?(nil), do: true
defp blank?(s) when is_binary(s), do: String.trim(s) == ""
defp blank?(_), do: false
defp truthy(true), do: true
defp truthy("true"), do: true
defp truthy("on"), do: true
defp truthy(_), do: false
defp truncate(nil, _), do: nil
defp truncate(s, max) when is_binary(s), do: String.slice(s, 0, max)
defp stringify(map) do
Map.new(map, fn {k, v} -> {to_string(k), v} end)
end
end