from __future__ import annotations
import typing as typ
from pathlib import Path
import yaml
PULL_REQUEST_EVENTS: typ.Final = ("pull_request", "pull_request_target")
WORKFLOW_PREFIX: typ.Final = ".github/workflows/"
WORKFLOW_SUFFIXES: typ.Final = (".yml", ".yaml")
class StrictLoader(yaml.SafeLoader):
def construct_mapping(
self, node: yaml.MappingNode, deep: bool = False
) -> dict[typ.Hashable, typ.Any]:
seen: dict[typ.Hashable, yaml.Mark] = {}
for key_node, _ in node.value:
key = self.construct_object(key_node, deep=deep)
if key in seen:
raise yaml.constructor.ConstructorError(
"while constructing a mapping",
seen[key],
f"found duplicate key {key!r}",
key_node.start_mark,
)
seen[key] = key_node.start_mark
return super().construct_mapping(node, deep=deep)
def triggers(document: dict[typ.Any, typ.Any]) -> dict[str, typ.Any]:
return _event_map(_trigger_declaration(document))
def _trigger_declaration(document: dict[typ.Any, typ.Any]) -> object:
keys = [key for key in ("on", True) if key in document]
if len(keys) > 1:
msg = "the workflow declares its triggers under both `on` and `true`"
raise ValueError(msg)
return document[keys[0]] if keys else None
def _event_map(raw: object) -> dict[str, typ.Any]:
if isinstance(raw, dict):
return {str(event): config for event, config in raw.items()}
events = [raw] if isinstance(raw, str) else raw
if isinstance(events, list) and all(isinstance(name, str) for name in events):
return dict.fromkeys(events)
msg = f"unreadable trigger declaration: {raw!r}"
raise ValueError(msg)
def local_callee(uses: object) -> str | None:
text = str(uses).strip()
path = text.removeprefix("./").removeprefix("$/")
if not path.startswith(WORKFLOW_PREFIX):
return None
if "@" in path:
msg = f"a local workflow call carries a ref: {text}"
raise ValueError(msg)
return path
class Workflow(typ.NamedTuple):
path: str
document: dict[typ.Any, typ.Any]
triggers: dict[str, typ.Any]
@classmethod
def parse(cls, path: str, text: str) -> Workflow:
document = yaml.load(text, Loader=StrictLoader)
if not isinstance(document, dict):
msg = f"{path} does not parse to a mapping"
raise TypeError(msg)
return cls(path, document, triggers(document))
@property
def on_pull_request(self) -> bool:
return any(event in self.triggers for event in PULL_REQUEST_EVENTS)
@property
def on_push_to_main_only(self) -> bool:
push = self.triggers.get("push")
if not isinstance(push, dict) or "tags" in push:
return False
return push.get("branches") == ["main"]
def jobs(self) -> list[tuple[str, dict[str, typ.Any]]]:
jobs = self.document.get("jobs")
pairs = jobs.items() if isinstance(jobs, dict) else ()
return [(str(name), job) for name, job in pairs if isinstance(job, dict)]
def steps(self) -> list[tuple[str, dict[str, typ.Any]]]:
return [
(name, step)
for name, job in self.jobs()
for step in job.get("steps") or []
if isinstance(step, dict)
]
def callees(self) -> list[str]:
found = (local_callee(job.get("uses", "")) for _, job in self.jobs())
return [path for path in found if path is not None]
def load_workflows(root: Path) -> list[Workflow]:
directory = root / WORKFLOW_PREFIX
found = [
Workflow.parse(
path.relative_to(root).as_posix(), path.read_text(encoding="utf-8")
)
for path in sorted(directory.iterdir())
if path.is_file() and path.suffix.lower() in WORKFLOW_SUFFIXES
]
assert found, f"no workflow parsed under {directory}"
return found
def pull_request_closure(workflows: list[Workflow]) -> list[Workflow]:
by_path = {flow.path: flow for flow in workflows}
pending = [flow.path for flow in workflows if flow.on_pull_request]
reached: list[str] = []
while pending:
path = pending.pop(0)
if path in reached:
continue
assert path in by_path, f"a pull-request lane calls missing {path}"
reached.append(path)
pending.extend(by_path[path].callees())
return [by_path[path] for path in reached]
def scalars(node: object) -> list[str]:
if isinstance(node, dict):
return [
text
for key, value in node.items()
for text in [*scalars(key), *scalars(value)]
]
if isinstance(node, list):
return [text for item in node for text in scalars(item)]
return [] if node is None else [str(node)]