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
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
4.7 KiB · text
History
b868907