mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 19:01:54 +00:00
The previous regex anchored the `name` capture at line start and used lazy `.+?`, which in production captured the full `<ts> p=<pid> u=<user> n=ansible INFO| <role>` prefix as the role name instead of just `<role>`. The CI step summary then rendered every row with the timestamped log header and the `total` filter silently failed (because `name` was no longer equal to `"total"`). Add an optional non-capturing `(?:.*\bINFO\|\s+)?` group so the ANSIBLE_LOG_PATH prefix is consumed when present and `name` captures just the role token (or `role : task`). Test sample didn't carry the log prefix, so the bug slipped through — add a prefixed-format test. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
77 lines
2.2 KiB
Python
77 lines
2.2 KiB
Python
"""Parse an Ansible `profile_roles` log and write the per-role runtimes to `$GITHUB_STEP_SUMMARY`."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
import sys
|
|
from collections import defaultdict
|
|
from pathlib import Path
|
|
|
|
ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
|
|
LINE_RE = re.compile(
|
|
r"^(?:.*\bINFO\|\s+)?(?P<name>\S.*?)\s-{2,}\s+(?P<seconds>\d+(?:\.\d+)?)s\s*$"
|
|
)
|
|
|
|
|
|
def _strip_ansi(line: str) -> str:
|
|
return ANSI_RE.sub("", line)
|
|
|
|
|
|
def parse_role_times(log_path: Path) -> list[tuple[str, float]]:
|
|
totals: dict[str, float] = defaultdict(float)
|
|
with log_path.open(encoding="utf-8", errors="replace") as fh:
|
|
for raw in fh:
|
|
line = _strip_ansi(raw).rstrip()
|
|
match = LINE_RE.match(line)
|
|
if not match:
|
|
continue
|
|
name = match.group("name").strip()
|
|
if not name or name.lower() == "total" or " : " in name:
|
|
continue
|
|
try:
|
|
seconds = float(match.group("seconds"))
|
|
except ValueError:
|
|
continue
|
|
totals[name] += seconds
|
|
return sorted(totals.items(), key=lambda kv: kv[1], reverse=True)
|
|
|
|
|
|
def _format_table(rows: list[tuple[str, float]]) -> str:
|
|
header = [
|
|
"## ⏱️ Role runtimes",
|
|
"",
|
|
"| # | Role | Duration |",
|
|
"|---|------|---------:|",
|
|
]
|
|
body = [
|
|
f"| {idx} | `{name}` | {seconds:.2f}s |"
|
|
for idx, (name, seconds) in enumerate(rows, start=1)
|
|
]
|
|
return "\n".join(header + body) + "\n"
|
|
|
|
|
|
def main(argv: list[str]) -> int:
|
|
if len(argv) < 2:
|
|
print("usage: role_summary.py <ansible-log>", file=sys.stderr)
|
|
return 2
|
|
log_path = Path(argv[1])
|
|
if not log_path.is_file():
|
|
print(f"[role_summary] log not found: {log_path}", file=sys.stderr)
|
|
return 0
|
|
rows = parse_role_times(log_path)
|
|
if not rows:
|
|
print("[role_summary] no profile_roles entries found", file=sys.stderr)
|
|
return 0
|
|
table = _format_table(rows)
|
|
print(table)
|
|
summary_path = os.environ.get("GITHUB_STEP_SUMMARY")
|
|
if summary_path:
|
|
with Path(summary_path).open("a", encoding="utf-8") as fh:
|
|
fh.write(table)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main(sys.argv))
|