"""``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\\\\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"]