feat: automate HWLAB G14 PR rollout monitoring

This commit is contained in:
Codex
2026-05-27 06:35:01 +00:00
parent 27ed8a261d
commit 6802fad158
11 changed files with 867 additions and 67 deletions
+3 -1
View File
@@ -82,6 +82,7 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文
## Critical GitHub Issue Write Rule
- P0: 对 GitHub issue/PR 做正式写入时必须优先使用 `bun scripts/cli.ts gh ...`;禁止用原生 `gh issue edit/create/comment` 直接写 UniDesk/HWLAB 长期看板、指挥简报或用户反馈 issue。事故和 CLI 补强需求见 [pikasTech/unidesk#142](https://github.com/pikasTech/unidesk/issues/142)。
- P0: GitHub PR/issue 读写、PR 合并、评论、状态观察和收口动作必须走 UniDesk `gh` 子命令;禁止绕过为原生 `gh`、手写 `curl`/GraphQL/REST 请求或临时脚本直连 GitHub。若 `bun scripts/cli.ts gh ...` 不顺手、字段不够、merge 不支持或可见性不足,必须先改进 UniDesk `gh` 子命令并用它完成任务,不能跳过该入口。
- #20、HWLAB #7 和指挥简报类正文不得使用原生 `gh issue edit --body-file -`、shell 管道 stdin 或无 guard 的整篇替换。当前 CLI 局部替换能力未完成前,必须先 dry-run、保留 before body、确认 body guard,再写入。
## Critical Git / Multi-Repo Sync Rule
@@ -141,8 +142,9 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文
- `bun scripts/cli.ts dev-env validate [--manifest path] [--kubectl-dry-run]` / `dev-env prewarm-images`:离线校验 D601 `unidesk-dev` 生产隔离护栏和 dev workload manifests,或把开发底座基础镜像预热到 D601 原生 k3s containerd,规则见 `docs/reference/deploy.md``docs/reference/microservices.md`
- `bun scripts/cli.ts artifact-registry plan|render|status|health|install|deploy-backend-core|deploy-service`:管理 D601 host-managed CNCF Distribution registry,并通过短生命周期 relay 或 D601 pull/import 做 commit-pinned pull-only artifact CD`deploy-backend-core` 是 deprecated 兼容名,`findjob`/`pipeline` 支持 D601 direct dev/prod`met-nonlinear``k3sctl-adapter` 只给受限计划路径,`code-queue` 只支持 dev,规则见 `docs/reference/artifact-registry.md`
- `bun scripts/cli.ts auth-broker contract|health --dry-run|credential-request --dry-run|pr-preflight --dry-run`:查看 Auth Broker P0 Rust skeleton 与 CLI adapter contractrunner 无 `GH_TOKEN`/`GITHUB_TOKEN` 时返回结构化 `auth-missing`/`broker-needed`,不读取或打印 token 值,规则见 `docs/reference/auth-broker.md`
- `bun scripts/cli.ts gh preflight|auth status|issue ...|pr list|files|diff --stat|read|view|preflight|closeout|create|edit|update|comment` / `bun scripts/code-queue-pr-preflight-example.ts`:通过 REST 执行安全 GitHub issue 读写、脱敏 auth/status 诊断、body-file Markdown 写入、当日滚动简报时间线 ClaudeQQ 通知、escape 扫描、只读 cleanup-plan 和 #20 board-audit、PR changed-file/stat summary、PR 创建/评论 dry-run、REST-only 低噪声 PR title/body 编辑、PR 收口元数据观察(含 merged/closed 区分与 merge commit)、低噪声 PR 收口 preflight 与 runner PR preflight`gh issue/pr read|view` 支持 `owner/repo#number` shorthand`--raw|--full` 是显式完整披露别名,`gh pr diff` 仅支持 `--stat` 紧凑 JSON`gh pr merge` 当前仍结构化拒绝但普通 PR 可按任务边界用 repo-owned GitHub 路径收口,规则见 `docs/reference/cli.md``docs/reference/code-queue-supervision.md`
- `bun scripts/cli.ts gh preflight|auth status|issue ...|pr list|files|diff --stat|read|view|preflight|closeout|create|edit|update|comment|merge` / `bun scripts/code-queue-pr-preflight-example.ts`:通过 REST 执行安全 GitHub issue 读写、脱敏 auth/status 诊断、body-file Markdown 写入、当日滚动简报时间线 ClaudeQQ 通知、escape 扫描、只读 cleanup-plan 和 #20 board-audit、PR changed-file/stat summary、PR 创建/评论 dry-run、REST-only 低噪声 PR title/body 编辑、PR 收口元数据观察(含 merged/closed 区分与 merge commit)、低噪声 PR 收口 preflight、guarded PR merge 与 runner PR preflight`gh issue/pr read|view` 支持 `owner/repo#number` shorthand`--raw|--full` 是显式完整披露别名,`gh pr diff` 仅支持 `--stat` 紧凑 JSON`gh pr merge` 会先执行 closeout 预检并拒绝非 open、draft、冲突、非 CLEAN、失败或 pending checks 的 PR,规则见 `docs/reference/cli.md``docs/reference/code-queue-supervision.md`
- `bun scripts/cli.ts commander contract|plan --dry-run|smoke --dry-run|approval request --dry-run|prompt-lint --kind gpt55-pr`:查看 host Codex 指挥官直管微服务 skeleton 的 source/contract、无 daemon smoke 验证计划、.state/commander/ 状态模型、trace summary 聚合、ClaudeQQ 高风险请示草案和 GPT-5.5 PR prompt 边界辅助 lint;当前只返回 dry-run 计划和 backend-core `microservice proxy claudeqq` 授权后候选命令,不接 live bridge、不接管人工指挥官,不发送消息,`prompt-lint` 不作为业务 PR 门禁也不改变 `codex submit` 默认行为,规则见 `docs/reference/host-codex-commander.md`
- `bun scripts/cli.ts hwlab g14 monitor-prs`:一行启动异步监控 HWLAB base=G14 的未合并 PR;可合并时走 UniDesk `gh pr merge` 合并、监控 G14 Tekton/GitOps/Argo DEV rollout,并向 #7 索引的北京日期每日简报追加 CI/CD 耗时与上线 changelog,规则见 `docs/reference/g14.md``docs/reference/cli.md`
- `bun scripts/cli.ts hwlab cd audit --env dev` / `status|preflight|apply --dry-run`:旧 D601 HWLAB DEV CD 指挥侧 wrapper,仅用于显式 legacy 诊断和迁移对照;当前 HWLAB DEV/PROD source/runtime truth 已迁到 G14 `/root/hwlab` 与 G14 k3s/GitOps,规则见 `docs/reference/hwlab.md`
- `bun scripts/cli.ts ci install/status/run/publish-backend-core/publish-user-service/run-dev-e2e/logs`:在 D601 原生 k3s 上安装和运行 Tekton CI,支持每 commit 检查、Code Queue 只读性能门禁、`CI.json` catalog 驱动的 backend-core 与 user-service commit-pinned 镜像发布和手动触发的 `origin/master:deploy.json#environments.dev` 临时 namespace e2ecatalog/producer/consumer 分工见 `docs/reference/cicd-standardization.md``run-dev-e2e` 的 Git 控制 runner、短 launcher 和 no-CD 边界见 `docs/reference/dev-ci-runner.md`Tekton 规则见 `docs/reference/ci.md`
- `bun scripts/cli.ts codex deploy <commitId>`:旧 Code Queue 兼容部署入口已禁用,原因是它会绕过受控部署边界直连 D601 部署 Code Queue;规则见 `docs/reference/codex-deploy.md`
File diff suppressed because one or more lines are too long
+8
View File
@@ -24,6 +24,7 @@ import { runCommanderCommand } from "./src/commander";
import { isHelpToken, rootHelp, serverHelp, sshHelp, staticNamespaceHelp } from "./src/help";
import { runServerCleanupCommand } from "./src/server-cleanup";
import { runHwlabCdCommand } from "./src/hwlab-cd";
import { runHwlabG14Command } from "./src/hwlab-g14";
const remoteOptions = extractRemoteCliOptions(process.argv.slice(2));
const args = remoteOptions.args;
@@ -281,6 +282,13 @@ async function main(): Promise<void> {
}
if (top === "hwlab") {
if (sub === "g14") {
const result = await runHwlabG14Command(readConfig(), args.slice(2));
const ok = (result as { ok?: unknown }).ok !== false;
emitJson(commandName, result, ok);
if (!ok) process.exitCode = 1;
return;
}
const result = await runHwlabCdCommand(args.slice(1));
const ok = (result as { ok?: unknown }).ok !== false;
emitJson(commandName, result, ok);
@@ -109,12 +109,12 @@ function remoteControlPlaneResult(overrides: Partial<JsonRecord> = {}): JsonReco
preflightCreatesPr: false,
preflightMergesPr: false,
},
unsupportedMergeBoundary: {
supported: false,
mergeBoundary: {
supported: true,
command: "bun scripts/cli.ts gh pr merge <number> --repo pikasTech/unidesk",
degradedReason: "unsupported-command",
runnerDisposition: "business-failed",
note: "UniDesk CLI intentionally does not merge PRs in this phase; runner handoff stops at PR creation and evidence.",
preflightRequired: true,
dryRunCommand: "bun scripts/cli.ts gh pr merge <number> --repo pikasTech/unidesk --dry-run",
note: "UniDesk CLI can merge PRs only after explicit task authorization and a ready closeout preflight; runner handoff still starts with PR creation and evidence.",
},
},
controlPlane: {
@@ -395,7 +395,7 @@ async function main(): Promise<void> {
pushDryRun: { requested: false, ref: "refs/heads/probe/code-queue-pr-capability-dryrun", writesRemote: false, commandShape: "git push --dry-run origin HEAD:refs/heads/probe/code-queue-pr-capability-dryrun" },
prCreateDryRun: { requested: false, headBranch: "feature/code-queue-pr-preflight", writesRemote: false, commandShape: "bun scripts/cli.ts gh pr create --repo pikasTech/unidesk --base master --head feature/code-queue-pr-preflight --dry-run" },
expectedPrHandoff: { sourceBranch: "feature/code-queue-pr-preflight", targetBranch: "master", runnerCreatesPrAfterAuthorization: true, commanderReviewsAndMerges: true, preflightCreatesPr: false, preflightMergesPr: false },
unsupportedMergeBoundary: { supported: false, command: "bun scripts/cli.ts gh pr merge <number> --repo pikasTech/unidesk", degradedReason: "unsupported-command", runnerDisposition: "business-failed", note: "UniDesk CLI intentionally does not merge PRs in this phase; runner handoff stops at PR creation and evidence." },
mergeBoundary: { supported: true, command: "bun scripts/cli.ts gh pr merge <number> --repo pikasTech/unidesk", preflightRequired: true, dryRunCommand: "bun scripts/cli.ts gh pr merge <number> --repo pikasTech/unidesk --dry-run", note: "UniDesk CLI can merge PRs only after explicit task authorization and a ready closeout preflight; runner handoff still starts with PR creation and evidence." },
},
}),
});
+22 -14
View File
@@ -161,6 +161,11 @@ async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockReque
sendJson(res, 200, pullRequest);
return;
}
if (req.method === "PUT" && req.url === "/repos/pikasTech/unidesk/pulls/42/merge") {
const parsed = JSON.parse(body) as JsonRecord;
sendJson(res, 200, { sha: "merged-by-rest-sha", merged: true, message: `merged via ${String(parsed.merge_method ?? "merge")}` });
return;
}
if (req.method === "GET" && req.url === "/repos/pikasTech/HWLAB/pulls/7") {
sendJson(res, 200, shorthandPullRequest);
return;
@@ -438,7 +443,7 @@ export async function runGhCliPrContract(): Promise<JsonRecord> {
const closeoutMergeBoundary = closeoutMetadata.mergeBoundary as JsonRecord;
assertCondition(closeoutMetadata.ok === true && closeoutMetadata.source === "github-graphql", "pr view closeout metadata should report GraphQL source", closeoutMetadata);
assertCondition(Array.isArray(closeoutMetadata.missingOrUnknownFields) && closeoutMetadata.missingOrUnknownFields.length === 0, "known closeout metadata should have no missing/unknown fields", closeoutMetadata);
assertCondition(closeoutMergeBoundary.unideskCliMergeSupported === false, "closeout metadata should keep UniDesk CLI merge unsupported", closeoutMetadata);
assertCondition(closeoutMergeBoundary.unideskCliMergeSupported === true, "closeout metadata should expose guarded UniDesk CLI merge support", closeoutMetadata);
assertCondition(mock.requests.some((request) => request.method === "POST" && request.url === "/graphql"), "closeout metadata should use GitHub GraphQL when requested", mock.requests);
const unknownCloseout = await runCli(["gh", "pr", "view", "44", "--repo", "pikasTech/unidesk", "--json", "headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup"], env);
@@ -504,7 +509,7 @@ export async function runGhCliPrContract(): Promise<JsonRecord> {
assertCondition(preflightStatus.state === "SUCCESS" && preflightStatus.rawOmitted === true, "pr preflight default status rollup should be compact", preflightStatus);
assertCondition(preflightCounts.success === 2, "pr preflight should count successful contexts", preflightStatus);
const policy = closeoutPreflightData.policy as JsonRecord;
assertCondition(policy.mergesPr === false && policy.mergeCommandSupported === false && policy.unideskCliMergeSupported === false, "pr preflight policy should block UniDesk CLI merge execution", policy);
assertCondition(policy.mergesPr === false && policy.mergeCommandSupported === true && policy.unideskCliMergeSupported === true, "pr preflight policy should expose guarded UniDesk CLI merge execution", policy);
const aliasPreflight = await runCli(["gh", "preflight", "42", "--repo", "pikasTech/unidesk"], env);
assertCondition(aliasPreflight.status === 0, "top-level gh preflight alias should succeed", aliasPreflight.json ?? { stdout: aliasPreflight.stdout });
@@ -519,6 +524,19 @@ export async function runGhCliPrContract(): Promise<JsonRecord> {
assertCondition(fullStatus.rawOmitted === false && Array.isArray(fullStatus.contexts), "pr preflight --full should include status contexts", fullStatus);
assertCondition(typeof fullPreflightData.raw === "object" && fullPreflightData.raw !== null, "pr preflight --full should include raw read payload summary", fullPreflightData);
const mergeDryRun = await runCli(["gh", "pr", "merge", "42", "--repo", "pikasTech/unidesk", "--dry-run"], env);
assertCondition(mergeDryRun.status === 0, "pr merge dry-run should expose a guarded merge plan", mergeDryRun.json ?? { stdout: mergeDryRun.stdout });
const mergeDryRunData = dataOf(mergeDryRun.json ?? {});
assertCondition(mergeDryRunData.wouldMerge === true && mergeDryRunData.method === "merge", "merge dry-run should not write but should plan merge", mergeDryRunData);
const mergeActual = await runCli(["gh", "pr", "merge", "42", "--repo", "pikasTech/unidesk", "--squash"], env);
assertCondition(mergeActual.status === 0, "pr merge should use guarded REST merge when preflight is ready", mergeActual.json ?? { stdout: mergeActual.stdout });
const mergeData = dataOf(mergeActual.json ?? {});
assertCondition(mergeData.method === "squash" && mergeData.rest === true, "merge result should report REST merge method", mergeData);
const mergeRequest = mock.requests.find((request) => request.method === "PUT" && request.url === "/repos/pikasTech/unidesk/pulls/42/merge");
assertCondition(mergeRequest !== undefined, "pr merge should call GitHub REST merge endpoint", mock.requests);
const mergePayload = JSON.parse(mergeRequest?.body ?? "{}") as JsonRecord;
assertCondition(mergePayload.merge_method === "squash", "pr merge should pass selected merge method", mergePayload);
const preflight = await runBun([
"scripts/code-queue-pr-preflight-example.ts",
"--repo",
@@ -545,7 +563,7 @@ export async function runGhCliPrContract(): Promise<JsonRecord> {
assertCondition(preflightComment.ok === true && preflightComment.dryRun === true && preflightComment.planned === true, "PR preflight comment must stay dry-run", preflightComment);
assertCondition(mock.requests.some((request) => request.method === "GET" && request.url === "/rate_limit"), "PR preflight should probe REST egress", mock.requests);
assertCondition(mock.requests.some((request) => request.method === "GET" && request.url === "/repos/pikasTech/unidesk"), "PR preflight should probe repo visibility", mock.requests);
assertCondition(mock.requests.every((request) => request.method === "GET" || request.method === "POST" && request.url === "/graphql"), "initial mock phase should remain read-only except GraphQL metadata reads", mock.requests);
assertCondition(mock.requests.some((request) => request.method === "PUT" && request.url === "/repos/pikasTech/unidesk/pulls/42/merge"), "initial mock phase should include the guarded REST merge write", mock.requests);
} finally {
await mock.close();
}
@@ -681,16 +699,6 @@ export async function runGhCliPrContract(): Promise<JsonRecord> {
await mock2.close();
}
const mergeBlocked = await runCli(["gh", "pr", "merge", "42", "--repo", "pikasTech/unidesk"]);
assertCondition(mergeBlocked.status !== 0, "pr merge should fail", mergeBlocked.json ?? { stdout: mergeBlocked.stdout });
const mergeData = mergeBlocked.json?.data as JsonRecord | undefined;
assertCondition(String(mergeData?.message ?? "").includes("intentionally unsupported"), "merge block message should be explicit", mergeData ?? {});
assertCondition(mergeData?.runnerDisposition === "business-failed", "merge block should classify as business-failed", mergeData ?? {});
const closeoutBoundary = mergeData?.closeoutBoundary as JsonRecord | undefined;
assertCondition(closeoutBoundary?.ordinaryRunnerFinalActionAllowed === true, "merge block should preserve ordinary runner PR closeout policy", closeoutBoundary ?? {});
assertCondition(closeoutBoundary?.unideskCliMergeSupported === false, "merge block should state UniDesk REST CLI merge remains unsupported", closeoutBoundary ?? {});
assertCondition(String(closeoutBoundary?.readOnlyCloseoutCommand ?? "").includes("gh pr view 42"), "merge block should point to read-only closeout command", closeoutBoundary ?? {});
const deleteBlocked = await runCli(["gh", "pr", "delete", "42", "--repo", "pikasTech/unidesk"]);
assertCondition(deleteBlocked.status !== 0, "pr hard delete should fail", deleteBlocked.json ?? { stdout: deleteBlocked.stdout });
const deleteData = deleteBlocked.json?.data as JsonRecord | undefined;
@@ -735,7 +743,7 @@ export async function runGhCliPrContract(): Promise<JsonRecord> {
"pr edit supports --body-file - stdin without echoing full body",
"pr update append and close/reopen are available",
"pr comment create/delete follows CRUD shape and --body remains supported",
"pr merge is blocked",
"pr merge is guarded by preflight and uses REST",
"pr hard delete is blocked",
"pr create validation failures are structured",
"unknown gh options are structured",
@@ -74,7 +74,7 @@ assertCondition(asRecord(plan.issueEntries, "issueEntries").mutation === false,
const prCloseout = asRecord(plan.prCloseout, "prCloseout");
assertCondition(prCloseout.mutation === false, "PR closeout plan must be non-mutating", prCloseout);
assertCondition(asRecord(prCloseout.runnerBoundary, "runnerBoundary").maySelfCloseOrMergeOrdinaryPrWithinTaskBoundary === true, "ordinary PR runner self-close/merge boundary must be explicit", prCloseout);
assertCondition(asRecord(prCloseout.unideskCliBoundary, "unideskCliBoundary").mergeSupported === false, "UniDesk REST gh pr merge must remain unsupported", prCloseout);
assertCondition(asRecord(prCloseout.unideskCliBoundary, "unideskCliBoundary").mergeSupported === true, "UniDesk REST gh pr merge must be guarded and supported", prCloseout);
assertCondition(asRecord(plan.claudeqqApproval, "claudeqqApproval").mutation === false, "approval plan must be non-mutating", plan);
const planWithoutDryRun = dataOf(runCli(["commander", "plan"], 1));
+8 -8
View File
@@ -7040,7 +7040,7 @@ function compactPrPreflightCommanderView(record: Record<string, unknown>, option
const unideskGhCli = asRecord(capability?.unideskGhCli);
const pushDryRun = asRecord(capability?.pushDryRun);
const prCreateDryRun = asRecord(capability?.prCreateDryRun);
const unsupportedMergeBoundary = asRecord(capability?.unsupportedMergeBoundary);
const mergeBoundary = asRecord(capability?.mergeBoundary) ?? asRecord(capability?.unsupportedMergeBoundary);
const commands = prPreflightCommandSet(record, options);
const schedulerAuthObserved = tokenCoverage !== null;
const schedulerAuthReady = tokenCoverage?.ok === true;
@@ -7123,8 +7123,8 @@ function compactPrPreflightCommanderView(record: Record<string, unknown>, option
writesRemote: prCreateDryRun.writesRemote ?? false,
commandShape: prCreateDryRun.commandShape ?? null,
},
mergeSupported: unsupportedMergeBoundary?.supported ?? false,
mergeCommand: unsupportedMergeBoundary?.command ?? "bun scripts/cli.ts gh pr merge <number> --repo pikasTech/unidesk",
mergeSupported: mergeBoundary?.supported ?? false,
mergeCommand: mergeBoundary?.command ?? "bun scripts/cli.ts gh pr merge <number> --repo pikasTech/unidesk",
},
authScopeSummary: record.authScopeSummary ?? prPreflightAuthScopeSummary(tokenCoverage, activeRunnerDevContainer),
scopeBoundary: record.scopeBoundary ?? prPreflightScopeBoundary(tokenCoverage),
@@ -7542,12 +7542,12 @@ function compactPrRuntimePreflight(preflight: Record<string, unknown>, options:
preflightCreatesPr: false,
preflightMergesPr: false,
},
unsupportedMergeBoundary: {
supported: false,
mergeBoundary: {
supported: true,
command: "bun scripts/cli.ts gh pr merge <number> --repo pikasTech/unidesk",
degradedReason: "unsupported-command",
runnerDisposition: "business-failed",
note: "UniDesk CLI intentionally does not merge PRs in this phase; runner handoff stops at PR creation and evidence.",
preflightRequired: true,
dryRunCommand: "bun scripts/cli.ts gh pr merge <number> --repo pikasTech/unidesk --dry-run",
note: "UniDesk CLI can merge PRs only after explicit task authorization and a ready closeout preflight; runner handoff still starts with PR creation and evidence.",
},
},
controlPlane: {
+5 -4
View File
@@ -171,18 +171,19 @@ function prCloseoutPlan(): Record<string, unknown> {
ordinaryRunnerAllowed: true,
hostCommanderAllowedAfterReview: true,
tools: [
"system gh pr merge <number> --repo pikasTech/unidesk",
"bun scripts/cli.ts gh pr merge <number> --repo pikasTech/unidesk",
"GitHub UI merge/close controls",
"bun scripts/cli.ts gh pr close <number> --repo pikasTech/unidesk",
],
sourceMergeClosePolicy: "Use repo-owned, auditable GitHub paths; do not directly push target branches as a merge substitute.",
},
unideskCliBoundary: {
mergeSupported: false,
mergeSupported: true,
closeSupported: true,
command: "bun scripts/cli.ts gh pr merge <number> --repo pikasTech/unidesk",
degradedReason: "unsupported-command",
automatedMergeImplemented: false,
dryRunCommand: "bun scripts/cli.ts gh pr merge <number> --repo pikasTech/unidesk --dry-run",
preflightRequired: true,
automatedMergeImplemented: true,
},
};
}
+115 -25
View File
@@ -21,7 +21,7 @@ const BOARD_ROW_FIELDS = ["progress", "status", "validation", "branch", "tasks",
const BOARD_ROW_UPSERT_TEXT_FIELDS = ["category", "branch", "tasks", "summary", "focus", "validation", "progress"] as const;
const ISSUE_VIEW_JSON_FIELDS = ["body", "title", "state", "comments", "number", "url", "author", "createdAt", "updatedAt"] as const;
const ISSUE_LIST_JSON_FIELDS = ["number", "title", "state", "url", "updatedAt", "createdAt", "author", "labels"] as const;
const PR_LIST_JSON_FIELDS = ["body", "title", "state", "number", "url", "author", "head", "base", "draft", "createdAt", "updatedAt"] as const;
const PR_LIST_JSON_FIELDS = ["body", "title", "state", "number", "url", "author", "head", "base", "draft", "createdAt", "updatedAt", "headRefName", "baseRefName"] as const;
const PR_READ_JSON_FIELDS = ["body", "title", "state", "stateDetail", "number", "url", "author", "head", "base", "draft", "createdAt", "updatedAt", "closed", "closedAt", "merged", "mergedAt", "mergeCommit", "headRefName", "baseRefName", "mergeable", "mergeStateStatus", "statusCheckRollup"] as const;
const PR_CLOSEOUT_JSON_FIELDS = ["mergeable", "mergeStateStatus", "statusCheckRollup"] as const;
const PR_CLOSEOUT_VIEW_JSON = "headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup";
@@ -30,7 +30,7 @@ const BODY_UPDATE_MODES = ["replace", "append"] as const;
const BOARD_MUTATION_SECTIONS = ["open", "closed"] as const;
const BOARD_GITHUB_STATUSES = ["OPEN", "CLOSED"] as const;
const GH_VALUE_OPTIONS = new Set(["--repo", "--limit", "--board-issue", "--known-meta-issue", "--ignore-issue", "--title", "--body-file", "--body", "--base", "--head", "--json", "--state", "--mode", "--expect-updated-at", "--expect-body-sha", "--body-profile", "--label", "--field", "--value", "--section", "--to", "--status", "--row-file", "--category", "--branch", "--tasks", "--summary", "--focus", "--validation", "--progress", "--number"]);
const GH_FLAG_OPTIONS = new Set(["--dry-run", "--draft", "--notify-claudeqq-brief-diff", "--allow-short-body", "--raw", "--full", "--stat"]);
const GH_FLAG_OPTIONS = new Set(["--dry-run", "--draft", "--notify-claudeqq-brief-diff", "--allow-short-body", "--raw", "--full", "--stat", "--merge", "--squash", "--rebase", "--delete-branch"]);
const MIN_SAFE_BODY_SCAN_CHARS = MIN_SAFE_ISSUE_BODY_CHARS;
const ISSUE_SCAN_MAX_FINDINGS = 60;
const ISSUE_BODY_PROFILES = {
@@ -61,6 +61,7 @@ type PrReadJsonField = typeof PR_READ_JSON_FIELDS[number];
type IssueListState = typeof ISSUE_LIST_STATES[number];
type PrListState = typeof ISSUE_LIST_STATES[number];
type BodyUpdateMode = typeof BODY_UPDATE_MODES[number];
type PullRequestMergeMethod = "merge" | "squash" | "rebase";
type BoardMutationSection = typeof BOARD_MUTATION_SECTIONS[number];
type BoardGithubStatus = typeof BOARD_GITHUB_STATUSES[number];
type IssueBodyProfileName = keyof typeof ISSUE_BODY_PROFILES;
@@ -339,6 +340,8 @@ interface GitHubOptions {
boardMoveTo?: BoardMutationSection;
boardGithubStatus?: BoardGithubStatus;
boardRowUpsertValues: BoardRowUpsertValues;
mergeMethod: PullRequestMergeMethod;
deleteBranch: boolean;
}
interface GitHubShorthandReference {
@@ -411,7 +414,7 @@ interface GitHubPullRequest {
html_url: string;
draft?: boolean;
user?: { login?: string };
head?: { ref?: string; sha?: string };
head?: { ref?: string; sha?: string; repo?: { full_name?: string | null } | null };
base?: { ref?: string; sha?: string };
additions?: number;
deletions?: number;
@@ -643,6 +646,12 @@ function parseBodyUpdateMode(args: string[]): BodyUpdateMode {
throw new Error(`unsupported --mode ${raw}; supported modes: ${BODY_UPDATE_MODES.join(",")}`);
}
function parsePullRequestMergeMethod(args: string[]): PullRequestMergeMethod {
const selected = ["merge", "squash", "rebase"].filter((method) => hasFlag(args, `--${method}`));
if (selected.length > 1) throw new Error("choose only one PR merge method flag: --merge, --squash, or --rebase");
return (selected[0] ?? "merge") as PullRequestMergeMethod;
}
function parseIssueBodyProfile(args: string[]): IssueBodyProfileOption {
const raw = optionValue(args, "--body-profile") ?? "auto";
if (raw === "auto" || raw === "code-queue-board" || raw === "commander-brief") return raw;
@@ -772,6 +781,8 @@ function parseOptions(args: string[]): GitHubOptions {
boardMoveTo: parseBoardMutationSection(args, "--to"),
boardGithubStatus: parseBoardGithubStatus(args),
boardRowUpsertValues: parseBoardRowUpsertValues(args),
mergeMethod: parsePullRequestMergeMethod(args),
deleteBranch: hasFlag(args, "--delete-branch"),
};
}
@@ -4066,12 +4077,11 @@ function prMetadataSummary(metadata: GitHubPullRequestGraphqlMetadata): Record<s
function prMergeBoundary(): Record<string, unknown> {
return {
runnerAllowed: ["pr create", "pr update/edit", "pr comment", "pr read/view", "pr close"],
runnerAllowed: ["pr create", "pr update/edit", "pr comment", "pr read/view", "pr close", "pr merge after explicit command authorization and preflight success"],
ordinaryRunnerFinalActionAllowed: true,
commanderRequiredWhen: ["conflicts", "failed required checks", "production/runtime/release/security/database scope", "ambiguous task boundary"],
hostAllowedToolsAfterReview: ["system gh pr merge", "GitHub UI merge/close"],
unideskCliMergeSupported: false,
degradedReason: "unsupported-command",
hostAllowedToolsAfterReview: ["bun scripts/cli.ts gh pr merge", "GitHub UI merge/close"],
unideskCliMergeSupported: true,
};
}
@@ -4267,11 +4277,11 @@ function prPreflightPolicy(repo: string, number: number): Record<string, unknown
createsPr: false,
comments: false,
mergesPr: false,
mergeCommandSupported: false,
unsupportedMergeCommand: `bun scripts/cli.ts gh pr merge ${number} --repo ${repo}`,
unideskCliMergeSupported: false,
mergeCommandSupported: true,
mergeCommand: `bun scripts/cli.ts gh pr merge ${number} --repo ${repo} --merge`,
unideskCliMergeSupported: true,
ordinaryRunnerFinalActionAllowed: true,
note: "This preflight only reads GitHub auth, PR metadata, mergeability, and status checks; the UniDesk REST CLI still never merges PRs.",
note: "This preflight only reads GitHub auth, PR metadata, mergeability, and status checks; use gh pr merge only after this command reports ready and the task boundary allows merge.",
};
}
@@ -4343,11 +4353,95 @@ function prCloseoutSummary(
blockers,
pending,
commanderAction: readyForCommanderMerge
? "review and merge through a repo-owned GitHub path when task boundaries allow; UniDesk REST gh pr merge remains unsupported"
? "review and merge through bun scripts/cli.ts gh pr merge when task boundaries allow"
: "resolve blockers or rerun after GitHub finishes computing mergeability/status checks",
};
}
async function deleteHeadBranchAfterMerge(repo: string, token: string, pr: GitHubPullRequest): Promise<Record<string, unknown>> {
const { owner, name } = repoParts(repo);
const headRepo = pr.head?.repo?.full_name ?? null;
const headRef = pr.head?.ref ?? null;
if (headRepo !== repo || headRef === null || headRef.length === 0) {
return {
attempted: false,
skippedReason: "head-repo-differs-or-ref-missing",
headRepo,
headRef,
};
}
const encodedRef = encodeURIComponent(`heads/${headRef}`);
const deleted = await githubRequest<unknown>(token, "DELETE", `/repos/${owner}/${name}/git/refs/${encodedRef}`);
if (isGitHubError(deleted)) {
return {
attempted: true,
ok: false,
headRepo,
headRef,
error: deleted,
};
}
return {
attempted: true,
ok: true,
headRepo,
headRef,
};
}
async function prMerge(repo: string, token: string, number: number, options: GitHubOptions): Promise<GitHubCommandResult> {
const { owner, name } = repoParts(repo);
const pr = await githubRequest<GitHubPullRequest>(token, "GET", `/repos/${owner}/${name}/pulls/${number}`);
if (isGitHubError(pr)) return commandError("pr merge", repo, pr, { number, phase: "fetch-pr" });
const summary = prSummary(pr);
const metadata = await prGraphqlMetadata(repo, token, number);
if (isGitHubError(metadata)) return commandError("pr merge", repo, metadata, { number, phase: "fetch-pr-closeout-metadata", pullRequest: summary });
const statusChecks = statusRollupSummary(repo, number, metadata.statusCheckRollup, false);
const mergeability = prCloseoutSummary(summary, prMetadataSummary(metadata), statusChecks);
if (mergeability.readyForCommanderMerge !== true) {
return validationError("pr merge", repo, "PR is not ready for merge; preflight blockers or pending states remain", {
number,
pullRequest: preflightPullRequestSummary(summary),
mergeability,
statusChecks,
closeoutMetadata: prCloseoutMetadata(metadata),
});
}
if (options.dryRun) {
return {
ok: true,
command: "pr merge",
repo,
number,
dryRun: true,
wouldMerge: true,
method: options.mergeMethod,
deleteBranch: options.deleteBranch,
pullRequest: preflightPullRequestSummary(summary),
mergeability,
statusChecks,
};
}
const merged = await githubRequest<Record<string, unknown>>(token, "PUT", `/repos/${owner}/${name}/pulls/${number}/merge`, {
merge_method: options.mergeMethod,
});
if (isGitHubError(merged)) return commandError("pr merge", repo, merged, { number, phase: "merge", method: options.mergeMethod, pullRequest: summary });
const after = await githubRequest<GitHubPullRequest>(token, "GET", `/repos/${owner}/${name}/pulls/${number}`);
if (isGitHubError(after)) return commandError("pr merge", repo, after, { number, phase: "fetch-after-merge", mergeResult: merged });
const branchDeletion = options.deleteBranch ? await deleteHeadBranchAfterMerge(repo, token, after) : { attempted: false, skippedReason: "delete-branch-not-requested" };
return {
ok: true,
command: "pr merge",
repo,
number,
method: options.mergeMethod,
mergeResult: merged,
pullRequest: prSummary(after),
branchDeletion,
rest: true,
};
}
async function prPreflight(repo: string, number: number, commandName: "preflight" | "pr preflight" | "pr closeout", includeRaw: boolean): Promise<GitHubCommandResult> {
const auth = await authStatus(repo);
const authCapability = compactAuthCapability(auth);
@@ -5807,6 +5901,7 @@ export function ghHelp(): unknown {
"bun scripts/cli.ts gh pr comment create <number> --body-file <file>|--body <text> [--repo owner/name] [--dry-run]",
"bun scripts/cli.ts gh pr comment delete <commentId> [--repo owner/name] [--dry-run]",
"bun scripts/cli.ts gh pr close|reopen <number> [--repo owner/name] [--dry-run]",
"bun scripts/cli.ts gh pr merge <number> [--repo owner/name] [--merge|--squash|--rebase] [--delete-branch] [--dry-run]",
"bun scripts/cli.ts gh pr delete <number> [unsupported: use close]",
],
defaults: { repo: DEFAULT_REPO },
@@ -5842,7 +5937,7 @@ export function ghHelp(): unknown {
"PR read is the canonical read path; view remains a compatibility alias. PR read/view accept owner/repo#number shorthand and --number N as a compatibility alias for the positional PR number; shorthand derives --repo unless an explicit conflicting --repo is supplied, which fails structurally with suggested commands. PR read/view supports REST closeout fields stateDetail, closed, closedAt, merged, mergedAt, mergeCommit, headRefName, and baseRefName; mergeable, mergeStateStatus, and statusCheckRollup are fetched through GitHub GraphQL only when requested or when --raw/--full requests full disclosure, and closeoutMetadata makes GraphQL errors plus UNKNOWN/null metadata explicit.",
"PR list does not fetch mergeability or statusCheckRollup; request those closeout fields with gh pr view <number> --json headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup.",
"PR preflight is a low-noise read-only closeout helper. It combines redacted auth capability, PR branch/state metadata, mergeability, mergeStateStatus, compact status check counts, and the explicit UniDesk REST CLI no-merge policy. Use --full or --raw to include all fetched status contexts.",
"PR create/edit/update/comment are safe-write operations with dry-run planning; merge is intentionally unsupported in this phase.",
"PR merge is a guarded write operation: it first reads closeout metadata, refuses non-open/draft/conflicting/non-clean/failed/pending PRs, then uses GitHub REST merge. Use --dry-run to see the exact merge plan without writing.",
],
};
}
@@ -6141,20 +6236,15 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
return prState(options.repo, token, number, sub === "close" ? "closed" : "open", false);
}
if (sub === "merge") {
return unsupportedCommand(
"pr merge",
options.repo,
"PR merge is intentionally unsupported by the UniDesk REST CLI; PR-bound GPT-5.5 runners may self-close/merge ordinary in-boundary PRs after checks using repo-owned GitHub paths, while high-risk or ambiguous PRs stay commander-reviewed.",
{
closeoutBoundary: {
...prMergeBoundary(),
readOnlyCloseoutCommand: `bun scripts/cli.ts gh pr view ${third ?? "<number>"} --repo ${options.repo} --json ${PR_CLOSEOUT_VIEW_JSON}`,
},
},
);
const number = parseNumberForCommand(options.repo, third, "pr merge");
if (typeof number !== "number") return number;
const { token, probe } = resolveToken(true);
const missing = authRequired(options.repo, "pr merge", probe);
if (missing !== null || token === null) return missing ?? authRequired(options.repo, "pr merge", { present: false, source: null, ghFallbackAttempted: true });
return prMerge(options.repo, token, number, options);
}
if (sub !== "list" && !isPrReadCommand(sub)) {
return unsupportedCommand(`pr ${sub ?? ""}`.trim(), options.repo, "PR supported commands are list, files, diff --stat, read/view, preflight/closeout, create, update/edit, close, reopen, comment create/delete, and unsupported merge/delete.");
return unsupportedCommand(`pr ${sub ?? ""}`.trim(), options.repo, "PR supported commands are list, files, diff --stat, read/view, preflight/closeout, create, update/edit, close, reopen, merge, comment create/delete, and unsupported delete.");
}
if (sub === "read" || sub === "view") {
const resolved = resolveReadViewNumberReference("pr", sub, third, options, args);
+6 -3
View File
@@ -1,6 +1,7 @@
import { ghHelp } from "./gh";
import { authBrokerHelp } from "./auth-broker";
import { hwlabHelp } from "./hwlab-cd";
import { hwlabG14Help } from "./hwlab-g14";
export function rootHelp(): unknown {
return {
@@ -52,9 +53,10 @@ export function rootHelp(): unknown {
{ command: "dev-env validate|prewarm-images", description: "Validate D601 unidesk-dev guardrails or prewarm dev foundation images into native k3s containerd through a bounded async job." },
{ command: "artifact-registry plan|render|status|health|install|deploy-backend-core|deploy-service", description: "Manage the D601 host-managed CNCF Distribution registry and run pull-only artifact CD for supported services, including D601 direct, k3s-managed, and code-queue dev-only consumers." },
{ command: "auth-broker contract|health --dry-run|credential-request --dry-run|pr-preflight --dry-run", description: "Inspect the P0 Rust auth broker and CLI adapter contract without reading token values, writing GitHub, or starting services." },
{ command: "gh preflight|auth|issue|pr", description: "Run safe GitHub issue and PR CRUD/lifecycle operations through REST with body-file update replace/append, comment delete, token diagnostics, PR closeout preflight, hard delete unsupported, and merge blocked." },
{ command: "gh preflight|auth|issue|pr", description: "Run safe GitHub issue and PR CRUD/lifecycle operations through REST with body-file update replace/append, comment delete, token diagnostics, PR closeout preflight, hard delete unsupported, and guarded PR merge." },
{ command: "commander contract|plan --dry-run|smoke --dry-run|approval request --dry-run|prompt-lint --kind gpt55-pr", description: "Host Codex commander skeleton contract, no-daemon smoke plan, dry-run approval preview, and advisory GPT-5.5 PR prompt boundary lint without live bridges, message sends, or submit gating." },
{ command: "hwlab cd audit --env dev | hwlab cd status --env dev | hwlab cd apply --env dev --dry-run", description: "Bounded HWLAB DEV CD wrapper that calls HWLAB repo-owned scripts, forces D601 native k3s kubeconfig, refuses Docker Desktop control-plane signals, and exposes read-only post-recovery blocker classification." },
{ command: "hwlab g14 monitor-prs", description: "Start a fire-and-forget monitor that watches HWLAB PRs targeting G14, merges ready PRs through UniDesk gh, waits for G14 Tekton/GitOps/Argo DEV rollout, and appends the #7-indexed daily brief." },
{ command: "hwlab cd audit --env dev | hwlab cd status --env dev | hwlab cd apply --env dev --dry-run", description: "Legacy D601 HWLAB DEV CD wrapper kept for explicit old-path diagnostics; current HWLAB rollout uses G14 GitOps." },
{ command: "code-agent-sandbox", description: "Independent Code Agent Sandbox service skeleton for adapter, mode, and credential-boundary diagnostics." },
{ command: "schedule list|get|runs|run|retry-run|delete", description: "Manage backend-core scheduled tasks and run history; schedule run <id> supports --wait-ms N and retry-run reuses the failed run's schedule." },
{ command: "schedule upsert-pgdata-backup [--time HH:MM] [--remote-base /SERVER_DATA/UNIDESK_PG_DATA]", description: "Create or update the daily PGDATA physical backup task that uploads monthly rotated archives to Baidu Netdisk." },
@@ -520,7 +522,7 @@ function artifactRegistryHelp(): unknown {
}
export function staticNamespaceHelp(args: string[]): unknown | null {
const [top] = args;
const [top, sub] = args;
if (!args.slice(1).some(isHelpToken)) return null;
if (top === "config") return configHelp();
if (top === "microservice") return microserviceHelp();
@@ -537,6 +539,7 @@ export function staticNamespaceHelp(args: string[]): unknown | null {
if (top === "artifact-registry") return artifactRegistryHelp();
if (top === "auth-broker") return authBrokerHelp();
if (top === "gh") return ghHelp();
if (top === "hwlab" && sub === "g14") return hwlabG14Help();
if (top === "hwlab") return hwlabHelp();
return null;
}
+687
View File
@@ -0,0 +1,687 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { repoRoot, rootPath, type Config } from "./config";
import { runCommand } from "./command";
import { startJob } from "./jobs";
const HWLAB_REPO = "pikasTech/HWLAB";
const G14_SOURCE_BRANCH = "G14";
const G14_PROVIDER = "G14";
const G14_WORKSPACE = "/root/hwlab";
const DEV_NAMESPACE = "hwlab-dev";
const CI_NAMESPACE = "hwlab-ci";
const ARGO_NAMESPACE = "argocd";
const DEV_APP = "hwlab-g14-dev";
const DEFAULT_INTERVAL_SECONDS = 600;
const DEFAULT_MAX_CYCLES = 0;
const DEFAULT_TIMEOUT_SECONDS = 1800;
const G14_BRIEF_INDEX_ISSUE = 7;
const BEIJING_OFFSET_MS = 8 * 60 * 60 * 1000;
interface G14MonitorOptions {
intervalSeconds: number;
maxCycles: number;
once: boolean;
dryRun: boolean;
worker: boolean;
timeoutSeconds: number;
}
interface G14RecordRolloutOptions {
prNumber: number;
sourceCommit?: string;
pipelineRun?: string;
gitopsRevision?: string;
mergedAt?: string;
pipelineSucceededAt?: string;
finishedAt?: string;
dryRun: boolean;
}
interface CommandJsonResult {
ok: boolean;
command: string[];
exitCode: number | null;
stdout: string;
stderr: string;
parsed: unknown | null;
}
interface OpenPullRequest {
number: number;
title?: string;
url?: string;
baseRefName?: string;
headRefName?: string;
mergeable?: string | null;
mergeStateStatus?: string | null;
draft?: boolean;
}
function parseOptions(args: string[]): G14MonitorOptions {
return {
intervalSeconds: positiveIntegerOption(args, "--interval-seconds", DEFAULT_INTERVAL_SECONDS, 86400),
maxCycles: positiveIntegerOption(args, "--max-cycles", DEFAULT_MAX_CYCLES, 100000),
once: args.includes("--once"),
dryRun: args.includes("--dry-run"),
worker: args.includes("--worker"),
timeoutSeconds: positiveIntegerOption(args, "--timeout-seconds", DEFAULT_TIMEOUT_SECONDS, 86400),
};
}
function optionValue(args: string[], name: string): string | undefined {
const index = args.indexOf(name);
if (index === -1) return undefined;
const value = args[index + 1];
if (value === undefined || value.startsWith("--")) throw new Error(`${name} requires a value`);
return value;
}
function parseRecordRolloutOptions(args: string[]): G14RecordRolloutOptions {
const prRaw = optionValue(args, "--pr") ?? optionValue(args, "--number");
const prNumber = Number(prRaw);
if (!Number.isInteger(prNumber) || prNumber <= 0) throw new Error("record-rollout requires --pr <number>");
return {
prNumber,
sourceCommit: optionValue(args, "--source-commit"),
pipelineRun: optionValue(args, "--pipeline-run"),
gitopsRevision: optionValue(args, "--gitops-revision"),
mergedAt: optionValue(args, "--merged-at"),
pipelineSucceededAt: optionValue(args, "--pipeline-succeeded-at"),
finishedAt: optionValue(args, "--finished-at"),
dryRun: args.includes("--dry-run"),
};
}
function positiveIntegerOption(args: string[], name: string, defaultValue: number, maxValue: number): number {
const index = args.indexOf(name);
if (index === -1) return defaultValue;
const raw = args[index + 1];
const value = Number(raw);
if (!Number.isInteger(value) || value < 0) throw new Error(`${name} must be a non-negative integer`);
return Math.min(value, maxValue);
}
function commandJson(command: string[], timeoutMs = 60_000): CommandJsonResult {
const result = runCommand(command, repoRoot, { timeoutMs });
let parsed: unknown | null = null;
if (result.stdout.trim().length > 0) {
try {
parsed = JSON.parse(result.stdout) as unknown;
} catch {
parsed = null;
}
}
return {
ok: result.exitCode === 0,
command,
exitCode: result.exitCode,
stdout: result.stdout,
stderr: result.stderr,
parsed,
};
}
function cliJson(args: string[], timeoutMs = 60_000): CommandJsonResult {
return commandJson(["bun", "scripts/cli.ts", ...args], timeoutMs);
}
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 expandedParsedRoot(result: CommandJsonResult): 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 record(result.parsed);
}
}
return record(result.parsed);
}
function commandData(result: CommandJsonResult): Record<string, unknown> {
return record(expandedParsedRoot(result).data);
}
function isCommandSuccess(result: CommandJsonResult): boolean {
if (!result.ok) return false;
const topOk = record(result.parsed).ok;
if (topOk === false) return false;
const dataOk = nested(result.parsed, ["data", "ok"]);
return dataOk !== false;
}
function extractPullRequests(result: CommandJsonResult): OpenPullRequest[] {
const prs = nested(result.parsed, ["data", "pullRequests"]);
if (!Array.isArray(prs)) return [];
return prs
.map((item) => record(item))
.filter((item) => item.baseRefName === G14_SOURCE_BRANCH || record(item.base).ref === G14_SOURCE_BRANCH)
.map((item) => ({
number: Number(item.number),
title: typeof item.title === "string" ? item.title : undefined,
url: typeof item.url === "string" ? item.url : undefined,
baseRefName: typeof item.baseRefName === "string" ? item.baseRefName : typeof record(item.base).ref === "string" ? record(item.base).ref as string : undefined,
headRefName: typeof item.headRefName === "string" ? item.headRefName : typeof record(item.head).ref === "string" ? record(item.head).ref as string : undefined,
mergeable: typeof item.mergeable === "string" ? item.mergeable : null,
mergeStateStatus: typeof item.mergeStateStatus === "string" ? item.mergeStateStatus : null,
draft: item.draft === true,
}))
.filter((item) => Number.isInteger(item.number) && item.number > 0);
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function printEvent(event: string, data: Record<string, unknown> = {}): void {
process.stdout.write(`${JSON.stringify({ event, at: new Date().toISOString(), ...data })}\n`);
}
function shortSha(sha: string): string {
return sha.slice(0, 12);
}
function precheckWorkspace(): CommandJsonResult {
return cliJson(["ssh", `${G14_PROVIDER}:${G14_WORKSPACE}`, "script", "--", "pwd; git fetch origin G14 --prune; git status --short --branch; git remote -v | sed -n '1,4p'"], 120_000);
}
function listOpenG14PullRequests(): CommandJsonResult {
return cliJson(["gh", "pr", "list", "--repo", HWLAB_REPO, "--state", "open", "--limit", "30", "--json", "number,title,state,url,head,base,draft,headRefName,baseRefName"], 60_000);
}
function preflightPullRequest(number: number): CommandJsonResult {
return cliJson(["gh", "pr", "preflight", String(number), "--repo", HWLAB_REPO], 80_000);
}
function mergePullRequest(number: number, dryRun: boolean): CommandJsonResult {
return cliJson(["gh", "pr", "merge", String(number), "--repo", HWLAB_REPO, "--merge", "--delete-branch", ...(dryRun ? ["--dry-run"] : [])], 100_000);
}
function getG14Head(): string | null {
const result = cliJson(["ssh", `${G14_PROVIDER}:${G14_WORKSPACE}`, "script", "--", "git fetch origin G14 --prune >/dev/null 2>&1; git rev-parse origin/G14"], 120_000);
if (!isCommandSuccess(result)) return null;
const output = String(nested(result.parsed, ["data", "stdout"]) ?? result.stdout).trim();
const match = /[0-9a-f]{40}/iu.exec(output);
return match?.[0] ?? null;
}
function refreshArgoDev(): void {
cliJson(["ssh", `${G14_PROVIDER}:k3s`, "kubectl", "annotate", "application", "-n", ARGO_NAMESPACE, DEV_APP, "argocd.argoproj.io/refresh=hard", "--overwrite"], 60_000);
}
function getPipelineStatus(sourceCommit: string): CommandJsonResult {
return cliJson(["ssh", `${G14_PROVIDER}:k3s`, "kubectl", "get", "pipelinerun", "-n", CI_NAMESPACE, `hwlab-g14-ci-poll-${shortSha(sourceCommit)}`, "-o", "jsonpath={.status.conditions[0].status}{\"\\n\"}{.status.conditions[0].reason}{\"\\n\"}{.status.conditions[0].message}{\"\\n\"}"], 60_000);
}
function getArgoStatus(): CommandJsonResult {
return cliJson(["ssh", `${G14_PROVIDER}:k3s`, "kubectl", "get", "application", "-n", ARGO_NAMESPACE, DEV_APP, "-o", "jsonpath={.status.sync.revision}{\"\\n\"}{.status.sync.status}{\"\\n\"}{.status.health.status}{\"\\n\"}{.status.operationState.phase}{\"\\n\"}{.status.operationState.syncResult.revision}{\"\\n\"}"], 60_000);
}
function getDevWorkloads(): CommandJsonResult {
return cliJson(["ssh", `${G14_PROVIDER}:k3s`, "kubectl", "get", "deploy,statefulset,pod", "-n", DEV_NAMESPACE, "-o", "wide"], 60_000);
}
function getLiveHealth(): CommandJsonResult {
return commandJson(["curl", "-fsS", "--max-time", "20", "http://74.48.78.17:17667/health/live"], 30_000);
}
function statusText(result: CommandJsonResult): string {
return String(nested(result.parsed, ["data", "stdout"]) ?? result.stdout).trim();
}
function workloadReadiness(workloadsText: string, commandOk: boolean): Record<string, unknown> {
const blockers: string[] = [];
const ignoredPods: string[] = [];
const readyWorkloads: string[] = [];
const allowedZeroDeployments = new Set(["deployment.apps/hwlab-gateway", "deployment.apps/hwlab-tunnel-client"]);
for (const line of workloadsText.split(/\r?\n/u)) {
const trimmed = line.trim();
if (trimmed.length === 0 || trimmed.startsWith("NAME ")) continue;
const fields = trimmed.split(/\s+/u);
const name = fields[0] ?? "";
const ready = fields[1] ?? "";
if (name.startsWith("deployment.apps/") || name.startsWith("statefulset.apps/")) {
if (ready === "0/0" && allowedZeroDeployments.has(name)) {
readyWorkloads.push(`${name}:${ready}:scaled-zero`);
continue;
}
const match = /^(\d+)\/(\d+)$/u.exec(ready);
if (match === null) {
blockers.push(`${name}:unparseable-ready:${ready}`);
continue;
}
const readyCount = Number(match[1]);
const desiredCount = Number(match[2]);
if (desiredCount > 0 && readyCount < desiredCount) blockers.push(`${name}:not-ready:${ready}`);
else readyWorkloads.push(`${name}:${ready}`);
continue;
}
if (name.startsWith("pod/")) {
const status = fields[2] ?? "";
if (status === "Completed" || status === "Succeeded") ignoredPods.push(`${name}:${status}`);
if (/^(CrashLoopBackOff|ImagePullBackOff|ErrImagePull|CreateContainerConfigError|CreateContainerError|RunContainerError)$/u.test(status)) {
blockers.push(`${name}:pod-status:${status}`);
}
}
}
return {
ready: commandOk && blockers.length === 0,
blockers,
readyWorkloads: readyWorkloads.slice(0, 20),
ignoredPods: ignoredPods.slice(0, 20),
};
}
function beijingParts(date = new Date()): { date: string; time: string; iso: string } {
const shifted = new Date(date.getTime() + BEIJING_OFFSET_MS).toISOString();
return { date: shifted.slice(0, 10), time: shifted.slice(11, 19), iso: shifted };
}
function durationSeconds(start?: string | null, end?: string | null): number | null {
if (start === undefined || start === null || end === undefined || end === null) return null;
const startMs = Date.parse(start);
const endMs = Date.parse(end);
if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs < startMs) return null;
return Math.round((endMs - startMs) / 1000);
}
function formatDuration(seconds: number | null): string {
if (seconds === null) return "n/a";
const minutes = Math.floor(seconds / 60);
const rest = seconds % 60;
if (minutes === 0) return `${rest}s`;
return `${minutes}m${String(rest).padStart(2, "0")}s`;
}
function readIssue(issueNumber: number): CommandJsonResult {
return cliJson(["gh", "issue", "read", String(issueNumber), "--repo", HWLAB_REPO, "--json", "title,body,state,updatedAt", "--raw"], 80_000);
}
function issueFromRead(result: CommandJsonResult): Record<string, unknown> {
return record(commandData(result).issue);
}
function parseBriefIssueFromIndex(indexBody: string, date: string): { number: number; url: string } | null {
const escapedDate = date.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
const linePattern = new RegExp(`^\\|\\s*${escapedDate}\\s*\\|([^\\n]+)$`, "mu");
const line = linePattern.exec(indexBody)?.[0];
if (line === undefined) return null;
const match = /\[#(\d+)\]\((https:\/\/github\.com\/pikasTech\/HWLAB\/issues\/\d+)\)/u.exec(line);
if (match === null) return null;
return { number: Number(match[1]), url: match[2] };
}
function dailyBriefTitle(date: string): string {
return `${date} 指挥简报(北京时间)`;
}
function writeStateFile(name: string, content: string): string {
const dir = rootPath(".state", "hwlab-g14");
mkdirSync(dir, { recursive: true });
const path = join(dir, name);
writeFileSync(path, content, "utf8");
return path;
}
function listIssues(): CommandJsonResult {
return cliJson(["gh", "issue", "list", "--repo", HWLAB_REPO, "--state", "open", "--limit", "100", "--json", "number,title,state,url"], 80_000);
}
function findBriefIssueInList(date: string): { number: number; url: string } | null {
const listed = listIssues();
if (!isCommandSuccess(listed)) return null;
const issues = commandData(listed).issues;
if (!Array.isArray(issues)) return null;
const title = dailyBriefTitle(date);
const match = issues.map((item) => record(item)).find((issue) => issue.title === title);
if (match === undefined) return null;
return { number: Number(match.number), url: String(match.url) };
}
function newDailyBriefBody(date: string): string {
return [
`# ${dailyBriefTitle(date)}`,
"",
"## 当日更新",
"",
"## 常驻观察与长期建议",
"",
"- G14 HWLAB source truth 固定为 G14 `/root/hwlab` 与 `origin/G14`DEV rollout 只接受 G14 Tekton/GitOps/Argo 和公网 health 证据。",
"- GitHub issue/PR 写操作必须走 UniDesk `gh` 子命令;G14 k3s 操作必须走 UniDesk `ssh G14:k3s`。",
].join("\n");
}
function insertBriefIndexRow(indexBody: string, date: string, brief: { number: number; url: string }): string {
if (parseBriefIssueFromIndex(indexBody, date) !== null) return indexBody;
const row = `| ${date} | [#${brief.number}](${brief.url}) | 已创建当日 G14 DEV rollout 自动简报入口;后续每次 DEV 上线记录 CI/CD 耗时和上线 changelog。 | 继续监控 HWLAB base=G14 PRready 后自动合并并滚动到 G14 DEV。 |`;
const marker = "### 指挥简报索引";
const markerIndex = indexBody.indexOf(marker);
if (markerIndex === -1) return `${indexBody.trimEnd()}\n\n${marker}\n\n| 日期(北京时间) | 指挥简报 issue | 当日推进 | 下一步计划 |\n| --- | --- | --- | --- |\n${row}\n`;
const afterMarker = indexBody.slice(markerIndex);
const separator = "| --- | --- | --- | --- |";
const separatorInBody = afterMarker.indexOf(separator);
if (separatorInBody === -1) return `${indexBody.slice(0, markerIndex + marker.length)}\n\n| 日期(北京时间) | 指挥简报 issue | 当日推进 | 下一步计划 |\n| --- | --- | --- | --- |\n${row}\n${indexBody.slice(markerIndex + marker.length)}`;
const insertAt = markerIndex + separatorInBody + separator.length;
return `${indexBody.slice(0, insertAt)}\n${row}${indexBody.slice(insertAt)}`;
}
function ensureDailyBriefIssue(date: string, dryRun: boolean): Record<string, unknown> {
const indexRead = readIssue(G14_BRIEF_INDEX_ISSUE);
if (!isCommandSuccess(indexRead)) return { ok: false, phase: "read-index", indexRead };
const indexIssue = issueFromRead(indexRead);
const indexBody = String(indexIssue.body ?? "");
let brief = parseBriefIssueFromIndex(indexBody, date) ?? findBriefIssueInList(date);
const actions: Record<string, unknown>[] = [];
if (brief === null) {
const bodyPath = writeStateFile(`daily-brief-${date}.md`, `${newDailyBriefBody(date)}\n`);
if (dryRun) {
brief = { number: 0, url: `https://github.com/pikasTech/HWLAB/issues/new?title=${encodeURIComponent(dailyBriefTitle(date))}` };
actions.push({ action: "would-create-daily-brief", title: dailyBriefTitle(date), bodyPath });
} else {
const created = cliJson(["gh", "issue", "create", "--repo", HWLAB_REPO, "--title", dailyBriefTitle(date), "--body-file", bodyPath], 80_000);
if (!isCommandSuccess(created)) return { ok: false, phase: "create-daily-brief", created };
const createdIssue = record(commandData(created).issue);
brief = { number: Number(createdIssue.number), url: String(createdIssue.url) };
actions.push({ action: "created-daily-brief", issue: brief, bodyPath });
}
}
if (brief.number > 0 && parseBriefIssueFromIndex(indexBody, date) === null) {
const nextBody = insertBriefIndexRow(indexBody, date, brief);
const bodyPath = writeStateFile(`issue-${G14_BRIEF_INDEX_ISSUE}-brief-index-${date}.md`, `${nextBody.trimEnd()}\n`);
if (dryRun) {
actions.push({ action: "would-update-brief-index", issue: G14_BRIEF_INDEX_ISSUE, bodyPath });
} else {
const bodySha = String(indexIssue.bodySha ?? "");
const update = cliJson(["gh", "issue", "update", String(G14_BRIEF_INDEX_ISSUE), "--repo", HWLAB_REPO, "--mode", "replace", "--body-file", bodyPath, ...(bodySha.length > 0 ? ["--expect-body-sha", bodySha] : [])], 100_000);
if (!isCommandSuccess(update)) return { ok: false, phase: "update-brief-index", update, bodyPath };
actions.push({ action: "updated-brief-index", issue: G14_BRIEF_INDEX_ISSUE, bodyPath });
}
}
return { ok: true, date, brief, actions };
}
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);
}
function readPullRequestFiles(number: number): CommandJsonResult {
return cliJson(["gh", "pr", "files", String(number), "--repo", HWLAB_REPO, "--limit", "30"], 80_000);
}
function summarizePrFiles(filesResult: CommandJsonResult): Record<string, unknown> {
if (!isCommandSuccess(filesResult)) return { ok: false, filesResult };
const data = commandData(filesResult);
const files = Array.isArray(data.files) ? data.files.map((item) => record(item)) : [];
return {
ok: true,
summary: data.summary ?? null,
keyFiles: files.slice(0, 8).map((file) => `${file.status ?? "changed"} ${file.filename ?? ""}`),
};
}
function mergeCommitFromPr(prData: Record<string, unknown>): string | null {
const mergeCommit = record(record(prData.json).mergeCommit);
const fromJson = mergeCommit.oid;
if (typeof fromJson === "string") return fromJson;
const fromSummary = record(record(prData.pullRequest).mergeCommit).oid;
return typeof fromSummary === "string" ? fromSummary : null;
}
function currentGitopsRevision(): string | null {
const argo = getArgoStatus();
if (!isCommandSuccess(argo)) return null;
return statusText(argo).split(/\r?\n/u)[0] ?? null;
}
function rolloutRecordBody(input: {
pr: OpenPullRequest;
prData: Record<string, unknown>;
fileSummary: Record<string, unknown>;
sourceCommit: string;
pipelineRun: string;
gitopsRevision: string | null;
mergedAt: string | null;
pipelineSucceededAt: string | null;
finishedAt: string | null;
rollout: Record<string, unknown>;
}): string {
const now = beijingParts();
const title = String(record(input.prData.json).title ?? input.pr.title ?? `PR #${input.pr.number}`);
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 mergeToPipeline = durationSeconds(input.mergedAt, input.pipelineSucceededAt);
const pipelineToDev = durationSeconds(input.pipelineSucceededAt, input.finishedAt);
const mergeToDev = durationSeconds(input.mergedAt, input.finishedAt);
return [
"",
"",
`## 更新 ${now.date} ${now.time.slice(0, 5)} 北京时间`,
"",
`### G14 DEV rolloutPR #${input.pr.number}`,
"",
`- PR: [#${input.pr.number} ${title}](${url})`,
`- 合并 commit: \`${input.sourceCommit}\``,
`- GitOps revision: \`${input.gitopsRevision ?? "unknown"}\``,
`- PipelineRun: \`${input.pipelineRun}\``,
"- CI/CD 耗时:",
` - merge -> pipeline succeeded: ${formatDuration(mergeToPipeline)}`,
` - pipeline succeeded -> DEV Healthy: ${formatDuration(pipelineToDev)}`,
` - merge -> DEV Healthy: ${formatDuration(mergeToDev)}`,
"- 上线 changelog:",
` - ${title}`,
` - 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 验证:",
` - Tekton: ${String(input.rollout.pipelineText ?? "Succeeded").split(/\r?\n/u).join(" / ")}`,
` - Argo: ${String(input.rollout.argoText ?? "Synced / Healthy").split(/\r?\n/u).slice(0, 5).join(" / ")}`,
` - health/live: ${input.rollout.healthOk === false ? "not-ok" : "ok"}`,
" - workload readiness: Deployment/StatefulSet ready;历史 Completed smoke/debug pod 不作为 DEV rollout blocker。",
].join("\n");
}
function appendRolloutBrief(options: G14RecordRolloutOptions, rollout: Record<string, unknown> = {}): Record<string, unknown> {
const now = beijingParts();
const ensured = ensureDailyBriefIssue(now.date, options.dryRun);
if (record(ensured).ok !== true) return ensured;
const brief = record(record(ensured).brief);
const prRead = readPullRequest(options.prNumber);
if (!isCommandSuccess(prRead)) return { ok: false, phase: "read-pr", prRead };
const prData = commandData(prRead);
const sourceCommit = options.sourceCommit ?? mergeCommitFromPr(prData);
if (sourceCommit === null || sourceCommit === undefined) return { ok: false, phase: "source-commit", message: "source commit unavailable", prData };
const pipelineRun = options.pipelineRun ?? `hwlab-g14-ci-poll-${shortSha(sourceCommit)}`;
const files = summarizePrFiles(readPullRequestFiles(options.prNumber));
const rolloutGitopsRevision = typeof rollout.gitopsRevision === "string" && rollout.gitopsRevision.length > 0 ? rollout.gitopsRevision : null;
const gitopsRevision = options.gitopsRevision ?? rolloutGitopsRevision ?? currentGitopsRevision();
const prJson = record(prData.json);
const prSummary = record(prData.pullRequest);
const prMergedAt = typeof prJson.mergedAt === "string" && prJson.mergedAt.length > 0
? prJson.mergedAt
: typeof prSummary.mergedAt === "string" && prSummary.mergedAt.length > 0
? prSummary.mergedAt
: null;
const rolloutPipelineSucceededAt = typeof rollout.pipelineSucceededAt === "string" && rollout.pipelineSucceededAt.length > 0 ? rollout.pipelineSucceededAt : null;
const rolloutFinishedAt = typeof rollout.finishedAt === "string" && rollout.finishedAt.length > 0 ? rollout.finishedAt : null;
const mergedAt = options.mergedAt ?? prMergedAt;
const pipelineSucceededAt = options.pipelineSucceededAt ?? rolloutPipelineSucceededAt;
const finishedAt = options.finishedAt ?? rolloutFinishedAt;
const pr: OpenPullRequest = {
number: options.prNumber,
title: String(record(prData.json).title ?? ""),
url: String(record(prData.json).url ?? ""),
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 });
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 };
}
const currentBrief = readIssue(Number(brief.number));
if (!isCommandSuccess(currentBrief)) return { ok: false, phase: "read-daily-brief", currentBrief, brief };
const currentBody = String(issueFromRead(currentBrief).body ?? "");
if (currentBody.includes(`PR #${options.prNumber}`) && currentBody.includes(sourceCommit)) {
return { ok: true, skipped: true, reason: "rollout-brief-already-recorded", brief, sourceCommit, ensured };
}
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 };
}
async function waitForG14Dev(sourceCommit: string, timeoutSeconds: number): Promise<Record<string, unknown>> {
const started = Date.now();
const startedAt = new Date(started).toISOString();
let pipelineText = "";
let argoText = "";
let healthOk = false;
let workloadsReady: Record<string, unknown> = { ready: false };
let pipelineSucceededAt: string | null = null;
while (Date.now() - started < timeoutSeconds * 1000) {
const pipeline = getPipelineStatus(sourceCommit);
pipelineText = statusText(pipeline);
printEvent("g14.pipeline.status", { sourceCommit, pipelineRun: `hwlab-g14-ci-poll-${shortSha(sourceCommit)}`, text: pipelineText.slice(0, 500) });
if (!pipelineText.startsWith("True\nSucceeded")) {
if (pipelineText.startsWith("False\n")) return { ok: false, phase: "pipeline", sourceCommit, pipelineText };
await sleep(30_000);
continue;
}
pipelineSucceededAt ??= new Date().toISOString();
refreshArgoDev();
const argo = getArgoStatus();
argoText = statusText(argo);
printEvent("g14.argo.status", { text: argoText.slice(0, 500) });
const argoLines = argoText.split(/\r?\n/u);
const synced = argoLines[1] === "Synced";
const healthy = argoLines[2] === "Healthy";
const gitopsRevision = argoLines[0] ?? null;
const workloads = getDevWorkloads();
const workloadsText = statusText(workloads);
workloadsReady = workloadReadiness(workloadsText, isCommandSuccess(workloads));
const health = getLiveHealth();
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 };
}
await sleep(30_000);
}
return { ok: false, phase: "timeout", sourceCommit, pipelineText, argoText, startedAt, pipelineSucceededAt, workloadsReady, healthOk, timeoutSeconds };
}
async function monitorCycle(options: G14MonitorOptions, cycle: number): Promise<Record<string, unknown>> {
printEvent("g14.monitor.cycle.start", { cycle, dryRun: options.dryRun });
const precheck = precheckWorkspace();
if (!isCommandSuccess(precheck)) return { ok: false, cycle, phase: "workspace-precheck", precheck };
const listed = listOpenG14PullRequests();
if (!isCommandSuccess(listed)) return { ok: false, cycle, phase: "list-prs", listed };
const prs = extractPullRequests(listed);
printEvent("g14.monitor.prs", { cycle, count: prs.length, pullRequests: prs });
if (prs.length === 0) return { ok: true, cycle, action: "none", pullRequests: [] };
const merged: unknown[] = [];
for (const pr of prs) {
const preflight = preflightPullRequest(pr.number);
printEvent("g14.pr.preflight", { cycle, number: pr.number, ok: isCommandSuccess(preflight) });
if (!isCommandSuccess(preflight) || nested(preflight.parsed, ["data", "mergeability", "readyForCommanderMerge"]) !== true) {
return { ok: false, cycle, phase: "pr-preflight", pullRequest: pr, preflight };
}
const merge = mergePullRequest(pr.number, options.dryRun);
printEvent("g14.pr.merge", { cycle, number: pr.number, dryRun: options.dryRun, ok: isCommandSuccess(merge) });
if (!isCommandSuccess(merge)) return { ok: false, cycle, phase: "pr-merge", pullRequest: pr, merge };
const sourceCommit = getG14Head();
const rollout = options.dryRun || sourceCommit === null ? { skipped: true, dryRun: options.dryRun, sourceCommit } : await waitForG14Dev(sourceCommit, options.timeoutSeconds);
const brief = options.dryRun || sourceCommit === null || record(rollout).ok !== true
? { skipped: true, dryRun: options.dryRun }
: appendRolloutBrief({ prNumber: pr.number, sourceCommit, dryRun: false }, rollout);
printEvent("g14.rollout.brief", { cycle, number: pr.number, ok: record(brief).ok, skipped: record(brief).skipped ?? false, brief: record(brief).brief ?? null });
merged.push({ pullRequest: pr, merge: commandData(merge), sourceCommit, rollout, brief });
if (record(rollout).ok === false) return { ok: false, cycle, phase: "rollout", pullRequest: pr, sourceCommit, rollout, merged };
if (record(brief).ok === false) return { ok: false, cycle, phase: "rollout-brief", pullRequest: pr, sourceCommit, rollout, brief, merged };
}
return { ok: true, cycle, action: options.dryRun ? "dry-run-merge" : "merged-and-rolled-dev", merged };
}
async function runMonitorWorker(options: G14MonitorOptions): Promise<Record<string, unknown>> {
const maxCycles = options.once ? 1 : options.maxCycles;
let cycle = 0;
const results: unknown[] = [];
while (maxCycles === 0 || cycle < maxCycles) {
cycle += 1;
const result = await monitorCycle(options, cycle);
results.push(result);
printEvent("g14.monitor.cycle.done", { cycle, ok: record(result).ok, action: record(result).action ?? null, phase: record(result).phase ?? null });
if (record(result).ok !== true) return { ok: false, cycles: cycle, lastResult: result, results };
if (options.once || record(result).action !== "none") return { ok: true, cycles: cycle, lastResult: result, results };
printEvent("g14.monitor.sleep", { cycle, intervalSeconds: options.intervalSeconds });
await sleep(options.intervalSeconds * 1000);
}
return { ok: true, cycles: cycle, results };
}
export function hwlabG14Help(): Record<string, unknown> {
return {
command: "hwlab g14",
output: "json",
usage: [
"bun scripts/cli.ts hwlab g14 monitor-prs",
"bun scripts/cli.ts hwlab g14 monitor-prs --once --dry-run",
"bun scripts/cli.ts hwlab g14 record-rollout --pr <number> [--source-commit sha]",
"bun scripts/cli.ts job status <jobId> --tail-bytes 30000",
],
description: "G14 HWLAB PR monitor and DEV rollout command. The public command starts a fire-and-forget job; the worker uses UniDesk gh and ssh routes for every GitHub and k3s operation, then appends the rollout record to the #7-indexed daily brief.",
defaults: {
repo: HWLAB_REPO,
base: G14_SOURCE_BRANCH,
provider: G14_PROVIDER,
workspace: G14_WORKSPACE,
intervalSeconds: DEFAULT_INTERVAL_SECONDS,
devApplication: DEV_APP,
briefIndexIssue: G14_BRIEF_INDEX_ISSUE,
},
};
}
export async function runHwlabG14Command(_config: Config, args: string[]): Promise<Record<string, unknown>> {
if (args.length === 0 || args.includes("--help") || args.includes("-h")) return { ok: true, ...hwlabG14Help() };
const [action] = args;
if (action === "record-rollout") {
const options = parseRecordRolloutOptions(args.slice(1));
return appendRolloutBrief(options);
}
if (action !== "monitor-prs") {
return { ok: false, command: `hwlab g14 ${action ?? ""}`.trim(), degradedReason: "unsupported-command", message: "supported commands: hwlab g14 monitor-prs, hwlab g14 record-rollout" };
}
const options = parseOptions(args.slice(1));
if (options.worker) return runMonitorWorker(options);
const command = ["bun", "scripts/cli.ts", "hwlab", "g14", "monitor-prs", "--worker", "--interval-seconds", String(options.intervalSeconds), "--timeout-seconds", String(options.timeoutSeconds), ...(options.once ? ["--once"] : []), ...(options.dryRun ? ["--dry-run"] : []), ...(options.maxCycles > 0 ? ["--max-cycles", String(options.maxCycles)] : [])];
const job = startJob("hwlab_g14_pr_monitor", command, `Monitor ${HWLAB_REPO} PRs targeting ${G14_SOURCE_BRANCH} and roll merged changes to G14 DEV`);
const statusCommand = `bun scripts/cli.ts job status ${job.id} --tail-bytes 30000`;
const stateDir = rootPath(".state", "hwlab-g14");
mkdirSync(stateDir, { recursive: true });
const latestPath = join(stateDir, "latest-monitor-job.json");
const previousLatest = existsSync(latestPath) ? readFileSync(latestPath, "utf8") : null;
writeFileSync(latestPath, `${JSON.stringify({ jobId: job.id, createdAt: job.createdAt, statusCommand }, null, 2)}\n`, "utf8");
return {
ok: true,
command: "hwlab g14 monitor-prs",
mode: "async-job",
job,
statusCommand,
latestPath,
previousLatest,
next: {
status: statusCommand,
tail: `tail -f ${job.stdoutFile}`,
},
};
}