8.2 KiB · text History cac71de
defmodule GitGud.Workflows.Matrix do
  @moduledoc """
  Expands a job's `strategy.matrix` into the combinations it stands for.

      strategy:
        matrix:
          arch: [amd64, arm64]
          otp: ["27", "28"]

  …is four jobs. Each becomes its own `WorkflowJob` row, with its own
  status, logs and runner — which is the point: two of those want an
  amd64 machine and two want arm64, and only separate rows can be
  claimed by separate runners.

  ## `include` and `exclude`

  Both are ordinary GitHub Actions semantics and both are fiddly, so
  they are spelled out here rather than left to the reader:

    * `exclude` drops every combination that matches *all* of the
      key/values it names. It is applied before `include`, so an
      `include` can add a combination back.

    * `include` is not "append these combinations". Each entry is
      merged into every existing combination whose overlapping keys
      already agree with it — so `{otp: "28", coverage: true}` adds
      `coverage` to the two OTP-28 jobs rather than creating a fifth.
      An entry that matches nothing is appended as a combination of its
      own, which is how you add a single odd platform to a matrix.

  An entry that *would* overwrite a value the base matrix set is where
  GitHub's own rules get genuinely ambiguous; here, overlapping keys
  must agree for a merge to happen, so such an entry appends instead.

  ## Ordering

  Combinations come back in the order GitHub produces them: the *last*
  matrix key varies fastest. That is only cosmetic, but it decides the
  order jobs appear in the UI, and matching the tool people already
  know beats an arbitrary choice.
  """

  @reserved ~w(include exclude)

  @typedoc "One combination: matrix key to the value this leg was expanded for."
  @type combination :: %{String.t() => term()}

  @doc """
  Every combination `strategy` expands to.

  Returns `[%{}]` — a single empty combination — when there is no
  matrix, so callers can treat "no matrix" as "one leg with nothing in
  it" rather than branching.
  """
  @spec expand(map() | nil) :: [combination()]
  def expand(strategy)

  def expand(%{"matrix" => %{} = matrix}) do
    axes =
      matrix
      |> Map.drop(@reserved)
      |> Enum.map(fn {key, values} -> {key, List.wrap(values)} end)
      # Map iteration order is undefined; sort so a given workflow always
      # expands the same way rather than shuffling between deploys.
      |> Enum.sort_by(&elem(&1, 0))

    axes
    |> product()
    |> exclude(List.wrap(matrix["exclude"]))
    |> include(List.wrap(matrix["include"]))
    |> case do
      [] -> [%{}]
      combinations -> combinations
    end
  end

  def expand(_strategy), do: [%{}]

  @doc """
  Whether `strategy` asks for a matrix at all.

  A `strategy:` with only `fail-fast:` on it is not a matrix, and its
  job should not be renamed as though it were one leg of something.
  """
  @spec matrix?(map() | nil) :: boolean()
  def matrix?(%{"matrix" => %{} = matrix}), do: matrix != %{}
  def matrix?(_), do: false

  @doc """
  Whether a failing leg should cancel its siblings. GitHub's default is
  `true`, and so is ours.
  """
  @spec fail_fast?(map() | nil) :: boolean()
  def fail_fast?(%{"fail-fast" => false}), do: false
  def fail_fast?(%{"fail-fast" => "false"}), do: false
  def fail_fast?(_), do: true

  @doc """
  The display suffix GitHub puts on a matrix leg — `build (arm64, 28)`.

  Values in the order their keys sort, which is the order `expand/1`
  varied them, so reading two names side by side tells you which axis
  differs.
  """
  @spec suffix(combination()) :: String.t() | nil
  def suffix(combination) when map_size(combination) == 0, do: nil

  def suffix(combination) do
    combination
    |> Enum.sort_by(&elem(&1, 0))
    |> Enum.map_join(", ", fn {_key, value} -> to_string(value) end)
  end

  @doc """
  Substitute `${{ matrix.key }}` in a `runs-on` value.

  Only `runs-on` needs this. Everything else the runner evaluates for
  itself — see `GitGud.Workflows.RunnerPayload`, which hands `act` a
  one-value matrix so the whole `matrix` context resolves natively.
  But routing is *our* decision and happens before any runner sees the
  job, so this one expression has to be resolved server-side.

  An unknown key resolves to the empty string, as it does in Actions.
  """
  @spec substitute(term(), combination()) :: term()
  def substitute(value, combination) when is_list(value),
    do: Enum.map(value, &substitute(&1, combination))

  def substitute(value, combination) when is_binary(value) do
    Regex.replace(~r/\$\{\{\s*matrix\.([A-Za-z_][A-Za-z0-9_-]*)\s*\}\}/, value, fn _, key ->
      combination |> Map.get(key, "") |> to_string()
    end)
  end

  def substitute(value, _combination), do: value

  @doc """
  Normalise a `runs-on` into the list of labels a runner must carry.

  `runs-on: ubuntu-latest` and `runs-on: [ubuntu-latest]` mean the same
  thing and are stored the same way, so claiming has one shape to match
  rather than two.
  """
  @spec labels(term()) :: [String.t()]
  def labels(runs_on) do
    runs_on
    |> List.wrap()
    |> Enum.map(&to_string/1)
    |> Enum.reject(&(&1 == ""))
    |> Enum.uniq()
  end

  @doc """
  Rewrite a job definition so it describes exactly this one leg.

  The matrix is replaced by the single combination rather than
  removed: `act` then expands it to exactly one job and populates the
  `matrix` context from it, so `${{ matrix.arch }}` inside a step keeps
  working without us implementing an expression evaluator. Dropping
  `strategy` entirely would leave those expressions resolving to
  nothing; leaving it whole would have the runner fan out *again*,
  inside the single job we just handed it.
  """
  @spec pin_definition(map(), combination()) :: map()
  def pin_definition(definition, combination) when map_size(combination) == 0,
    do: Map.drop(definition, ["strategy"])

  def pin_definition(definition, combination) do
    pinned = Map.new(combination, fn {key, value} -> {key, [value]} end)
    strategy = Map.get(definition, "strategy") || %{}

    # `fail-fast` rides along even though it means nothing to a
    # one-value matrix: the forge reads it back off the stored
    # definition to decide whether a failing leg cancels its siblings,
    # and the pinned strategy is the only copy that survives.
    kept =
      case Map.fetch(strategy, "fail-fast") do
        {:ok, value} -> %{"matrix" => pinned, "fail-fast" => value}
        :error -> %{"matrix" => pinned}
      end

    Map.put(definition, "strategy", kept)
  end

  # ── expansion internals ─────────────────────────────────────────────

  # Cartesian product with the last axis varying fastest, matching the
  # order Actions lists a matrix's jobs in.
  defp product([]), do: []

  defp product(axes) do
    Enum.reduce(axes, [%{}], fn {key, values}, acc ->
      for combination <- acc, value <- values, do: Map.put(combination, key, value)
    end)
  end

  defp exclude(combinations, []), do: combinations

  defp exclude(combinations, exclusions) do
    Enum.reject(combinations, fn combination ->
      Enum.any?(exclusions, &subset_of?(&1, combination))
    end)
  end

  defp include(combinations, []), do: combinations

  defp include(combinations, inclusions) do
    Enum.reduce(inclusions, combinations, fn entry, acc ->
      entry = Map.new(entry, fn {k, v} -> {to_string(k), v} end)

      merged = Enum.map(acc, &merge_if_compatible(&1, entry))

      # Nothing took it — it describes a combination of its own.
      if merged == acc, do: acc ++ [entry], else: merged
    end)
  end

  # Compatible means every key they share already agrees. An entry that
  # would change an existing value describes a different combination,
  # not an addition to this one.
  defp merge_if_compatible(combination, entry) do
    overlapping = Map.take(entry, Map.keys(combination))

    if subset_of?(overlapping, combination) and entry != overlapping,
      do: Map.merge(combination, entry),
      else: combination
  end

  defp subset_of?(subset, combination) do
    Enum.all?(subset, fn {key, value} ->
      Map.has_key?(combination, key) and to_string(Map.get(combination, key)) == to_string(value)
    end)
  end
end