From 32b3bd7ce4a1120c9f4d00df80a2c02d29b38e50 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 31 May 2026 08:35:12 +0000 Subject: [PATCH] fix: smooth hwlab pr and rollout visibility --- docs/reference/cli.md | 4 +- scripts/src/gh.ts | 45 +++++++++++++---- scripts/src/hwlab-g14.ts | 106 ++++++++++++++++++++------------------- 3 files changed, 93 insertions(+), 62 deletions(-) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index fcaf2b54..d13395aa 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -46,7 +46,7 @@ CI/CD、GitOps、rollout、artifact 发布、PR 合并后的 DEV/PROD 滚动、P - `hwlab g14 control-plane status|apply --lane v02 [--dry-run|--confirm]` 是 HWLAB `v0.2` 加法 lane 的受控 Tekton/Argo 控制面维护入口,只面向 G14 `/root/hwlab-v02`、branch `v0.2`、namespace `hwlab-ci` 和 Argo application `hwlab-g14-v02`;`status` 只读汇总 pipeline、RBAC/ServiceAccount、Argo、当前 commit PipelineRun、最近 PipelineRun 摘要、活跃 PipelineRun 和遗留 v02 CronJob 清理状态,默认只读取必要字段,禁止把完整 PipelineRun spec、Tekton 内联脚本或历史大对象展开到默认输出;`apply` 先在 G14 workspace 快进并执行 render check,再经 `G14:k3s` server-side apply `tekton-v02/rbac.yaml`、`pipeline.yaml`、`argocd/project.yaml` 和 `argocd/application-v02.yaml`,confirmed apply 会删除遗留 v02 CronJob,但不会应用 runtime-v02 workload、Secret 或数据迁移。 - `hwlab g14 control-plane trigger-current --lane v02 [--dry-run|--confirm]` 是 v02 标准手动触发入口:解析当前 `origin/v0.2` full SHA,创建 commit-pinned `hwlab-v02-ci-poll-` PipelineRun;读 Git 走 `git-mirror-http.devops-infra.svc.cluster.local`,GitOps promotion 写 `git-mirror-write.devops-infra.svc.cluster.local`;confirmed trigger 在删除/创建 PipelineRun 前会先按当前 source commit render 并 server-side apply v02 Tekton RBAC、Pipeline 与 Argo Application,避免 CI/CD 脚本或 runtime-ready 逻辑已合并但集群仍执行旧 Pipeline 定义;同名 PipelineRun 成功或运行中时拒绝重复触发,失败或不存在时才删除旧对象并重新创建。 创建 PipelineRun 前会读取 `devops-infra` mirror refs,若 `localV02` 未等于当前 source commit,则自动执行一次受控 manual `git-mirror sync` Job 并复核 ref,复核失败时停止触发,避免 Tekton `prepare-source` 已知失败;services 参数只包含 v02 runtime service matrix,`hwlab-cli` 是固定 repo 短连接源码工具,不进入 PipelineRun service build。 - `--dry-run` 只报告是否会 pre-sync,不创建 Job;confirmed trigger 默认创建 `.state/jobs/` 异步 job 并立刻返回 `job.id`、`statusCommand`、stdout/stderr 路径,避免 git mirror pre-sync 或 PipelineRun 创建期间长时间阻塞;`--wait` 路径也必须向 stderr 输出 `hwlab.v02.trigger.progress` JSON 事件,覆盖 `control-plane-refresh`、`git-mirror-pre-sync`、`delete-existing-pipelinerun` 和 `create-pipelinerun`,避免异步 job 长时间只有启动命令而无法判断卡点;默认 JSON 必须对 `manifest_b64`、长脚本和远端 stdout/stderr 做有界摘要,保留长度与 hash,完整内容通过 job stdout/stderr 文件渐进披露;只有现场同步调试才显式加 `--wait`;旧 `rerun-current` 只作为输入别名保留。 + `--dry-run` 只报告是否会 pre-sync,不创建 Job;confirmed trigger 默认创建 `.state/jobs/` 异步 job 并立刻返回 `job.id`、`statusCommand`、stdout/stderr 路径,避免 git mirror pre-sync 或 PipelineRun 创建期间长时间阻塞;`--wait` 路径也必须向 stderr 输出 `hwlab.v02.trigger.progress` JSON 事件,覆盖 `control-plane-refresh`、`git-mirror-pre-sync`、`delete-existing-pipelinerun` 和 `create-pipelinerun`,避免异步 job 长时间只有启动命令而无法判断卡点;默认 JSON 必须对 `manifest_b64`、长脚本和远端 stdout/stderr 做有界摘要,保留长度与 hash,最终 trigger 结果只返回阶段摘要和关键 tail,完整内容通过 job stdout/stderr 文件渐进披露;只有现场同步调试才显式加 `--wait`;旧 `rerun-current` 只作为输入别名保留。 - `hwlab g14 control-plane runtime-migration --lane v02 [--dry-run|--allow-live-db-read --dry-run|--confirm]` 只通过 `hwlab-v02` namespace 当前 `deployment/hwlab-cloud-api -c hwlab-cloud-api` 内 repo-owned migration CLI 执行;不读取或打印 Secret 值、不触碰 PROD、不绕到手工 `psql`。 - `hwlab g14 control-plane cleanup-runs --lane v02|g14|all [--min-age-minutes N] [--limit N] [--dry-run|--confirm]` 是完成态 PipelineRun 工作区 retention 入口;真实清理只删除已完成 PipelineRun,让 Tekton/local-path 回收临时 PVC,不触碰 registry storage、业务 PVC、Secret、runtime workload 或 GitOps desired state。 - `hwlab g14 control-plane cleanup-released-pvs --lane all [--limit N] [--dry-run|--confirm]` 是 local-path 未自动回收后的补充 retention 入口;只列并删除 `Released`、`local-path`、`Delete`、`claimNamespace=hwlab-ci` 且 claim 名称形如 Tekton 临时 `pvc-*` 的 PV。 @@ -64,7 +64,7 @@ CI/CD、GitOps、rollout、artifact 发布、PR 合并后的 DEV/PROD 滚动、P - `gh issue edit 24 --body-file --notify-claudeqq-brief-diff [--dry-run]` 是 legacy #24 指挥简报的通知入口。正式执行会先读取 GitHub 上 #24 旧正文并通过 #24 body profile guard,再从 `--body-file` 读取新正文;随后先 PATCH issue 主体,再把本次新增的 `## 更新 YYYY-MM-DD HH:MM 北京时间` 段落发送给 ClaudeQQ,ClaudeQQ 失败不会回滚 issue 正文,失败只体现在返回 JSON 的 `claudeqq.ok=false` 和结构化 `degradedReason`。每日滚动简报 issue 可用普通 `gh issue update --body-profile commander-brief --dry-run` 和并发 guard 更新,但此通知 helper 仍只支持 #24。带通知 flag 的 `--dry-run` 不 PATCH、不发送;它按新正文做发送预览,并在输出中标明非 dry-run 才会读取旧正文做可靠 diff。默认 ClaudeQQ 目标是私聊 `645275593`,默认 base URL 是 UniDesk 受控入口 `http://backend-core:8080/api/microservices/claudeqq/proxy`;`UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_BASE_URL` 只接受 backend-core `/api/microservices/claudeqq/proxy` 等价路径,非 proxy URL 会结构化为 `notification-path-unavailable`。可用 `UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_ENABLED`、`UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_TARGET_TYPE`、`UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_USER_ID`、`UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_GROUP_ID` 和 `UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_TIMEOUT_MS` 调整开关、目标和超时。 - `gh issue board-audit [--repo owner/name] [--board-issue 20] [--limit N] [--known-meta-issue N[,N...]] [--ignore-issue N[,N...]] [--dry-run]` 是总看板只读结构审计入口,默认 repo 为 `pikasTech/unidesk`、board issue 为 `20`、输出 JSON 且不 PATCH/POST/DELETE GitHub。它只读取目标 board issue 正文,返回正文长度、行数、body SHA、可解析 Markdown board sections、section 行数和 parser warnings;不再拉取 GitHub open/closed issue 列表,也不再校验 OPEN/CLOSED 表是否覆盖全部 issue。兼容字段 `missingOpenIssues`、`closedInOpenRows`、`missingClosedRows`、`openInClosedRows`、`rowValidationWarnings`、`ignoredIssues` 和 `recommendedActions` 仍保留,但固定为空数组或 0,用于避免旧调用方因字段缺失失败。需要维护旧式 OPEN/CLOSED 明细表时,继续使用 `gh issue board-row list|get|update|add|move|delete|upsert` 的行级结构化入口。 - `gh issue board-row list --board-issue 20 [--state open|closed|all] [--dry-run]`、`gh issue board-row get --board-issue 20` 和 `gh issue board-row update --board-issue 20 --field progress|status|validation|branch|tasks|focus --value [--dry-run] [--expect-updated-at ts|--expect-body-sha sha256]` 是 #20 看板表格单行结构化入口。list/get 复用 board-audit parser,只读返回 row、cells、fields、section、lineNumber、bodySha 和 rowValidationWarnings。update 只替换命中的一行里一个单元格,返回 old/new row、old/new body SHA、body guard、request plan 和 parser 结果;默认没有并发期望时即使不写 `--dry-run` 也只做 dry-run,正式 PATCH 必须带 `--expect-body-sha` 或 `--expect-updated-at`。字段映射固定为:`branch` -> Branch,`progress` -> 进度,`status`/`validation` -> 验收状态,`tasks` -> 相关 Code Queue 任务,`focus` -> 当前关注点。单元格值中的 Markdown 表格管道会转义为 `\|`,真实换行会折叠为空格,避免新增字面量 `\n` 污染。`gh issue board-row upsert --board-issue 20 --section open|closed [--category text] --branch --tasks --summary --focus --validation --progress [--status OPEN|CLOSED] [--dry-run] [--expect-body-sha|--expect-updated-at]` 是行级补齐入口:若 issue 已存在则只更新传入字段并返回 `operation=update`,未传字段保留原值;若不存在则按目标 section 表头生成完整行并返回 `operation=add`。新增时 `--section` 必需,且目标表头中的 category/branch/tasks/summary/focus/validation/progress 列都必须有对应值;若表没有独立 Summary/摘要列,`--summary` 会并入 Issue 单元格。upsert 不关闭、不删除、不重开 GitHub issue,也不做 OPEN/CLOSED 迁移;已存在行的 `--section` 或 `--status` 与当前 section 冲突时会结构化失败并提示使用 `board-row move`。`gh issue board-row add --board-issue 20 --section open|closed --row-file [--dry-run] [--expect-body-sha|--expect-updated-at]`、`move --board-issue 20 --to open|closed [--status OPEN|CLOSED] [--dry-run] [--expect-body-sha|--expect-updated-at]` 和 `delete --board-issue 20 [--dry-run] [--expect-body-sha|--expect-updated-at]` 是 row-scoped #20 结构化写入口。add 校验一行 `--row-file` 的 Issue 列、列数和 GitHub 状态列与目标 section 一致;move 允许跨 OPEN/CLOSED 表迁移并在需要时同步 GitHub 状态列;delete 仅删除匹配行。四类写入口默认 dry-run,非 dry-run 必须带 `--expect-body-sha` 或 `--expect-updated-at`,并返回 old/new row、body SHA、line/section 计划和 parser 结果;duplicate/ambiguous row、列数不匹配、缺少新增必填字段、section/status 冲突或 body SHA 不匹配都会结构化失败,不会 fallback 到整篇 body 手工替换。 -- `gh issue scan-escape [--repo owner/name] [--limit N] [--dry-run]` 只读扫描 issue 主体和 comments 中的字面量 `\n`、可疑 `\t`、shell newline escape、escaped backtick、ANSI escape 字符串、短 body、blank body 和 null body。输出固定 JSON,`findings` 会带 `bodyKind=issue-body|comment-body`、`issueNumber`、`issueId`、`commentId`、`lineNumber`、`column`、`kind`、`snippet` 和 `classification=suspected-pollution|explanatory-mention|risk`,用于区分说明性提到 `\n` 和疑似污染;`cleanupSuggestions` 只给 dry-run 清理建议、body/comment 定位和 diff-like preview,不 PATCH、不 DELETE、不真实清理历史 comment。`gh issue cleanup-plan` 是同一只读能力的别名,默认 `dryRun=true`。`gh pr list [--state open|closed|all] [--json ...]` 提供 REST 列表,默认 `state=all` 以保持既有 UniDesk CLI 行为,字段白名单是 `body,title,state,number,url,author,head,base,draft,createdAt,updatedAt,headRefName,baseRefName`;未知 state 或未知 `--json` 字段必须结构化失败并带 `runnerDisposition=business-failed`。`mergeable`、`mergeStateStatus` 和 `statusCheckRollup` 不属于 list 字段,请对具体 PR 使用 `gh pr view --json headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup`,避免列表默认拉取 noisy/raw 状态汇总。`gh pr files [--limit N]` 是 PR changed-file/stat summary 的稳定 REST 入口,返回 bounded `files`、`filesReturned`、`summary.files/additions/deletions/changes/commits`、`truncation` 和 `next.command`,默认不输出 raw diff 或 patch;`gh pr diff --stat` 是兼容别名,返回同一 JSON,未带 `--stat` 的 raw diff 请求会结构化拒绝。`gh pr read|view [--json ...] [--raw|--full]` 继续稳定返回这些字段,并额外支持 `stateDetail,closed,closedAt,merged,mergedAt,mergeCommit,headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup`。`owner/repo#number` shorthand 和冲突 `--repo` 规则与 issue read/view 相同。`stateDetail` 是 UniDesk 归一化生命周期值 `open|closed|merged`,用于区分 REST `state=closed` 中的普通关闭和已合并;`closed`、`closedAt`、`merged`、`mergedAt`、`mergeCommit`、`headRefName` 与 `baseRefName` 都来自 REST,不需要 GraphQL。`mergeable`、`mergeStateStatus` 和 `statusCheckRollup` 只在 read/view 明确请求这些字段或用 `--raw|--full` 显式完整披露时通过 GitHub GraphQL 查询,GraphQL 权限不足或网络失败会结构化失败;GitHub 暂未计算完成时仍保留原始 `UNKNOWN`/null,并额外返回 `closeoutMetadata.ok=false`、`missingOrUnknownFields`、advice 和 `mergeBoundary.unideskCliMergeSupported=true`。此时收口人员应优先重试一次;若仍缺失,应继续改进 UniDesk `gh` 子命令或使用人工 GitHub UI 做最终交叉确认,禁止原生 `gh` 或手拼 GitHub API 绕过。`gh pr preflight [--repo owner/name] [--full|--raw]` 是低噪声 PR 收口入口,`gh preflight ` 和 `gh pr closeout ` 是兼容别名;它先输出脱敏 auth capability,再读取 PR state/draft/head/base、mergeable、mergeStateStatus 和 statusCheckRollup,默认只给 status check 计数与失败/等待预览,完整 contexts 和原始读取摘要必须显式加 `--full` 或 `--raw`。该命令固定 `readOnly=true`、`writesRemote=false`、`policy.mergesPr=false`,不会创建、评论、更新或 merge PR。`gh pr create --title --body-file <file>|--body <text> --base <branch> --head <branch> [--draft] [--dry-run]`、`gh pr edit <number> [--title ...] [--body-file <file>|--body-file -|--body <text>] [--dry-run]`、`gh pr update <number> --mode replace|append [--body-file <file>|--body-file -|--body <text>] [--title ...] [--dry-run]`、`gh pr comment create <number> --body-file <file>|--body <text> [--dry-run]`、`gh pr comment delete <commentId> [--dry-run]`、`gh pr close|reopen <number> [--dry-run]` 和 `gh pr merge <number> [--merge|--squash|--rebase] [--delete-branch] [--dry-run]` 是 PR CRUD/生命周期入口。`pr create --dry-run` 只输出 planned operation,不访问 GitHub;非 dry-run 创建前会校验 repo、base、head 和 compare ahead 状态,成功时返回 PR number/url。`pr edit/update` 使用 REST `PATCH /repos/{owner}/{repo}/pulls/{number}`,只发送显式提供的 `title` 和/或 `body` 字段,完全避开 GitHub Projects Classic GraphQL/projectCards;输出低噪声 JSON:`ok`、`repo`、PR number、`changedFields`、`url`、body 长度/SHA/source 元数据和 request plan,不默认回显完整正文。`pr update --mode append` 会先读取当前 PR body 再追加正文。`pr merge` 会先执行同源 closeout 预检,拒绝非 open、draft、冲突、非 CLEAN、失败或 pending checks 的 PR,只有 ready 时才调用 GitHub REST merge;`--delete-branch` 只删除同 repo head ref。`gh pr delete <number>` 不开放,PR 生命周期删除语义请使用 `close`。 +- `gh issue scan-escape [--repo owner/name] [--limit N] [--dry-run]` 只读扫描 issue 主体和 comments 中的字面量 `\n`、可疑 `\t`、shell newline escape、escaped backtick、ANSI escape 字符串、短 body、blank body 和 null body。输出固定 JSON,`findings` 会带 `bodyKind=issue-body|comment-body`、`issueNumber`、`issueId`、`commentId`、`lineNumber`、`column`、`kind`、`snippet` 和 `classification=suspected-pollution|explanatory-mention|risk`,用于区分说明性提到 `\n` 和疑似污染;`cleanupSuggestions` 只给 dry-run 清理建议、body/comment 定位和 diff-like preview,不 PATCH、不 DELETE、不真实清理历史 comment。`gh issue cleanup-plan` 是同一只读能力的别名,默认 `dryRun=true`。`gh pr list [--state open|closed|all] [--json ...]` 提供 REST 列表,默认 `state=all` 以保持既有 UniDesk CLI 行为,字段白名单是 `body,title,state,number,url,author,head,base,draft,createdAt,updatedAt,headRefName,baseRefName`;未知 state 或未知 `--json` 字段必须结构化失败并带 `runnerDisposition=business-failed`。`mergeable`、`mergeStateStatus` 和 `statusCheckRollup` 不属于 list 字段,请对具体 PR 使用 `gh pr view <number> --json headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup`,避免列表默认拉取 noisy/raw 状态汇总。`gh pr files <number> [--limit N]` 是 PR changed-file/stat summary 的稳定 REST 入口,返回 bounded `files`、`filesReturned`、`summary.files/additions/deletions/changes/commits`、`truncation` 和 `next.command`,默认不输出 raw diff 或 patch;`gh pr diff <number> --stat` 是兼容别名,返回同一 JSON,未带 `--stat` 的 raw diff 请求会结构化拒绝。`gh pr read|view <number|owner/repo#number> [--json ...] [--raw|--full]` 继续稳定返回这些字段,并额外支持 `stateDetail,closed,closedAt,merged,mergedAt,mergeCommit,headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup`。`owner/repo#number` shorthand 和冲突 `--repo` 规则与 issue read/view 相同。`stateDetail` 是 UniDesk 归一化生命周期值 `open|closed|merged`,用于区分 REST `state=closed` 中的普通关闭和已合并;`closed`、`closedAt`、`merged`、`mergedAt`、`mergeCommit`、`headRefName` 与 `baseRefName` 都来自 REST,不需要 GraphQL。`mergeable`、`mergeStateStatus` 和 `statusCheckRollup` 只在 read/view 明确请求这些字段或用 `--raw|--full` 显式完整披露时通过 GitHub GraphQL 查询,GraphQL 权限不足或网络失败会结构化失败;GitHub 暂未计算完成时仍保留原始 `UNKNOWN`/null,并额外返回 `closeoutMetadata.ok=false`、`missingOrUnknownFields`、advice 和 `mergeBoundary.unideskCliMergeSupported=true`。此时收口人员应优先重试一次;若仍缺失,应继续改进 UniDesk `gh` 子命令或使用人工 GitHub UI 做最终交叉确认,禁止原生 `gh` 或手拼 GitHub API 绕过。`gh pr preflight <number|owner/repo#number> [--repo owner/name] [--full|--raw]` 是低噪声 PR 收口入口,`gh preflight <number|owner/repo#number>` 和 `gh pr closeout <number|owner/repo#number>` 是兼容别名;shorthand 与 `gh pr view` 一致,已规范化为 `pikasTech/HWLAB#624` 这类形式时不需要再重复 `--repo`。它先输出脱敏 auth capability,再读取 PR state/draft/head/base、mergeable、mergeStateStatus 和 statusCheckRollup,默认只给 status check 计数与失败/等待预览,完整 contexts 和原始读取摘要必须显式加 `--full` 或 `--raw`。该命令固定 `readOnly=true`、`writesRemote=false`、`policy.mergesPr=false`,不会创建、评论、更新或 merge PR。`gh pr create --title <title> --body-file <file>|--body <text> --base <branch> --head <branch> [--draft] [--dry-run]`、`gh pr edit <number> [--title ...] [--body-file <file>|--body-file -|--body <text>] [--dry-run]`、`gh pr update <number> --mode replace|append [--body-file <file>|--body-file -|--body <text>] [--title ...] [--dry-run]`、`gh pr comment create <number> --body-file <file>|--body <text> [--dry-run]`、`gh pr comment delete <commentId> [--dry-run]`、`gh pr close|reopen <number> [--dry-run]` 和 `gh pr merge <number> [--merge|--squash|--rebase] [--delete-branch] [--dry-run]` 是 PR CRUD/生命周期入口。`pr create --dry-run` 只输出 planned operation,不访问 GitHub;非 dry-run 创建前会校验 repo、base、head 和 compare ahead 状态,成功时返回 PR number/url。`pr edit/update` 使用 REST `PATCH /repos/{owner}/{repo}/pulls/{number}`,只发送显式提供的 `title` 和/或 `body` 字段,完全避开 GitHub Projects Classic GraphQL/projectCards;输出低噪声 JSON:`ok`、`repo`、PR number、`changedFields`、`url`、body 长度/SHA/source 元数据和 request plan,不默认回显完整正文。`pr update --mode append` 会先读取当前 PR body 再追加正文。`pr merge` 会先执行同源 closeout 预检,拒绝非 open、draft、冲突、非 CLEAN、失败或 pending checks 的 PR,只有 ready 时才调用 GitHub REST merge;`--delete-branch` 只删除同 repo head ref。`gh pr delete <number>` 不开放,PR 生命周期删除语义请使用 `close`。 - `gh pr list` 与 `gh issue list` 一样接受单个位置参数 `owner/repo` 作为 `--repo owner/repo` 兼容别名;位置 repo 与显式 `--repo` 冲突时会结构化失败,输出里的 `repo` 始终反映真实请求目标。`gh pr read|view --number N --repo owner/repo` 是位置 PR number 的兼容别名,不能在 list/create/edit/comment 等命令中被静默忽略。 - PR dry-run/probe 的最小手动序列是:`bun scripts/cli.ts gh auth status --repo pikasTech/unidesk` 只读检查 token 来源、GitHub REST egress、repo 可见性和 issue read;`bun scripts/cli.ts gh pr create --repo pikasTech/unidesk --title <title> --body-file <file> --base master --head <head> --dry-run` 检查创建计划;`bun scripts/cli.ts gh pr list --repo pikasTech/unidesk --state open --limit 5 --json number,title,state,url,head,base`、`bun scripts/cli.ts gh pr files <number> --repo pikasTech/unidesk --limit 30`、`bun scripts/cli.ts gh pr view <number> --repo pikasTech/unidesk --json body,title,state,stateDetail,closed,closedAt,merged,mergedAt,mergeCommit,head,base,headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup` 和 `bun scripts/cli.ts gh pr preflight <number> --repo pikasTech/unidesk` 做只读 PR 观察、文件统计和收口元数据检查;`bun scripts/cli.ts gh pr edit <number> --repo pikasTech/unidesk --title <title> --body-file <file> --dry-run` 或 `cat <file> | bun scripts/cli.ts gh pr edit <number> --repo pikasTech/unidesk --body-file - --dry-run` 检查低噪声 PR 标题/正文编辑计划;`bun scripts/cli.ts gh pr comment <number> --repo pikasTech/unidesk --body-file <file> --dry-run` 检查评论计划;`bun scripts/cli.ts gh pr merge <number> --repo pikasTech/unidesk --dry-run` 检查 guarded merge plan,真实 merge 只能在任务边界明确允许且 preflight ready 后执行。Code Queue runner 可用 `bun scripts/code-queue-pr-preflight-example.ts --repo pikasTech/unidesk --base master --head <head> --comment-pr <number>` 一次性跑只读 auth status 与 PR create/comment dry-run;该脚本不得输出 token 值,也不会创建、评论或 merge PR。 - `ci install|status|run|publish-backend-core|publish-user-service|run-dev-e2e|logs` 管理 D601 原生 k3s 上的 Tekton CI。`run` 手动创建每 commit 检查和 Code Queue 只读性能门禁;`publish-backend-core` 与 `publish-user-service` 从 pushed Git commit 构建并发布 `127.0.0.1:5000/unidesk/<service>:<commit>` commit-pinned artifacts,输出 `artifactSummary`(含 `serviceId`、`sourceCommit`、`sourceRepo`、`dockerfile`、`imageRef`、`tag`、`digest`、`digestRef`),但不部署生产;`run-dev-e2e` 的 Git 控制 runner、短 launcher、host fetch 边界、临时 smoke namespace 和 no-CD 规则只在 `docs/reference/dev-ci-runner.md` 定义;Tekton CI 通用规则见 `docs/reference/ci.md`。 diff --git a/scripts/src/gh.ts b/scripts/src/gh.ts index b2efc06e..de7c7a58 100644 --- a/scripts/src/gh.ts +++ b/scripts/src/gh.ts @@ -814,6 +814,32 @@ function parsePositionalNumberForCommand(repo: string, args: string[], startInde return parseNumberForCommand(repo, targets[0], label); } +function resolvePositionalPrReference(args: string[], startIndex: number, label: string, options: GitHubOptions): GitHubResolvedNumberReference | GitHubCommandResult { + const targets = positionalArgs(args.slice(startIndex)); + if (targets.length !== 1) { + return validationError(label, options.repo, `${label} requires exactly one positive integer or owner/repo#number positional argument`, { + supportedCommands: [ + `bun scripts/cli.ts gh ${label} <number> --repo ${options.repo}`, + `bun scripts/cli.ts gh ${label} ${options.repo}#<number>`, + ], + }); + } + const shorthand = parseOwnerRepoNumberShorthand(targets[0]); + if (shorthand !== null) { + const explicitRepo = optionValue(args, "--repo"); + if (explicitRepo !== undefined && explicitRepo !== shorthand.repo) { + return validationError(label, explicitRepo, `${label} target ${shorthand.input} resolves to repo ${shorthand.repo}, but --repo ${explicitRepo} was also provided.`, { + shorthand, + explicitRepo, + }); + } + return { repo: shorthand.repo, number: shorthand.number, shorthand }; + } + const number = parseNumberForCommand(options.repo, targets[0], label); + if (typeof number !== "number") return number; + return { repo: options.repo, number }; +} + function parseOwnerRepoNumberShorthand(raw: string | undefined): GitHubShorthandReference | null { if (raw === undefined) return null; const match = /^([^/#\s]+)\/([^/#\s]+)#([1-9]\d*)$/u.exec(raw); @@ -5911,14 +5937,14 @@ export function ghHelp(): unknown { "bun scripts/cli.ts gh issue board-row upsert <issueNumber> [--repo owner/name] --board-issue 20 --section open|closed [--category text] --branch <branch> --tasks <task> --summary <text> --focus <text> --validation <text> --progress <text> [--status OPEN|CLOSED] [--dry-run] [--expect-updated-at ts|--expect-body-sha sha256]", "bun scripts/cli.ts gh issue board-row move <issueNumber> [--repo owner/name] --board-issue 20 --to open|closed [--status OPEN|CLOSED] [--dry-run] [--expect-body-sha sha256]", "bun scripts/cli.ts gh issue board-row delete <issueNumber> [--repo owner/name] --board-issue 20 [--dry-run] [--expect-body-sha sha256]", - "bun scripts/cli.ts gh preflight <prNumber> [--repo owner/name] [--full|--raw] [compatibility alias for gh pr preflight]", + "bun scripts/cli.ts gh preflight <prNumber|owner/repo#number> [--repo owner/name] [--full|--raw] [compatibility alias for gh pr preflight]", "bun scripts/cli.ts gh pr list [owner/repo] [--repo owner/name] [--state open|closed|all] [--limit N] [--json number,title,state,url,updatedAt,createdAt,author,head,base,draft]", "bun scripts/cli.ts gh pr files <number> [--repo owner/name] [--limit N] [number may appear before or after options]", "bun scripts/cli.ts gh pr diff <number> --stat [--repo owner/name] [--limit N] [number may appear before or after options; compatibility alias for pr files; no raw diff]", "bun scripts/cli.ts gh pr read <number|owner/repo#number> [--repo owner/name] [--number N] [--json body,title,state,stateDetail,closed,closedAt,merged,mergedAt,mergeCommit,head,base,draft,headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup] [--raw|--full]", "bun scripts/cli.ts gh pr view <number|owner/repo#number> [--repo owner/name] [--raw|--full] [compatibility alias for pr read]", - "bun scripts/cli.ts gh pr preflight <number> [--repo owner/name] [--full|--raw] [number may appear before or after options]", - "bun scripts/cli.ts gh pr closeout <number> [--repo owner/name] [--full|--raw] [number may appear before or after options; compatibility alias for pr preflight]", + "bun scripts/cli.ts gh pr preflight <number|owner/repo#number> [--repo owner/name] [--full|--raw] [number may appear before or after options]", + "bun scripts/cli.ts gh pr closeout <number|owner/repo#number> [--repo owner/name] [--full|--raw] [number may appear before or after options; compatibility alias for pr preflight]", "bun scripts/cli.ts gh pr create --title <title> --body-file <file>|--body <text> --base <branch> --head <branch> [--repo owner/name] [--draft] [--dry-run]", "bun scripts/cli.ts gh pr edit <number> [--title title] [--body-file <file>|--body-file -|--body <text>] [--repo owner/name] [--dry-run]", "bun scripts/cli.ts gh pr update <number> --mode replace|append [--body-file <file>|--body-file -|--body <text>] [--title title] [--repo owner/name] [--dry-run]", @@ -5958,6 +5984,7 @@ export function ghHelp(): unknown { "PR files is the canonical compact changed-file/stat summary. It uses GitHub REST, returns bounded file rows, additions/deletions/changes when available, truncation metadata, and a next command for full details. Raw diff patches are not emitted by default; gh pr diff <number> --stat is a compatibility alias for the same JSON summary.", "PR edit/update PATCHes /repos/{owner}/{repo}/pulls/{number} through REST only, never GitHub Projects Classic GraphQL/projectCards, and returns low-noise JSON with repo, PR number, changedFields, url, and body size/SHA metadata instead of echoing the full body.", "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 preflight/closeout accept the same owner/repo#number shorthand as PR read/view so merge readiness checks do not require repeating --repo after a PR URL has already been normalized.", "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 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.", @@ -6073,9 +6100,9 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult if (top === "auth" && sub === "status") return authStatus(options.repo); if (top === "preflight") { - const number = parsePositionalNumberForCommand(options.repo, args, 1, "preflight"); - if (typeof number !== "number") return number; - return prPreflight(options.repo, number, "preflight", options.full || options.raw); + const resolved = resolvePositionalPrReference(args, 1, "preflight", options); + if (isGitHubCommandResult(resolved)) return resolved; + return prPreflight(resolved.repo, resolved.number, "preflight", options.full || options.raw); } if (top === "issue") { @@ -6197,9 +6224,9 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult } if (sub === "delete") return unsupportedCommand("pr delete", options.repo, "GitHub REST does not support hard-deleting pull requests; use gh pr close for lifecycle deletion semantics."); if (sub === "preflight" || sub === "closeout") { - const number = parsePositionalNumberForCommand(options.repo, args, 2, `pr ${sub}`); - if (typeof number !== "number") return number; - return prPreflight(options.repo, number, sub === "closeout" ? "pr closeout" : "pr preflight", options.full || options.raw); + const resolved = resolvePositionalPrReference(args, 2, `pr ${sub}`, options); + if (isGitHubCommandResult(resolved)) return resolved; + return prPreflight(resolved.repo, resolved.number, sub === "closeout" ? "pr closeout" : "pr preflight", options.full || options.raw); } if (sub === "comment" && third === "delete") { const commentId = parseNumberForCommand(options.repo, args[3], "pr comment delete"); diff --git a/scripts/src/hwlab-g14.ts b/scripts/src/hwlab-g14.ts index d3bbcc95..63577d01 100644 --- a/scripts/src/hwlab-g14.ts +++ b/scripts/src/hwlab-g14.ts @@ -414,6 +414,31 @@ function compactCommandResult(result: CommandJsonResult): Record<string, unknown }; } +function compactControlPlaneRefresh(value: Record<string, unknown>): Record<string, unknown> { + return { + ok: value.ok === true, + phase: value.phase ?? null, + sourceCommit: value.sourceCommit ?? null, + renderDir: value.renderDir ?? null, + applyOk: nested(value, ["apply", "ok"]) ?? null, + cleanupOk: nested(value, ["cleanupObsoleteCronJobs", "ok"]) ?? null, + applyStdout: tailText(nested(value, ["apply", "stdout"]), 1200), + degradedReason: value.degradedReason ?? null, + }; +} + +function compactTriggerCommandResult(value: Record<string, unknown> | null): Record<string, unknown> | null { + if (value === null) return null; + return { + ok: value.ok ?? null, + exitCode: value.exitCode ?? null, + stdout: tailText(value.stdout, 1200), + stderr: tailText(value.stderr, 1200), + stdoutBytes: value.stdoutBytes ?? null, + stderrBytes: value.stderrBytes ?? null, + }; +} + function isCommandSuccess(result: CommandJsonResult): boolean { if (!result.ok) return false; const topOk = record(result.parsed).ok; @@ -477,14 +502,6 @@ function getV02Head(): string | null { return match?.[0] ?? null; } -function getV02CachedHead(): string | null { - const result = v02WorkspaceScript("git rev-parse origin/v0.2", 30_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 v02PipelineRunName(sourceCommit: string): string { return `${V02_PIPELINERUN_PREFIX}-${shortSha(sourceCommit)}`; } @@ -539,31 +556,6 @@ function secondsBetween(start: unknown, end: unknown): number | null { return Math.round((endMs - startMs) / 1000); } -function pipelineRunStatusRow(item: unknown, nowMs: number): Record<string, unknown> { - const root = record(item); - const metadata = record(root.metadata); - const status = record(root.status); - const labels = record(metadata.labels); - const conditions = Array.isArray(status.conditions) ? status.conditions : []; - const condition = record(conditions[0]); - const startTime = status.startTime ?? null; - const completionTime = status.completionTime ?? null; - const startMs = timestampMs(startTime); - const conditionStatus = typeof condition.status === "string" ? condition.status : null; - return { - name: metadata.name ?? null, - sourceCommit: labels["hwlab.pikastech.local/source-commit"] ?? null, - status: conditionStatus, - reason: condition.reason ?? null, - createdAt: metadata.creationTimestamp ?? null, - startTime, - completionTime, - durationSeconds: secondsBetween(startTime, completionTime), - elapsedSeconds: startMs === null || completionTime !== null ? null : Math.max(0, Math.round((nowMs - startMs) / 1000)), - active: completionTime === null && conditionStatus !== "True" && conditionStatus !== "False", - }; -} - function pipelineRunStatusRowFromLine(line: string, nowMs: number): Record<string, unknown> | null { const [ name = "", @@ -592,8 +584,7 @@ function pipelineRunStatusRowFromLine(line: string, nowMs: number): Record<strin }; } -function parsePipelineRunRows(text: string, limit: number): Record<string, unknown> { - const nowMs = Date.now(); +function parsePipelineRunRows(text: string, limit: number, nowMs = Date.now()): Record<string, unknown> { const rows = text .split(/\r?\n/u) .map((line) => pipelineRunStatusRowFromLine(line.trim(), nowMs)) @@ -698,9 +689,15 @@ function shellSectionOk(section: ShellSection | undefined): boolean { return section?.exitCode === 0; } -function v02ControlPlaneStatusBundle(pipelineRun: string | null): CommandJsonResult { +function v02ControlPlaneStatusBundle(sourceCommit: string | null | undefined): CommandJsonResult { + const sourceCommitLine = sourceCommit === undefined + ? `source_commit=$(git -C ${shellQuote(V02_WORKSPACE)} rev-parse origin/v0.2 2>/dev/null || true)` + : `source_commit=${shellQuote(sourceCommit ?? "")}`; const script = [ "set +e", + sourceCommitLine, + "pipeline_run=", + `if [ -n "$source_commit" ]; then pipeline_run="${V02_PIPELINERUN_PREFIX}-$(printf '%s' "$source_commit" | cut -c1-12)"; fi`, "section() {", " name=\"$1\"", " shift", @@ -709,18 +706,18 @@ function v02ControlPlaneStatusBundle(pipelineRun: string | null): CommandJsonRes " code=$?", " printf '\\n__UNIDESK_SECTION_END__ %s exit=%s\\n' \"$name\" \"$code\"", "}", + "section sourceCommit printf '%s\\n' \"$source_commit\"", + "section queryNow date -u +%Y-%m-%dT%H:%M:%SZ", `section controlPlane kubectl get pipeline,role,rolebinding,serviceaccount -n ${shellQuote(CI_NAMESPACE)} -l hwlab.pikastech.local/gitops-target=v02 -o name`, `section obsoleteCronJobs kubectl get cronjob -n ${shellQuote(CI_NAMESPACE)} ${shellQuote(V02_POLLER)} ${shellQuote(V02_RECONCILER)} --ignore-not-found -o name`, `section argo kubectl get application -n ${shellQuote(ARGO_NAMESPACE)} ${shellQuote(V02_APP)} -o 'jsonpath={.spec.source.targetRevision}{"\\n"}{.spec.source.path}{"\\n"}{.status.sync.revision}{"\\n"}{.status.sync.status}{"\\n"}{.status.health.status}{"\\n"}'`, - pipelineRun === null - ? "section pipelineRun sh -c 'true'" - : `section pipelineRun kubectl get pipelinerun -n ${shellQuote(CI_NAMESPACE)} ${shellQuote(pipelineRun)} -o 'jsonpath={.status.conditions[0].status}{"\\n"}{.status.conditions[0].reason}{"\\n"}{.status.conditions[0].message}{"\\n"}'`, + `if [ -n "$pipeline_run" ]; then section pipelineRun kubectl get pipelinerun -n ${shellQuote(CI_NAMESPACE)} "$pipeline_run" -o 'jsonpath={.status.conditions[0].status}{"\\n"}{.status.conditions[0].reason}{"\\n"}{.status.conditions[0].message}{"\\n"}'; else section pipelineRun sh -c 'true'; fi`, `section recentPipelineRuns kubectl get pipelinerun -n ${shellQuote(CI_NAMESPACE)} -l hwlab.pikastech.local/gitops-target=v02 -o ${shellQuote(pipelineRunRowsJsonPath())}`, ].join("\n"); return g14K3s(["script", "--", script], 60_000); } -function listV02PipelineRunsCompactFromText(text: string, commandOk: boolean, command: string[] | string, exitCode: number | null, stderr: string, limit = 8): Record<string, unknown> { +function listV02PipelineRunsCompactFromText(text: string, commandOk: boolean, command: string[] | string, exitCode: number | null, stderr: string, limit = 8, nowMs = Date.now()): Record<string, unknown> { if (!commandOk) { return { ok: false, @@ -731,7 +728,7 @@ function listV02PipelineRunsCompactFromText(text: string, commandOk: boolean, co activeItems: [], }; } - return parsePipelineRunRows(text, limit); + return parsePipelineRunRows(text, limit, nowMs); } function pipelinePrefixesForLane(lane: "v02" | "g14" | "all"): string[] { @@ -1177,10 +1174,12 @@ function deleteV02PipelineRun(pipelineRun: string): CommandJsonResult { return g14K3s(["kubectl", "delete", "pipelinerun", "-n", CI_NAMESPACE, pipelineRun, "--ignore-not-found=true"], 60_000); } -function v02ControlPlaneStatus(sourceCommit: string | null = getV02CachedHead()): Record<string, unknown> { - const pipelineRun = sourceCommit === null ? null : v02PipelineRunName(sourceCommit); - const bundle = v02ControlPlaneStatusBundle(pipelineRun); +function v02ControlPlaneStatus(sourceCommitInput?: string | null): Record<string, unknown> { + const bundle = v02ControlPlaneStatusBundle(sourceCommitInput); const sections = parseShellSections(statusText(bundle)); + const sourceCommit = stringOrNull(sections.sourceCommit?.stdout) ?? null; + const pipelineRun = sourceCommit === null ? null : v02PipelineRunName(sourceCommit); + const queryNowMs = timestampMs(sections.queryNow?.stdout) ?? Date.now(); const controlPlane = sections.controlPlane; const obsoleteCronJobs = sections.obsoleteCronJobs; const argo = sections.argo; @@ -1191,6 +1190,8 @@ function v02ControlPlaneStatus(sourceCommit: string | null = getV02CachedHead()) "kubectl get pipelinerun -n hwlab-ci -l hwlab.pikastech.local/gitops-target=v02 -o jsonpath=<summary-fields>", sections.recentPipelineRuns?.exitCode ?? null, bundle.stderr, + 8, + queryNowMs, ); const activePipelineRuns = Array.isArray(recentPipelineRuns.activeItems) ? recentPipelineRuns.activeItems : []; const [targetRevision = "", path = "", syncRevision = "", syncStatus = "", health = ""] = String(argo?.stdout ?? "").split(/\r?\n/u); @@ -1252,12 +1253,12 @@ function v02ControlPlaneStatus(sourceCommit: string | null = getV02CachedHead()) function runV02ControlPlane(options: G14ControlPlaneOptions): Record<string, unknown> { if (options.action === "cleanup-runs") return runControlPlaneCleanup(options); if (options.action === "cleanup-released-pvs") return runControlPlaneReleasedPvCleanup(options); - const sourceCommit = options.action === "status" ? getV02CachedHead() : getV02Head(); + const sourceCommit = options.action === "status" ? undefined : getV02Head(); if (sourceCommit === null) { return { ok: false, command: `hwlab g14 control-plane ${options.action} --lane v02`, degradedReason: "v02-head-unresolved", workspace: V02_WORKSPACE }; } if (options.action === "runtime-migration") return runV02RuntimeMigration(options, sourceCommit); - if (options.action === "status") return v02ControlPlaneStatus(sourceCommit); + if (options.action === "status") return v02ControlPlaneStatus(); if (options.action === "apply") { const render = runV02RenderToTemp(sourceCommit); if (!isCommandSuccess(render)) { @@ -1370,11 +1371,14 @@ function runV02ControlPlane(options: G14ControlPlaneOptions): Record<string, unk sourceCommit, pipelineRun: v02PipelineRunName(sourceCommit), before, - controlPlaneRefresh, + controlPlaneRefresh: compactControlPlaneRefresh(controlPlaneRefresh), gitMirrorPreSync, - deletePipelineRun: compactCommandResult(deletePipelineRun), - createPipelineRun: createPipelineRun === null ? null : compactCommandResult(createPipelineRun), + deletePipelineRun: compactTriggerCommandResult(compactCommandResult(deletePipelineRun)), + createPipelineRun: createPipelineRun === null ? null : compactTriggerCommandResult(compactCommandResult(createPipelineRun)), after: getPipelineRunCompact(v02PipelineRunName(sourceCommit)), + disclosure: { + fullTriggerOutput: "Use the async job stdout/stderr files from job status for full command details.", + }, }; } @@ -1543,8 +1547,8 @@ function compactGitMirrorSync(sync: Record<string, unknown>): Record<string, unk namespace: sync.namespace ?? GIT_MIRROR_NAMESPACE, jobName: sync.jobName ?? null, exitCode: nested(sync, ["result", "exitCode"]) ?? null, - stdoutTail: tailText(nested(sync, ["result", "stdout"]), 3000), - stderrTail: tailText(nested(sync, ["result", "stderr"]), 3000), + stdoutTail: tailText(nested(sync, ["result", "stdout"]), 1600), + stderrTail: tailText(nested(sync, ["result", "stderr"]), 1200), stdoutBytes: nested(sync, ["result", "stdoutBytes"]) ?? null, stderrBytes: nested(sync, ["result", "stderrBytes"]) ?? null, };