4.7 KiB · text History 9b97d32
defmodule GitGud.Workflows.DispatchInputs do
  @moduledoc """
  A `workflow_dispatch`'s inputs, checked against what the workflow
  declares under `on.workflow_dispatch.inputs` — GitHub's (and Gitea's)
  rules, which answer 422 for:

    * an input the workflow doesn't declare;
    * a `required` input that is neither given (nor blank) nor defaulted;
    * a `choice` whose value isn't one of its `options`;
    * a `boolean` that isn't `true` / `false`;
    * a `number` that isn't numeric.

  `type` defaults to `string`; `environment` is taken as a string (there
  are no environments here). An input that isn't given takes its
  `default`, or `""` (`"false"` for a boolean) when it has none.

  Values come out as strings — booleans `"true"` / `"false"` — which is
  how GitHub puts them in `github.event.inputs`; the runner converts
  `inputs.<id>` per the declared type.
  """

  @type errors :: [{String.t(), String.t()}]

  @doc """
  `{:ok, inputs}` — every declared input, defaults applied — or
  `{:error, [{name, reason}]}`, sorted by name.
  """
  @spec resolve(map(), map()) :: {:ok, %{String.t() => String.t()}} | {:error, errors()}
  def resolve(doc, given) when is_map(given) do
    declared = declared(doc)
    given = Map.new(given, fn {k, v} -> {to_string(k), v} end)

    unknown =
      for name <- Map.keys(given),
          not Map.has_key?(declared, name),
          do: {name, "is not an input of this workflow"}

    {values, errors} =
      Enum.reduce(declared, {%{}, unknown}, fn {name, spec}, {values, errors} ->
        case value(spec, Map.get(given, name)) do
          {:ok, value} -> {Map.put(values, name, value), errors}
          {:error, reason} -> {values, [{name, reason} | errors]}
        end
      end)

    case errors do
      [] -> {:ok, values}
      errors -> {:error, Enum.sort(errors)}
    end
  end

  @doc """
  The declared inputs, `name => spec` (`spec` always a map), from a
  normalized workflow or its raw document.
  """
  @spec declared(map()) :: %{String.t() => map()}
  def declared(%{triggers: triggers}), do: declared_in(triggers)
  def declared(%{"on" => on}) when is_map(on), do: declared_in(on)
  def declared(_doc), do: %{}

  defp declared_in(%{"workflow_dispatch" => %{"inputs" => %{} = inputs}}),
    do: Map.new(inputs, fn {name, spec} -> {to_string(name), spec_map(spec)} end)

  defp declared_in(_), do: %{}

  defp spec_map(%{} = spec), do: spec
  defp spec_map(_), do: %{}

  @doc "An input's `type`, `\"string\"` when it doesn't say."
  @spec type(map()) :: String.t()
  def type(spec), do: to_string(Map.get(spec, "type") || "string")

  @doc "A `choice` input's options, as strings."
  @spec options(map()) :: [String.t()]
  def options(spec), do: spec |> Map.get("options") |> List.wrap() |> Enum.map(&to_string/1)

  defp value(spec, nil) do
    case Map.get(spec, "default") do
      nil ->
        if Map.get(spec, "required") in [true, "true"],
          do: {:error, "is required"},
          else: {:ok, if(type(spec) == "boolean", do: "false", else: "")}

      default ->
        check(type(spec), spec, default)
    end
  end

  # A required input left blank (an emptied form field) is a missing one.
  defp value(%{"required" => required} = spec, "") when required in [true, "true"],
    do: value(spec, nil)

  defp value(spec, given), do: check(type(spec), spec, given)

  defp check("boolean", _spec, v) when is_boolean(v), do: {:ok, to_string(v)}

  defp check("boolean", _spec, v) when is_binary(v) do
    case String.downcase(v) do
      b when b in ["true", "false"] -> {:ok, b}
      _ -> {:error, "must be true or false"}
    end
  end

  defp check("boolean", _spec, _v), do: {:error, "must be true or false"}

  defp check("number", _spec, v) when is_number(v), do: {:ok, to_string(v)}

  defp check("number", _spec, v) when is_binary(v) do
    trimmed = String.trim(v)

    case Float.parse(trimmed) do
      {_n, ""} -> {:ok, trimmed}
      _ -> {:error, "must be a number"}
    end
  end

  defp check("number", _spec, _v), do: {:error, "must be a number"}

  defp check("choice", spec, v) when is_binary(v) or is_number(v) or is_boolean(v) do
    v = to_string(v)
    options = options(spec)

    if v in options,
      do: {:ok, v},
      else: {:error, "must be one of: " <> Enum.join(options, ", ")}
  end

  defp check(_type, _spec, v) when is_binary(v), do: {:ok, v}
  defp check(_type, _spec, v) when is_number(v) or is_boolean(v), do: {:ok, to_string(v)}
  defp check(_type, _spec, _v), do: {:error, "must be a string"}

  @doc "`[{name, reason}]` as one sentence per input, for an API reply or a flash."
  @spec describe(errors()) :: String.t()
  def describe(errors),
    do: Enum.map_join(errors, "; ", fn {name, reason} -> "input #{name} #{reason}" end)
end