Files
local-ci-cd-system/src/ci_orchestrator/commands/report.py
T
Simone d0f67ce807
Lint / pssa (push) Successful in 10s
Lint / python (push) Successful in 20s
fix: improve data extraction in _read_events function for better type handling
2026-05-26 21:34:07 +02:00

259 lines
6.8 KiB
Python

"""``report`` sub-commands.
Phase A2 ports ``scripts/Get-CIJobSummary.ps1`` to ``report job``. Reads
JSONL log files written by the (still-PowerShell) ``Invoke-CIJob.ps1``
under ``F:\\CI\\Logs\\<jobId>\\invoke-ci.jsonl`` and prints either a
summary table of recent jobs or a per-phase breakdown for one job.
"""
from __future__ import annotations
import json
import sys
from dataclasses import dataclass
from pathlib import Path
import click
@dataclass(frozen=True)
class _Event:
phase: str
status: str
ts: str
elapsed_sec: int | None
error: str | None
raw: dict[str, object]
def _read_events(jsonl_path: Path) -> list[_Event]:
out: list[_Event] = []
try:
text = jsonl_path.read_text(encoding="utf-8")
except OSError:
return out
for line in text.splitlines():
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
except json.JSONDecodeError:
continue
if not isinstance(obj, dict):
continue
_raw = obj.get("data")
data: dict[str, object] = _raw if isinstance(_raw, dict) else {}
elapsed: int | None = None
raw_elapsed = data.get("elapsedSec")
if isinstance(raw_elapsed, int | float):
elapsed = int(raw_elapsed)
err: str | None = None
if isinstance(data.get("error"), str):
err = str(data["error"])
out.append(
_Event(
phase=str(obj.get("phase", "")),
status=str(obj.get("status", "")),
ts=str(obj.get("ts", "")),
elapsed_sec=elapsed,
error=err,
raw=obj,
)
)
return out
def _format_hms(sec: int) -> str:
if sec < 0:
return "?"
h, rem = divmod(sec, 3600)
m, s = divmod(rem, 60)
if h > 0:
return f"{h}h{m:02d}m{s:02d}s"
return f"{m}m{s:02d}s"
@dataclass(frozen=True)
class _Row:
job_id: str
status: str
elapsed: str
started: str
error: str
def _summarise(events: list[_Event], default_id: str) -> _Row:
job_start = next(
(e for e in events if e.phase == "job" and e.status == "start"), None
)
job_success = next(
(e for e in events if e.phase == "job" and e.status == "success"), None
)
job_failure = next(
(e for e in events if e.phase == "job" and e.status == "failure"), None
)
raw_id = events[0].raw.get("jobId") if events else None
job_id = str(raw_id) if isinstance(raw_id, str) and raw_id else default_id
started = job_start.ts if job_start else ""
if job_success:
return _Row(
job_id=job_id,
status="success",
elapsed=_format_hms(job_success.elapsed_sec or -1),
started=started,
error="",
)
if job_failure:
err = job_failure.error or ""
if len(err) > 60:
err = err[:57] + "..."
return _Row(
job_id=job_id,
status="FAILED",
elapsed=_format_hms(job_failure.elapsed_sec or -1),
started=started,
error=err,
)
return _Row(
job_id=job_id,
status="in-progress",
elapsed="?",
started=started,
error="",
)
@click.group("report")
def report() -> None:
"""Reporting commands (job summaries)."""
@report.command("job")
@click.option(
"--log-dir",
"log_dir",
default=r"F:\CI\Logs",
show_default=True,
help="Base directory containing per-job log subdirectories.",
)
@click.option(
"--last",
type=click.IntRange(1, 1000),
default=20,
show_default=True,
help="Show only the N most recent jobs.",
)
@click.option(
"--job-id",
"job_id",
default="",
help="Show a phase-by-phase breakdown for a single job.",
)
@click.option(
"--failed",
is_flag=True,
default=False,
help="Filter to failed jobs only.",
)
@click.option(
"--json",
"as_json",
is_flag=True,
default=False,
help="Emit JSON instead of a human-readable table.",
)
def report_job(
log_dir: str,
last: int,
job_id: str,
failed: bool,
as_json: bool,
) -> None:
"""Display CI job summaries parsed from invoke-ci.jsonl files."""
base = Path(log_dir)
if not base.is_dir():
click.echo(f"Log directory not found: {log_dir}", err=True)
sys.exit(1)
if job_id:
jsonl = base / job_id / "invoke-ci.jsonl"
if not jsonl.is_file():
click.echo(f"No JSONL log found for job '{job_id}' at: {jsonl}", err=True)
sys.exit(1)
events = _read_events(jsonl)
if not events:
click.echo(f"JSONL file is empty or unreadable: {jsonl}", err=True)
sys.exit(1)
if as_json:
click.echo(json.dumps([e.raw for e in events], default=str))
return
click.echo(f"\nJob: {job_id}")
click.echo("=" * 60)
click.echo(f"{'Phase':<35} {'Status':<10} {'Timestamp'}")
click.echo(f"{'-' * 35:<35} {'-' * 10:<10} {'-' * 24}")
for ev in events:
click.echo(f"{ev.phase:<35} {ev.status:<10} {ev.ts}")
fail = next(
(e for e in reversed(events) if e.status == "failure"),
None,
)
if fail and fail.error:
click.echo(f"\nError: {fail.error}")
return
files = sorted(
base.rglob("invoke-ci.jsonl"),
key=lambda p: p.stat().st_mtime,
reverse=True,
)
if not files:
click.echo(f"No invoke-ci.jsonl files found under {log_dir}")
return
rows: list[_Row] = []
for f in files:
events = _read_events(f)
if not events:
continue
default_id = f.parent.name
rows.append(_summarise(events, default_id))
if failed:
rows = [r for r in rows if r.status == "FAILED"]
rows = rows[:last]
if not rows:
click.echo("No matching jobs found.")
return
if as_json:
click.echo(
json.dumps(
[
{
"jobId": r.job_id,
"status": r.status,
"elapsed": r.elapsed,
"started": r.started,
"error": r.error,
}
for r in rows
]
)
)
return
click.echo(f"\nCI Job Summary (last {len(rows)} jobs):")
header = f"{'JobId':<40} {'Status':<12} {'Elapsed':<10} {'Started':<26} Error"
click.echo(header)
click.echo(
f"{'-' * 40:<40} {'-' * 12:<12} {'-' * 10:<10} {'-' * 26:<26} {'-' * 30}"
)
for r in rows:
click.echo(
f"{r.job_id:<40} {r.status:<12} {r.elapsed:<10} {r.started:<26} {r.error}"
)
__all__ = ["report"]