Skip to main content

lib/mix/tasks/hexorcist.update.ex

defmodule Mix.Tasks.Hexorcist.Update do
  @shortdoc "Applies a project's Hexorcist upgrade plan locally (mix deps.update)"

  @moduledoc """
  Fetches this project's upgrade plan from Hexorcist and applies the safe,
  lockfile-only part (`mix deps.update`), optionally test-gated, leaving the
  `mix.lock` change as a reviewable diff. It never commits or pushes by default.

      mix hexorcist.update --project <public-id>

  Configuration — flags override env, env overrides the repo config file
  `.hexorcist.exs` (a keyword list, e.g. `[project: "…", url: "…"]`):

    * `--project` / `HEXORCIST_PROJECT` / `:project` — the project's public id (required)
    * `--token`   / `HEXORCIST_TOKEN`               — account token (required; never put in the file)
    * `--url`     / `HEXORCIST_URL`   / `:url`        — base URL (default https://hexorcist.net)
    * `--test`     — run `mix test` after updating; roll back changes if it fails
    * `--edit-mix` — raise `mix.exs` constraints to unlock still-blocked majors
    * `--commit`   — `git commit` the change (still never pushes)
    * `--dry-run`  — just print the plan; change nothing

  Commit `.hexorcist.exs` so the project id travels with the repo; keep the
  **token** in your environment (it's a secret).

  Without `--edit-mix`, only **lockfile** bumps are applied (updates that fit
  your existing `mix.exs`). With it, a still-blocked update has its literal
  `{:dep, "~> x"}` constraint raised — in the root `mix.exs` or, for an umbrella,
  whichever `apps/*/mix.exs` declares it (all of them, if several do). Anything
  not safely rewritable is reported as a suggestion, and an edit that doesn't
  actually unlock the dep is reverted.
  """
  use Mix.Task

  @switches [
    project: :string,
    token: :string,
    url: :string,
    test: :boolean,
    commit: :boolean,
    edit_mix: :boolean,
    dry_run: :boolean
  ]
  @default_url "https://hexorcist.net"
  @config_file ".hexorcist.exs"

  @impl true
  def run(argv) do
    {opts, _, _} = OptionParser.parse(argv, switches: @switches)
    config = load_config()

    project =
      opt(opts, :project, "HEXORCIST_PROJECT") || config[:project] ||
        abort("--project (or HEXORCIST_PROJECT, or :project in #{@config_file}) is required")

    token =
      opt(opts, :token, "HEXORCIST_TOKEN") || abort("--token (or HEXORCIST_TOKEN) is required")

    url = opt(opts, :url, "HEXORCIST_URL") || config[:url] || @default_url

    plan = fetch_plan(url, project, token)
    print_plan(plan)

    cond do
      plan["deps_update_command"] in [nil, ""] ->
        Mix.shell().info("\nNothing to update. 🎉")

      opts[:dry_run] ->
        Mix.shell().info("\n[dry-run] would run: #{plan["deps_update_command"]}")

      true ->
        apply_plan(plan, opts)
    end
  end

  ## Fetch

  defp fetch_plan(url, project, token) do
    {:ok, _} = Application.ensure_all_started(:inets)
    {:ok, _} = Application.ensure_all_started(:ssl)

    endpoint = "#{String.trim_trailing(url, "/")}/api/projects/#{project}/upgrade-plan"
    headers = [{~c"authorization", ~c"Bearer #{token}"}, {~c"accept", ~c"application/json"}]

    case :httpc.request(:get, {String.to_charlist(endpoint), headers}, [], body_format: :binary) do
      {:ok, {{_, 200, _}, _h, body}} ->
        :json.decode(body)

      {:ok, {{_, status, _}, _h, body}} ->
        abort("Hexorcist returned HTTP #{status}: #{String.slice(body, 0, 300)}")

      {:error, reason} ->
        abort("request failed: #{inspect(reason)}")
    end
  end

  ## Apply

  defp apply_plan(plan, opts) do
    packages = Enum.map(plan["updates"], & &1["package"])

    Mix.shell().info("\nUpdating #{length(packages)} package(s) in the lockfile…")
    cmd!("mix", ["deps.update" | packages])
    cmd!("mix", ["deps.get"])

    if opts[:edit_mix], do: edit_constraints(plan)

    if opts[:test], do: gate_on_tests(["mix.lock", "mix.exs"])

    files = ["mix.lock"] ++ if(opts[:edit_mix], do: mix_files(), else: [])
    diff = git(["diff", "--stat" | files])

    if String.trim(diff) == "" do
      Mix.shell().info(
        "\nNo changes (everything was constraint-capped — try --edit-mix to raise mix.exs)."
      )
    else
      Mix.shell().info("\nChanges:\n#{diff}")
      if opts[:commit], do: commit(files)

      Mix.shell().info(
        "\nDone. Review the diff" <>
          if(opts[:commit], do: " (committed, not pushed).", else: " and commit when ready.")
      )
    end
  end

  ## Phase 2 — raise mix.exs constraints to unlock still-blocked majors

  defp edit_constraints(plan) do
    locked = locked_versions()
    paths = mix_files()

    blocked =
      Enum.filter(plan["updates"], fn u ->
        cur = Map.get(locked, u["package"])
        is_binary(cur) and is_binary(u["latest"]) and newer?(u["latest"], cur)
      end)

    if blocked == [] do
      Mix.shell().info("\nNo constraint-blocked updates.")
    else
      Mix.shell().info(
        "\nTrying to raise mix.exs constraints for #{length(blocked)} blocked dep(s)…"
      )

      Enum.each(blocked, &try_unblock(&1, paths))
    end
  end

  # The mix.exs files that can declare a dep's constraint: the root `mix.exs`,
  # plus — for an umbrella — every child app under `apps_path` (`apps/*/mix.exs`
  # by default). A dep declared in several child apps gets raised in each.
  defp mix_files do
    apps =
      case File.read("mix.exs") do
        {:ok, contents} ->
          case HexorcistUpdate.MixEdit.umbrella_apps_path(contents) do
            {:ok, apps_path} -> Path.wildcard(Path.join(apps_path, "*/mix.exs"))
            :none -> []
          end

        _ ->
          []
      end

    ["mix.exs" | apps] |> Enum.filter(&File.exists?/1) |> Enum.uniq()
  end

  defp try_unblock(update, paths) do
    dep = update["package"]
    latest = update["latest"]

    case HexorcistUpdate.MixEdit.new_constraint(latest) do
      {:ok, new_constraint} ->
        # Snapshot every candidate file so a failed unblock reverts to exactly
        # the state on entry — preserving edits a previous dep already kept.
        snapshots = Map.new(paths, &{&1, read!(&1)})

        if Enum.any?(snapshots, fn {_path, contents} ->
             HexorcistUpdate.MixEdit.overridden?(contents, dep)
           end) do
          # A deliberate override pin (often load-bearing in umbrellas) — never
          # bump it automatically; just flag it.
          suggest(dep, latest, "pinned with `override: true` — bump it by hand if you mean to")
        else
          unblock_files(dep, latest, new_constraint, snapshots)
        end

      :error ->
        suggest(dep, latest, "couldn't parse the target version")
    end
  end

  defp unblock_files(dep, latest, new_constraint, snapshots) do
    results =
      Map.new(snapshots, fn {path, contents} ->
        {path, HexorcistUpdate.MixEdit.bump(contents, dep, new_constraint)}
      end)

    edited =
      for {path, {:ok, new_contents, _old}} <- results do
        File.write!(path, new_contents)
        path
      end

    if edited == [] do
      explain_no_edit(dep, latest, Map.values(results))
    else
      finish_unblock(dep, latest, new_constraint, edited, snapshots)
    end
  end

  defp finish_unblock(dep, latest, new_constraint, edited, snapshots) do
    cmd("mix", ["deps.update", dep])

    if newer?(latest, Map.get(locked_versions(), dep, "0.0.0")) do
      # The edit didn't actually unlock it (a transitive parent still caps it).
      Enum.each(edited, fn path -> File.write!(path, Map.fetch!(snapshots, path)) end)
      cmd("mix", ["deps.get"])

      Mix.shell().info(
        "  #{dep}: raised to #{new_constraint} in #{Enum.join(edited, ", ")}, " <>
          "but still blocked — reverted."
      )
    else
      Mix.shell().info(
        "  #{dep}: → #{new_constraint} in #{Enum.join(edited, ", ")} ✓ " <>
          "(now #{Map.get(locked_versions(), dep)})"
      )
    end
  end

  # No file held an editable literal — report the most informative reason.
  defp explain_no_edit(dep, latest, results) do
    cond do
      Enum.any?(results, &(&1 == :ambiguous)) ->
        suggest(dep, latest, "appears multiple times in a single mix.exs")

      old =
          Enum.find_value(results, fn
            {:skip, o} -> o
            _ -> nil
          end) ->
        suggest(dep, latest, "constraint #{old} isn't a `~>` requirement")

      true ->
        suggest(dep, latest, "no literal {:#{dep}, \"\"} in any mix.exs (transitive or dynamic)")
    end
  end

  defp suggest(dep, latest, why),
    do: Mix.shell().info("  #{dep}: not auto-edited (#{why}) — bump it to ~> #{latest} by hand.")

  # dep => version, read from mix.lock's `{:hex, :dep, "version", …}` entries.
  defp locked_versions do
    case File.read("mix.lock") do
      {:ok, contents} ->
        ~r/\{:hex,\s*:([a-z0-9_]+),\s*"([^"]+)"/
        |> Regex.scan(contents)
        |> Map.new(fn [_, dep, version] -> {dep, version} end)

      _ ->
        %{}
    end
  end

  defp newer?(a, b) do
    case {Version.parse(a), Version.parse(b)} do
      {{:ok, va}, {:ok, vb}} -> Version.compare(va, vb) == :gt
      _ -> false
    end
  end

  defp gate_on_tests(files) do
    Mix.shell().info("\nRunning `mix test`…")

    case System.cmd("mix", ["test"], into: IO.stream(:stdio, :line)) do
      {_, 0} ->
        :ok

      {_, _} ->
        git(["checkout", "--" | files])
        cmd("mix", ["deps.get"])
        abort("tests failed — rolled back #{Enum.join(files, ", ")}. Nothing changed.")
    end
  end

  defp commit(files) do
    git(["add" | files])
    git(["commit", "-m", "Update dependencies (via Hexorcist)"])
  end

  ## Output

  defp print_plan(plan) do
    Mix.shell().info("Upgrade plan for #{plan["project"]}:")

    case plan["updates"] do
      [] ->
        Mix.shell().info("  (no available updates)")

      updates ->
        Enum.each(updates, fn u ->
          tag = if u["reason"] == "vulnerability", do: " [VULN → #{u["fix"]}]", else: ""

          Mix.shell().info(
            "  #{u["package"]}: #{u["current"]}#{u["latest"]} (#{u["type"]})#{tag}"
          )
        end)
    end
  end

  ## Helpers

  defp opt(opts, key, env), do: opts[key] || System.get_env(env)

  # Reads `.hexorcist.exs` (a keyword list) from the current repo. Parsed as a
  # literal, never `eval`'d — a cloned repo's config can't run code — and only
  # the literal string values for :project/:url are taken.
  defp load_config do
    path = Path.join(File.cwd!(), @config_file)

    with true <- File.exists?(path),
         {:ok, contents} <- File.read(path),
         {:ok, pairs} when is_list(pairs) <-
           Code.string_to_quoted(contents, static_atoms_encoder: &keep_key/2) do
      Enum.flat_map(pairs, fn
        {"project", v} when is_binary(v) -> [project: v]
        {"url", v} when is_binary(v) -> [url: v]
        _ -> []
      end)
    else
      _ -> []
    end
  end

  # Keep source atoms as binaries so a hostile config can't intern junk atoms.
  defp keep_key(atom_string, _meta), do: {:ok, atom_string}

  defp cmd!(bin, args) do
    case System.cmd(bin, args, into: IO.stream(:stdio, :line), stderr_to_stdout: true) do
      {_, 0} -> :ok
      {_, status} -> abort("`#{bin} #{Enum.join(args, " ")}` failed (exit #{status})")
    end
  end

  # Non-raising variant (a per-dep `mix deps.update` that can't resolve isn't fatal).
  defp cmd(bin, args) do
    {_out, status} = System.cmd(bin, args, into: IO.stream(:stdio, :line), stderr_to_stdout: true)
    status
  end

  defp read!(path), do: File.read!(path)

  defp git(args) do
    {out, 0} = System.cmd("git", args, stderr_to_stdout: true)
    out
  end

  defp abort(message) do
    Mix.raise("hexorcist.update: #{message}")
  end
end