feat(a2): port leaf PS scripts to ci_orchestrator CLI
Implements Phase A2 of plans/implementation-plan-A-B.md: - commands/wait.py -> wait-ready (replaces Wait-VMReady.ps1) - commands/vm.py -> vm remove + vm cleanup (replaces Remove-BuildVM.ps1, Cleanup-OrphanedBuildVMs.ps1); cleanup accepts injected VmBackend (Phase C ESXi hook preserved) - commands/monitor.py -> monitor disk + monitor runner (replaces Watch-DiskSpace.ps1, Watch-RunnerHealth.ps1) - commands/report.py -> report job (replaces Get-CIJobSummary.ps1) Each PS script reduced to a 3-line shim that delegates to the Python CLI and preserves \0. Tests: 69 pytest cases across new test_commands_*.py modules; ruff clean, mypy --strict clean, coverage 74.5% (>=70 gate).
This commit is contained in:
@@ -0,0 +1,258 @@
|
||||
"""``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
|
||||
data = obj.get("data") if isinstance(obj.get("data"), dict) else {}
|
||||
elapsed: int | None = None
|
||||
if isinstance(data, dict):
|
||||
raw_elapsed = data.get("elapsedSec")
|
||||
if isinstance(raw_elapsed, int | float):
|
||||
elapsed = int(raw_elapsed)
|
||||
err: str | None = None
|
||||
if isinstance(data, dict) and 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"]
|
||||
Reference in New Issue
Block a user