259 lines
6.8 KiB
Python
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"]
|