feat: enrich hwlab rollout briefs

This commit is contained in:
Codex
2026-05-27 09:24:42 +00:00
parent 8b03898bc0
commit 18f49922ba
7 changed files with 1074 additions and 9 deletions
+2 -1
View File
@@ -33,7 +33,8 @@ CLI 可以从 `master` 快速演进,但必须兼容 `deploy.json` 固定的 CI
- `dev-env prewarm-images [--image image] [--provider-id D601] [--no-pull] [--proxy-url URL] [--pull-timeout-ms N] [--dry-run]` 创建异步 job,通过 UniDesk SSH 维护桥在 D601 上把开发底座依赖镜像从 Docker 缓存导入原生 k3s containerd。默认镜像是 `postgres:16-alpine``rancher/mirrored-library-busybox:1.36.1`,用于避免 `postgres-dev` 与 local-path helper pod 卡在外部 registry 拉取。该命令固定验证 `/etc/rancher/k3s/k3s.yaml` 指向的 native k3s 上下文,并输出 `dev_env_containerd_image_ready=...` 作为成功判据;它不 apply manifest、不修改生产 `unidesk` namespace。
- `artifact-registry plan|render|status|health|install|deploy-backend-core|deploy-service` 管理 D601 host-managed CNCF Distribution registry 的声明、安装、只读检查和 pull-only artifact CD。该 registry 固定为 D601 loopback `127.0.0.1:5000`,由 systemd + Docker Compose 管理,位于 native k3s 故障域外;`deploy-service` 只拉取 CI 已发布的 commit-pinned 镜像、retag/recreate 或导入 native k3s,并做 live commit 验证,不构建 runtime source。`deploy-backend-core` 是 deprecated 兼容名,标准 backend-core prod CD 入口是 `deploy apply --env prod --service backend-core`。长期规则见 `docs/reference/artifact-registry.md`
- `commander contract|plan --dry-run|smoke --dry-run|approval request --dry-run|prompt-lint --kind gpt55-pr` 是 host Codex 指挥官直管微服务 skeleton 入口。当前命令返回 `phase=source-contract`、service/API/state/bridge/prompt/trace/#20/#46/ClaudeQQ 审批边界、.state/commander/ 状态模型、dev 无 daemon smoke contract、dry-run 计划和 GPT-5.5 PR prompt 边界辅助 lint,不接 live bridge、不注入 prompt、不发送 ClaudeQQ。`approval request --dry-run` 会生成 200 字以内中文纯文本 ClaudeQQ 审批草案、`notification-path-unavailable` blocker 和授权后唯一可用的 `bun scripts/cli.ts microservice proxy claudeqq /api/push/text --method POST --body-json '<payload>' --raw` 命令;不得提示使用本机 ClaudeQQ skill、powershell 或本地 server。`prompt-lint` 支持 `--prompt-file``--stdin`,输出 `ok``missingClauses``riskLevel``suggestedPatchSnippet` 且不回显完整 prompt;它是 commander 辅助检查,不是业务 PR 门禁,也不改变 `codex submit` 默认行为。`plan``smoke``approval request` 必须带 `--dry-run`;缺少时返回 `error=dry-run-required`。长期规则见 `docs/reference/host-codex-commander.md`
- `hwlab g14 monitor-prs [--once] [--dry-run] [--interval-seconds N] [--max-cycles N] [--timeout-seconds N]` 是当前 HWLAB G14 PR -> CI/CD -> DEV rollout 的一行式入口。普通调用创建 `.state/jobs/` 异步 job 并立刻返回 `job.id``statusCommand` 和 stdout/stderr 路径;后台 worker 每轮通过 UniDesk `gh pr list/preflight/merge` 监控 `pikasTech/HWLAB` base=`G14` 的 open PRready 时合并,然后通过 UniDesk `ssh G14:k3s` 观察 `hwlab-g14-ci-poll-<short>`、Argo `hwlab-g14-dev` 和 DEV `/health/live`,直到 DEV `Synced/Healthy` 且 Deployment/StatefulSet ready;历史 `Completed` smoke/debug pod 不作为 rollout blocker。每次成功 DEV rollout 后,worker 会定位或创建 #7“指挥简报索引”中的北京日期每日简报 issue,并追加 CI/CD 耗时、上线 changelog、PipelineRun、GitOps revision 和 DEV 验证摘要;也可用 `hwlab g14 record-rollout --pr <number> --source-commit <sha>` 手动补记。状态指针按用途分离:长期监控只写 `.state/hwlab-g14/latest-monitor-job.json``--once``latest-once-job.json``--dry-run``latest-dry-run-job.json``--once --dry-run``latest-once-dry-run-job.json`,避免一次性收口覆盖持续监控入口。`--once --dry-run` 只做单轮监控和 merge plan,不写 GitHub、不等待 rollout。该命令禁止使用原生 `gh` 或手拼 GitHub 请求;如果 UniDesk `gh` 子命令字段或行为不够,必须先改进 `scripts/src/gh.ts` 后再使用。
- `hwlab g14 monitor-prs [--once] [--dry-run] [--interval-seconds N] [--max-cycles N] [--timeout-seconds N]` 是当前 HWLAB G14 PR -> CI/CD -> DEV rollout 的一行式入口。普通调用创建 `.state/jobs/` 异步 job 并立刻返回 `job.id``statusCommand` 和 stdout/stderr 路径;后台 worker 每轮通过 UniDesk `gh pr list/preflight/merge` 监控 `pikasTech/HWLAB` base=`G14` 的 open PRready 时合并,然后通过 UniDesk `ssh G14:k3s` 观察 `hwlab-g14-ci-poll-<short>`、Argo `hwlab-g14-dev` 和 DEV `/health/live`,直到 DEV `Synced/Healthy` 且 Deployment/StatefulSet ready;历史 `Completed` smoke/debug pod 不作为 rollout blocker。每次成功 DEV rollout 后,worker 会定位或创建 #7“指挥简报索引”中的北京日期每日简报 issue,并追加 CI/CD 耗时、CI/CD 关键指标、语义化上线 changelog、自动 diff 摘要、PipelineRun、GitOps revision 和 DEV 验证摘要;关键指标来自 G14 Tekton TaskRun results,固定包含 `lazy build reused: x/y`、reused services、rebuild services 和每个 service 的独立耗时/状态/backend,用于观察 lazy build 机制效果。语义化 changelog 优先从 PR body 的 `## 修改`/`## 变更`/`## Changelog` 等段落提取,diff 摘要只作为文件和统计证据保留,不替代 changelog。也可用 `hwlab g14 record-rollout --pr <number> --source-commit <sha>` 手动补记,手动补记同样会按 PipelineRun 采集 TaskRun 指标。状态指针按用途分离:长期监控只写 `.state/hwlab-g14/latest-monitor-job.json``--once``latest-once-job.json``--dry-run``latest-dry-run-job.json``--once --dry-run``latest-once-dry-run-job.json`,避免一次性收口覆盖持续监控入口。`--once --dry-run` 只做单轮监控和 merge plan,不写 GitHub、不等待 rollout。该命令禁止使用原生 `gh` 或手拼 GitHub 请求;如果 UniDesk `gh` 子命令字段或行为不够,必须先改进 `scripts/src/gh.ts` 后再使用。
- `ssh gh:/owner/repo ...` 把 GitHub issue/PR 映射成只读/受控写入的虚拟文本目录,适合日报、PR 正文和 issue 正文的小补丁维护:`ssh gh:/pikasTech/HWLAB ls` 展示 `pr/``issue/``ssh gh:/pikasTech/HWLAB/pr ls [--limit N] [--full]``ssh gh:/pikasTech/HWLAB/issue ls [--limit N] [--full]` 展示条目状态、楼层数、正文长度和标题,`ssh gh:/pikasTech/HWLAB/pr/507 ls` 展示单个 PR 的一楼正文文件,`ssh gh:/pikasTech/HWLAB/505/1 cat|rg|patch-apply` 兼容旧式 issue/PR number route。`patch-apply` 使用 UniDesk 默认 apply-patch v2 的虚拟文件 executor,把正文一楼映射为 `body.md`,写回仍走 `bun scripts/cli.ts gh issue/pr update` 的 guard/concurrency 规则;`rm` 对正文一楼结构化拒绝,避免误删 issue/PR 正文。大正文读取必须展开 UniDesk gh dump 文件,否则 `cat/rg/patch-apply` 会误读为空,这是 `gh:` 虚拟文件接口的 P0 可见性契约。
- `hwlab cd status|audit|preflight|apply --env dev [--dry-run]` 是旧 D601 HWLAB DEV CD 指挥侧 wrapper,仅用于显式 legacy 诊断和迁移对照。默认通过 UniDesk provider `host.ssh` 进入 D601,再调用 HWLAB repo-owned `scripts/dev-cd-apply.mjs`,不内嵌发布 kubectl 逻辑:`status` 汇总固定 CD mirror、Git clean/main/origin-main、`deploy/deploy.json`/artifact catalog/report、D601 native k3s guard 和 CD Lease lock,并用 `scripts/dev-cd-apply.mjs --status --skip-live-verify` 取得 target/promotion 摘要;`audit` 在 k3s/CD 恢复后做只读健康审计,返回有界 JSON 的 blocker 分类、D601 guard/node、SecretRef 存在性、registry 可达性、Lease phase/holder/staleness、deploy.json 与 artifact/workload image 收敛、current Deployment image/revision/rollout、16666/16667 public health commit/readiness 和 DB/runtime durability 摘要;`preflight` 进一步检查必需 SecretRef 对象/键存在性并运行 HWLAB `scripts/dev-cd-apply.mjs --dry-run --skip-live-verify` 受控事务摘要。完整远端 stdout/stderr 写入 D601 `~/.state/unidesk-hwlab-cd/<run-id>/` 和本地 `.state/hwlab-cd/<run-id>/` task dumpstdout 只返回有界摘要。默认 HWLAB CD repo 是 `/home/ubuntu/hwlab_cd``/home/ubuntu/hwlab` runner 历史目录不得作为发布真相。wrapper 强制 `KUBECONFIG=/etc/rancher/k3s/k3s.yaml` 并只以这个显式目标作为 gate;显式目标出现 `docker-desktop``desktop-control-plane``127.0.0.1:11700` 信号会结构化拒绝,audit/preflight/apply --dry-run 都必须观察到 node `d601`。真实 apply 只暴露 `scripts/dev-cd-apply.mjs --apply --confirm-dev --confirmed-non-production --write-report` 命令形状并标注 host-commander-only,本 runner 不执行 live apply、rollout、Lease mutation 或 DEV deploy apply。长期规则见 `docs/reference/hwlab.md`
- `gh auth status [--repo owner/name]` 探测 GitHub 操作前置条件并输出脱敏 JSON:是否存在 `gh` binary、是否存在 `GH_TOKEN`/`GITHUB_TOKEN` 或可用 `gh auth token` fallback、REST API 是否可达、目标 repo 是否可见、issue 是否可读。degraded reason 必须归类为 `missing-binary``missing-token``auth-failed``github-transient``network-proxy-failed``permission-denied``repo-not-found``repo-forbidden``issue-not-found``pr-not-found``scope-insufficient``validation-failed``invalid-response``unsupported-command`,不得打印 token;失败对象必须包含 `runnerDisposition=infra-blocked|business-failed`runner 应优先用该字段分流。`github-transient` 表示 GitHub DNS/API 连接在收到 HTTP 状态前失败,输出应带 `retryable=true` 或等价 commander action;这不是缺 token、认证失败、权限不足或 PR 语义失败。
- `codex prompt-lint [prompt|--prompt-file path|--prompt-stdin]` 是派发/steer 前的本地 dry-run prompt lint。它只读取 prompt 文本,返回 `dryRun=true``mutation=false``declaredClass``effectiveClass``requiredClass``dispatchDisposition`、缺失或矛盾项和有界 evidence,不访问 live service、不提交任务、不打印完整 prompt。分级固定为 `read-only``live-read``live-mutating`;未声明时按 `read-only` 处理。`codex submit --dry-run``codex steer --dry-run` 会嵌入同一 `promptLint` 结果,帮助指挥官在 dispatch/steer 前发现缺失或矛盾的 live mutation 授权。长期规则见 `docs/reference/code-queue-supervision.md` 的 DEV 测试授权分级。
+6
View File
@@ -20,6 +20,7 @@ import { runDevEnvCommand } from "./src/dev-env";
import { runArtifactRegistryCommand } from "./src/artifact-registry";
import { runAuthBrokerCommand } from "./src/auth-broker";
import { runGhCommand } from "./src/gh";
import { isGhContentRoute, runGhContentRoute } from "./src/gh-route";
import { runCommanderCommand } from "./src/commander";
import { isHelpToken, rootHelp, serverHelp, sshHelp, staticNamespaceHelp } from "./src/help";
import { runServerCleanupCommand } from "./src/server-cleanup";
@@ -205,6 +206,11 @@ async function main(): Promise<void> {
return;
}
if (top === "ssh" && isGhContentRoute(sub)) {
process.exitCode = await runGhContentRoute(sub ?? "", args.slice(2));
return;
}
if (top === "check" && isHelpToken(sub)) {
emitJson(commandName, checkHelp());
return;
+66
View File
@@ -0,0 +1,66 @@
import { GhVirtualFileExecutor, applyPatchToGhBody, parseGhContentRoute } from "./src/gh-route";
function assertCondition(condition: unknown, message: string, detail: unknown = {}): void {
if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`);
}
const route = parseGhContentRoute("gh:/pikasTech/HWLAB/503/1");
assertCondition(route.repo === "pikasTech/HWLAB", "route should preserve owner/repo");
assertCondition(route.number === 503, "route should parse issue or PR number");
assertCondition(route.floor === 1, "route should parse first-floor body");
const prListRoute = parseGhContentRoute("gh:/pikasTech/HWLAB/pr");
assertCondition(prListRoute.area === "pr-list" && prListRoute.number === null, "PR directory route should list pull requests", prListRoute);
const prBodyRoute = parseGhContentRoute("gh:/pikasTech/HWLAB/pr/503/1");
assertCondition(prBodyRoute.area === "pr-item" && prBodyRoute.number === 503 && prBodyRoute.floor === 1, "PR body route should parse explicit first floor", prBodyRoute);
const issueListRoute = parseGhContentRoute("gh:/pikasTech/HWLAB/issue");
assertCondition(issueListRoute.area === "issue-list" && issueListRoute.number === null, "issue directory route should list issues", issueListRoute);
const original = [
"# Title",
"",
"- 上线 changelog:",
" - fix: old title only",
" - changed files: 2; +10 / -1; commits: 1",
"",
].join("\n");
const patchResult = await applyPatchToGhBody(original, [
"*** Begin Patch",
"*** Update File: body.md",
"@@",
" - 上线 changelog:",
"- - fix: old title only",
"- - changed files: 2; +10 / -1; commits: 1",
"+ - 修复目标:说明真实用户可见问题。",
"+- 自动 diff 摘要:",
"+ - changed files: 2; +10 / -1; commits: 1",
" ",
"*** End Patch",
].join("\n"));
const patched = patchResult.body;
assertCondition(
patched.includes("- 上线 changelog:\n - 修复目标:说明真实用户可见问题。\n- 自动 diff 摘要:"),
"patch-apply should update the virtual body with apply_patch v2 semantics",
patched,
);
assertCondition(!patched.includes("fix: old title only"), "patch-apply should remove old title-only changelog", patched);
assertCondition(patchResult.applyPatchOutput.includes("Success. Updated the following files:"), "gh route should run through the shared apply-patch v2 engine", patchResult.applyPatchOutput);
const executor = new GhVirtualFileExecutor("hello\n");
const stat = await executor.run(["sh", "-c", "ignored", "unidesk-apply-patch-v2", "stat", "body.md"]);
assertCondition(stat.exitCode === 0 && /^6 [0-9a-f]{64}\n$/u.test(stat.stdout), "virtual file executor should expose apply-patch v2 stat protocol", stat);
console.log(JSON.stringify({
ok: true,
checks: [
"gh:/owner/repo/number/1 route parses to a first-floor GitHub body",
"gh:/owner/repo/pr and gh:/owner/repo/issue directory routes parse",
"gh:/owner/repo/pr/number/1 explicit PR body route parses",
"gh route patch-apply uses the shared apply-patch v2 engine",
"gh virtual file executor exposes the apply-patch v2 low-level file protocol",
],
}, null, 2));
+100 -1
View File
@@ -1,4 +1,4 @@
import { hwlabG14MonitorStateFileName } from "./src/hwlab-g14";
import { hwlabG14MonitorStateFileName, parsePipelineTaskRunMetrics, rolloutRecordBody, semanticChangelogBullets } from "./src/hwlab-g14";
function assertCondition(condition: unknown, message: string, detail: unknown = {}): void {
if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`);
@@ -21,6 +21,102 @@ assertCondition(
"once dry-runs need a distinct pointer for low-noise diagnostics",
);
const prBody = [
"## 背景",
"",
"G14 DEV Cloud Workbench 中,内部 `message-trace-body` 的 `pre` 会在 trace 更新时被替换,导致用户滚到中间后被重置到顶部。",
"",
"## 修改",
"",
"- trace row patch 从 index/replace 改为 stable `data-trace-row-id` keyed reconciliation。",
"- 已存在 row 只移动和原地更新 header/body,不替换 `li/pre`。",
"- `message-trace-body` 内部滚动状态按 `traceUiKey + rowId` 记忆。",
].join("\n");
const semanticBullets = semanticChangelogBullets("fix: preserve inner trace scroll", prBody, {
keyFiles: [
"modified web/hwlab-cloud-web/app.mjs",
"modified web/hwlab-cloud-web/scripts/trace-scroll.test.mjs",
],
});
assertCondition(
semanticBullets.some((line) => line.includes("修复目标") && line.includes("滚到中间后被重置到顶部")),
"semantic changelog should explain the user-visible bug being fixed",
semanticBullets,
);
assertCondition(
semanticBullets.some((line) => line.includes("data-trace-row-id")),
"semantic changelog should include PR body change bullets instead of only file stats",
semanticBullets,
);
const summaryBullets = semanticChangelogBullets("feat: preload gateway tran helper", [
"## 摘要",
"- 新增 `/app/tools/hwlab-gateway-tran.mjs`,支持 `cmd`、`ps`、`upload`、`download`。",
].join("\n"), {});
assertCondition(
summaryBullets.some((line) => line.includes("hwlab-gateway-tran.mjs") && line.includes("upload")),
"semantic changelog should also extract Chinese summary sections",
summaryBullets,
);
const rolloutBody = rolloutRecordBody({
pr: { number: 506, title: "fix: preserve inner trace scroll", url: "https://github.com/pikasTech/HWLAB/pull/506" },
prData: { json: { title: "fix: preserve inner trace scroll", body: prBody, url: "https://github.com/pikasTech/HWLAB/pull/506" } },
fileSummary: {
summary: { files: 2, additions: 268, deletions: 22, commits: 1 },
keyFiles: [
"modified web/hwlab-cloud-web/app.mjs",
"modified web/hwlab-cloud-web/scripts/trace-scroll.test.mjs",
],
},
sourceCommit: "1a3fa9e6fbc987142463ecff2cbcef240a6278f2",
pipelineRun: "hwlab-g14-ci-poll-1a3fa9e6fbc9",
gitopsRevision: "21462b78ce4e7dba4ea374398f60db690e290147",
mergedAt: "2026-05-27T06:52:22Z",
pipelineSucceededAt: "2026-05-27T06:55:38Z",
finishedAt: "2026-05-27T06:55:47Z",
rollout: { pipelineText: "True\nSucceeded", argoText: "21462\nSynced\nHealthy\nSucceeded\n21462", healthOk: true },
ciMetrics: parsePipelineTaskRunMetrics("hwlab-g14-ci-poll-test", [
"taskrun-a\tbuild-hwlab-cloud-api\t2026-05-27T06:54:43Z\t2026-05-27T06:54:52Z\tservice-id=hwlab-cloud-api;status=reused;build-backend=reused-catalog;",
"taskrun-b\tbuild-hwlab-cloud-web\t2026-05-27T06:54:43Z\t2026-05-27T06:55:08Z\tservice-id=hwlab-cloud-web;status=published;build-backend=buildkit;",
].join("\n")),
});
assertCondition(
rolloutBody.includes("- 上线 changelog(语义化):"),
"rollout record must label natural-language changelog separately",
);
assertCondition(
rolloutBody.includes("- 自动 diff 摘要:"),
"rollout record must keep automatic diff summary separately",
);
assertCondition(
rolloutBody.indexOf("- 上线 changelog(语义化):") < rolloutBody.indexOf("- 自动 diff 摘要:"),
"semantic changelog should appear before automatic diff summary",
);
assertCondition(
rolloutBody.includes("changed files: 2; +268 / -22; commits: 1"),
"automatic diff summary should preserve file/stat evidence",
);
assertCondition(
rolloutBody.includes("lazy build reused: 1/2"),
"rollout record should include lazy-build reused ratio",
rolloutBody,
);
assertCondition(
rolloutBody.includes("reused services: hwlab-cloud-api") && rolloutBody.includes("rebuild services: hwlab-cloud-web"),
"rollout record should list reused and rebuild services",
rolloutBody,
);
assertCondition(
rolloutBody.includes("hwlab-cloud-api: reused, 9s") && rolloutBody.includes("hwlab-cloud-web: published, 25s"),
"rollout record should include each service duration",
rolloutBody,
);
console.log(JSON.stringify({
ok: true,
checks: [
@@ -28,5 +124,8 @@ console.log(JSON.stringify({
"once jobs do not overwrite long-running monitor state",
"dry-run jobs do not overwrite live monitor state",
"once dry-run jobs have a distinct diagnostic state file",
"rollout brief includes natural-language changelog before automatic diff summary",
"semantic changelog extracts Chinese summary sections",
"rollout brief includes lazy-build reused/rebuild metrics and service durations",
],
}, null, 2));
+593
View File
@@ -0,0 +1,593 @@
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Readable, Writable } from "node:stream";
import { runCommand } from "./command";
import { repoRoot } from "./config";
import { runApplyPatchV2, type ApplyPatchV2Executor, type ApplyPatchV2RemoteResult } from "./apply-patch-v2";
interface GhContentRoute {
repo: string;
area: "root" | "pr-list" | "issue-list" | "pr-item" | "issue-item" | "legacy-item";
number: number | null;
floor: number | null;
raw: string;
}
interface GhRouteDocument {
kind: "pr" | "issue";
repo: string;
number: number;
floor: number;
title: string;
url: string;
state: string;
commentCount: number;
body: string;
bodySha: string | null;
}
interface CommandResult {
ok: boolean;
exitCode: number | null;
stdout: string;
stderr: string;
parsed: Record<string, unknown>;
}
function record(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function nested(value: unknown, keys: string[]): unknown {
let current = value;
for (const key of keys) current = record(current)[key];
return current;
}
function cliJson(args: string[], timeoutMs = 80_000): CommandResult {
const result = runCommand(["bun", "scripts/cli.ts", ...args], repoRoot, { timeoutMs });
let parsed: Record<string, unknown> = {};
if (result.stdout.trim().length > 0) {
try {
parsed = record(JSON.parse(result.stdout) as unknown);
} catch {
parsed = {};
}
}
const ok = result.exitCode === 0 && parsed.ok !== false && nested(parsed, ["data", "ok"]) !== false;
return { ok, exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr, parsed };
}
function decodeSegment(segment: string): string {
try {
return decodeURIComponent(segment);
} catch {
throw new Error(`invalid percent-encoded gh route segment: ${segment}`);
}
}
export function isGhContentRoute(target: string | undefined): boolean {
return typeof target === "string" && target.startsWith("gh:");
}
export function parseGhContentRoute(target: string): GhContentRoute {
if (!target.startsWith("gh:/")) throw new Error("gh route must use gh:/owner/repo, for example: gh:/pikasTech/HWLAB/pr ls or gh:/pikasTech/HWLAB/pr/503/1 cat");
const parts = target.slice("gh:/".length).split("/").filter((part) => part.length > 0).map(decodeSegment);
if (parts.length < 2 || parts.length > 5) throw new Error("gh route supports gh:/owner/repo, gh:/owner/repo/pr, gh:/owner/repo/issue, gh:/owner/repo/pr/number[/floor], gh:/owner/repo/issue/number[/floor], and legacy gh:/owner/repo/number/floor");
const [owner, repoName, areaRaw, numberRaw, floorRaw] = parts;
if (!owner || !repoName) throw new Error("gh route requires non-empty owner and repo");
const repo = `${owner}/${repoName}`;
if (areaRaw === undefined) return { repo, area: "root", number: null, floor: null, raw: target };
if (areaRaw === "pr" || areaRaw === "prs" || areaRaw === "pull" || areaRaw === "pulls") {
if (numberRaw === undefined) return { repo, area: "pr-list", number: null, floor: null, raw: target };
const number = positiveRouteNumber(numberRaw, "PR number");
const floor = floorRaw === undefined ? null : positiveRouteNumber(floorRaw, "PR floor");
return { repo, area: "pr-item", number, floor, raw: target };
}
if (areaRaw === "issue" || areaRaw === "issues") {
if (numberRaw === undefined) return { repo, area: "issue-list", number: null, floor: null, raw: target };
const number = positiveRouteNumber(numberRaw, "issue number");
const floor = floorRaw === undefined ? null : positiveRouteNumber(floorRaw, "issue floor");
return { repo, area: "issue-item", number, floor, raw: target };
}
if (parts.length === 4) {
const number = positiveRouteNumber(areaRaw, "legacy issue/PR number");
const floor = positiveRouteNumber(numberRaw ?? "", "legacy floor");
return { repo, area: "legacy-item", number, floor, raw: target };
}
throw new Error("unsupported gh route; use gh:/owner/repo/pr[/number[/floor]] or gh:/owner/repo/issue[/number[/floor]]");
}
function positiveRouteNumber(raw: string, label: string): number {
const value = Number(raw);
if (!Number.isInteger(value) || value <= 0) throw new Error(`gh route ${label} must be a positive integer`);
return value;
}
function commandData(result: CommandResult): Record<string, unknown> {
return record(expandedParsedRoot(result).data);
}
function expandedParsedRoot(result: CommandResult): Record<string, unknown> {
const dumpPath = nested(result.parsed, ["data", "dump", "path"]);
if (typeof dumpPath === "string" && existsSync(dumpPath)) {
try {
return record(JSON.parse(readFileSync(dumpPath, "utf8")) as unknown);
} catch {
return result.parsed;
}
}
return result.parsed;
}
function readDocument(route: GhContentRoute): GhRouteDocument {
if (route.number === null) throw new Error("gh route document requires a PR or issue number");
if (route.floor !== 1) {
throw new Error("only floor 1 is currently supported; floor 1 maps to the issue/PR body");
}
const shouldReadPr = route.area === "pr-item" || route.area === "legacy-item";
const shouldReadIssue = route.area === "issue-item" || route.area === "legacy-item";
const pr = shouldReadPr ? cliJson(["gh", "pr", "read", String(route.number), "--repo", route.repo, "--json", "title,body,url,stateDetail,merged,mergedAt,mergeCommit,headRefName,baseRefName", "--raw"]) : null;
if (pr?.ok) {
const data = commandData(pr);
const prRecord = record(data.pullRequest);
const json = record(data.json);
const issueMeta = readIssueMetadata(route.repo, route.number);
return {
kind: "pr",
repo: route.repo,
number: route.number,
floor: route.floor,
title: String(json.title ?? prRecord.title ?? ""),
url: String(json.url ?? prRecord.url ?? ""),
state: issueMeta.state ?? String(json.stateDetail ?? prRecord.stateDetail ?? ""),
commentCount: issueMeta.commentCount ?? 0,
body: String(json.body ?? prRecord.body ?? ""),
bodySha: null,
};
}
if (!shouldReadIssue) {
throw new Error(`failed to read ${route.repo}#${route.number} as PR: ${pr?.stderr || pr?.stdout}`);
}
const issue = cliJson(["gh", "issue", "read", String(route.number), "--repo", route.repo, "--json", "title,body,state,updatedAt", "--raw"]);
if (!issue.ok) {
throw new Error(`failed to read ${route.repo}#${route.number} as ${route.area === "issue-item" ? "issue" : "PR or issue"}: pr=${pr?.stderr || pr?.stdout || "skipped"}; issue=${issue.stderr || issue.stdout}`);
}
const issueRecord = record(commandData(issue).issue);
const json = record(commandData(issue).json);
const commentCount = numberFromUnknown(issueRecord.commentCount) ?? commentsCount(commandData(issue).comments) ?? commentsCount(json.comments) ?? 0;
return {
kind: "issue",
repo: route.repo,
number: route.number,
floor: route.floor,
title: String(json.title ?? issueRecord.title ?? ""),
url: String(issueRecord.url ?? ""),
state: String(json.state ?? issueRecord.state ?? ""),
commentCount,
body: String(json.body ?? issueRecord.body ?? ""),
bodySha: typeof issueRecord.bodySha === "string" ? issueRecord.bodySha : null,
};
}
function numberFromUnknown(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function commentsCount(value: unknown): number | null {
return Array.isArray(value) ? value.length : null;
}
function readIssueMetadata(repo: string, number: number): { state: string | null; commentCount: number | null; bodyChars: number | null } {
const issue = cliJson(["gh", "issue", "read", String(number), "--repo", repo, "--json", "title,body,state,updatedAt,comments", "--raw"], 80_000);
if (!issue.ok) return { state: null, commentCount: null, bodyChars: null };
const data = commandData(issue);
const issueRecord = record(data.issue);
const json = record(data.json);
const body = String(json.body ?? issueRecord.body ?? "");
return {
state: typeof json.state === "string" ? json.state : typeof issueRecord.state === "string" ? issueRecord.state : null,
commentCount: numberFromUnknown(issueRecord.commentCount) ?? commentsCount(data.comments) ?? commentsCount(json.comments),
bodyChars: body.length,
};
}
function parseListOptions(args: string[]): { limit: number; full: boolean } {
const limitIndex = args.indexOf("--limit");
return {
limit: limitIndex >= 0 ? positiveRouteNumber(args[limitIndex + 1] ?? "", "--limit") : 30,
full: args.includes("--full"),
};
}
function listRoute(route: GhContentRoute, args: string[]): number {
const options = parseListOptions(args);
if (route.area === "root") {
printDirectory([
{ name: "pr/", type: "dir", description: "pull request directory" },
{ name: "issue/", type: "dir", description: "issue directory" },
]);
return 0;
}
if (route.area === "pr-list") {
const result = cliJson(["gh", "pr", "list", "--repo", route.repo, "--state", "all", "--limit", String(options.limit), "--json", "number,title,state,url,updatedAt,createdAt,author,headRefName,baseRefName,draft"], 80_000);
if (!result.ok) throw new Error(result.stderr || result.stdout || "gh pr list failed");
const prs = commandData(result).pullRequests;
const rows = Array.isArray(prs) ? prs.map((item) => prDirectoryEntry(route.repo, record(item), options.full)) : [];
printDirectory(rows);
return 0;
}
if (route.area === "issue-list") {
const result = cliJson(["gh", "issue", "list", "--repo", route.repo, "--state", "all", "--limit", String(options.limit), "--json", "number,title,state,url,updatedAt,createdAt,author"], 80_000);
if (!result.ok) throw new Error(result.stderr || result.stdout || "gh issue list failed");
const issues = commandData(result).issues;
const rows = Array.isArray(issues) ? issues.map((item) => issueDirectoryEntry(route.repo, record(item), options.full)) : [];
printDirectory(rows);
return 0;
}
const document = readDocument({ ...route, floor: 1 });
printDirectory([
{
name: "1",
type: "file",
kind: document.kind,
state: document.state,
floors: 1 + document.commentCount,
commentCount: document.commentCount,
bodyChars: document.body.length,
title: document.title,
url: document.url,
},
]);
return 0;
}
function prDirectoryEntry(repo: string, item: Record<string, unknown>, full: boolean): Record<string, unknown> {
const number = Number(item.number);
const meta = full && Number.isInteger(number) ? readIssueMetadata(repo, number) : null;
const commentCount = meta?.commentCount ?? null;
return {
name: `${item.number}/`,
type: "dir",
kind: "pr",
state: meta?.state ?? item.state ?? null,
draft: item.draft === true,
floors: commentCount === null ? 1 : 1 + commentCount,
commentCount,
bodyChars: meta?.bodyChars ?? null,
title: item.title ?? "",
url: item.url ?? `https://github.com/${repo}/pull/${item.number}`,
headRefName: item.headRefName ?? null,
baseRefName: item.baseRefName ?? null,
updatedAt: item.updatedAt ?? null,
};
}
function issueDirectoryEntry(repo: string, item: Record<string, unknown>, full: boolean): Record<string, unknown> {
const number = Number(item.number);
const meta = full && Number.isInteger(number) ? readIssueMetadata(repo, number) : null;
const commentCount = meta?.commentCount ?? null;
return {
name: `${item.number}/`,
type: "dir",
kind: "issue",
state: meta?.state ?? item.state ?? null,
floors: commentCount === null ? 1 : 1 + commentCount,
commentCount,
bodyChars: meta?.bodyChars ?? null,
title: item.title ?? "",
url: item.url ?? `https://github.com/${repo}/issues/${item.number}`,
updatedAt: item.updatedAt ?? null,
};
}
function printDirectory(rows: Array<Record<string, unknown>>): void {
for (const row of rows) {
const fields = [
String(row.name ?? ""),
`type=${String(row.type ?? "")}`,
row.kind === undefined ? null : `kind=${String(row.kind)}`,
row.state === undefined || row.state === null ? null : `state=${String(row.state)}`,
row.draft === undefined ? null : `draft=${String(row.draft)}`,
row.floors === undefined || row.floors === null ? null : `floors=${String(row.floors)}`,
row.commentCount === undefined || row.commentCount === null ? null : `commentCount=${String(row.commentCount)}`,
row.bodyChars === undefined || row.bodyChars === null ? null : `bodyChars=${String(row.bodyChars)}`,
row.updatedAt === undefined || row.updatedAt === null ? null : `updatedAt=${String(row.updatedAt)}`,
row.title === undefined || row.title === "" ? null : `title=${String(row.title)}`,
].filter((item): item is string => item !== null);
process.stdout.write(`${fields.join("\t")}\n`);
}
}
function readStdin(): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
process.stdin.on("data", (chunk: Buffer) => chunks.push(chunk));
process.stdin.on("error", reject);
process.stdin.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
});
}
function sha256Hex(value: Buffer): string {
return createHash("sha256").update(value).digest("hex");
}
function bodyVirtualPath(path: string): string | null {
const normalized = path.replace(/\\/gu, "/").replace(/^\.\/+/u, "");
if (normalized === "body.md" || normalized === "body" || normalized === "1.md") return "body.md";
return null;
}
function verifyVirtualWrite(target: string, buffer: Buffer, expectedBytes: string, expectedSha256: string): ApplyPatchV2RemoteResult | null {
const expectedByteCount = Number(expectedBytes);
if (!Number.isSafeInteger(expectedByteCount) || expectedByteCount < 0) {
return { exitCode: 2, stdout: "", stderr: `invalid expected byte count for ${target}\n` };
}
if (buffer.length !== expectedByteCount) {
return { exitCode: 23, stdout: "", stderr: `v2 virtual byte count mismatch for ${target}: expected=${expectedByteCount} actual=${buffer.length}\n` };
}
const actualSha256 = sha256Hex(buffer);
if (actualSha256 !== expectedSha256) {
return { exitCode: 24, stdout: "", stderr: `v2 virtual sha256 mismatch for ${target}: expected=${expectedSha256} actual=${actualSha256}\n` };
}
return null;
}
export class GhVirtualFileExecutor implements ApplyPatchV2Executor {
private readonly files = new Map<string, Buffer>();
private readonly temp = new Map<string, string>();
constructor(body: string) {
this.files.set("body.md", Buffer.from(body, "utf8"));
}
body(): string {
const body = this.files.get("body.md");
return body === undefined ? "" : body.toString("utf8");
}
async run(command: string[], input?: string): Promise<ApplyPatchV2RemoteResult> {
const operation = command[4] ?? "";
const args = command.slice(5);
try {
return this.runOperation(operation, args, input);
} catch (error) {
return { exitCode: 1, stdout: "", stderr: `${error instanceof Error ? error.message : String(error)}\n` };
}
}
private canonical(rawPath: string): string {
const path = bodyVirtualPath(rawPath);
if (path === null) throw new Error(`unsupported gh virtual file path: ${rawPath}; use body.md for floor 1`);
return path;
}
private file(path: string): Buffer {
const body = this.files.get(path);
if (body === undefined) throw new Error(`virtual file not found: ${path}`);
return body;
}
private runOperation(operation: string, args: string[], input?: string): ApplyPatchV2RemoteResult {
if (operation === "stat") {
const path = this.canonical(args[0] ?? "");
const body = this.file(path);
return { exitCode: 0, stdout: `${body.length} ${sha256Hex(body)}\n`, stderr: "" };
}
if (operation === "read-b64-block") {
const path = this.canonical(args[0] ?? "");
const blockIndex = Number(args[1] ?? "");
const blockSize = Number(args[2] ?? "");
if (!Number.isSafeInteger(blockIndex) || blockIndex < 0 || !Number.isSafeInteger(blockSize) || blockSize <= 0) {
return { exitCode: 2, stdout: "", stderr: "invalid read block args\n" };
}
const body = this.file(path);
const offset = blockIndex * blockSize;
return { exitCode: 0, stdout: body.subarray(offset, offset + blockSize).toString("base64"), stderr: "" };
}
if (operation === "write-b64-argv") {
const path = this.canonical(args[0] ?? "");
const expectedBytes = args[1] ?? "";
const expectedSha256 = args[2] ?? "";
const buffer = Buffer.from(args.slice(3).join(""), "base64");
const failure = verifyVirtualWrite(path, buffer, expectedBytes, expectedSha256);
if (failure !== null) return failure;
this.files.set(path, buffer);
return { exitCode: 0, stdout: "", stderr: "" };
}
if (operation === "write-b64-stdin") {
const path = this.canonical(args[0] ?? "");
const expectedBytes = args[1] ?? "";
const expectedSha256 = args[2] ?? "";
const buffer = Buffer.from((input ?? "").replace(/\s+/gu, ""), "base64");
const failure = verifyVirtualWrite(path, buffer, expectedBytes, expectedSha256);
if (failure !== null) return failure;
this.files.set(path, buffer);
return { exitCode: 0, stdout: "", stderr: "" };
}
if (operation === "write-b64-begin") {
this.canonical(args[0] ?? "");
const token = args[1] ?? "";
this.temp.set(token, "");
return { exitCode: 0, stdout: "", stderr: "" };
}
if (operation === "write-b64-append") {
this.canonical(args[0] ?? "");
const token = args[1] ?? "";
if (!this.temp.has(token)) return { exitCode: 2, stdout: "", stderr: "unknown virtual write token\n" };
this.temp.set(token, `${this.temp.get(token) ?? ""}${args[2] ?? ""}`);
return { exitCode: 0, stdout: "", stderr: "" };
}
if (operation === "write-b64-commit") {
const path = this.canonical(args[0] ?? "");
const token = args[1] ?? "";
const expectedBytes = args[2] ?? "";
const expectedSha256 = args[3] ?? "";
const encoded = this.temp.get(token);
if (encoded === undefined) return { exitCode: 2, stdout: "", stderr: "unknown virtual write token\n" };
const buffer = Buffer.from(encoded, "base64");
const failure = verifyVirtualWrite(path, buffer, expectedBytes, expectedSha256);
if (failure !== null) return failure;
this.temp.delete(token);
this.files.set(path, buffer);
return { exitCode: 0, stdout: "", stderr: "" };
}
if (operation === "delete") {
const path = this.canonical(args[0] ?? "");
this.files.delete(path);
return { exitCode: 0, stdout: "", stderr: "" };
}
if (operation === "move") {
const from = this.canonical(args[0] ?? "");
const to = this.canonical(args[1] ?? "");
const body = this.file(from);
this.files.set(to, body);
this.files.delete(from);
return { exitCode: 0, stdout: "", stderr: "" };
}
return { exitCode: 2, stdout: "", stderr: `unsupported virtual apply-patch operation: ${operation}\n` };
}
}
function writableCapture(): { stream: Writable; text: () => string } {
const chunks: Buffer[] = [];
return {
stream: new Writable({
write(chunk, _encoding, callback) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
callback();
},
}),
text: () => Buffer.concat(chunks).toString("utf8"),
};
}
export async function applyPatchToGhBody(currentBody: string, patchText: string): Promise<{ body: string; applyPatchOutput: string; executor: GhVirtualFileExecutor }> {
const executor = new GhVirtualFileExecutor(currentBody);
const output = writableCapture();
const exitCode = await runApplyPatchV2({
executor,
stdin: Readable.from([patchText]),
stdout: output.stream,
argv: [],
});
if (exitCode !== 0) throw new Error(`apply-patch-v2 failed for gh virtual body: ${output.text()}`);
return { body: executor.body(), applyPatchOutput: output.text(), executor };
}
function writeTempBody(body: string): string {
const dir = mkdtempSync(join(tmpdir(), "unidesk-gh-route-"));
const path = join(dir, "body.md");
writeFileSync(path, body, "utf8");
return path;
}
function updateDocument(document: GhRouteDocument, nextBody: string, dryRun: boolean): CommandResult {
const bodyPath = writeTempBody(nextBody);
try {
if (document.kind === "pr") {
return cliJson(["gh", "pr", "update", String(document.number), "--repo", document.repo, "--mode", "replace", "--body-file", bodyPath, ...(dryRun ? ["--dry-run"] : [])], 100_000);
}
return cliJson([
"gh",
"issue",
"update",
String(document.number),
"--repo",
document.repo,
"--mode",
"replace",
"--body-file",
bodyPath,
...(document.bodySha !== null ? ["--expect-body-sha", document.bodySha] : []),
...(dryRun ? ["--dry-run"] : []),
], 100_000);
} finally {
rmSync(join(bodyPath, ".."), { recursive: true, force: true });
}
}
function printJson(payload: unknown): void {
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
}
function runRg(body: string, args: string[]): number {
if (args.length === 0) throw new Error("gh route rg requires a pattern");
const dir = mkdtempSync(join(tmpdir(), "unidesk-gh-route-rg-"));
try {
const bodyPath = join(dir, "body.md");
writeFileSync(bodyPath, body, "utf8");
const result = runCommand(["rg", "--color=never", ...args, bodyPath], repoRoot, { timeoutMs: 30_000 });
process.stdout.write(result.stdout.replaceAll(`${bodyPath}:`, ""));
process.stderr.write(result.stderr.replaceAll(`${bodyPath}:`, ""));
return result.exitCode ?? 1;
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
export async function runGhContentRoute(target: string, args: string[]): Promise<number> {
const route = parseGhContentRoute(target);
const operation = args[0] ?? "cat";
const opArgs = args.slice(1);
if (operation === "ls" || operation === "dir") {
return listRoute(route, opArgs);
}
const document = readDocument(route);
if (operation === "cat") {
process.stdout.write(document.body);
return 0;
}
if (operation === "rg") {
return runRg(document.body, opArgs);
}
if (operation === "patch-apply" || operation === "apply-patch") {
const dryRun = opArgs.includes("--dry-run");
const unsupported = opArgs.filter((arg) => arg !== "--dry-run");
if (unsupported.length > 0) throw new Error(`unsupported gh ${operation} args: ${unsupported.join(" ")}`);
const patchText = await readStdin();
const patchResult = await applyPatchToGhBody(document.body, patchText);
const nextBody = patchResult.body;
const update = updateDocument(document, nextBody, dryRun);
printJson({
ok: update.ok,
route,
target: {
kind: document.kind,
repo: document.repo,
number: document.number,
floor: document.floor,
title: document.title,
url: document.url,
},
dryRun,
bodyCharsBefore: document.body.length,
bodyCharsAfter: nextBody.length,
applyPatch: {
engine: "apply-patch-v2",
virtualFiles: ["body.md"],
output: patchResult.applyPatchOutput.trim(),
},
update: commandData(update),
});
return update.ok ? 0 : 1;
}
if (operation === "rm") {
printJson({
ok: false,
degradedReason: "unsupported-operation",
route,
message: "gh route floor 1 is the issue/PR body and cannot be removed safely; use patch-apply to delete reviewed sections.",
});
return 2;
}
printJson({
ok: false,
degradedReason: "unsupported-command",
route,
supported: ["cat", "rg", "patch-apply", "apply-patch", "rm"],
});
return 2;
}
+1
View File
@@ -21,6 +21,7 @@ export function rootHelp(): unknown {
{ command: "server rebuild <backend-core|frontend|dev-frontend-proxy|provider-gateway|todo-note|code-queue-mgr|project-manager|baidu-netdisk|oa-event-flow>", description: "Maintenance-only local Compose rebuild for reviewed main-server services; frontend standard release must use CI artifact plus deploy apply dev/prod artifact consumers." },
{ command: "provider attach <providerId> [--master-server URL] [--up] [--force] | provider triage <providerId> [--observed-error text] [--observed-scope scope] [--microservice id ...] [--full|--raw]", description: "Generate the minimal external provider-gateway env/compose bundle or run the low-noise read-only provider health triage contract." },
{ command: "ssh <route> [operation args...]", description: "Open a Host SSH / WSL SSH maintenance session through the provider-gateway bridge; route syntax such as `G14:k3s` or `D601:win/c/test` only locates distributed targets." },
{ command: "ssh gh:/owner/repo[/pr|/issue][/number[/1]] ls|cat|rg|patch-apply", description: "Treat GitHub PRs/issues as virtual text directories; `ls --full` shows state/floors/body length, and `patch-apply` updates first-floor `body.md` through UniDesk gh plus apply-patch v2." },
{ command: "ssh <route> apply-patch < patch.diff", description: "Default remote text patch entry: apply a standard patch with the local TypeScript v2 engine while the remote route only reads and writes files." },
{ command: "ssh <route> upload <local-file> <remote-file> | ssh <route> download <remote-file> <local-file>", description: "Transfer whole files through SSH passthrough with remote temp files, byte-count checks, SHA-256 verification, and client-side chunk fallback." },
{ command: "ssh <providerId> apply-patch-v1 [tool args...] < patch.diff", description: "Fallback to the injected legacy remote apply_patch helper directly over SSH passthrough and stream the patch from local stdin." },
+306 -7
View File
@@ -58,6 +58,30 @@ interface OpenPullRequest {
draft?: boolean;
}
interface CiServiceMetric {
taskRun: string;
serviceId: string;
status: string;
durationSeconds: number | null;
buildBackend: string | null;
reusedFrom: string | null;
imageTag: string | null;
sourceCommitId: string | null;
}
interface CiPipelineMetrics {
ok: boolean;
pipelineRun: string;
serviceTotal: number;
reusedCount: number;
rebuildCount: number;
reusedServices: string[];
rebuildServices: string[];
services: CiServiceMetric[];
degradedReason?: string;
rawText?: string;
}
export function hwlabG14MonitorStateFileName(options: Pick<G14MonitorOptions, "once" | "dryRun">): string {
if (options.once && options.dryRun) return "latest-once-dry-run-job.json";
if (options.once) return "latest-once-job.json";
@@ -317,6 +341,272 @@ function formatDuration(seconds: number | null): string {
return `${minutes}m${String(rest).padStart(2, "0")}s`;
}
function pipelineTaskRunResultsJsonPath(): string {
return [
"jsonpath=",
"{range .items[*]}",
"{.metadata.name}{\"\\t\"}",
"{.metadata.labels.tekton\\.dev/pipelineTask}{\"\\t\"}",
"{.status.startTime}{\"\\t\"}",
"{.status.completionTime}{\"\\t\"}",
"{range .status.results[*]}{.name}{\"=\"}{.value}{\";\"}{end}",
"{\"\\n\"}",
"{end}",
].join("");
}
function getPipelineTaskRunResults(pipelineRun: string): CommandJsonResult {
return cliJson([
"ssh",
`${G14_PROVIDER}:k3s`,
"kubectl",
"get",
"taskrun",
"-n",
CI_NAMESPACE,
"-l",
`tekton.dev/pipelineRun=${pipelineRun}`,
"-o",
pipelineTaskRunResultsJsonPath(),
], 80_000);
}
function parseResultMap(raw: string): Record<string, string> {
const values: Record<string, string> = {};
for (const item of raw.split(";")) {
if (item.length === 0) continue;
const separator = item.indexOf("=");
if (separator <= 0) continue;
values[item.slice(0, separator)] = item.slice(separator + 1);
}
return values;
}
function serviceMetricSort(left: CiServiceMetric, right: CiServiceMetric): number {
return left.serviceId.localeCompare(right.serviceId);
}
export function parsePipelineTaskRunMetrics(pipelineRun: string, text: string): CiPipelineMetrics {
const services: CiServiceMetric[] = [];
for (const line of text.split(/\r?\n/u)) {
const trimmed = line.trimEnd();
if (trimmed.length === 0) continue;
const [taskRunName = "", pipelineTask = "", startTime = "", completionTime = "", resultsRaw = ""] = trimmed.split("\t");
if (!pipelineTask.startsWith("build-")) continue;
const results = parseResultMap(resultsRaw);
const serviceId = results["service-id"] ?? pipelineTask.replace(/^build-/u, "");
if (serviceId.length === 0) continue;
services.push({
taskRun: taskRunName,
serviceId,
status: results.status ?? "unknown",
durationSeconds: durationSeconds(startTime, completionTime),
buildBackend: results["build-backend"] ?? null,
reusedFrom: results["reused-from"] ?? null,
imageTag: results["image-tag"] ?? null,
sourceCommitId: results["source-commit-id"] ?? null,
});
}
services.sort(serviceMetricSort);
const reusedServices = services.filter((service) => service.status === "reused").map((service) => service.serviceId);
const rebuildServices = services.filter((service) => service.status !== "reused").map((service) => service.serviceId);
return {
ok: services.length > 0,
pipelineRun,
serviceTotal: services.length,
reusedCount: reusedServices.length,
rebuildCount: rebuildServices.length,
reusedServices,
rebuildServices,
services,
...(services.length === 0 ? { degradedReason: "no-build-task-results", rawText: text.slice(0, 2000) } : {}),
};
}
function ciMetricsFromValue(value: unknown): CiPipelineMetrics | null {
const data = record(value);
if (data.ok !== true || typeof data.pipelineRun !== "string") return null;
const services = Array.isArray(data.services) ? data.services.map((item) => {
const service = record(item);
return {
taskRun: String(service.taskRun ?? ""),
serviceId: String(service.serviceId ?? ""),
status: String(service.status ?? "unknown"),
durationSeconds: typeof service.durationSeconds === "number" ? service.durationSeconds : null,
buildBackend: typeof service.buildBackend === "string" ? service.buildBackend : null,
reusedFrom: typeof service.reusedFrom === "string" ? service.reusedFrom : null,
imageTag: typeof service.imageTag === "string" ? service.imageTag : null,
sourceCommitId: typeof service.sourceCommitId === "string" ? service.sourceCommitId : null,
};
}).filter((service) => service.serviceId.length > 0).sort(serviceMetricSort) : [];
const reusedServices = Array.isArray(data.reusedServices) ? data.reusedServices.map(String) : services.filter((service) => service.status === "reused").map((service) => service.serviceId);
const rebuildServices = Array.isArray(data.rebuildServices) ? data.rebuildServices.map(String) : services.filter((service) => service.status !== "reused").map((service) => service.serviceId);
return {
ok: true,
pipelineRun: data.pipelineRun,
serviceTotal: typeof data.serviceTotal === "number" ? data.serviceTotal : services.length,
reusedCount: typeof data.reusedCount === "number" ? data.reusedCount : reusedServices.length,
rebuildCount: typeof data.rebuildCount === "number" ? data.rebuildCount : rebuildServices.length,
reusedServices,
rebuildServices,
services,
};
}
function collectPipelineMetrics(pipelineRun: string): CiPipelineMetrics {
const result = getPipelineTaskRunResults(pipelineRun);
if (!isCommandSuccess(result)) {
return {
ok: false,
pipelineRun,
serviceTotal: 0,
reusedCount: 0,
rebuildCount: 0,
reusedServices: [],
rebuildServices: [],
services: [],
degradedReason: "taskrun-query-failed",
rawText: `${result.stderr}\n${result.stdout}`.trim().slice(0, 2000),
};
}
return parsePipelineTaskRunMetrics(pipelineRun, statusText(result));
}
function ciMetricLines(metrics: CiPipelineMetrics | null): string[] {
if (metrics === null) {
return [
"- CI/CD 关键指标:",
" - lazy build reused: n/a",
" - reused services: n/a",
" - rebuild services: n/a",
" - service durations: n/a",
];
}
if (metrics.ok !== true) {
return [
"- CI/CD 关键指标:",
` - lazy build reused: unavailable (${metrics.degradedReason ?? "unknown"})`,
" - reused services: n/a",
" - rebuild services: n/a",
" - service durations: n/a",
];
}
const lines = [
"- CI/CD 关键指标:",
` - lazy build reused: ${metrics.reusedCount}/${metrics.serviceTotal}`,
` - reused services: ${metrics.reusedServices.length > 0 ? metrics.reusedServices.join(", ") : "none"}`,
` - rebuild services: ${metrics.rebuildServices.length > 0 ? metrics.rebuildServices.join(", ") : "none"}`,
" - service durations:",
];
for (const service of metrics.services) {
const backend = service.buildBackend === null ? "" : `, backend=${service.buildBackend}`;
lines.push(` - ${service.serviceId}: ${service.status}, ${formatDuration(service.durationSeconds)}${backend}`);
}
return lines;
}
function stripMarkdownInline(value: string): string {
return value
.replace(/\[([^\]]+)\]\([^)]+\)/gu, "$1")
.replace(/\*\*([^*]+)\*\*/gu, "$1")
.replace(/\*([^*]+)\*/gu, "$1")
.replace(/__([^_]+)__/gu, "$1")
.replace(/_([^_]+)_/gu, "$1")
.replace(/\s+/gu, " ")
.trim();
}
function markdownSection(body: string, headings: string[]): string {
const normalizedHeadings = new Set(headings.map((heading) => heading.toLowerCase()));
const lines = body.split(/\r?\n/u);
let capturing = false;
const captured: string[] = [];
for (const line of lines) {
const heading = /^(#{1,6})\s+(.+?)\s*$/u.exec(line);
if (heading !== null) {
const title = stripMarkdownInline(heading[2] ?? "").replace(/[:]\s*$/u, "").toLowerCase();
if (capturing) break;
capturing = normalizedHeadings.has(title);
continue;
}
if (capturing) captured.push(line);
}
return captured.join("\n").trim();
}
function firstParagraph(section: string): string | null {
for (const paragraph of section.split(/\n\s*\n/u)) {
const trimmed = stripMarkdownInline(paragraph.replace(/^[-*]\s+/gmu, ""));
if (trimmed.length > 0) return trimmed;
}
return null;
}
function topLevelBullets(section: string, limit = 8): string[] {
const bullets: string[] = [];
for (const line of section.split(/\r?\n/u)) {
const match = /^[-*]\s+(.+)$/u.exec(line);
if (match === null) continue;
const text = stripMarkdownInline(match[1] ?? "");
if (text.length > 0) bullets.push(text);
if (bullets.length >= limit) break;
}
return bullets;
}
function conventionalTitleSummary(title: string): string {
const match = /^(feat|fix|docs|test|refactor|perf|ci|build|chore)(?:\([^)]+\))?:\s*(.+)$/iu.exec(title.trim());
if (match === null) return stripMarkdownInline(title);
const kind = String(match[1] ?? "").toLowerCase();
const subject = stripMarkdownInline(match[2] ?? title);
const labels: Record<string, string> = {
feat: "功能",
fix: "修复",
docs: "文档",
test: "测试",
refactor: "重构",
perf: "性能",
ci: "CI/CD",
build: "构建",
chore: "维护",
};
return `${labels[kind] ?? "变更"}${subject}`;
}
function fileHeuristicBullets(fileSummary: Record<string, unknown>): string[] {
const keyFiles = Array.isArray(fileSummary.keyFiles) ? fileSummary.keyFiles.map(String) : [];
const bullets: string[] = [];
if (keyFiles.some((file) => file.includes("web/hwlab-cloud-web/"))) {
bullets.push("前端:更新 HWLAB Cloud Web / Workbench 交互行为。");
}
if (keyFiles.some((file) => /\.(test|spec)\./u.test(file) || file.includes("/test"))) {
bullets.push("验证:补充或更新回归测试,覆盖本次变更路径。");
}
if (keyFiles.some((file) => file.includes("deploy/") || file.includes("k8s") || file.includes("gitops"))) {
bullets.push("部署:更新 G14 Kubernetes/GitOps 发布声明或发布计划。");
}
if (keyFiles.some((file) => file.includes("internal/cloud/") || file.includes("code-agent"))) {
bullets.push("运行时:调整 Cloud API / Code Agent 运行链路。");
}
return bullets;
}
export function semanticChangelogBullets(title: string, body: string, fileSummary: Record<string, unknown>): string[] {
const bullets: string[] = [];
const background = firstParagraph(markdownSection(body, ["背景", "问题", "problem", "background", "context"]));
if (background !== null && /^(fix|perf)(?:\([^)]+\))?:/iu.test(title)) {
bullets.push(`修复目标:${background}`);
}
const changeSection = markdownSection(body, ["上线 changelog", "changelog", "摘要", "概述", "总结", "修改", "变更", "改动", "changes", "what changed", "summary"]);
bullets.push(...topLevelBullets(changeSection));
if (bullets.length === 0) bullets.push(conventionalTitleSummary(title));
for (const bullet of fileHeuristicBullets(fileSummary)) {
if (bullets.length >= 8) break;
if (!bullets.includes(bullet)) bullets.push(bullet);
}
return bullets.slice(0, 8);
}
function readIssue(issueNumber: number): CommandJsonResult {
return cliJson(["gh", "issue", "read", String(issueNumber), "--repo", HWLAB_REPO, "--json", "title,body,state,updatedAt", "--raw"], 80_000);
}
@@ -425,7 +715,7 @@ function ensureDailyBriefIssue(date: string, dryRun: boolean): Record<string, un
}
function readPullRequest(number: number): CommandJsonResult {
return cliJson(["gh", "pr", "read", String(number), "--repo", HWLAB_REPO, "--json", "title,url,stateDetail,merged,mergedAt,mergeCommit,headRefName,baseRefName"], 80_000);
return cliJson(["gh", "pr", "read", String(number), "--repo", HWLAB_REPO, "--json", "title,body,url,stateDetail,merged,mergedAt,mergeCommit,headRefName,baseRefName"], 80_000);
}
function readPullRequestFiles(number: number): CommandJsonResult {
@@ -457,7 +747,7 @@ function currentGitopsRevision(): string | null {
return statusText(argo).split(/\r?\n/u)[0] ?? null;
}
function rolloutRecordBody(input: {
export function rolloutRecordBody(input: {
pr: OpenPullRequest;
prData: Record<string, unknown>;
fileSummary: Record<string, unknown>;
@@ -468,15 +758,19 @@ function rolloutRecordBody(input: {
pipelineSucceededAt: string | null;
finishedAt: string | null;
rollout: Record<string, unknown>;
ciMetrics?: CiPipelineMetrics | null;
}): string {
const now = beijingParts();
const title = String(record(input.prData.json).title ?? input.pr.title ?? `PR #${input.pr.number}`);
const prBody = String(record(input.prData.json).body ?? record(input.prData.pullRequest).body ?? "");
const url = String(record(input.prData.json).url ?? input.pr.url ?? `https://github.com/pikasTech/HWLAB/pull/${input.pr.number}`);
const summary = record(input.fileSummary.summary);
const keyFiles = Array.isArray(input.fileSummary.keyFiles) ? input.fileSummary.keyFiles.map(String) : [];
const semanticBullets = semanticChangelogBullets(title, prBody, input.fileSummary);
const mergeToPipeline = durationSeconds(input.mergedAt, input.pipelineSucceededAt);
const pipelineToDev = durationSeconds(input.pipelineSucceededAt, input.finishedAt);
const mergeToDev = durationSeconds(input.mergedAt, input.finishedAt);
const metrics = input.ciMetrics ?? ciMetricsFromValue(input.rollout.ciMetrics);
return [
"",
"",
@@ -492,8 +786,10 @@ function rolloutRecordBody(input: {
` - merge -> pipeline succeeded: ${formatDuration(mergeToPipeline)}`,
` - pipeline succeeded -> DEV Healthy: ${formatDuration(pipelineToDev)}`,
` - merge -> DEV Healthy: ${formatDuration(mergeToDev)}`,
"- 上线 changelog:",
` - ${title}`,
...ciMetricLines(metrics),
"- 上线 changelog(语义化):",
...semanticBullets.map((bullet) => ` - ${bullet}`),
"- 自动 diff 摘要:",
` - changed files: ${String(summary.files ?? "n/a")}; +${String(summary.additions ?? "n/a")} / -${String(summary.deletions ?? "n/a")}; commits: ${String(summary.commits ?? "n/a")}`,
...keyFiles.map((file) => ` - ${file}`),
"- DEV 验证:",
@@ -530,6 +826,7 @@ function appendRolloutBrief(options: G14RecordRolloutOptions, rollout: Record<st
const mergedAt = options.mergedAt ?? prMergedAt;
const pipelineSucceededAt = options.pipelineSucceededAt ?? rolloutPipelineSucceededAt;
const finishedAt = options.finishedAt ?? rolloutFinishedAt;
const ciMetrics = ciMetricsFromValue(rollout.ciMetrics) ?? collectPipelineMetrics(pipelineRun);
const pr: OpenPullRequest = {
number: options.prNumber,
title: String(record(prData.json).title ?? ""),
@@ -537,7 +834,7 @@ function appendRolloutBrief(options: G14RecordRolloutOptions, rollout: Record<st
baseRefName: String(record(prData.json).baseRefName ?? ""),
headRefName: String(record(prData.json).headRefName ?? ""),
};
const body = rolloutRecordBody({ pr, prData, fileSummary: files, sourceCommit, pipelineRun, gitopsRevision, mergedAt, pipelineSucceededAt, finishedAt, rollout });
const body = rolloutRecordBody({ pr, prData, fileSummary: files, sourceCommit, pipelineRun, gitopsRevision, mergedAt, pipelineSucceededAt, finishedAt, rollout, ciMetrics });
if (Number(brief.number) <= 0 || options.dryRun) {
return { ok: true, dryRun: true, date: now.date, brief, wouldAppend: { bodyPreview: body.slice(0, 1000), bodyChars: body.length }, ensured };
}
@@ -550,7 +847,7 @@ function appendRolloutBrief(options: G14RecordRolloutOptions, rollout: Record<st
const bodyPath = writeStateFile(`rollout-pr-${options.prNumber}-${shortSha(sourceCommit)}.md`, `${body}\n`);
const update = cliJson(["gh", "issue", "update", String(brief.number), "--repo", HWLAB_REPO, "--mode", "append", "--body-file", bodyPath, "--body-profile", "commander-brief"], 100_000);
if (!isCommandSuccess(update)) return { ok: false, phase: "append-rollout-brief", update, bodyPath, brief, ensured };
return { ok: true, date: now.date, brief, sourceCommit, pipelineRun, gitopsRevision, bodyPath, update: commandData(update), ensured };
return { ok: true, date: now.date, brief, sourceCommit, pipelineRun, gitopsRevision, ciMetrics, bodyPath, update: commandData(update), ensured };
}
async function waitForG14Dev(sourceCommit: string, timeoutSeconds: number): Promise<Record<string, unknown>> {
@@ -586,7 +883,9 @@ async function waitForG14Dev(sourceCommit: string, timeoutSeconds: number): Prom
healthOk = health.ok && /"status"\s*:\s*"ok"/u.test(health.stdout);
if (synced && healthy && record(workloadsReady).ready === true && healthOk) {
const finishedAt = new Date().toISOString();
return { ok: true, sourceCommit, gitopsRevision, pipelineRun: `hwlab-g14-ci-poll-${shortSha(sourceCommit)}`, pipelineText, argoText, startedAt, pipelineSucceededAt, finishedAt, workloadsReady, healthOk };
const pipelineRun = `hwlab-g14-ci-poll-${shortSha(sourceCommit)}`;
const ciMetrics = collectPipelineMetrics(pipelineRun);
return { ok: true, sourceCommit, gitopsRevision, pipelineRun, pipelineText, argoText, startedAt, pipelineSucceededAt, finishedAt, workloadsReady, healthOk, ciMetrics };
}
await sleep(30_000);
}