267 lines
12 KiB
TypeScript
267 lines
12 KiB
TypeScript
// SPEC: PJ2026-01060703 CI/CD branch follower rendering.
|
|
// Responsibility: machine and human output rendering for cicd branch-follower.
|
|
import type { RenderedCliResult } from "./output";
|
|
import type { ParsedOptions } from "./cicd-types";
|
|
import { renderDebugStepHuman } from "./cicd-debug";
|
|
import { renderDrillDownHuman } from "./cicd-drilldown-render";
|
|
|
|
export function renderResult(command: string, payload: Record<string, unknown>, options: ParsedOptions): RenderedCliResult {
|
|
const ok = payload.ok !== false;
|
|
if (options.output === "json") return renderMachine(command, payload, "json", ok);
|
|
if (options.output === "yaml") return renderMachine(command, payload, "yaml", ok);
|
|
return rendered(ok, command, renderHuman(command, payload, options));
|
|
}
|
|
|
|
export function renderMachine(command: string, value: unknown, mode: "json" | "yaml", ok = true): RenderedCliResult {
|
|
return rendered(ok, command, mode === "json" ? `${JSON.stringify(value, null, 2)}\n` : `${Bun.YAML.stringify(value)}\n`, mode === "json" ? "application/json" : "application/yaml");
|
|
}
|
|
|
|
function rendered(ok: boolean, command: string, renderedText: string, contentType: RenderedCliResult["contentType"] = "text/plain"): RenderedCliResult {
|
|
return { ok, command, renderedText, contentType };
|
|
}
|
|
|
|
function renderHuman(command: string, payload: Record<string, unknown>, options: ParsedOptions): string {
|
|
if (command.endsWith(" plan")) return renderPlanHuman(payload);
|
|
if (command.endsWith(" apply")) return renderApplyHuman(payload);
|
|
if (command.endsWith(" status")) return renderStatusHuman(payload, options);
|
|
if (command.endsWith(" run-once")) return renderRunOnceHuman(payload);
|
|
if (command.endsWith(" debug-step")) return renderDebugStepHuman(payload);
|
|
if (command.endsWith(" cleanup-state")) return renderCleanupStateHuman(payload);
|
|
if (command.endsWith(" events") || command.endsWith(" logs") || command.endsWith(" taskrun") || command.endsWith(" job") || command.endsWith(" runtime")) return renderDrillDownHuman(payload);
|
|
return `${JSON.stringify(payload, null, 2)}\n`;
|
|
}
|
|
|
|
function renderPlanHuman(payload: Record<string, unknown>): string {
|
|
const followers = arrayRecords(payload.followers);
|
|
const rows = followers.map((item) => {
|
|
const source = asOptionalRecord(item.source);
|
|
const target = asOptionalRecord(item.target);
|
|
const budgets = asOptionalRecord(item.budgets);
|
|
return [
|
|
item.id,
|
|
item.enabled,
|
|
item.adapter,
|
|
`${source?.repository ?? "-"}@${source?.branch ?? "-"}`,
|
|
`${target?.node ?? "-"}/${target?.lane ?? "-"}`,
|
|
budgets?.endToEndSeconds ?? "-",
|
|
arrayRecords(item.configRefGraph).length,
|
|
arrayText(item.closeoutChecks),
|
|
];
|
|
});
|
|
const next = asOptionalRecord(payload.next);
|
|
return [
|
|
`CI/CD BRANCH-FOLLOWER PLAN (${payload.ok === false ? "blocked" : "ok"})`,
|
|
"",
|
|
table(["FOLLOWER", "ENABLED", "ADAPTER", "SOURCE", "TARGET", "BUDGET", "REFS", "CHECKS"], rows),
|
|
"",
|
|
"SOURCE AUTHORITY",
|
|
`hostWorktreeAuthority=${payload.hostWorktreeAuthority === true ? "true" : "false"} mode=${asOptionalRecord(payload.sourceAuthority)?.mode ?? "-"} resolver=${asOptionalRecord(payload.sourceAuthority)?.resolver ?? "-"}`,
|
|
"",
|
|
"NEXT",
|
|
`apply: ${next?.apply ?? "-"}`,
|
|
`status: ${next?.status ?? "-"}`,
|
|
`dry-run: ${next?.dryRun ?? "-"}`,
|
|
"",
|
|
].join("\n");
|
|
}
|
|
|
|
function renderApplyHuman(payload: Record<string, unknown>): string {
|
|
const controller = asOptionalRecord(payload.controller);
|
|
const command = asOptionalRecord(payload.command);
|
|
const next = asOptionalRecord(payload.next);
|
|
return [
|
|
`CI/CD BRANCH-FOLLOWER APPLY (${payload.ok === false ? "failed" : payload.dryRun === true ? "dry-run" : "ok"})`,
|
|
"",
|
|
table(
|
|
["NAMESPACE", "ROUTE", "DEPLOYMENT", "STATE_CM", "LEASE", "HOST_WORKTREE"],
|
|
[[controller?.namespace ?? "-", controller?.route ?? "-", controller?.deploymentName ?? "-", controller?.stateConfigMapName ?? "-", controller?.leaseName ?? "-", controller?.hostWorktreeMounted === true ? "mounted" : "not-mounted"]],
|
|
),
|
|
"",
|
|
table(["OBJECTS", "MANIFEST_SHA", "EXIT", "TIMED_OUT"], [[arrayRecords(payload.objects).length, shortSha(stringOrNull(payload.manifestSha256)), command?.exitCode ?? "-", command?.timedOut ?? "-"]]),
|
|
command?.stderrTail ? `\nSTDERR\n${command.stderrTail}` : "",
|
|
"",
|
|
"NEXT",
|
|
`status: ${next?.status ?? "-"}`,
|
|
`dry-run: ${next?.dryRun ?? "-"}`,
|
|
"",
|
|
].filter((line) => line !== "").join("\n");
|
|
}
|
|
|
|
function renderStatusHuman(payload: Record<string, unknown>, _options: ParsedOptions): string {
|
|
const controller = asOptionalRecord(payload.controller);
|
|
const followers = arrayRecords(payload.followers);
|
|
const rows = followers.map((item) => {
|
|
const source = asOptionalRecord(item.source);
|
|
const target = asOptionalRecord(item.target);
|
|
const budgets = asOptionalRecord(item.budgetSource);
|
|
return [
|
|
item.id,
|
|
item.phase,
|
|
item.adapter,
|
|
`${source?.branch ?? "-"}:${shortSha(stringOrNull(source?.observedSha))}`,
|
|
shortSha(stringOrNull(target?.targetSha)),
|
|
shortSha(stringOrNull(item.lastTriggeredSha)),
|
|
shortSha(stringOrNull(item.lastSucceededSha)),
|
|
item.pipelineRun ?? item.inFlightJob ?? "-",
|
|
budgets?.endToEndSeconds ?? "-",
|
|
item.message ?? "-",
|
|
];
|
|
});
|
|
const next = asOptionalRecord(payload.next);
|
|
const errors = Array.isArray(payload.errors) ? payload.errors : [];
|
|
const timingRows = followers.flatMap(timingRowsForFollower).slice(0, 48);
|
|
return [
|
|
`CI/CD BRANCH-FOLLOWER STATUS (${payload.ok === false ? "degraded" : "ok"})`,
|
|
"",
|
|
table(
|
|
["CTRL_NS", "ROUTE", "DEPLOY", "READY", "PODS", "STATE_CM", "LEASE"],
|
|
[[controller?.namespace ?? "-", controller?.route ?? "-", controller?.deploymentName ?? "-", `${controller?.availableReplicas ?? 0}/${controller?.replicas ?? 0}`, controller?.pods ?? "-", controller?.stateConfigMapPresent === true ? "present" : "missing", controller?.leaseHolder ?? "-"]],
|
|
),
|
|
"",
|
|
table(["FOLLOWER", "PHASE", "ADAPTER", "OBSERVED", "TARGET", "TRIGGERED", "SUCCEEDED", "IN_FLIGHT", "BUDGET", "MESSAGE"], rows),
|
|
timingRows.length === 0 ? "" : `\nSTAGE TIMINGS\n${table(["FOLLOWER", "STAGE", "STATUS", "SECONDS", "BUDGET", "OBJECT"], timingRows)}`,
|
|
errors.length === 0 ? "" : `\nERRORS\n${errors.map((item) => `- ${item}`).join("\n")}`,
|
|
"",
|
|
"NEXT",
|
|
`live-status: ${next?.liveStatus ?? "-"}`,
|
|
`dry-run: ${next?.dryRun ?? "-"}`,
|
|
"",
|
|
].filter((line) => line !== "").join("\n");
|
|
}
|
|
|
|
function renderRunOnceHuman(payload: Record<string, unknown>): string {
|
|
const followers = arrayRecords(payload.followers);
|
|
const stateWrites = arrayRecords(payload.stateWrites);
|
|
const rows = followers.map((item) => {
|
|
const source = asOptionalRecord(item.source);
|
|
const target = asOptionalRecord(item.target);
|
|
return [
|
|
item.id,
|
|
item.phase,
|
|
`${source?.branch ?? "-"}:${shortSha(stringOrNull(source?.observedSha))}`,
|
|
shortSha(stringOrNull(target?.targetSha)),
|
|
shortSha(stringOrNull(item.lastTriggeredSha)),
|
|
item.inFlightJob ?? "-",
|
|
item.decision ?? "-",
|
|
];
|
|
});
|
|
const next = asOptionalRecord(payload.next);
|
|
const timingRows = followers.flatMap(timingRowsForFollower).slice(0, 48);
|
|
const writeRows = stateWrites.map((item) => [
|
|
item.follower,
|
|
item.ok === true ? "ok" : "failed",
|
|
item.beforeResourceVersion ?? "-",
|
|
item.afterResourceVersion ?? "-",
|
|
item.preservedTiming === true ? "yes" : "no",
|
|
item.exitCode ?? "-",
|
|
item.message ?? "-",
|
|
]);
|
|
return [
|
|
`CI/CD BRANCH-FOLLOWER RUN-ONCE (${payload.ok === false ? "blocked" : payload.dryRun === true ? "dry-run" : "ok"})`,
|
|
"",
|
|
table(["FOLLOWER", "PHASE", "OBSERVED", "TARGET", "TRIGGERED", "IN_FLIGHT", "DECISION"], rows),
|
|
timingRows.length === 0 ? "" : `\nSTAGE TIMINGS\n${table(["FOLLOWER", "STAGE", "STATUS", "SECONDS", "BUDGET", "OBJECT"], timingRows)}`,
|
|
writeRows.length === 0 ? "" : `\nSTATE WRITES\n${table(["FOLLOWER", "STATUS", "BEFORE_RV", "AFTER_RV", "PRESERVED", "EXIT", "MESSAGE"], writeRows)}`,
|
|
"",
|
|
"NEXT",
|
|
`status: ${next?.status ?? "-"}`,
|
|
`live-status: ${next?.liveStatus ?? "-"}`,
|
|
"",
|
|
].join("\n");
|
|
}
|
|
|
|
function renderCleanupStateHuman(payload: Record<string, unknown>): string {
|
|
const controller = asOptionalRecord(payload.controller);
|
|
const command = asOptionalRecord(payload.command);
|
|
const followers = arrayRecords(payload.followers);
|
|
const next = asOptionalRecord(payload.next);
|
|
const rows = followers.map((item) => [
|
|
item.id,
|
|
item.statePresent === true ? "present" : "missing",
|
|
item.cleanup ?? "-",
|
|
]);
|
|
return [
|
|
`CI/CD BRANCH-FOLLOWER CLEANUP-STATE (${payload.ok === false ? "failed" : payload.dryRun === true ? "dry-run" : "ok"})`,
|
|
"",
|
|
table(
|
|
["NAMESPACE", "ROUTE", "STATE_CM", "STATE_CM_PRESENT"],
|
|
[[controller?.namespace ?? "-", controller?.route ?? "-", controller?.stateConfigMapName ?? "-", payload.stateConfigMapPresent === true ? "present" : "missing"]],
|
|
),
|
|
"",
|
|
table(["FOLLOWER", "STATE", "CLEANUP"], rows),
|
|
command === null ? "" : `\nPATCH\nexit=${command.exitCode ?? "-"} timedOut=${command.timedOut ?? "-"}`,
|
|
"",
|
|
"NEXT",
|
|
`status: ${next?.status ?? "-"}`,
|
|
`run-once: ${next?.runOnce ?? "-"}`,
|
|
"",
|
|
].filter((line) => line !== "").join("\n");
|
|
}
|
|
|
|
function timingRowsForFollower(item: Record<string, unknown>): unknown[][] {
|
|
const timings = asOptionalRecord(item.timings);
|
|
if (timings === null) return [];
|
|
const budget = numberOrNull(timings.budgetSeconds);
|
|
const rows: unknown[][] = [[
|
|
item.id,
|
|
"total",
|
|
stringOrNull(timings.totalStatus) ?? "unknown",
|
|
formatSeconds(numberOrNull(timings.totalSeconds)),
|
|
formatSeconds(budget),
|
|
[stringOrNull(timings.totalSource), shortSha(stringOrNull(timings.sourceCommit))].filter((value) => value !== null && value !== "-").join(":") || "-",
|
|
]];
|
|
for (const stage of arrayRecords(timings.stages)) {
|
|
rows.push([
|
|
item.id,
|
|
stage.stage,
|
|
stage.status,
|
|
formatSeconds(numberOrNull(stage.seconds)),
|
|
formatSeconds(numberOrNull(stage.budgetSeconds)),
|
|
stringOrNull(stage.object) ?? "-",
|
|
]);
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
function formatSeconds(value: number | null): string {
|
|
return value === null ? "-" : `${value}s`;
|
|
}
|
|
|
|
function asOptionalRecord(value: unknown): Record<string, unknown> | null {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : null;
|
|
}
|
|
|
|
function arrayRecords(value: unknown): Record<string, unknown>[] {
|
|
return Array.isArray(value) ? value.filter((item): item is Record<string, unknown> => typeof item === "object" && item !== null && !Array.isArray(item)) : [];
|
|
}
|
|
|
|
function arrayText(value: unknown): string {
|
|
return Array.isArray(value) ? value.map(String).join(",") : "-";
|
|
}
|
|
|
|
function stringOrNull(value: unknown): string | null {
|
|
return typeof value === "string" && value.length > 0 ? value : null;
|
|
}
|
|
|
|
function numberOrNull(value: unknown): number | null {
|
|
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
}
|
|
|
|
function shortSha(value: string | null): string {
|
|
if (value === null) return "-";
|
|
return value.length > 12 ? value.slice(0, 12) : value;
|
|
}
|
|
|
|
function table(headers: readonly string[], rows: readonly (readonly unknown[])[]): string {
|
|
const normalized = rows.map((row) => headers.map((_, index) => cell(row[index])));
|
|
const widths = headers.map((header, index) => Math.max(header.length, ...normalized.map((row) => row[index]?.length ?? 0)));
|
|
const format = (row: readonly string[]) => row.map((value, index) => value.padEnd(widths[index] ?? 0)).join(" ").trimEnd();
|
|
return [format(headers), format(headers.map((header) => "-".repeat(header.length))), ...normalized.map(format)].join("\n");
|
|
}
|
|
|
|
function cell(value: unknown): string {
|
|
if (value === null || value === undefined || value === "") return "-";
|
|
const text = String(value).replace(/\s+/gu, " ");
|
|
return text.length > 96 ? `${text.slice(0, 93)}...` : text;
|
|
}
|