Coverage for src/rhiza_hooks/check_workflow_names.py: 100%
62 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-07-30 04:36 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-07-30 04:36 +0000
1#!/usr/bin/env python3
2"""Script to ensure GitHub Actions workflows have the (RHIZA) prefix.
4This hook checks that all rhiza workflow files have their 'name' field
5properly formatted with the (RHIZA) prefix in uppercase. If not, it
6automatically updates the file.
8Migrated from: https://github.com/Jebel-Quant/rhiza/.rhiza/scripts/check_workflow_names.py
9"""
11from __future__ import annotations
13import sys
15import yaml
18def _expected_name(name: str) -> str:
19 """Return the canonical ``(RHIZA) <UPPERCASE>`` form of a workflow name."""
20 prefix = "(RHIZA) "
21 # Remove prefix if present to verify the rest of the string
22 clean_name = name[len(prefix) :] if name.startswith(prefix) else name
23 # Collapse any internal/trailing whitespace (e.g. from a folded/block YAML
24 # scalar, where PyYAML yields newlines) so the rewrite is a single line.
25 clean_name = " ".join(clean_name.split())
26 return f"{prefix}{clean_name.upper()}"
29def _is_block_scalar_name(line: str) -> bool:
30 """True if a top-level ``name:`` line opens a block scalar (``|`` / ``>``).
32 Detection keys on the leading indicator character (``[:1]``) so chomping
33 variants like ``>-`` / ``|-`` are recognised too.
34 """
35 return line[len("name:") :].strip()[:1] in ("|", ">")
38def _is_block_continuation(line: str) -> bool:
39 """True if ``line`` continues a block scalar's value.
41 YAML indentation is spaces, so a scalar's continuation lines are blank or
42 space-indented; the first flush-left line ends the scalar.
43 """
44 return line.strip() == "" or line.startswith(" ")
47def _count_block_continuations(following: list[str]) -> int:
48 """Count the leading block-scalar continuation lines in ``following``."""
49 count = 0
50 for line in following:
51 if not _is_block_continuation(line):
52 break
53 count += 1
54 return count
57def _replace_name_lines(lines: list[str], expected_name: str) -> list[str]:
58 """Return ``lines`` with the first top-level ``name:`` set to ``expected_name``.
60 Only the top-level workflow ``name`` is rewritten: it is the sole ``name:``
61 key at column 0, so job- and step-level ``name:`` keys (always indented)
62 never match, and only the first match is replaced. If the value is a block
63 scalar (``name: >`` / ``name: |``), its continuation lines are dropped so no
64 orphan scalar text is left behind. When no top-level ``name:`` line exists,
65 the lines are returned unchanged.
66 """
67 for idx, line in enumerate(lines):
68 if line.startswith("name:"):
69 tail = idx + 1
70 if _is_block_scalar_name(line):
71 tail += _count_block_continuations(lines[idx + 1 :])
72 return [*lines[:idx], f'name: "{expected_name}"\n', *lines[tail:]]
73 return list(lines)
76def _rewrite_workflow_name(filepath: str, expected_name: str) -> None:
77 """Rewrite the top-level ``name:`` of a workflow file, preserving comments."""
78 with open(filepath) as f_read:
79 lines = f_read.readlines()
80 with open(filepath, "w") as f_write:
81 f_write.writelines(_replace_name_lines(lines, expected_name))
84def check_file(filepath: str) -> bool:
85 """Check if the workflow file has the correct name prefix and update if needed.
87 Args:
88 filepath: Path to the workflow file.
90 Returns:
91 bool: True if file is correct, False if it was updated or has errors.
92 """
93 with open(filepath) as f:
94 try:
95 content = yaml.safe_load(f)
96 except yaml.YAMLError as exc:
97 print(f"Error parsing YAML {filepath}: {exc}")
98 return False
100 if not isinstance(content, dict):
101 # Empty file or not a dict
102 return True
104 name = content.get("name")
105 if not name:
106 print(f"Error: {filepath} missing 'name' field.")
107 return False
109 expected_name = _expected_name(name)
111 if name == expected_name:
112 return True
114 print(f"Updating {filepath}: name '{name}' -> '{expected_name}'")
115 _rewrite_workflow_name(filepath, expected_name)
116 return False # Fail so pre-commit knows files were modified
119def main(argv: list[str] | None = None) -> int:
120 """Execute the script."""
121 files = argv if argv is not None else sys.argv[1:]
122 failed = False # pragma: no mutate # equivalent: only ever read via `if failed`
123 for f in files:
124 if not check_file(f):
125 failed = True
127 if failed:
128 sys.exit(1)
129 return 0
132def _run() -> None:
133 """Entry point: delegate to :func:`main` and exit with its return code."""
134 sys.exit(main())
137if __name__ == "__main__": # pragma: no cover # pragma: no mutate
138 _run()