diff --git a/scripts/native/cicd/read-state-summary.mjs b/scripts/native/cicd/read-state-summary.mjs index 491da301..d69243aa 100644 --- a/scripts/native/cicd/read-state-summary.mjs +++ b/scripts/native/cicd/read-state-summary.mjs @@ -97,6 +97,94 @@ function compactStateText(text) { timings: compactTimings(state.timings), warnings: arrayStrings(state.warnings).slice(0, 6), stateFormat: stringOrNull(state.stateFormat), + command: compactCommand(state.command), + }; +} + +function compactCommand(command) { + const value = recordOrNull(command); + if (value === null) return null; + return { + mode: stringOrNull(value.mode) ?? stringOrNull(value.status), + namespace: stringOrNull(value.namespace), + pipelineRun: stringOrNull(value.pipelineRun), + sourceCommit: stringOrNull(value.sourceCommit), + sourceStageRef: stringOrNull(value.sourceStageRef), + wait: value.wait === true ? true : null, + pipelineRunCompleted: value.pipelineRunCompleted === true ? true : null, + stillRunning: value.stillRunning === true ? true : null, + closeout: compactCloseout(value.closeout), + payload: compactNativePayload(value.payload), + exitCode: numberOrNull(value.exitCode), + timedOut: value.timedOut === true, + statusAuthority: stringOrNull(value.statusAuthority), + parsedDownstreamCliOutput: false, + }; +} + +function compactCloseout(closeout) { + const value = recordOrNull(closeout); + if (value === null) return null; + return { + ok: value.ok === true, + completed: value.completed === true, + timedOut: value.timedOut === true, + polls: numberOrNull(value.polls), + elapsedMs: numberOrNull(value.elapsedMs), + summary: compactNativePayload(value.summary), + statusAuthority: stringOrNull(value.statusAuthority), + parsedDownstreamCliOutput: false, + }; +} + +function compactNativePayload(payload) { + const value = recordOrNull(payload); + if (value === null) return null; + return { + source: recordOrNull(value.source), + sourceSync: recordOrNull(value.sourceSync), + gitMirror: recordOrNull(value.gitMirror), + tekton: recordOrNull(value.tekton), + taskRuns: compactTaskRuns(value.taskRuns), + planArtifacts: compactPlanArtifacts(value.planArtifacts), + argo: recordOrNull(value.argo), + runtime: recordOrNull(value.runtime), + errors: arrayStrings(value.errors).slice(0, 5), + statusAuthority: stringOrNull(value.statusAuthority), + parsedDownstreamCliOutput: false, + }; +} + +function compactTaskRuns(taskRuns) { + const value = recordOrNull(taskRuns); + if (value === null) return null; + return { + ok: value.ok === true, + count: numberOrNull(value.count), + succeededCount: numberOrNull(value.succeededCount), + failedCount: numberOrNull(value.failedCount), + activeCount: numberOrNull(value.activeCount), + performance: recordOrNull(value.performance), + items: arrayRecords(value.items).slice(0, 16), + }; +} + +function compactPlanArtifacts(planArtifacts) { + const value = recordOrNull(planArtifacts); + if (value === null) return null; + return { + ok: value.ok === true, + pipelineRun: stringOrNull(value.pipelineRun), + eventFound: value.eventFound === true, + degradedReason: stringOrNull(value.degradedReason), + sourceCommitId: stringOrNull(value.sourceCommitId), + affectedServices: arrayStrings(value.affectedServices).slice(0, 40), + rolloutServices: arrayStrings(value.rolloutServices).slice(0, 40), + buildServices: arrayStrings(value.buildServices).slice(0, 40), + reusedServices: arrayStrings(value.reusedServices).slice(0, 40), + buildSkippedCount: numberOrNull(value.buildSkippedCount), + summary: stringOrNull(value.summary), + disclosure: stringOrNull(value.disclosure), }; } diff --git a/scripts/src/cicd-drilldown-render.ts b/scripts/src/cicd-drilldown-render.ts new file mode 100644 index 00000000..63f13151 --- /dev/null +++ b/scripts/src/cicd-drilldown-render.ts @@ -0,0 +1,97 @@ +// SPEC: PJ2026-01060703 CI/CD branch follower drill-down rendering. +// Responsibility: bounded human summaries for branch-follower events/logs gates. + +export function renderDrillDownHuman(payload: Record): string { + if (payload.follower === undefined) { + const followers = arrayRecords(payload.followers); + return [ + `CI/CD BRANCH-FOLLOWER ${String(payload.action ?? "drill-down").toUpperCase()}`, + "", + table(["FOLLOWER", "ADAPTER", "STATUS_AUTHORITY"], followers.map((item) => [item.id, item.adapter, item.statusAuthority ?? "k8s-native"])), + "", + ].join("\n"); + } + const summary = asOptionalRecord(payload.summary); + const native = asOptionalRecord(payload.native); + const gateRows = nativeGateRows(native); + return [ + `CI/CD BRANCH-FOLLOWER ${String(payload.action ?? "drill-down").toUpperCase()} (${payload.ok === false ? "failed" : "ok"})`, + "", + table( + ["FOLLOWER", "ADAPTER", "AUTHORITY", "PHASE", "OBSERVED", "TARGET", "PIPELINERUN", "MESSAGE"], + [[payload.follower, payload.adapter ?? "-", payload.statusAuthority ?? "k8s-native", summary?.phase ?? "-", shortSha(stringOrNull(summary?.observedSha)), shortSha(stringOrNull(summary?.targetSha)), summary?.pipelineRun ?? "-", summary?.message ?? "-"]], + ), + gateRows.length === 0 ? "" : `\nGATES\n${table(["GATE", "STATUS", "DETAIL", "OBJECT"], gateRows)}`, + "", + ].filter((line) => line !== "").join("\n"); +} + +function nativeGateRows(native: Record | null): unknown[][] { + if (native === null) return []; + const rows: unknown[][] = []; + const gitMirror = asOptionalRecord(native.gitMirror); + if (gitMirror !== null) { + const hasGitops = stringOrNull(gitMirror.gitopsBranch) !== null; + const status = gitMirror.pendingFlush === true + ? "pending-flush" + : hasGitops + ? gitMirror.githubInSync === true && gitMirror.sourceSnapshotReady === true ? "ready" : "not-ready" + : gitMirror.sourceSnapshotReady === true ? "source-ready" : "source-not-ready"; + rows.push(["git-mirror", status, `${shortSha(stringOrNull(gitMirror.localSource))}/${shortSha(stringOrNull(gitMirror.githubSource))}`, stringOrNull(gitMirror.gitopsBranch) ?? stringOrNull(gitMirror.sourceBranch) ?? "-"]); + } + const tekton = asOptionalRecord(native.tekton); + if (tekton !== null) { + const status = tekton.succeeded === true ? "succeeded" : tekton.succeeded === false ? `failed:${stringOrNull(tekton.reason) ?? "unknown"}` : "running"; + const duration = numberOrNull(tekton.durationSeconds); + rows.push(["tekton", status, duration === null ? stringOrNull(tekton.reason) ?? "-" : `${duration}s`, stringOrNull(tekton.name) ?? "-"]); + } + const argo = asOptionalRecord(native.argo); + if (argo !== null) { + rows.push(["argo", `${stringOrNull(argo.syncStatus) ?? "unknown"}/${stringOrNull(argo.healthStatus) ?? "unknown"}`, shortSha(stringOrNull(argo.revision)), stringOrNull(argo.name) ?? "-"]); + } + const runtime = asOptionalRecord(native.runtime); + if (runtime !== null) { + const status = runtime.ready === true ? (runtime.aligned === true ? "ready/aligned" : "ready/stale") : "not-ready"; + rows.push(["runtime", status, `${shortSha(stringOrNull(runtime.targetSha))}/${shortSha(stringOrNull(runtime.expectedSha))}`, stringOrNull(runtime.namespace) ?? "-"]); + } + for (const error of arrayTextItems(native.errors).slice(0, 5)) rows.push(["error", "present", error, "-"]); + return rows; +} + +function asOptionalRecord(value: unknown): Record | null { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : null; +} + +function arrayRecords(value: unknown): Record[] { + return Array.isArray(value) ? value.filter((item): item is Record => typeof item === "object" && item !== null && !Array.isArray(item)) : []; +} + +function arrayTextItems(value: unknown): string[] { + return Array.isArray(value) ? value.map(String) : []; +} + +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; +} diff --git a/scripts/src/cicd.ts b/scripts/src/cicd.ts index b15d5735..225aa2ba 100644 --- a/scripts/src/cicd.ts +++ b/scripts/src/cicd.ts @@ -22,6 +22,7 @@ import { transPath } from "./hwlab-node/runtime-common"; import { configRefGraph, resolveConfigRefString } from "./ops/config-refs"; import { renderControllerManifests, renderControllerReconcileJob, waitForJobShell } from "./cicd-controller-render"; import { buildDebugStep, renderDebugStepHuman } from "./cicd-debug"; +import { renderDrillDownHuman } from "./cicd-drilldown-render"; import { runNativeHwlabControlPlaneRefresh } from "./cicd-hwlab-refresh"; import { nativeCicdScriptLoadShell, readNativeObjectBundle } from "./cicd-native-bundle"; import { runNativeK8sJob, runNativeTektonPipelineRun } from "./cicd-native"; @@ -2951,28 +2952,6 @@ function renderCleanupStateHuman(payload: Record): string { ].filter((line) => line !== "").join("\n"); } -function renderDrillDownHuman(payload: Record): string { - if (payload.follower === undefined) { - const followers = arrayRecords(payload.followers); - return [ - `CI/CD BRANCH-FOLLOWER ${String(payload.action ?? "drill-down").toUpperCase()}`, - "", - table(["FOLLOWER", "ADAPTER", "STATUS_AUTHORITY"], followers.map((item) => [item.id, item.adapter, item.statusAuthority ?? "k8s-native"])), - "", - ].join("\n"); - } - const summary = asOptionalRecord(payload.summary); - return [ - `CI/CD BRANCH-FOLLOWER ${String(payload.action ?? "drill-down").toUpperCase()} (${payload.ok === false ? "failed" : "ok"})`, - "", - table( - ["FOLLOWER", "ADAPTER", "AUTHORITY", "PHASE", "OBSERVED", "TARGET", "PIPELINERUN", "MESSAGE"], - [[payload.follower, payload.adapter ?? "-", payload.statusAuthority ?? "k8s-native", summary?.phase ?? "-", shortSha(stringOrNull(summary?.observedSha)), shortSha(stringOrNull(summary?.targetSha)), summary?.pipelineRun ?? "-", summary?.message ?? "-"]], - ), - "", - ].filter((line) => line !== "").join("\n"); -} - function arrayRecords(value: unknown): Record[] { return Array.isArray(value) ? value.filter((item): item is Record => typeof item === "object" && item !== null && !Array.isArray(item)) : []; }