defmodule GitGud.Workflows.Yaml do
@moduledoc """
Parses GitHub-Actions-shaped workflow YAML into a normalized map.
Only a subset of the spec is supported — enough to compile a workflow
into our run/job/step model. Specifically:
* Top-level `name`, `on`, `jobs` keys
* `on:` as a string ("push"), list (["push", "pull_request"]) or map
with per-trigger config; we keep the raw map for filtering later
* `jobs.<id>.{name, runs-on, needs, steps, env, if, container}` are
preserved as the job's definition; richer features (services,
strategy/matrix, reusable workflows, etc.) flow through as-is and
will be honored by the runner that consumes them.
"""
@type workflow :: %{
name: String.t() | nil,
triggers: map(),
jobs: [job()],
raw: map()
}
@type job :: %{
id: String.t(),
name: String.t() | nil,
runs_on: any(),
needs: [String.t()],
steps: [map()],
raw: map()
}
@spec parse(binary()) :: {:ok, workflow()} | {:error, term()}
def parse(yaml) when is_binary(yaml) do
case YamlElixir.read_from_string(yaml) do
{:ok, %{} = doc} -> {:ok, normalize(doc)}
{:ok, other} -> {:error, {:expected_map, other}}
{:error, %YamlElixir.ParsingError{} = e} -> {:error, e}
err -> err
end
end
@doc """
Normalize an already-parsed YAML doc (e.g. the one we persisted in
`workflow_runs.workflow_doc` after a successful initial run) into
the `workflow()` shape `create_run/2` consumes. Saves a YAML
round-trip when re-running a stored doc.
"""
@spec normalize_doc(map()) :: workflow()
def normalize_doc(%{} = doc), do: normalize(doc)
defp normalize(doc) do
%{
name: Map.get(doc, "name"),
triggers: normalize_on(Map.get(doc, "on")),
jobs: doc |> Map.get("jobs", %{}) |> normalize_jobs(),
raw: doc
}
end
defp normalize_on(nil), do: %{}
defp normalize_on(name) when is_binary(name), do: %{name => %{}}
defp normalize_on(list) when is_list(list) do
Map.new(list, fn
name when is_binary(name) -> {name, %{}}
%{} = entry -> {Map.get(entry, "type", "push"), entry}
end)
end
defp normalize_on(%{} = m), do: m
defp normalize_jobs(%{} = jobs) do
jobs
|> Enum.map(fn {id, definition} ->
definition = definition || %{}
%{
id: id,
name: Map.get(definition, "name"),
runs_on: Map.get(definition, "runs-on", "ubuntu-latest"),
needs: Map.get(definition, "needs", []) |> List.wrap(),
steps: Map.get(definition, "steps", []),
raw: definition
}
end)
end
defp normalize_jobs(_), do: []
@doc """
Decide whether the workflow's `on:` matches an incoming event.
`event` is one of `"push"`, `"pull_request"`, `"manual"`. `ctx` is a
map with optional keys: `:branch`, `:tag`. Branch/tag filters
(`branches:`, `tags:`, `paths:`) are honored loosely — wildcards via
`:fnmatch`.
"""
def matches?(%{triggers: triggers}, event, ctx \\ %{}) when is_map(triggers) do
case Map.get(triggers, event) do
nil -> false
%{} = config -> branch_match?(config, ctx) and tag_match?(config, ctx)
_ -> true
end
end
defp branch_match?(config, %{branch: branch}) when is_binary(branch) do
case Map.get(config, "branches") do
nil -> true
patterns -> Enum.any?(List.wrap(patterns), &glob_match?(&1, branch))
end
end
defp branch_match?(_, _), do: true
defp tag_match?(config, %{tag: tag}) when is_binary(tag) do
case Map.get(config, "tags") do
nil -> true
patterns -> Enum.any?(List.wrap(patterns), &glob_match?(&1, tag))
end
end
defp tag_match?(_, _), do: true
defp glob_match?(pattern, str) do
# Cheap fnmatch: treat * as .*, ? as ., everything else literal.
regex =
pattern
|> Regex.escape()
|> String.replace("\\*", ".*")
|> String.replace("\\?", ".")
Regex.match?(~r/^#{regex}$/, str)
end
end
4.0 KiB · text
5af55d9