From 18f49922ba089eb8f255cdb1f6a4854bf5d188d7 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 27 May 2026 09:24:42 +0000 Subject: [PATCH] feat: enrich hwlab rollout briefs --- docs/reference/cli.md | 3 +- scripts/cli.ts | 6 + scripts/gh-route-contract-test.ts | 66 ++++ scripts/hwlab-g14-contract-test.ts | 101 ++++- scripts/src/gh-route.ts | 593 +++++++++++++++++++++++++++++ scripts/src/help.ts | 1 + scripts/src/hwlab-g14.ts | 313 ++++++++++++++- 7 files changed, 1074 insertions(+), 9 deletions(-) create mode 100644 scripts/gh-route-contract-test.ts create mode 100644 scripts/src/gh-route.ts diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 2d6e0cf0..2c843f75 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -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 '' --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 PR,ready 时合并,然后通过 UniDesk `ssh G14:k3s` 观察 `hwlab-g14-ci-poll-`、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 --source-commit ` 手动补记。状态指针按用途分离:长期监控只写 `.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 PR,ready 时合并,然后通过 UniDesk `ssh G14:k3s` 观察 `hwlab-g14-ci-poll-`、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 --source-commit ` 手动补记,手动补记同样会按 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//` 和本地 `.state/hwlab-cd//` task dump,stdout 只返回有界摘要。默认 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 测试授权分级。 diff --git a/scripts/cli.ts b/scripts/cli.ts index 08b6c327..7fe13841 100644 --- a/scripts/cli.ts +++ b/scripts/cli.ts @@ -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 { return; } + if (top === "ssh" && isGhContentRoute(sub)) { + process.exitCode = await runGhContentRoute(sub ?? "", args.slice(2)); + return; + } + if (top === "check" && isHelpToken(sub)) { emitJson(commandName, checkHelp()); return; diff --git a/scripts/gh-route-contract-test.ts b/scripts/gh-route-contract-test.ts new file mode 100644 index 00000000..29ed9c94 --- /dev/null +++ b/scripts/gh-route-contract-test.ts @@ -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)); diff --git a/scripts/hwlab-g14-contract-test.ts b/scripts/hwlab-g14-contract-test.ts index dda13ab5..f89cdc92 100644 --- a/scripts/hwlab-g14-contract-test.ts +++ b/scripts/hwlab-g14-contract-test.ts @@ -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)); diff --git a/scripts/src/gh-route.ts b/scripts/src/gh-route.ts new file mode 100644 index 00000000..f270d090 --- /dev/null +++ b/scripts/src/gh-route.ts @@ -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; +} + +function record(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : {}; +} + +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 = {}; + 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 { + return record(expandedParsedRoot(result).data); +} + +function expandedParsedRoot(result: CommandResult): Record { + 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, full: boolean): Record { + 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, full: boolean): Record { + 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>): 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 { + 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(); + private readonly temp = new Map(); + + 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 { + 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 { + 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; +} diff --git a/scripts/src/help.ts b/scripts/src/help.ts index 1556f63d..4f2d6750 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -21,6 +21,7 @@ export function rootHelp(): unknown { { command: "server rebuild ", 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 [--master-server URL] [--up] [--force] | provider triage [--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 [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 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 upload | ssh download ", description: "Transfer whole files through SSH passthrough with remote temp files, byte-count checks, SHA-256 verification, and client-side chunk fallback." }, { command: "ssh 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." }, diff --git a/scripts/src/hwlab-g14.ts b/scripts/src/hwlab-g14.ts index 4f5ae952..35ccf547 100644 --- a/scripts/src/hwlab-g14.ts +++ b/scripts/src/hwlab-g14.ts @@ -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): 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 { + const values: Record = {}; + 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 = { + feat: "功能", + fix: "修复", + docs: "文档", + test: "测试", + refactor: "重构", + perf: "性能", + ci: "CI/CD", + build: "构建", + chore: "维护", + }; + return `${labels[kind] ?? "变更"}:${subject}`; +} + +function fileHeuristicBullets(fileSummary: Record): 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[] { + 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; fileSummary: Record; @@ -468,15 +758,19 @@ function rolloutRecordBody(input: { pipelineSucceededAt: string | null; finishedAt: string | null; rollout: Record; + 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> { @@ -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); }