diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 2768cb1b..ac3a083f 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -78,6 +78,7 @@ CI/CD、GitOps、rollout、artifact 发布、PR 合并后的 DEV/PROD 滚动、P - `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 ...] [--raw|--full]` 提供 REST 列表,默认 `state=all` 以保持既有 UniDesk CLI 行为,字段白名单是 `body,title,state,number,url,author,head,base,draft,createdAt,updatedAt,headRefName,baseRefName`;未知 state 或未知 `--json` 字段必须结构化失败并带 `runnerDisposition=business-failed`。`--raw|--full` 在 `gh pr list` 上等价 `gh issue list --raw|--full`:响应带 `noDump=true`,inline 输出完整数据,绕开 20 KiB stdout 截断。`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 view|read [--json ...] [--raw|--full]` 继续稳定返回这些字段,并额外支持 `stateDetail,closed,closedAt,merged,mergedAt,mergeCommit,headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup`。`owner/repo#number` shorthand 和冲突 `--repo` 规则与 issue view/read 相同。`stateDetail` 是 UniDesk 归一化生命周期值 `open|closed|merged`,用于区分 REST `state=closed` 中的普通关闭和已合并;`closed`、`closedAt`、`merged`、`mergedAt`、`mergeCommit`、`headRefName` 与 `baseRefName` 都来自 REST,不需要 GraphQL。`mergeable`、`mergeStateStatus` 和 `statusCheckRollup` 只在 view/read 明确请求这些字段或用 `--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 ` 是兼容别名;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 --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 merge` 的 already-merged 终态是 guarded merge 的幂等成功例外:当目标 PR 已经处于 `merged` 状态时,命令返回 `ok=true`、`alreadyMerged=true`、`pullRequest.merged=true` 和 merge commit 摘要,不再把并发 monitor、GitHub UI 或人工合并后的 `closed` 状态误报为 validation-failed。 - `gh pr list` 与 `gh issue list` 一样接受单个位置参数 `owner/repo` 作为 `--repo owner/repo` 兼容别名;位置 repo 与显式 `--repo` 冲突时会结构化失败,输出里的 `repo` 始终反映真实请求目标。`--number N --repo owner/repo` 是单 PR/comment 数字目标命令的位置参数兼容别名,适用于 `view/read/files/diff/preflight/closeout/edit/update/comment create/comment delete/close/reopen/merge`,成功输出必须带 `standardSyntaxHint`;comment delete 中的 `--number` 表示 commentId,不是 PR number;`list/create` 不能静默忽略 `--number`。 - 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/gh-cli-pr-contract-test.ts b/scripts/gh-cli-pr-contract-test.ts index b5ab34c5..d9ad24bf 100644 --- a/scripts/gh-cli-pr-contract-test.ts +++ b/scripts/gh-cli-pr-contract-test.ts @@ -561,6 +561,15 @@ export async function runGhCliPrContract(): Promise<JsonRecord> { 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 mergeRequestCount = mock.requests.filter((request) => request.method === "PUT" && request.url === "/repos/pikasTech/unidesk/pulls/42/merge").length; + const alreadyMerged = await runCli(["gh", "pr", "merge", "43", "--repo", "pikasTech/unidesk", "--squash"], env); + assertCondition(alreadyMerged.status === 0, "pr merge should treat an already merged PR as idempotent success", alreadyMerged.json ?? { stdout: alreadyMerged.stdout }); + const alreadyMergedData = dataOf(alreadyMerged.json ?? {}); + assertCondition(alreadyMergedData.alreadyMerged === true, "already merged PR response should expose alreadyMerged=true", alreadyMergedData); + const alreadyMergedPullRequest = alreadyMergedData.pullRequest as JsonRecord | undefined; + assertCondition(alreadyMergedPullRequest?.merged === true, "already merged PR response should expose merged pullRequest", alreadyMergedData); + const mergeRequestCountAfterAlreadyMerged = mock.requests.filter((request) => request.method === "PUT" && request.url === "/repos/pikasTech/unidesk/pulls/42/merge").length; + assertCondition(mergeRequestCountAfterAlreadyMerged === mergeRequestCount, "already merged PR should not call REST merge endpoint again", mock.requests); const preflight = await runBun([ "scripts/code-queue-pr-preflight-example.ts", diff --git a/scripts/src/gh.ts b/scripts/src/gh.ts index 374a62f6..d9721da9 100644 --- a/scripts/src/gh.ts +++ b/scripts/src/gh.ts @@ -4900,6 +4900,19 @@ async function prMerge(repo: string, token: string, number: number, options: Git 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); + if (summary.merged === true) { + return { + ok: true, + command: "pr merge", + repo, + number, + method: options.mergeMethod, + alreadyMerged: true, + pullRequest: summary, + branchDeletion: { attempted: false, skippedReason: "already-merged" }, + rest: true, + }; + } 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);