feat: automate HWLAB G14 PR rollout monitoring
This commit is contained in:
@@ -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 contract,runner 无 `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 e2e;catalog/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
@@ -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." },
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 PR,ready 后自动合并并滚动到 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 rollout:PR #${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}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user