3.6 KiB · text History b868907
defmodule GitGud.Repositories.Workers.UpstreamSync do
  @moduledoc """
  Scheduled re-sync of repositories that opted into it
  (`upstream_auto_sync`).

  Two jobs, one module. Without arguments it is the cron entry: it walks
  the opted-in repositories and enqueues one job per repo, so a slow or
  unreachable upstream delays only itself. With `%{"repository_id" => id}`
  it syncs that one repository.

  Each repo syncs in its own `upstream_sync_mode`: a `"mirror"` repo is
  force-updated as always, a `"reconcile"` repo only fast-forwards and
  leaves divergence for a human. That distinction is what makes running
  this unattended safe — see `GitGud.Repositories.sync_upstream/3`.

  ## Credentials

  Scheduled syncs are **anonymous**. The import token is wiped the
  moment a batch finishes and nothing else retains a credential, so a
  private upstream can only be synced by hand, from repo settings, where
  the operator supplies the token for that one fetch.

  The seam for changing that is `credentials_for/1`: it is the single
  place a scheduled sync obtains credentials, and it returns `nil`
  today. Wiring per-repo stored credentials means implementing that
  function (and the storage behind it) — every caller, error path and
  the "needs credentials" reporting below already routes through it.
  """

  use Oban.Worker, queue: :git_cache, max_attempts: 3

  require Logger

  alias GitGud.Repositories
  alias GitGud.Repositories.Repository

  @impl Oban.Worker
  def perform(%Oban.Job{args: %{"repository_id" => id}}) do
    case GitGud.Repo.get(Repository, id) do
      nil -> :ok
      repo -> sync(repo)
    end
  end

  def perform(%Oban.Job{}) do
    repos = Repositories.list_auto_sync_repositories()

    Enum.each(repos, fn %Repository{id: id} ->
      {:ok, _} = %{"repository_id" => id} |> __MODULE__.new() |> Oban.insert()
    end)

    {:ok, length(repos)}
  end

  @doc """
  Credentials for an unattended sync of `repo`.

  Always `nil` for now: nothing retains a credential past the import
  that created the repository. Implementing stored per-repo credentials
  starts here.
  """
  def credentials_for(%Repository{}), do: nil

  defp sync(%Repository{} = repo) do
    case Repositories.sync_upstream(repo, credentials_for(repo)) do
      {:ok, result} ->
        _ = Repositories.clear_upstream_sync_error(result.repository)
        :ok

      {:error, reason} ->
        message = describe(reason)
        _ = Repositories.record_upstream_sync_error(repo, message)
        Logger.warning("scheduled upstream sync failed for repo #{repo.id}: #{message}")

        # Recorded, not retried: the next tick comes round soon enough,
        # and a permanently private upstream would otherwise fail three
        # times an hour forever.
        :ok
    end
  end

  defp describe({:git_fetch, out}) when is_binary(out) do
    if needs_credentials?(out) do
      "upstream needs credentials; scheduled syncs are anonymous — " <>
        "fetch it by hand from the repository's settings"
    else
      out |> String.split("\n", trim: true) |> Enum.take(-2) |> Enum.join(" / ")
    end
  end

  defp describe(:no_upstream), do: "no upstream URL"
  defp describe(other), do: inspect(other)

  # git's phrasing for "this remote wants a login" varies by transport
  # and version; these are the ones that reach us with credentials
  # deliberately absent.
  defp needs_credentials?(out) do
    out = String.downcase(out)

    Enum.any?(
      [
        "authentication failed",
        "could not read username",
        "could not read password",
        "terminal prompts disabled",
        "403 forbidden",
        "permission denied"
      ],
      &String.contains?(out, &1)
    )
  end
end