diff --git a/.agents/skills/unidesk-cicd/SKILL.md b/.agents/skills/unidesk-cicd/SKILL.md index 97581ccb..597c4216 100644 --- a/.agents/skills/unidesk-cicd/SKILL.md +++ b/.agents/skills/unidesk-cicd/SKILL.md @@ -17,6 +17,8 @@ bun scripts/cli.ts hwlab g14 git-mirror status --lane v02 bun scripts/cli.ts agentrun control-plane status bun scripts/cli.ts platform-infra gitea mirror status --target JD01 bun scripts/cli.ts platform-infra pipelines-as-code status --target JD01 +bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 --limit 10 +bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 --id bun scripts/cli.ts cicd branch-follower status bun scripts/cli.ts cicd branch-follower debug-step --follower web-probe-sentinel-master --step state-read ``` diff --git a/scripts/src/platform-infra-pipelines-as-code-remote.sh b/scripts/src/platform-infra-pipelines-as-code-remote.sh index 847edef0..faf9cc0c 100644 --- a/scripts/src/platform-infra-pipelines-as-code-remote.sh +++ b/scripts/src/platform-infra-pipelines-as-code-remote.sh @@ -258,6 +258,220 @@ NODE rm -f "$payload_file" } +history_rows() { + node <<'NODE' +const fs = require('node:fs'); +const cp = require('node:child_process'); +const limit = Math.max(1, Math.min(50, Number.parseInt(process.env.UNIDESK_PAC_HISTORY_LIMIT || '5', 10) || 5)); +const detailId = process.env.UNIDESK_PAC_HISTORY_ID || ''; +const consumers = JSON.parse(Buffer.from(process.env.UNIDESK_PAC_HISTORY_CONSUMERS_B64 || 'W10=', 'base64').toString('utf8')); + +function kubectlJson(args, fallback) { + try { + const out = cp.execFileSync('kubectl', args, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 30000, + maxBuffer: 10 * 1024 * 1024, + }); + return JSON.parse(out || JSON.stringify(fallback)); + } catch { + return fallback; + } +} + +function condition(item) { + const c = (item.status?.conditions || []).find((x) => x.type === 'Succeeded') || {}; + return { status: c.status || '', reason: c.reason || '' }; +} + +function durationSeconds(start, done) { + if (!start) return null; + const startMs = Date.parse(start); + const doneMs = done ? Date.parse(done) : Date.now(); + if (!Number.isFinite(startMs) || !Number.isFinite(doneMs)) return null; + return Math.max(0, Math.round((doneMs - startMs) / 1000)); +} + +function mapParams(params) { + const out = {}; + for (const item of params || []) { + if (!item || typeof item.name !== 'string') continue; + if (typeof item.value === 'string') out[item.name] = item.value; + else if (item.value !== undefined && item.value !== null) out[item.name] = JSON.stringify(item.value); + } + return out; +} + +function firstString(...values) { + for (const value of values) { + if (typeof value === 'string' && value.length > 0) return value; + } + return null; +} + +function extractCommit(item, params) { + const labels = item.metadata?.labels || {}; + const annotations = item.metadata?.annotations || {}; + return firstString( + labels['pipelinesascode.tekton.dev/sha'], + labels['pipelinesascode.tekton.dev/commit'], + labels['unidesk.ai/source-commit'], + labels['hwlab.pikastech.local/source-commit'], + labels['agentrun.pikastech.local/source-commit'], + annotations['pipelinesascode.tekton.dev/sha'], + annotations['pipelinesascode.tekton.dev/commit'], + annotations['unidesk.ai/source-commit'], + params.revision, + params.source_revision, + params.sourceCommit, + params.source_commit, + params.git_sha, + params.sha, + params.commit + ); +} + +function extractBranch(item, params) { + const labels = item.metadata?.labels || {}; + const annotations = item.metadata?.annotations || {}; + return firstString( + labels['pipelinesascode.tekton.dev/branch'], + annotations['pipelinesascode.tekton.dev/branch'], + params.source_branch, + params.branch, + params.git_branch + ); +} + +function recursiveEnvReuse(value, state) { + if (value === null || value === undefined) return; + if (Array.isArray(value)) { + for (const item of value) recursiveEnvReuse(item, state); + return; + } + if (typeof value !== 'object') return; + if (typeof value.buildSkippedCount === 'number') state.buildSkippedCount = value.buildSkippedCount; + if (typeof value.serviceReusedCount === 'number') state.serviceReusedCount = value.serviceReusedCount; + if (typeof value.reusedServiceCount === 'number') state.serviceReusedCount = value.reusedServiceCount; + if (typeof value.envReuse === 'string') state.status = value.envReuse; + if (typeof value.envReuseStatus === 'string') state.status = value.envReuseStatus; + if (value.envReuse && typeof value.envReuse === 'object') { + const env = value.envReuse; + if (typeof env.mode === 'string') state.status = env.mode; + if (typeof env.status === 'string') state.status = env.status; + if (typeof env.dependencyReuse === 'string') state.status = env.dependencyReuse; + if (typeof env.buildSkippedCount === 'number') state.buildSkippedCount = env.buildSkippedCount; + if (typeof env.serviceReusedCount === 'number') state.serviceReusedCount = env.serviceReusedCount; + } + if (typeof value.status === 'string' && /reus|skip|built|publish/u.test(value.status)) state.status = value.status; + for (const item of Object.values(value)) recursiveEnvReuse(item, state); +} + +function envReuseForPipelineRun(namespace, name) { + const state = { status: null, buildSkippedCount: null, serviceReusedCount: null, buildCache: null, source: 'logs' }; + let logs = ''; + try { + logs = cp.execFileSync('kubectl', ['-n', namespace, 'logs', '-l', `tekton.dev/pipelineRun=${name}`, '--all-containers', '--tail=320'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 12000, + maxBuffer: 1024 * 1024, + }); + } catch { + return { ...state, source: 'logs-unavailable' }; + } + const lines = logs.split(/\r?\n/u); + const headerIndex = lines.findIndex((line) => /^ENV_REUSE\s+NODE_DEPS\s+/u.test(line.trim())); + if (headerIndex >= 0) { + const row = (lines.slice(headerIndex + 1).find((line) => line.trim()) || '').trim().split(/\s+/u); + if (row.length >= 5) { + state.status = row[0]; + state.buildCache = row[4]; + } + } + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) continue; + try { + recursiveEnvReuse(JSON.parse(trimmed), state); + } catch {} + } + return state; +} + +function taskSummary(taskRuns, name) { + const rows = (taskRuns.items || []) + .filter((item) => item.metadata?.labels?.['tekton.dev/pipelineRun'] === name) + .map((item) => { + const c = condition(item); + return { + name: item.metadata?.name || '', + status: c.status || null, + reason: c.reason || null, + startTime: item.status?.startTime || null, + completionTime: item.status?.completionTime || null, + durationSeconds: durationSeconds(item.status?.startTime, item.status?.completionTime), + }; + }); + const longest = [...rows].sort((a, b) => (b.durationSeconds || 0) - (a.durationSeconds || 0))[0] || null; + return { count: rows.length, longest }; +} + +function rowFor(consumer, item, taskRuns) { + const params = mapParams(item.spec?.params); + const c = condition(item); + const tasks = taskSummary(taskRuns, item.metadata.name); + const labels = item.metadata?.labels || {}; + const annotations = item.metadata?.annotations || {}; + return { + id: item.metadata.name, + consumer: consumer.id, + repository: consumer.repository, + repo: consumer.repo, + repoUrl: consumer.repoUrl, + pipeline: consumer.pipeline, + pipelineRun: item.metadata.name, + triggeredAt: item.metadata?.creationTimestamp || item.status?.startTime || null, + startTime: item.status?.startTime || null, + completionTime: item.status?.completionTime || null, + durationSeconds: durationSeconds(item.status?.startTime || item.metadata?.creationTimestamp, item.status?.completionTime), + status: c.status || null, + reason: c.reason || null, + commit: extractCommit(item, params), + branch: extractBranch(item, params), + eventType: firstString(labels['pipelinesascode.tekton.dev/event-type'], annotations['pipelinesascode.tekton.dev/event-type']), + sender: firstString(labels['pipelinesascode.tekton.dev/sender'], annotations['pipelinesascode.tekton.dev/sender']), + envReuse: envReuseForPipelineRun(consumer.namespace, item.metadata.name), + taskRuns: tasks, + source: 'gitea-pac-tekton-live', + valuesPrinted: false, + }; +} + +const rows = []; +const consumerSummaries = []; +for (const consumer of consumers) { + const pipelineRuns = kubectlJson(['-n', consumer.namespace, 'get', 'pipelinerun', '-o', 'json'], { items: [] }); + const taskRuns = kubectlJson(['-n', consumer.namespace, 'get', 'taskrun', '-o', 'json'], { items: [] }); + let matches = (pipelineRuns.items || []).filter((item) => { + const labels = item.metadata?.labels || {}; + const name = item.metadata?.name || ''; + if (detailId) return name === detailId; + return name.startsWith(consumer.pipelineRunPrefix) + || labels['tekton.dev/pipeline'] === consumer.pipeline + || labels['tekton.dev/pipelineName'] === consumer.pipeline; + }); + matches = matches.sort((a, b) => Date.parse(b.metadata?.creationTimestamp || 0) - Date.parse(a.metadata?.creationTimestamp || 0)); + if (!detailId) matches = matches.slice(0, limit); + for (const item of matches) rows.push(rowFor(consumer, item, taskRuns)); + consumerSummaries.push({ id: consumer.id, namespace: consumer.namespace, repository: consumer.repository, matched: matches.length }); +} +rows.sort((a, b) => Date.parse(b.triggeredAt || 0) - Date.parse(a.triggeredAt || 0)); +process.stdout.write(JSON.stringify({ rows, consumers: consumerSummaries })); +NODE +} + task_rows() { payload_file=$(mktemp) kubectl -n "$UNIDESK_PAC_TARGET_NAMESPACE" get taskrun -o json >"$payload_file" 2>/dev/null || printf '{"items":[]}' >"$payload_file" @@ -428,6 +642,27 @@ status_action() { "$hooks" "$pipelines" "$tasks" "$artifact" "$argo" "$runtime" } +history_action() { + crd=$(kubectl get crd repositories.pipelinesascode.tekton.dev -o name 2>/dev/null || true) + controller_ready=$(kubectl -n "$UNIDESK_PAC_RELEASE_NAMESPACE" get deploy "$UNIDESK_PAC_CONTROLLER_SERVICE_NAME" -o jsonpath='{.status.readyReplicas}/{.spec.replicas}' 2>/dev/null || echo "0/0") + repository_condition=$(condition_status "$UNIDESK_PAC_TARGET_NAMESPACE" repository "$UNIDESK_PAC_REPOSITORY_NAME") + hooks=$(hook_summary) + history=$(history_rows) + rows=$(printf '%s' "$history" | node -e 'const fs=require("fs"); const h=JSON.parse(fs.readFileSync(0,"utf8")||"{}"); process.stdout.write(JSON.stringify(h.rows||[]));') + consumers=$(printf '%s' "$history" | node -e 'const fs=require("fs"); const h=JSON.parse(fs.readFileSync(0,"utf8")||"{}"); process.stdout.write(JSON.stringify(h.consumers||[]));') + printf '{"ok":%s,"crdPresent":%s,"controllerReady":"%s","repositoryCondition":"%s","repository":{"name":"%s","repo":"%s/%s","url":"%s"},"consumer":"%s","consumerRows":%s,"rows":%s,"webhooks":%s,"source":"gitea-pac-tekton-live","historyStore":"none","valuesPrinted":false}\n' \ + "$( [ -n "$crd" ] && [ "$controller_ready" != "0/0" ] && echo true || echo false )" \ + "$( [ -n "$crd" ] && echo true || echo false )" \ + "$(json_string "$controller_ready")" \ + "$(json_string "$repository_condition")" \ + "$(json_string "$UNIDESK_PAC_REPOSITORY_NAME")" \ + "$(json_string "$UNIDESK_PAC_GITEA_OWNER")" \ + "$(json_string "$UNIDESK_PAC_GITEA_REPO")" \ + "$(json_string "$UNIDESK_PAC_REPOSITORY_URL")" \ + "$(json_string "$UNIDESK_PAC_CONSUMER_ID")" \ + "$consumers" "$rows" "$hooks" +} + webhook_test_action() { hooks=$(hook_summary) hook_id=$(printf '%s' "$hooks" | node -e 'const fs=require("fs"); const a=JSON.parse(fs.readFileSync(0,"utf8")||"[]"); process.stdout.write(String(a[0]?.id||""))') @@ -439,6 +674,7 @@ webhook_test_action() { case "$UNIDESK_PAC_ACTION" in apply) apply_action ;; status) status_action ;; + history) history_action ;; webhook-test) webhook_test_action ;; *) printf '{"ok":false,"error":"unsupported-action","valuesPrinted":false}\n'; exit 2 ;; esac diff --git a/scripts/src/platform-infra-pipelines-as-code.ts b/scripts/src/platform-infra-pipelines-as-code.ts index f7c698ac..61cd28d4 100644 --- a/scripts/src/platform-infra-pipelines-as-code.ts +++ b/scripts/src/platform-infra-pipelines-as-code.ts @@ -116,6 +116,11 @@ interface CommonOptions { raw: boolean; } +interface HistoryOptions extends CommonOptions { + limit: number; + detailId: string | null; +} + interface ApplyOptions extends CommonOptions { confirm: boolean; dryRun: boolean; @@ -153,6 +158,11 @@ export async function runPlatformInfraPipelinesAsCodeCommand(config: UniDeskConf const result = await status(config, options); return options.full || options.raw ? result : renderStatus(result); } + if (action === "history" || action === "runs") { + const options = parseHistoryOptions(args.slice(1)); + const result = await history(config, options); + return options.full || options.raw ? result : renderHistory(result); + } if (action === "webhook-test") { const options = parseWebhookTestOptions(args.slice(1)); const result = await webhookTest(config, options); @@ -163,13 +173,15 @@ export async function runPlatformInfraPipelinesAsCodeCommand(config: UniDeskConf function help(): Record { return { - command: "platform-infra pipelines-as-code plan|apply|status|webhook-test", + command: "platform-infra pipelines-as-code plan|apply|status|history|webhook-test", configTruth: configLabel, usage: [ "bun scripts/cli.ts platform-infra pipelines-as-code plan --target JD01", "bun scripts/cli.ts platform-infra pipelines-as-code apply --target JD01 --dry-run", "bun scripts/cli.ts platform-infra pipelines-as-code apply --target JD01 --confirm", "bun scripts/cli.ts platform-infra pipelines-as-code status --target JD01 [--full|--raw]", + "bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 [--consumer hwlab-jd01-v03] [--limit 10]", + "bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 --id ", "bun scripts/cli.ts platform-infra pipelines-as-code webhook-test --target JD01 --confirm", ], boundary: "Sole CI trigger path for GH-1552: Gitea webhook -> Pipelines-as-Code -> Tekton -> Argo/k8s runtime.", @@ -392,6 +404,38 @@ async function status(config: UniDeskConfig, options: CommonOptions): Promise> { + const pac = readPacConfig(); + const target = resolveTarget(pac, options.targetId); + const selectedConsumers = options.consumerId === null + ? pac.consumers + : [resolveConsumer(pac, options.consumerId)]; + const firstConsumer = selectedConsumers[0]; + if (firstConsumer === undefined) throw new Error("no Pipelines-as-Code consumers are configured"); + const firstRepository = resolveRepository(pac, firstConsumer.repositoryRef); + const secrets = ensureSecrets(pac, false); + const result = await capture(config, target.route, ["sh"], remoteScript("history", pac, target, firstRepository, firstConsumer, { ...options, confirm: false, dryRun: true, wait: false }, secrets, "", selectedConsumers)); + const parsed = parseJsonOutput(result.stdout); + const remote = parsed ?? compactCapture(result, { full: true }); + return { + ok: result.exitCode === 0 && parsed?.ok !== false, + action: "platform-infra-pipelines-as-code-history", + mutation: false, + target: targetSummary(target), + config: { + path: configLabel, + source: "Gitea Repository CR + Tekton PipelineRun/TaskRun live objects; no UniDesk-owned history store is written.", + limitPerConsumer: options.limit, + valuesPrinted: false, + }, + consumers: selectedConsumers.map((consumer) => consumer.id), + detailId: options.detailId, + rows: arrayRecords(record(remote).rows), + remote, + next: nextCommands(target.id, options.consumerId ?? pac.defaults.consumerId, pac.defaults.consumerId), + }; +} + async function webhookTest(config: UniDeskConfig, options: WebhookTestOptions): Promise> { const pac = readPacConfig(); const target = resolveTarget(pac, options.targetId); @@ -420,7 +464,7 @@ async function fetchReleaseManifest(pac: PacConfig): Promise { return text; } -function remoteScript(action: "apply" | "status" | "webhook-test", pac: PacConfig, target: PacTarget, repository: PacRepository, consumer: PacConsumer, options: ApplyOptions | WebhookTestOptions, secrets: SecretMaterial, releaseManifest: string): string { +function remoteScript(action: "apply" | "status" | "history" | "webhook-test", pac: PacConfig, target: PacTarget, repository: PacRepository, consumer: PacConsumer, options: ApplyOptions | WebhookTestOptions | HistoryOptions, secrets: SecretMaterial, releaseManifest: string, historyConsumers: PacConsumer[] = [consumer]): string { const webhookUrl = `${pac.gitea.internalBaseUrl.replace(/\/+$/u, "").replace(/gitea-http\.[^.]+\.svc\.cluster\.local:3000/u, `${pac.release.controllerServiceName}.${pac.release.namespace}.svc.cluster.local:${pac.release.controllerServicePort}`)}`; const env: Record = { UNIDESK_PAC_ACTION: action, @@ -448,7 +492,11 @@ function remoteScript(action: "apply" | "status" | "webhook-test", pac: PacConfi UNIDESK_PAC_WEBHOOK_SECRET_KEY: repository.webhookSecretKey, UNIDESK_PAC_CONCURRENCY_LIMIT: String(repository.concurrencyLimit), UNIDESK_PAC_PARAMS_JSON: JSON.stringify(repository.params), + UNIDESK_PAC_PIPELINE_NAME: consumer.pipeline, UNIDESK_PAC_PIPELINE_RUN_PREFIX: consumer.pipelineRunPrefix, + UNIDESK_PAC_HISTORY_LIMIT: "limit" in options ? String(options.limit) : "5", + UNIDESK_PAC_HISTORY_ID: "detailId" in options && options.detailId !== null ? options.detailId : "", + UNIDESK_PAC_HISTORY_CONSUMERS_B64: Buffer.from(JSON.stringify(historyConsumers.map((item) => historyConsumerConfig(pac, item))), "utf8").toString("base64"), UNIDESK_PAC_ARGO_NAMESPACE: consumer.argoNamespace, UNIDESK_PAC_ARGO_APPLICATION: consumer.argoApplication, UNIDESK_PAC_PART_OF: pacPartOf(consumer.id), @@ -458,6 +506,19 @@ function remoteScript(action: "apply" | "status" | "webhook-test", pac: PacConfi return `${exports}\n${readFileSync(remoteScriptFile, "utf8")}`; } +function historyConsumerConfig(pac: PacConfig, consumer: PacConsumer): Record { + const repository = resolveRepository(pac, consumer.repositoryRef); + return { + id: consumer.id, + namespace: consumer.namespace, + pipeline: consumer.pipeline, + pipelineRunPrefix: consumer.pipelineRunPrefix, + repository: repository.name, + repo: `${repository.owner}/${repository.repo}`, + repoUrl: repository.url, + }; +} + function credentialPath(root: string, sourceRef: string): string { return sourceRef.startsWith("/") ? sourceRef : join(root, sourceRef); } @@ -628,6 +689,7 @@ function nextCommands(targetId: string, consumerId: string, defaultConsumerId: s return { apply: `bun scripts/cli.ts platform-infra pipelines-as-code apply --target ${targetId}${suffix} --confirm`, status: `bun scripts/cli.ts platform-infra pipelines-as-code status --target ${targetId}${suffix}`, + history: `bun scripts/cli.ts platform-infra pipelines-as-code history --target ${targetId}${suffix}`, webhookTest: `bun scripts/cli.ts platform-infra pipelines-as-code webhook-test --target ${targetId}${suffix} --confirm`, }; } @@ -709,6 +771,35 @@ function renderStatus(result: Record): RenderedCliResult { return rendered(result, "platform-infra pipelines-as-code status", lines); } +function renderHistory(result: Record): RenderedCliResult { + const rows = arrayRecords(result.rows); + const config = record(result.config); + const detailId = stringValue(result.detailId); + const lines = [ + "PLATFORM-INFRA PIPELINES-AS-CODE HISTORY", + `DATA: ${stringValue(config.source)}`, + `ROWS: ${stringValue(rows.length)} LIMIT_PER_CONSUMER: ${stringValue(config.limitPerConsumer)}`, + "", + "TRIGGERS", + ...(rows.length === 0 ? ["-"] : table(["TIME", "CONSUMER", "REPO", "STATUS", "DUR_S", "COMMIT", "ENV_REUSE", "ID"], rows.map((row) => [ + isoShort(stringValue(row.triggeredAt)), + stringValue(row.consumer), + stringValue(row.repo), + statusText(row), + stringValue(row.durationSeconds), + short(stringValue(row.commit)), + envReuseText(record(row.envReuse)), + stringValue(row.id ?? row.pipelineRun), + ]))), + ...(detailId === "-" || rows.length !== 1 ? [] : renderHistoryDetail(rows[0] ?? {})), + "", + "NEXT", + rows.length > 0 && detailId === "-" ? ` detail: ${stringValue(record(result.next).history)} --id ${stringValue(rows[0]?.id ?? rows[0]?.pipelineRun)}` : null, + ` full: ${stringValue(record(result.next).history)}${detailId === "-" ? "" : ` --id ${detailId}`} --full`, + ]; + return rendered(result, "platform-infra pipelines-as-code history", lines.filter((line): line is string => line !== null)); +} + function renderWebhookTest(result: Record): RenderedCliResult { const remote = record(result.remote); const lines = [ @@ -760,6 +851,36 @@ function parseWebhookTestOptions(args: string[]): WebhookTestOptions { return { ...parseCommonOptions(commonArgs), confirm }; } +function parseHistoryOptions(args: string[]): HistoryOptions { + const commonArgs: string[] = []; + let limit = 5; + let detailId: string | null = null; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === "--limit") { + const value = args[index + 1]; + if (value === undefined || value.startsWith("--")) throw new Error("--limit requires a value"); + const parsed = Number.parseInt(value, 10); + if (!Number.isInteger(parsed) || parsed < 1 || parsed > 50) throw new Error("--limit must be an integer from 1 to 50"); + limit = parsed; + index += 1; + } else if (arg === "--id" || arg === "--run" || arg === "--pipeline-run") { + const value = args[index + 1]; + if (value === undefined || value.startsWith("--")) throw new Error(`${arg} requires a value`); + if (!/^[A-Za-z0-9._:-]+$/u.test(value)) throw new Error(`${arg} must be a PipelineRun id`); + detailId = value; + index += 1; + } else { + commonArgs.push(arg); + if (arg === "--target" || arg === "--node" || arg === "--consumer") { + commonArgs.push(args[index + 1] ?? ""); + index += 1; + } + } + } + return { ...parseCommonOptions(commonArgs), limit, detailId }; +} + function parseCommonOptions(args: string[]): CommonOptions { let targetId: string | null = null; let consumerId: string | null = null; @@ -839,6 +960,57 @@ function short(value: string, length = 12): string { return value.slice(0, length); } +function isoShort(value: string): string { + if (value === "-") return value; + return value.replace(/:\d\d(?:\.\d+)?Z$/u, "Z"); +} + +function statusText(row: Record): string { + const status = stringValue(row.status); + const reason = stringValue(row.reason); + if (status === "-" && reason === "-") return "-"; + if (status === "True" && reason !== "-") return reason; + if (reason === "-") return status; + if (status === "-") return reason; + return `${status}/${reason}`; +} + +function envReuseText(envReuse: Record): string { + const status = stringValue(envReuse.status); + const skipped = stringValue(envReuse.buildSkippedCount); + const reused = stringValue(envReuse.serviceReusedCount); + const cache = stringValue(envReuse.buildCache); + const parts = [ + status === "-" ? null : status, + skipped === "-" ? null : `skip=${skipped}`, + reused === "-" ? null : `reuse=${reused}`, + cache === "-" ? null : `cache=${cache}`, + ].filter((item): item is string => item !== null); + return parts.length === 0 ? "-" : parts.join(","); +} + +function renderHistoryDetail(row: Record): string[] { + const envReuse = record(row.envReuse); + const taskRuns = record(row.taskRuns); + const longest = record(taskRuns.longest); + return [ + "", + "DETAIL", + ...table(["FIELD", "VALUE"], [ + ["id", stringValue(row.id ?? row.pipelineRun)], + ["pipeline", stringValue(row.pipeline)], + ["branch", stringValue(row.branch)], + ["commit", stringValue(row.commit)], + ["started", stringValue(row.startTime)], + ["completed", stringValue(row.completionTime)], + ["taskRuns", stringValue(taskRuns.count)], + ["longestTask", stringValue(longest.name)], + ["longestTaskDurationS", stringValue(longest.durationSeconds)], + ["envReuseSource", stringValue(envReuse.source)], + ]), + ]; +} + function compactLine(value: string): string { const trimmed = value.replace(/\s+/gu, " ").trim(); return trimmed.length > 0 ? trimmed.slice(0, 120) : "-";