fix: relax gh number target compatibility

This commit is contained in:
Codex
2026-06-05 00:18:29 +00:00
parent 316fdedc3f
commit 384fd613ff
6 changed files with 425 additions and 202 deletions
+2 -2
View File
@@ -141,11 +141,11 @@
## T26 GitHub CLI PR 安全写入口 ## T26 GitHub CLI PR 安全写入口
阅读 `AGENTS.md``docs/reference/cli.md`,然后用 cli 手动测试以下内容:准备一份包含真实换行、反引号和 Markdown 表格的临时正文文件,运行 `bun scripts/cli.ts gh help`,确认 help 中包含 `gh pr create``gh pr edit``gh pr comment``gh pr read <number|owner/repo#number>``gh pr preflight <number>``gh preflight <prNumber>``--raw|--full``gh pr files <number>``gh pr diff <number> --stat`。运行 `bun scripts/gh-cli-pr-contract-test.ts`,确认 mock GitHub 覆盖 PR read/view 的 `owner/repo#number` shorthand、`--raw` 完整披露、冲突 `--repo` 结构化失败、PR closeout GraphQL 字段、低噪声 `gh pr preflight`/`gh preflight`、PR edit/update REST PATCH payload、stdin `--body-file -` 和不回显完整正文。运行 `bun scripts/gh-cli-pr-files-contract-test.ts`,确认 mock GitHub 覆盖 `gh pr files` 的 REST changed-file/stat JSON、bounded file list、truncation metadata、next command、无 raw patch,以及 `gh pr diff --stat` 兼容别名和无 `--stat` raw diff 的结构化拒绝。对真实仓库只读观察可运行 `bun scripts/cli.ts gh pr files <number> --repo pikasTech/unidesk --limit 30``bun scripts/cli.ts gh pr diff <number> --repo pikasTech/unidesk --stat --limit 30``bun scripts/cli.ts gh pr preflight <number> --repo pikasTech/unidesk`,确认输出固定 JSON 且默认不含 raw diff 或完整 status contexts;需要完整 status contexts 时显式加 `--full`。运行 `bun scripts/cli.ts gh pr create --repo pikasTech/unidesk --title <title> --body-file <file> --base master --head <branch> --draft --dry-run`,确认命令不访问 GitHub、不创建 PR,JSON 中包含 `dryRun=true``planned=true`、repo、title、base、head、draft、bodyChars、bodyPreviewLines、request plan,并且正文预览保留真实换行和反引号。运行 `bun scripts/cli.ts gh pr edit <number> --repo pikasTech/unidesk --title <title> --body-file <file> --dry-run`,确认命令使用 REST PATCH 计划、不访问 GitHub Projects Classic GraphQL/projectCardsJSON 只包含 repo、PR number、changedFields、url、body 长度/SHA/source 和 request plan,不默认回显完整正文;再运行 `cat <file> | bun scripts/cli.ts gh pr edit <number> --repo pikasTech/unidesk --body-file - --dry-run`,确认 stdin source 标记为 `kind=stdin` 且同样低噪声。运行 `bun scripts/cli.ts gh pr comment <number> --repo pikasTech/unidesk --body-file <file> --dry-run`,确认命令不写评论,JSON 中包含 PR number、bodyChars、bodySource 和 request plan,且没有把换行污染成字面量 `\n`运行 `bun scripts/cli.ts gh pr merge <number> --repo pikasTech/unidesk`,确认返回非零状态和结构化 JSON`degradedReason=unsupported-command``runnerDisposition=business-failed`,且不会真实 merge。需要测试真实创建、编辑评论时,只允许使用明确的 throwaway 源分支和 PR,并在记录中写明 PR URL、number、源/目标分支和清理动作;默认验收只做 dry-run,不创建或修改真实 PR。 阅读 `AGENTS.md``docs/reference/cli.md`,然后用 cli 手动测试以下内容:准备一份包含真实换行、反引号和 Markdown 表格的临时正文文件,运行 `bun scripts/cli.ts gh help`,确认 help 中包含 `gh pr create``gh pr edit``gh pr comment``gh pr view <number|url|owner/repo#number>``gh pr read ... [compatibility alias for pr view]``--number N compat``gh pr preflight <number>``gh preflight <prNumber>``--raw|--full``gh pr files <number>``gh pr diff <number> --stat`。运行 `bun scripts/gh-cli-pr-contract-test.ts`,确认 mock GitHub 覆盖 PR view/read 的 GitHub URL、`owner/repo#number` shorthand、`--number` 兼容提示、`--raw` 完整披露、冲突 `--repo` 结构化失败、PR closeout GraphQL 字段、低噪声 `gh pr preflight`/`gh preflight`、PR edit/update REST PATCH payload、stdin `--body-file -` 和不回显完整正文。运行 `bun scripts/gh-cli-pr-files-contract-test.ts`,确认 mock GitHub 覆盖 `gh pr files` 的 REST changed-file/stat JSON、bounded file list、truncation metadata、next command、无 raw patch,以及 `gh pr diff --stat` 兼容别名和无 `--stat` raw diff 的结构化拒绝。对真实仓库只读观察可运行 `bun scripts/cli.ts gh pr files <number> --repo pikasTech/unidesk --limit 30``bun scripts/cli.ts gh pr diff <number> --repo pikasTech/unidesk --stat --limit 30``bun scripts/cli.ts gh pr preflight <number> --repo pikasTech/unidesk`,确认输出固定 JSON 且默认不含 raw diff 或完整 status contexts;需要完整 status contexts 时显式加 `--full`。运行 `bun scripts/cli.ts gh pr create --repo pikasTech/unidesk --title <title> --body-file <file> --base master --head <branch> --draft --dry-run`,确认命令不访问 GitHub、不创建 PR,JSON 中包含 `dryRun=true``planned=true`、repo、title、base、head、draft、bodyChars、bodyPreviewLines、request plan,并且正文预览保留真实换行和反引号。运行 `bun scripts/cli.ts gh pr edit <number> --repo pikasTech/unidesk --title <title> --body-file <file> --dry-run``bun scripts/cli.ts gh pr edit --number <number> --repo pikasTech/unidesk --title <title> --body-file <file> --dry-run`,确认命令使用 REST PATCH 计划、不访问 GitHub Projects Classic GraphQL/projectCards`--number` 兼容路径返回 `standardSyntaxHint`JSON 只包含 repo、PR number、changedFields、url、body 长度/SHA/source 和 request plan,不默认回显完整正文;再运行 `cat <file> | bun scripts/cli.ts gh pr edit <number> --repo pikasTech/unidesk --body-file - --dry-run`,确认 stdin source 标记为 `kind=stdin` 且同样低噪声。运行 `bun scripts/cli.ts gh pr comment <number> --repo pikasTech/unidesk --body-file <file> --dry-run`,确认命令不写评论,JSON 中包含 PR number、bodyChars、bodySource 和 request plan,且没有把换行污染成字面量 `\n`运行 `bun scripts/cli.ts gh pr comment delete --number <commentId> --repo pikasTech/unidesk --dry-run`,确认 `--number` 按 commentId 兼容并返回 `standardSyntaxHint`。运行 `bun scripts/cli.ts gh pr merge <number> --repo pikasTech/unidesk`,确认 guarded merge plan 或结构化失败不会绕过预检。需要测试真实创建、编辑评论或 merge 时,只允许使用明确的 throwaway 源分支和 PR,并在记录中写明 PR URL、number、源/目标分支和清理动作;默认验收只做 dry-run,不创建或修改真实 PR。
## T27 GitHub Issue/Comment 换行转义卫生扫描 ## T27 GitHub Issue/Comment 换行转义卫生扫描
阅读 `AGENTS.md``docs/reference/cli.md`,然后用 cli 手动测试以下内容:运行 `bun scripts/cli.ts gh help`,确认 help 中包含 `gh issue create --title <title> --body-file <file> [--label label[,label...]]...``gh issue read <number|owner/repo#number>``--raw|--full``gh issue scan-escape``gh issue cleanup-plan``gh issue board-row list``gh issue board-row update`notes 中明确推荐 `--body-file`、quoted heredoc、只读 cleanup-plan、read/view shorthand、raw/full 显式完整披露、board-row update 默认 dry-run 和 `--expect-body-sha`/`--expect-updated-at` 并发保护。运行 `bun scripts/gh-cli-issue-guard-contract-test.ts`,确认 mock GitHub 覆盖 issue read/view 的 `owner/repo#number` shorthand、`--raw` 完整披露、冲突 `--repo` 结构化失败、污染命中、说明性 `\n` 命中不误报、短 body/null body guard、body-file dry-run 写入路径、`issue create --label cli,infra --label ops --dry-run` labels 解析和 request plan、真实 create REST payload labels、missing label 的结构化 `validation-failed`、comment-id/body-id 定位和 cleanupSuggestions、board-row list/get 复用 #20 表格解析、board-row update 给出 old/new row、body SHA、guard 结果、表格管道转义、默认 dry-run 不写入、带 `--expect-body-sha` 时只对 mock server PATCH、以及 board-row move 迁移支持。对真实仓库只允许运行 `bun scripts/cli.ts gh issue scan-escape --repo pikasTech/unidesk --limit <N> --dry-run``bun scripts/cli.ts gh issue cleanup-plan --repo pikasTech/unidesk --limit <N>``bun scripts/cli.ts gh issue board-row list --repo pikasTech/unidesk --board-issue 20 --state open --dry-run``bun scripts/cli.ts gh issue board-row get <issueNumber> --repo pikasTech/unidesk --board-issue 20` 这类只读命令;不得运行真实历史评论清理、不得真实改写 #20/#24 正文,除非另有明确人工指令并先审阅 dry-run 输出和 body SHA。 阅读 `AGENTS.md``docs/reference/cli.md`,然后用 cli 手动测试以下内容:运行 `bun scripts/cli.ts gh help`,确认 help 中包含 `gh issue create --title <title> --body-file <file> [--label label[,label...]]...``gh issue view <number|url|owner/repo#number>``gh issue read ... [compatibility alias for issue view]``--number N compat``--raw|--full``gh issue scan-escape``gh issue cleanup-plan``gh issue board-row list``gh issue board-row update`notes 中明确推荐 `--body-file`、quoted heredoc、只读 cleanup-plan、view/read shorthand、GitHub URL、`--number` 兼容提示、raw/full 显式完整披露、board-row update 默认 dry-run 和 `--expect-body-sha`/`--expect-updated-at` 并发保护。运行 `bun scripts/gh-cli-issue-guard-contract-test.ts`,确认 mock GitHub 覆盖 issue view/read 的 GitHub URL、`owner/repo#number` shorthand、`--number` 兼容提示、`--raw` 完整披露、冲突 `--repo` 结构化失败、污染命中、说明性 `\n` 命中不误报、短 body/null body guard、body-file dry-run 写入路径、`issue create --label cli,infra --label ops --dry-run` labels 解析和 request plan、真实 create REST payload labels、missing label 的结构化 `validation-failed`、comment-id/body-id 定位、comment delete --number commentId dry-run 和 cleanupSuggestions、board-row list/get 复用 #20 表格解析、board-row update 给出 old/new row、body SHA、guard 结果、表格管道转义、默认 dry-run 不写入、带 `--expect-body-sha` 时只对 mock server PATCH、以及 board-row move 迁移支持。对真实仓库只允许运行 `bun scripts/cli.ts gh issue scan-escape --repo pikasTech/unidesk --limit <N> --dry-run``bun scripts/cli.ts gh issue cleanup-plan --repo pikasTech/unidesk --limit <N>``bun scripts/cli.ts gh issue board-row list --repo pikasTech/unidesk --board-issue 20 --state open --dry-run``bun scripts/cli.ts gh issue board-row get <issueNumber> --repo pikasTech/unidesk --board-issue 20` 这类只读命令;不得运行真实历史评论清理、不得真实改写 #20/#24 正文,除非另有明确人工指令并先审阅 dry-run 输出和 body SHA。
## T28 Host Codex Commander Skeleton Contract ## T28 Host Codex Commander Skeleton Contract
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+53 -17
View File
@@ -649,8 +649,8 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
const usage = Array.isArray(helpData.usage) ? helpData.usage.map((value) => String(value)) : []; const usage = Array.isArray(helpData.usage) ? helpData.usage.map((value) => String(value)) : [];
const notes = Array.isArray(helpData.notes) ? helpData.notes.map((value) => String(value)) : []; const notes = Array.isArray(helpData.notes) ? helpData.notes.map((value) => String(value)) : [];
assertCondition(usage.some((line) => line.includes("gh issue list")), "gh help should list issue list", { usage }); assertCondition(usage.some((line) => line.includes("gh issue list")), "gh help should list issue list", { usage });
assertCondition(usage.some((line) => line.includes("gh issue read")), "gh help should list issue read", { usage }); assertCondition(usage.some((line) => line.includes("gh issue view") && line.includes("number|url|owner/repo#number")), "gh help should list standard issue view target forms", { usage });
assertCondition(usage.some((line) => line.includes("gh issue view")), "gh help should list issue view", { usage }); assertCondition(usage.some((line) => line.includes("gh issue read") && line.includes("compatibility alias for issue view")), "gh help should list issue read compatibility alias", { usage });
assertCondition(usage.some((line) => line.includes("gh issue comment create") && line.includes("--body <short-text>")), "gh help should list short inline issue comment body", { usage }); assertCondition(usage.some((line) => line.includes("gh issue comment create") && line.includes("--body <short-text>")), "gh help should list short inline issue comment body", { usage });
assertCondition(usage.some((line) => line.includes("owner/repo#number") && line.includes("--raw|--full")), "gh help should document issue shorthand and raw/full disclosure", { usage }); assertCondition(usage.some((line) => line.includes("owner/repo#number") && line.includes("--raw|--full")), "gh help should document issue shorthand and raw/full disclosure", { usage });
assertCondition(usage.some((line) => line.includes("gh issue board-row list")), "gh help should list board-row list", { usage }); assertCondition(usage.some((line) => line.includes("gh issue board-row list")), "gh help should list board-row list", { usage });
@@ -660,9 +660,10 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
assertCondition(usage.some((line) => line.includes("gh issue board-row move")), "gh help should list board-row move", { usage }); assertCondition(usage.some((line) => line.includes("gh issue board-row move")), "gh help should list board-row move", { usage });
assertCondition(usage.some((line) => line.includes("gh issue board-row delete")), "gh help should list board-row delete", { usage }); assertCondition(usage.some((line) => line.includes("gh issue board-row delete")), "gh help should list board-row delete", { usage });
assertCondition(usage.some((line) => line.includes("gh issue list") && line.includes("--search text")), "gh help should list issue list search", { usage }); assertCondition(usage.some((line) => line.includes("gh issue list") && line.includes("--search text")), "gh help should list issue list search", { usage });
assertCondition(notes.some((line) => line.includes("canonical read path")), "gh help should state issue read is canonical", { notes }); assertCondition(notes.some((line) => line.includes("issue view is the canonical")), "gh help should state issue view is canonical", { notes });
assertCondition(notes.some((line) => line.includes("compatibility alias")), "gh help should state issue view is alias", { notes }); assertCondition(notes.some((line) => line.includes("read remains") && line.includes("compatibility alias")), "gh help should state issue read is alias", { notes });
assertCondition(notes.some((line) => line.includes("owner/repo#number shorthand")), "gh help should explain read/view shorthand", { notes }); assertCondition(notes.some((line) => line.includes("GitHub issue URLs") && line.includes("owner/repo#number shorthand")), "gh help should explain issue view/read URL and shorthand targets", { notes });
assertCondition(notes.some((line) => line.includes("--number is accepted on single issue/comment numeric target commands") && line.includes("Comment delete treats --number as commentId")), "gh help should document issue --number compatibility scope", { notes });
assertCondition(notes.some((line) => line.includes("--raw and --full are explicit full-disclosure aliases")), "gh help should explain raw/full read disclosure", { notes }); assertCondition(notes.some((line) => line.includes("--raw and --full are explicit full-disclosure aliases")), "gh help should explain raw/full read disclosure", { notes });
assertCondition(notes.some((line) => line.includes("issue comment create accepts --body-file <file|->") && line.includes("--body only for short single-line text")), "gh help should document issue comment stdin and inline safety limits", { notes }); assertCondition(notes.some((line) => line.includes("issue comment create accepts --body-file <file|->") && line.includes("--body only for short single-line text")), "gh help should document issue comment stdin and inline safety limits", { notes });
assertCondition(notes.some((line) => line.includes("board-row update changes one table cell")), "gh help should describe board-row update safety", { notes }); assertCondition(notes.some((line) => line.includes("board-row update changes one table cell")), "gh help should describe board-row update safety", { notes });
@@ -1256,12 +1257,33 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
assertCondition(!("comments" in selectedJson), "--json body should not imply comments field", selectedJson); assertCondition(!("comments" in selectedJson), "--json body should not imply comments field", selectedJson);
const viewBody = await runCli(["gh", "issue", "view", "20", "--repo", "pikasTech/unidesk", "--json", "body"], env); const viewBody = await runCli(["gh", "issue", "view", "20", "--repo", "pikasTech/unidesk", "--json", "body"], env);
assertCondition(viewBody.status === 0, "issue view alias should succeed", viewBody.json ?? { stdout: viewBody.stdout }); assertCondition(viewBody.status === 0, "issue view should succeed as canonical read path", viewBody.json ?? { stdout: viewBody.stdout });
const viewBodyData = dataOf(viewBody.json ?? {}); const viewBodyData = dataOf(viewBody.json ?? {});
const viewIssue = viewBodyData.issue as JsonRecord; const viewIssue = viewBodyData.issue as JsonRecord;
assertCondition(typeof viewIssue.body === "string" && viewIssue.body.includes("## 看板(OPEN"), "issue view alias should keep .data.issue.body readable", viewBodyData); assertCondition(typeof viewIssue.body === "string" && viewIssue.body.includes("## 看板(OPEN"), "issue view should keep .data.issue.body readable", viewBodyData);
const viewSelectedJson = viewBodyData.json as JsonRecord; const viewSelectedJson = viewBodyData.json as JsonRecord;
assertCondition(typeof viewSelectedJson.body === "string" && viewSelectedJson.body === readIssue.body, "issue view alias should preserve selected json body", viewBodyData); assertCondition(typeof viewSelectedJson.body === "string" && viewSelectedJson.body === readIssue.body, "issue view should preserve selected json body", viewBodyData);
const issueUrlView = await runCli(["gh", "issue", "view", "https://github.com/pikasTech/HWLAB/issues/7", "--json", "body,title,state"], env);
assertCondition(issueUrlView.status === 0, "issue view should accept GitHub issue URL target", issueUrlView.json ?? { stdout: issueUrlView.stdout });
const issueUrlViewData = dataOf(issueUrlView.json ?? {});
assertCondition(issueUrlViewData.repo === "pikasTech/HWLAB", "issue URL target should derive repo", issueUrlViewData);
assertCondition((issueUrlViewData.issue as JsonRecord).number === 7, "issue URL target should derive issue number", issueUrlViewData);
const issueUrlDisclosure = issueUrlViewData.disclosure as JsonRecord;
assertCondition(issueUrlDisclosure.shorthand && (issueUrlDisclosure.shorthand as JsonRecord).source === "github-url", "issue URL target should be disclosed", issueUrlDisclosure);
const issuePrUrlMismatch = await runCli(["gh", "issue", "view", "https://github.com/pikasTech/HWLAB/pull/7", "--json", "body"], env);
assertCondition(issuePrUrlMismatch.status !== 0, "issue view should reject PR URLs", issuePrUrlMismatch.json ?? { stdout: issuePrUrlMismatch.stdout });
const issuePrUrlMismatchData = failedDataOf(issuePrUrlMismatch.json ?? {});
assertCondition(failureMessageOf(issuePrUrlMismatchData).includes("GitHub pr URL"), "issue view PR URL mismatch should be explicit", issuePrUrlMismatchData);
const issueNumberOption = await runCli(["gh", "issue", "view", "--repo", "pikasTech/HWLAB", "--number", "7", "--json", "body"], env);
assertCondition(issueNumberOption.status === 0, "issue view should accept --number compatibility alias", issueNumberOption.json ?? { stdout: issueNumberOption.stdout });
const issueNumberOptionData = dataOf(issueNumberOption.json ?? {});
assertCondition(issueNumberOptionData.repo === "pikasTech/HWLAB", "issue view --number should preserve explicit repo", issueNumberOptionData);
assertCondition((issueNumberOptionData.issue as JsonRecord).number === 7, "issue view --number should read the requested issue", issueNumberOptionData);
const issueNumberOptionHint = issueNumberOptionData.standardSyntaxHint as JsonRecord;
assertCondition(issueNumberOptionHint.compatibility === true && String(issueNumberOptionHint.standardCommand ?? "").includes("gh issue view 7 --repo pikasTech/HWLAB"), "issue view --number should return standard syntax hint", issueNumberOptionHint);
const shorthandRaw = await runCli(["gh", "issue", "view", "pikasTech/HWLAB#7", "--raw"], env); const shorthandRaw = await runCli(["gh", "issue", "view", "pikasTech/HWLAB#7", "--raw"], env);
assertCondition(shorthandRaw.status === 0, "issue view should accept owner/repo#number shorthand with --raw", shorthandRaw.json ?? { stdout: shorthandRaw.stdout }); assertCondition(shorthandRaw.status === 0, "issue view should accept owner/repo#number shorthand with --raw", shorthandRaw.json ?? { stdout: shorthandRaw.stdout });
@@ -1281,7 +1303,7 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
assertCondition(shorthandConflictData.degradedReason === "validation-failed", "conflicting --repo should be validation-failed", shorthandConflictData); assertCondition(shorthandConflictData.degradedReason === "validation-failed", "conflicting --repo should be validation-failed", shorthandConflictData);
assertCondition(String(shorthandConflictData.message ?? "").includes("resolves to repo pikasTech/HWLAB"), "conflict message should name the derived repo", shorthandConflictData); assertCondition(String(shorthandConflictData.message ?? "").includes("resolves to repo pikasTech/HWLAB"), "conflict message should name the derived repo", shorthandConflictData);
const issueConflictCommands = shorthandConflictData.supportedCommands as string[]; const issueConflictCommands = shorthandConflictData.supportedCommands as string[];
assertCondition(Array.isArray(issueConflictCommands) && issueConflictCommands.some((command) => command === "bun scripts/cli.ts gh issue read 7 --repo pikasTech/HWLAB --json body,title,state,closed,closedAt,comments,number,url,author,createdAt,updatedAt"), "conflict should include the exact supported issue read command", shorthandConflictData); assertCondition(Array.isArray(issueConflictCommands) && issueConflictCommands.some((command) => command === "bun scripts/cli.ts gh issue view 7 --repo pikasTech/HWLAB --json body,title,state,closed,closedAt,comments,number,url,author,createdAt,updatedAt"), "conflict should include the exact supported issue view command", shorthandConflictData);
const rawIssueList = await runCli(["gh", "issue", "list", "--raw"], env); const rawIssueList = await runCli(["gh", "issue", "list", "--raw"], env);
assertCondition(rawIssueList.status === 0, "issue list --raw should be a supported explicit list disclosure path", rawIssueList.json ?? { stdout: rawIssueList.stdout }); assertCondition(rawIssueList.status === 0, "issue list --raw should be a supported explicit list disclosure path", rawIssueList.json ?? { stdout: rawIssueList.stdout });
@@ -1576,11 +1598,17 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
const replaceDisclosure = replaceData.disclosure as JsonRecord; const replaceDisclosure = replaceData.disclosure as JsonRecord;
const replaceReadCommands = replaceData.readCommands as JsonRecord; const replaceReadCommands = replaceData.readCommands as JsonRecord;
assertCondition(replaceDisclosure.bodyOmitted === true && replaceDisclosure.dryRunBoundedPreview === true, "issue update dry-run should disclose compact body policy", replaceDisclosure); assertCondition(replaceDisclosure.bodyOmitted === true && replaceDisclosure.dryRunBoundedPreview === true, "issue update dry-run should disclose compact body policy", replaceDisclosure);
assertCondition(typeof replaceReadCommands.full === "string" && String(replaceReadCommands.full).includes("gh issue read 20"), "issue update dry-run should expose full body drill-down", replaceReadCommands); assertCondition(typeof replaceReadCommands.full === "string" && String(replaceReadCommands.full).includes("gh issue view 20"), "issue update dry-run should expose full body drill-down", replaceReadCommands);
const replaceWouldPatch = replaceData.wouldPatch as JsonRecord; const replaceWouldPatch = replaceData.wouldPatch as JsonRecord;
assertCondition(typeof replaceWouldPatch.bodySha === "string" && String(replaceWouldPatch.bodySha).length === 64, "issue update dry-run should include wouldPatch body sha", replaceWouldPatch); assertCondition(typeof replaceWouldPatch.bodySha === "string" && String(replaceWouldPatch.bodySha).length === 64, "issue update dry-run should include wouldPatch body sha", replaceWouldPatch);
assertCondition(Number(replaceWouldPatch.bodyChars ?? 0) === Number(replaceData.bodyChars ?? 0), "issue update dry-run wouldPatch should include final body chars", replaceWouldPatch); assertCondition(Number(replaceWouldPatch.bodyChars ?? 0) === Number(replaceData.bodyChars ?? 0), "issue update dry-run wouldPatch should include final body chars", replaceWouldPatch);
const replaceNumberDryRun = await runCli(["gh", "issue", "update", "--number", "20", "--repo", "pikasTech/unidesk", "--mode", "replace", "--body-file", safeFile, "--dry-run"], env);
assertCondition(replaceNumberDryRun.status === 0, "issue update should accept --number compatibility alias", replaceNumberDryRun.json ?? { stdout: replaceNumberDryRun.stdout });
const replaceNumberData = dataOf(replaceNumberDryRun.json ?? {});
const replaceNumberHint = replaceNumberData.standardSyntaxHint as JsonRecord;
assertCondition(String(replaceNumberHint.standardCommand ?? "").includes("gh issue update 20 --repo pikasTech/unidesk"), "issue update --number should return standard syntax hint", replaceNumberHint);
const compactLongBody = Array.from({ length: 260 }, (_, index) => `compact-success-line-${String(index + 1).padStart(4, "0")} ${"x".repeat(80)}`).join("\n"); const compactLongBody = Array.from({ length: 260 }, (_, index) => `compact-success-line-${String(index + 1).padStart(4, "0")} ${"x".repeat(80)}`).join("\n");
const compactLongFile = join(tmp, "compact-long-body.md"); const compactLongFile = join(tmp, "compact-long-body.md");
writeFileSync(compactLongFile, compactLongBody, "utf8"); writeFileSync(compactLongFile, compactLongBody, "utf8");
@@ -1604,7 +1632,7 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
const compactDisclosure = compactUpdateData.disclosure as JsonRecord; const compactDisclosure = compactUpdateData.disclosure as JsonRecord;
assertCondition(compactDisclosure.bodyOmitted === true && compactDisclosure.fullBodyIncluded === false && compactDisclosure.defaultCompact === true, "compact update disclosure should be explicit", compactDisclosure); assertCondition(compactDisclosure.bodyOmitted === true && compactDisclosure.fullBodyIncluded === false && compactDisclosure.defaultCompact === true, "compact update disclosure should be explicit", compactDisclosure);
const compactCommands = compactUpdateData.readCommands as JsonRecord; const compactCommands = compactUpdateData.readCommands as JsonRecord;
assertCondition(String(compactCommands.body ?? "").includes("gh issue read 7 --repo pikasTech/HWLAB --json body"), "compact update should expose body read command", compactCommands); assertCondition(String(compactCommands.body ?? "").includes("gh issue view 7 --repo pikasTech/HWLAB --json body"), "compact update should expose body view command", compactCommands);
assertCondition(String(compactCommands.full ?? "").includes("--full") && String(compactCommands.raw ?? "").includes("--raw"), "compact update should expose full/raw drill-down", compactCommands); assertCondition(String(compactCommands.full ?? "").includes("--full") && String(compactCommands.raw ?? "").includes("--raw"), "compact update should expose full/raw drill-down", compactCommands);
const compactUpdatePatchCount = mock.requests.slice(compactUpdateRequestCountBefore).filter((request) => request.method === "PATCH" && request.url === "/repos/pikasTech/HWLAB/issues/7").length; const compactUpdatePatchCount = mock.requests.slice(compactUpdateRequestCountBefore).filter((request) => request.method === "PATCH" && request.url === "/repos/pikasTech/HWLAB/issues/7").length;
assertCondition(compactUpdatePatchCount === 1, "compact update should PATCH GitHub exactly once", { requests: mock.requests.slice(compactUpdateRequestCountBefore) }); assertCondition(compactUpdatePatchCount === 1, "compact update should PATCH GitHub exactly once", { requests: mock.requests.slice(compactUpdateRequestCountBefore) });
@@ -1655,7 +1683,7 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
assertCondition(Number(inlineDryRunData.bodyChars ?? 0) === inlineBody.length && typeof inlineDryRunData.bodySha === "string", "inline issue comment dry-run should expose bodyChars/bodySha", inlineDryRunData); assertCondition(Number(inlineDryRunData.bodyChars ?? 0) === inlineBody.length && typeof inlineDryRunData.bodySha === "string", "inline issue comment dry-run should expose bodyChars/bodySha", inlineDryRunData);
assertCondition(String(inlineDryRunData.bodyPreview ?? "") === inlineBody, "inline issue comment dry-run should provide bounded preview for short text", inlineDryRunData); assertCondition(String(inlineDryRunData.bodyPreview ?? "") === inlineBody, "inline issue comment dry-run should provide bounded preview for short text", inlineDryRunData);
const inlineDryRunReadCommands = inlineDryRunData.readCommands as JsonRecord; const inlineDryRunReadCommands = inlineDryRunData.readCommands as JsonRecord;
assertCondition(String(inlineDryRunReadCommands.comments ?? "").includes("gh issue read 36") && String(inlineDryRunReadCommands.comments ?? "").includes("--json comments"), "inline issue comment dry-run should expose comment read command", inlineDryRunReadCommands); assertCondition(String(inlineDryRunReadCommands.comments ?? "").includes("gh issue view 36") && String(inlineDryRunReadCommands.comments ?? "").includes("--json comments"), "inline issue comment dry-run should expose comment view command", inlineDryRunReadCommands);
const inlineDryRunWriteCount = mock.requests.slice(inlineDryRunRequestCountBefore).filter((request) => request.method === "POST" && request.url.includes("/comments")).length; const inlineDryRunWriteCount = mock.requests.slice(inlineDryRunRequestCountBefore).filter((request) => request.method === "POST" && request.url.includes("/comments")).length;
assertCondition(inlineDryRunWriteCount === 0, "inline issue comment dry-run must not POST GitHub", { requests: mock.requests.slice(inlineDryRunRequestCountBefore) }); assertCondition(inlineDryRunWriteCount === 0, "inline issue comment dry-run must not POST GitHub", { requests: mock.requests.slice(inlineDryRunRequestCountBefore) });
@@ -1751,6 +1779,13 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
const commentDeleteDryRunData = dataOf(commentDeleteDryRun.json ?? {}); const commentDeleteDryRunData = dataOf(commentDeleteDryRun.json ?? {});
assertCondition(commentDeleteDryRunData.command === "issue comment delete" && commentDeleteDryRunData.planned === true, "comment delete dry-run should plan DELETE", commentDeleteDryRunData); assertCondition(commentDeleteDryRunData.command === "issue comment delete" && commentDeleteDryRunData.planned === true, "comment delete dry-run should plan DELETE", commentDeleteDryRunData);
const commentDeleteNumberDryRun = await runCli(["gh", "issue", "comment", "delete", "--number", "9001", "--repo", "pikasTech/unidesk", "--dry-run"], env);
assertCondition(commentDeleteNumberDryRun.status === 0, "issue comment delete should accept --number commentId compatibility alias", commentDeleteNumberDryRun.json ?? { stdout: commentDeleteNumberDryRun.stdout });
const commentDeleteNumberData = dataOf(commentDeleteNumberDryRun.json ?? {});
assertCondition(commentDeleteNumberData.commentId === 9001 && commentDeleteNumberData.standardSyntaxHint, "issue comment delete --number should return commentId and standard syntax hint", commentDeleteNumberData);
const commentDeleteNumberHint = commentDeleteNumberData.standardSyntaxHint as JsonRecord;
assertCondition(String(commentDeleteNumberHint.standardCommand ?? "").includes("gh issue comment delete 9001 --repo pikasTech/unidesk"), "issue comment delete --number should point to positional commentId syntax", commentDeleteNumberHint);
const commentDelete = await runCli(["gh", "issue", "comment", "delete", "9001", "--repo", "pikasTech/unidesk"], env); const commentDelete = await runCli(["gh", "issue", "comment", "delete", "9001", "--repo", "pikasTech/unidesk"], env);
assertCondition(commentDelete.status === 0, "issue comment delete should succeed", commentDelete.json ?? { stdout: commentDelete.stdout }); assertCondition(commentDelete.status === 0, "issue comment delete should succeed", commentDelete.json ?? { stdout: commentDelete.stdout });
const commentDeleteData = dataOf(commentDelete.json ?? {}); const commentDeleteData = dataOf(commentDelete.json ?? {});
@@ -1764,15 +1799,16 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
return { return {
ok: true, ok: true,
checks: [ checks: [
"issue read --json body preserves .data.issue.body", "issue view --json body preserves .data.issue.body",
"issue view remains a compatibility alias", "issue read remains a compatibility alias",
"issue read/view accept owner/repo#number shorthand and reject conflicting --repo", "issue view/read accept GitHub URL and owner/repo#number targets and reject conflicting --repo",
"issue read/view --raw is explicit full disclosure", "issue single numeric target commands accept --number compatibility with a standard syntax hint",
"issue view/read --raw is explicit full disclosure",
"issue list supports state/limit/json with stable selected fields", "issue list supports state/limit/json with stable selected fields",
"issue list positional owner/repo targets the requested repo and conflicting --repo fails", "issue list positional owner/repo targets the requested repo and conflicting --repo fails",
"acceptance issue list command succeeds under mock GitHub", "acceptance issue list command succeeds under mock GitHub",
"issue list default fields include labels and filter pull requests", "issue list default fields include labels and filter pull requests",
"large gh issue read output is dumped to a temp file with bounded stdout and head/tail metadata", "large gh issue view/read output is dumped to a temp file with bounded stdout and head/tail metadata",
"issue scan-escape classifies pollution, explanatory mentions, and body risks", "issue scan-escape classifies pollution, explanatory mentions, and body risks",
"issue cleanup-plan remains dry-run with body/comment cleanup suggestions", "issue cleanup-plan remains dry-run with body/comment cleanup suggestions",
"issue board-audit returns read-only board structure, disables OPEN/CLOSED coverage validation, and keeps compatibility fields empty without writes", "issue board-audit returns read-only board structure, disables OPEN/CLOSED coverage validation, and keeps compatibility fields empty without writes",
+46 -15
View File
@@ -288,6 +288,10 @@ function failedDataOf(response: JsonRecord): JsonRecord {
return response.data as JsonRecord; return response.data as JsonRecord;
} }
function failureMessageOf(data: JsonRecord): string {
return String((data.details as JsonRecord | undefined)?.message ?? data.message ?? "");
}
export async function runGhCliPrContract(): Promise<JsonRecord> { export async function runGhCliPrContract(): Promise<JsonRecord> {
const help = await runCli(["gh", "help"]); const help = await runCli(["gh", "help"]);
assertCondition(help.status === 0, "gh help should succeed", help.json ?? { stdout: help.stdout }); assertCondition(help.status === 0, "gh help should succeed", help.json ?? { stdout: help.stdout });
@@ -295,9 +299,8 @@ export async function runGhCliPrContract(): Promise<JsonRecord> {
const usage = Array.isArray(helpData.usage) ? helpData.usage.map((value) => String(value)) : []; const usage = Array.isArray(helpData.usage) ? helpData.usage.map((value) => String(value)) : [];
const notes = Array.isArray(helpData.notes) ? helpData.notes.map((value) => String(value)) : []; const notes = Array.isArray(helpData.notes) ? helpData.notes.map((value) => String(value)) : [];
assertCondition(usage.some((line) => line.includes("gh pr list")), "gh help should list pr list", { usage }); assertCondition(usage.some((line) => line.includes("gh pr list")), "gh help should list pr list", { usage });
assertCondition(usage.some((line) => line.includes("gh pr read")), "gh help should list pr read", { usage }); assertCondition(usage.some((line) => line.includes("gh pr view") && line.includes("number|url|owner/repo#number") && line.includes("--raw|--full")), "gh help should document standard pr view targets and raw/full disclosure", { usage });
assertCondition(usage.some((line) => line.includes("gh pr view")), "gh help should list pr view", { usage }); assertCondition(usage.some((line) => line.includes("gh pr read") && line.includes("compatibility alias for pr view")), "gh help should list pr read compatibility alias", { usage });
assertCondition(usage.some((line) => line.includes("gh pr read") && line.includes("owner/repo#number") && line.includes("--raw|--full")), "gh help should document pr shorthand and raw/full disclosure", { usage });
assertCondition(usage.some((line) => line.includes("gh preflight")), "gh help should list top-level preflight alias", { usage }); assertCondition(usage.some((line) => line.includes("gh preflight")), "gh help should list top-level preflight alias", { usage });
assertCondition(usage.some((line) => line.includes("gh pr preflight")), "gh help should list pr preflight", { usage }); assertCondition(usage.some((line) => line.includes("gh pr preflight")), "gh help should list pr preflight", { usage });
assertCondition(usage.some((line) => line.includes("gh pr create")), "gh help should list pr create", { usage }); assertCondition(usage.some((line) => line.includes("gh pr create")), "gh help should list pr create", { usage });
@@ -305,9 +308,10 @@ export async function runGhCliPrContract(): Promise<JsonRecord> {
assertCondition(usage.some((line) => line.includes("gh pr comment")), "gh help should list pr comment", { usage }); assertCondition(usage.some((line) => line.includes("gh pr comment")), "gh help should list pr comment", { usage });
assertCondition(usage.some((line) => line.includes("gh pr list") && line.includes("--state open|closed|all")), "gh help should document pr list state filtering", { usage }); assertCondition(usage.some((line) => line.includes("gh pr list") && line.includes("--state open|closed|all")), "gh help should document pr list state filtering", { usage });
assertCondition(usage.some((line) => line.includes("mergedAt") && line.includes("mergeCommit")), "gh help should document merged PR closeout fields", { usage }); assertCondition(usage.some((line) => line.includes("mergedAt") && line.includes("mergeCommit")), "gh help should document merged PR closeout fields", { usage });
assertCondition(notes.some((line) => line.includes("canonical read path")), "gh help should state pr read is canonical", { notes }); assertCondition(notes.some((line) => line.includes("PR view is the canonical")), "gh help should state pr view is canonical", { notes });
assertCondition(notes.some((line) => line.includes("compatibility alias")), "gh help should state pr view is alias", { notes }); assertCondition(notes.some((line) => line.includes("read remains") && line.includes("compatibility alias")), "gh help should state pr read is alias", { notes });
assertCondition(notes.some((line) => line.includes("PR read/view accept owner/repo#number shorthand")), "gh help should explain pr read/view shorthand", { notes }); assertCondition(notes.some((line) => line.includes("GitHub PR URLs") && line.includes("owner/repo#number shorthand")), "gh help should explain pr view/read URL and shorthand targets", { notes });
assertCondition(notes.some((line) => line.includes("--number is accepted on single PR/comment numeric target commands") && line.includes("PR comment delete treats --number as commentId")), "gh help should document --number compatibility hint", { notes });
assertCondition(notes.some((line) => line.includes("--raw and --full are explicit full-disclosure aliases")), "gh help should explain raw/full read disclosure", { notes }); assertCondition(notes.some((line) => line.includes("--raw and --full are explicit full-disclosure aliases")), "gh help should explain raw/full read disclosure", { notes });
assertCondition(notes.some((line) => line.includes("PR list defaults to --state all")), "gh help should document pr list default state", { notes }); assertCondition(notes.some((line) => line.includes("PR list defaults to --state all")), "gh help should document pr list default state", { notes });
assertCondition(notes.some((line) => line.includes("stateDetail") && line.includes("mergedAt")), "gh help should describe closeout field normalization", { notes }); assertCondition(notes.some((line) => line.includes("stateDetail") && line.includes("mergedAt")), "gh help should describe closeout field normalization", { notes });
@@ -383,15 +387,17 @@ export async function runGhCliPrContract(): Promise<JsonRecord> {
assertCondition(selected.body === "PR body" && selected.title === "contract PR", "pr read --json should select fields", readData); assertCondition(selected.body === "PR body" && selected.title === "contract PR", "pr read --json should select fields", readData);
const readNumberAlias = await runCli(["gh", "pr", "read", "--repo", "pikasTech/HWLAB", "--number", "7", "--json", "body,title,state,head,base"], env); const readNumberAlias = await runCli(["gh", "pr", "read", "--repo", "pikasTech/HWLAB", "--number", "7", "--json", "body,title,state,head,base"], env);
assertCondition(readNumberAlias.status === 0, "pr read should accept --number alias", readNumberAlias.json ?? { stdout: readNumberAlias.stdout }); assertCondition(readNumberAlias.status === 0, "pr read should accept --number compatibility alias", readNumberAlias.json ?? { stdout: readNumberAlias.stdout });
const readNumberAliasData = dataOf(readNumberAlias.json ?? {}); const readNumberAliasData = dataOf(readNumberAlias.json ?? {});
assertCondition(readNumberAliasData.repo === "pikasTech/HWLAB", "pr read --number should preserve explicit repo", readNumberAliasData); assertCondition(readNumberAliasData.repo === "pikasTech/HWLAB", "pr read --number should preserve explicit repo", readNumberAliasData);
const readNumberAliasPr = readNumberAliasData.pullRequest as JsonRecord; const readNumberAliasPr = readNumberAliasData.pullRequest as JsonRecord;
assertCondition(readNumberAliasPr.number === 7 && readNumberAliasPr.url === "https://github.com/pikasTech/HWLAB/pull/7", "pr read --number should read the requested PR", readNumberAliasData); assertCondition(readNumberAliasPr.number === 7 && readNumberAliasPr.url === "https://github.com/pikasTech/HWLAB/pull/7", "pr read --number should read the requested PR", readNumberAliasData);
const readNumberAliasDisclosure = readNumberAliasData.disclosure as JsonRecord;
assertCondition(String(readNumberAliasDisclosure.compatibilityHint ?? "").includes("standard gh syntax") && String(readNumberAliasDisclosure.standardCommand ?? "").includes("gh pr view 7 --repo pikasTech/HWLAB"), "pr read --number should return standard syntax hint", readNumberAliasDisclosure);
assertCondition(mock.requests.some((request) => request.method === "GET" && request.url === "/repos/pikasTech/HWLAB/pulls/7"), "pr read --number should call explicit repo REST path", mock.requests); assertCondition(mock.requests.some((request) => request.method === "GET" && request.url === "/repos/pikasTech/HWLAB/pulls/7"), "pr read --number should call explicit repo REST path", mock.requests);
const numberAliasUnsupported = await runCli(["gh", "pr", "list", "--repo", "pikasTech/unidesk", "--number", "7"], env); const numberAliasUnsupported = await runCli(["gh", "pr", "list", "--repo", "pikasTech/unidesk", "--number", "7"], env);
assertCondition(numberAliasUnsupported.status !== 0, "--number should not be silently ignored outside pr read/view", numberAliasUnsupported.json ?? { stdout: numberAliasUnsupported.stdout }); assertCondition(numberAliasUnsupported.status !== 0, "--number should not be silently ignored outside standard view/read", numberAliasUnsupported.json ?? { stdout: numberAliasUnsupported.stdout });
const numberAliasUnsupportedData = failedDataOf(numberAliasUnsupported.json ?? {}); const numberAliasUnsupportedData = failedDataOf(numberAliasUnsupported.json ?? {});
assertCondition(numberAliasUnsupportedData.degradedReason === "validation-failed", "unsupported --number should be validation-failed", numberAliasUnsupportedData); assertCondition(numberAliasUnsupportedData.degradedReason === "validation-failed", "unsupported --number should be validation-failed", numberAliasUnsupportedData);
@@ -404,11 +410,24 @@ export async function runGhCliPrContract(): Promise<JsonRecord> {
assertCondition(openLifecycleJson.merged === false && openLifecycleJson.mergedAt === null && openLifecycleJson.mergeCommit === null, "open pr should expose merged=false", openLifecycleData); assertCondition(openLifecycleJson.merged === false && openLifecycleJson.mergedAt === null && openLifecycleJson.mergeCommit === null, "open pr should expose merged=false", openLifecycleData);
const view = await runCli(["gh", "pr", "view", "42", "--repo", "pikasTech/unidesk", "--json", "body,title,state,head,base"], env); const view = await runCli(["gh", "pr", "view", "42", "--repo", "pikasTech/unidesk", "--json", "body,title,state,head,base"], env);
assertCondition(view.status === 0, "pr view alias should succeed through REST", view.json ?? { stdout: view.stdout }); assertCondition(view.status === 0, "pr view should succeed as canonical read path", view.json ?? { stdout: view.stdout });
const viewData = dataOf(view.json ?? {}); const viewData = dataOf(view.json ?? {});
assertCondition((viewData.pullRequest as JsonRecord).number === 42, "pr view alias should expose PR details", viewData); assertCondition((viewData.pullRequest as JsonRecord).number === 42, "pr view should expose PR details", viewData);
const viewSelected = viewData.json as JsonRecord; const viewSelected = viewData.json as JsonRecord;
assertCondition(viewSelected.body === "PR body" && viewSelected.title === "contract PR", "pr view alias should preserve selected fields", viewData); assertCondition(viewSelected.body === "PR body" && viewSelected.title === "contract PR", "pr view should preserve selected fields", viewData);
const prUrlView = await runCli(["gh", "pr", "view", "https://github.com/pikasTech/HWLAB/pull/7", "--json", "body,title,state,head,base"], env);
assertCondition(prUrlView.status === 0, "pr view should accept GitHub PR URL target", prUrlView.json ?? { stdout: prUrlView.stdout });
const prUrlViewData = dataOf(prUrlView.json ?? {});
assertCondition(prUrlViewData.repo === "pikasTech/HWLAB", "PR URL target should derive repo", prUrlViewData);
assertCondition((prUrlViewData.pullRequest as JsonRecord).number === 7, "PR URL target should derive PR number", prUrlViewData);
const prUrlDisclosure = prUrlViewData.disclosure as JsonRecord;
assertCondition(prUrlDisclosure.shorthand && (prUrlDisclosure.shorthand as JsonRecord).source === "github-url", "PR URL target should be disclosed", prUrlDisclosure);
const prIssueUrlMismatch = await runCli(["gh", "pr", "view", "https://github.com/pikasTech/HWLAB/issues/7", "--json", "body"], env);
assertCondition(prIssueUrlMismatch.status !== 0, "pr view should reject issue URLs", prIssueUrlMismatch.json ?? { stdout: prIssueUrlMismatch.stdout });
const prIssueUrlMismatchData = failedDataOf(prIssueUrlMismatch.json ?? {});
assertCondition(failureMessageOf(prIssueUrlMismatchData).includes("GitHub issue URL"), "pr view issue URL mismatch should be explicit", prIssueUrlMismatchData);
const shorthandRaw = await runCli(["gh", "pr", "view", "pikasTech/HWLAB#7", "--raw"], env); const shorthandRaw = await runCli(["gh", "pr", "view", "pikasTech/HWLAB#7", "--raw"], env);
assertCondition(shorthandRaw.status === 0, "pr view should accept owner/repo#number shorthand with --raw", shorthandRaw.json ?? { stdout: shorthandRaw.stdout }); assertCondition(shorthandRaw.status === 0, "pr view should accept owner/repo#number shorthand with --raw", shorthandRaw.json ?? { stdout: shorthandRaw.stdout });
@@ -428,7 +447,7 @@ export async function runGhCliPrContract(): Promise<JsonRecord> {
assertCondition(shorthandConflictData.degradedReason === "validation-failed", "pr conflicting --repo should be validation-failed", shorthandConflictData); assertCondition(shorthandConflictData.degradedReason === "validation-failed", "pr conflicting --repo should be validation-failed", shorthandConflictData);
assertCondition(String(shorthandConflictData.message ?? "").includes("resolves to repo pikasTech/HWLAB"), "pr conflict message should name the derived repo", shorthandConflictData); assertCondition(String(shorthandConflictData.message ?? "").includes("resolves to repo pikasTech/HWLAB"), "pr conflict message should name the derived repo", shorthandConflictData);
const prConflictCommands = shorthandConflictData.supportedCommands as string[]; const prConflictCommands = shorthandConflictData.supportedCommands as string[];
assertCondition(Array.isArray(prConflictCommands) && prConflictCommands.some((command) => command === "bun scripts/cli.ts gh pr read 7 --repo pikasTech/HWLAB --json body,title,state,stateDetail,closed,closedAt,merged,mergedAt,mergeCommit,head,base,draft,headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup"), "pr conflict should include exact supported read command", shorthandConflictData); assertCondition(Array.isArray(prConflictCommands) && prConflictCommands.some((command) => command === "bun scripts/cli.ts gh pr view 7 --repo pikasTech/HWLAB --json body,title,state,stateDetail,closed,closedAt,merged,mergedAt,mergeCommit,head,base,draft,headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup"), "pr conflict should include exact supported view command", shorthandConflictData);
const closeout = await runCli(["gh", "pr", "view", "42", "--repo", "pikasTech/unidesk", "--json", "mergeable,mergeStateStatus,statusCheckRollup,headRefName,baseRefName"], env); const closeout = await runCli(["gh", "pr", "view", "42", "--repo", "pikasTech/unidesk", "--json", "mergeable,mergeStateStatus,statusCheckRollup,headRefName,baseRefName"], env);
assertCondition(closeout.status === 0, "pr view closeout metadata fields should not be rejected", closeout.json ?? { stdout: closeout.stdout }); assertCondition(closeout.status === 0, "pr view closeout metadata fields should not be rejected", closeout.json ?? { stdout: closeout.stdout });
@@ -648,6 +667,12 @@ export async function runGhCliPrContract(): Promise<JsonRecord> {
assertCondition(appendBody.mode === "append", "pr append mode should be explicit", updateAppendData); assertCondition(appendBody.mode === "append", "pr append mode should be explicit", updateAppendData);
assertCondition(finalBody.containsBackticks === true && finalBody.containsMarkdownTable === true, "pr append should preserve markdown signals", updateAppendData); assertCondition(finalBody.containsBackticks === true && finalBody.containsMarkdownTable === true, "pr append should preserve markdown signals", updateAppendData);
const updateNumberDryRun = await runCli(["gh", "pr", "update", "--number", "42", "--repo", "pikasTech/unidesk", "--mode", "replace", "--body-file", bodyFile, "--dry-run"], env2);
assertCondition(updateNumberDryRun.status === 0, "pr update should accept --number compatibility alias", updateNumberDryRun.json ?? { stdout: updateNumberDryRun.stdout });
const updateNumberData = dataOf(updateNumberDryRun.json ?? {});
const updateNumberHint = updateNumberData.standardSyntaxHint as JsonRecord;
assertCondition(String(updateNumberHint.standardCommand ?? "").includes("gh pr update 42 --repo pikasTech/unidesk"), "pr update --number should return standard syntax hint", updateNumberHint);
const editStdinBody = "stdin line\n`stdin code`\n| c | d |\n"; const editStdinBody = "stdin line\n`stdin code`\n| c | d |\n";
const beforeEditRequests = mock2.requests.length; const beforeEditRequests = mock2.requests.length;
const editStdin = await runCli(["gh", "pr", "edit", "42", "--repo", "pikasTech/unidesk", "--title", "stdin title", "--body-file", "-"], env2, editStdinBody); const editStdin = await runCli(["gh", "pr", "edit", "42", "--repo", "pikasTech/unidesk", "--title", "stdin title", "--body-file", "-"], env2, editStdinBody);
@@ -701,6 +726,12 @@ export async function runGhCliPrContract(): Promise<JsonRecord> {
assertCondition(commentDelete.status === 0, "pr comment delete should succeed", commentDelete.json ?? { stdout: commentDelete.stdout }); assertCondition(commentDelete.status === 0, "pr comment delete should succeed", commentDelete.json ?? { stdout: commentDelete.stdout });
const commentDeleteData = dataOf(commentDelete.json ?? {}); const commentDeleteData = dataOf(commentDelete.json ?? {});
assertCondition(commentDeleteData.deleted === true, "pr comment delete should report deleted", commentDeleteData); assertCondition(commentDeleteData.deleted === true, "pr comment delete should report deleted", commentDeleteData);
const commentDeleteNumber = await runCli(["gh", "pr", "comment", "delete", "--number", "9101", "--repo", "pikasTech/unidesk", "--dry-run"], env2);
assertCondition(commentDeleteNumber.status === 0, "pr comment delete should accept --number commentId compatibility alias", commentDeleteNumber.json ?? { stdout: commentDeleteNumber.stdout });
const commentDeleteNumberData = dataOf(commentDeleteNumber.json ?? {});
const commentDeleteNumberHint = commentDeleteNumberData.standardSyntaxHint as JsonRecord;
assertCondition(commentDeleteNumberData.commentId === 9101 && String(commentDeleteNumberHint.standardCommand ?? "").includes("gh pr comment delete 9101 --repo pikasTech/unidesk"), "pr comment delete --number should point to positional commentId syntax", commentDeleteNumberData);
} finally { } finally {
await mock2.close(); await mock2.close();
} }
@@ -731,9 +762,9 @@ export async function runGhCliPrContract(): Promise<JsonRecord> {
"gh help lists pr create/comment", "gh help lists pr create/comment",
"pr list/read/view work through REST with token and no gh binary dependency", "pr list/read/view work through REST with token and no gh binary dependency",
"pr list positional owner/repo targets the requested repo and conflicting --repo fails", "pr list positional owner/repo targets the requested repo and conflicting --repo fails",
"pr read supports --number alias without silently ignoring it elsewhere", "pr single numeric target commands accept --number compatibility with a standard syntax hint",
"pr read/view accept owner/repo#number shorthand and reject conflicting --repo", "pr view/read accept GitHub URL and owner/repo#number targets and reject conflicting --repo",
"pr read/view --raw is explicit full disclosure", "pr view/read --raw is explicit full disclosure",
"pr list rejects closeout fields and points to pr view", "pr list rejects closeout fields and points to pr view",
"pr read normalizes open and merged lifecycle fields from REST", "pr read normalizes open and merged lifecycle fields from REST",
"GitHub DNS/API transients are retryable and distinct from auth or PR semantic failures", "GitHub DNS/API transients are retryable and distinct from auth or PR semantic failures",
+313 -158
View File
@@ -382,6 +382,9 @@ interface GitHubShorthandReference {
input: string; input: string;
repo: string; repo: string;
number: number; number: number;
source?: "owner-repo-number" | "github-url" | "number-option";
urlKind?: "issue" | "pr";
standardCommand?: string;
} }
interface GitHubResolvedNumberReference { interface GitHubResolvedNumberReference {
@@ -695,6 +698,21 @@ function isPrReadCommand(sub: string | undefined): boolean {
return sub === "read" || sub === "view"; return sub === "read" || sub === "view";
} }
function allowsNumberTargetAlias(top: string | undefined, sub: string | undefined, third: string | undefined): boolean {
if (top === "preflight") return true;
if (top === "issue") {
if (sub === "read" || sub === "view" || sub === "edit" || sub === "update" || sub === "close" || sub === "reopen" || sub === "delete") return true;
if (sub === "comment") return true;
if (sub === "board-row" && ["get", "update", "add", "move", "delete", "upsert"].includes(third ?? "")) return true;
return false;
}
if (top === "pr") {
if (sub === "read" || sub === "view" || sub === "files" || sub === "diff" || sub === "preflight" || sub === "closeout" || sub === "edit" || sub === "update" || sub === "close" || sub === "reopen" || sub === "merge" || sub === "delete") return true;
if (sub === "comment") return true;
}
return false;
}
function parseIssueListState(args: string[]): IssueListState { function parseIssueListState(args: string[]): IssueListState {
const raw = optionValue(args, "--state") ?? "open"; const raw = optionValue(args, "--state") ?? "open";
if ((ISSUE_LIST_STATES as readonly string[]).includes(raw)) return raw as IssueListState; if ((ISSUE_LIST_STATES as readonly string[]).includes(raw)) return raw as IssueListState;
@@ -889,24 +907,92 @@ function parsePositionalNumberForCommand(repo: string, args: string[], startInde
return parseNumberForCommand(repo, targets[0], label); return parseNumberForCommand(repo, targets[0], label);
} }
function resolvePositionalPrReference(args: string[], startIndex: number, label: string, options: GitHubOptions): GitHubResolvedNumberReference | GitHubCommandResult { function resolvedNumberOptionReference(kind: "issue" | "pr", command: string, repo: string, raw: string): GitHubResolvedNumberReference | GitHubCommandResult {
const number = parseNumberForCommand(repo, raw, command);
if (typeof number !== "number") return number;
return {
repo,
number,
shorthand: {
input: `--number ${number}`,
repo,
number,
source: "number-option",
standardCommand: `bun scripts/cli.ts gh ${command} ${number} --repo ${repo}`,
urlKind: kind,
},
};
}
function resolvePositionalNumberReference(kind: "issue" | "pr", args: string[], startIndex: number, label: string, options: GitHubOptions): GitHubResolvedNumberReference | GitHubCommandResult {
const targets = positionalArgs(args.slice(startIndex)); const targets = positionalArgs(args.slice(startIndex));
const prOption = optionValue(args, "--pr"); const numberOption = optionValue(args, "--number");
if (targets.length > 0 && prOption !== undefined) { if (targets.length > 0 && numberOption !== undefined) {
return validationError(label, options.repo, `${label} accepts either a positional PR target or --pr, not both`, { return validationError(label, options.repo, `${label} accepts either a positional numeric target or --number, not both`, {
supportedCommands: [ supportedCommands: [
`bun scripts/cli.ts gh ${label} <number> --repo ${options.repo}`, `bun scripts/cli.ts gh ${label} <number> --repo ${options.repo}`,
`bun scripts/cli.ts gh ${label} --pr <number> --repo ${options.repo}`, `bun scripts/cli.ts gh ${label} --number <number> --repo ${options.repo}`,
`bun scripts/cli.ts gh ${label} ${options.repo}#<number>`, `bun scripts/cli.ts gh ${label} ${options.repo}#<number>`,
], ],
}); });
} }
if (numberOption !== undefined) return resolvedNumberOptionReference(kind, label, options.repo, numberOption);
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} --number <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 resolvePositionalPrReference(args: string[], startIndex: number, label: string, options: GitHubOptions): GitHubResolvedNumberReference | GitHubCommandResult {
const targets = positionalArgs(args.slice(startIndex));
const prOption = optionValue(args, "--pr");
const numberOption = optionValue(args, "--number");
if (prOption !== undefined && numberOption !== undefined) {
return validationError(label, options.repo, `${label} accepts either --pr or --number as a compatibility alias, not both`, {
supportedCommands: [
`bun scripts/cli.ts gh ${label} <number> --repo ${options.repo}`,
`bun scripts/cli.ts gh ${label} --number <number> --repo ${options.repo}`,
`bun scripts/cli.ts gh ${label} ${options.repo}#<number>`,
],
});
}
if (targets.length > 0 && (prOption !== undefined || numberOption !== undefined)) {
return validationError(label, options.repo, `${label} accepts either a positional PR target or a compatibility number option, not both`, {
supportedCommands: [
`bun scripts/cli.ts gh ${label} <number> --repo ${options.repo}`,
`bun scripts/cli.ts gh ${label} --pr <number> --repo ${options.repo}`,
`bun scripts/cli.ts gh ${label} --number <number> --repo ${options.repo}`,
`bun scripts/cli.ts gh ${label} ${options.repo}#<number>`,
],
});
}
if (numberOption !== undefined) return resolvedNumberOptionReference("pr", label, options.repo, numberOption);
const effectiveTargets = prOption !== undefined ? [prOption] : targets; const effectiveTargets = prOption !== undefined ? [prOption] : targets;
if (effectiveTargets.length !== 1) { if (effectiveTargets.length !== 1) {
return validationError(label, options.repo, `${label} requires exactly one positive integer or owner/repo#number positional argument`, { return validationError(label, options.repo, `${label} requires exactly one positive integer or owner/repo#number positional argument`, {
supportedCommands: [ supportedCommands: [
`bun scripts/cli.ts gh ${label} <number> --repo ${options.repo}`, `bun scripts/cli.ts gh ${label} <number> --repo ${options.repo}`,
`bun scripts/cli.ts gh ${label} --pr <number> --repo ${options.repo}`, `bun scripts/cli.ts gh ${label} --pr <number> --repo ${options.repo}`,
`bun scripts/cli.ts gh ${label} --number <number> --repo ${options.repo}`,
`bun scripts/cli.ts gh ${label} ${options.repo}#<number>`, `bun scripts/cli.ts gh ${label} ${options.repo}#<number>`,
], ],
}); });
@@ -927,12 +1013,24 @@ function resolvePositionalPrReference(args: string[], startIndex: number, label:
return { repo: options.repo, number }; return { repo: options.repo, number };
} }
function resolvePositionalIssueReference(args: string[], startIndex: number, label: string, options: GitHubOptions): GitHubResolvedNumberReference | GitHubCommandResult { function resolvePositionalIssueReference(args: string[], startIndex: number, label: string, options: GitHubOptions, allowNumberOption = true): GitHubResolvedNumberReference | GitHubCommandResult {
const targets = positionalArgs(args.slice(startIndex)); const targets = positionalArgs(args.slice(startIndex));
const numberOption = allowNumberOption ? optionValue(args, "--number") : undefined;
if (targets.length > 0 && numberOption !== undefined) {
return validationError(label, options.repo, `${label} accepts either a positional issue target or --number, not both`, {
supportedCommands: [
`bun scripts/cli.ts gh ${label} <number> --repo ${options.repo}`,
`bun scripts/cli.ts gh ${label} --number <number> --repo ${options.repo}`,
`bun scripts/cli.ts gh ${label} ${options.repo}#<number>`,
],
});
}
if (numberOption !== undefined) return resolvedNumberOptionReference("issue", label, options.repo, numberOption);
if (targets.length !== 1) { if (targets.length !== 1) {
return validationError(label, options.repo, `${label} requires exactly one positive integer or owner/repo#number positional argument`, { return validationError(label, options.repo, `${label} requires exactly one positive integer or owner/repo#number positional argument`, {
supportedCommands: [ supportedCommands: [
`bun scripts/cli.ts gh ${label} <number> --repo ${options.repo}`, `bun scripts/cli.ts gh ${label} <number> --repo ${options.repo}`,
...(allowNumberOption ? [`bun scripts/cli.ts gh ${label} --number <number> --repo ${options.repo}`] : []),
`bun scripts/cli.ts gh ${label} ${options.repo}#<number>`, `bun scripts/cli.ts gh ${label} ${options.repo}#<number>`,
], ],
}); });
@@ -961,13 +1059,43 @@ function parseOwnerRepoNumberShorthand(raw: string | undefined): GitHubShorthand
input: raw, input: raw,
repo: `${match[1]}/${match[2]}`, repo: `${match[1]}/${match[2]}`,
number: Number(match[3]), number: Number(match[3]),
source: "owner-repo-number",
}; };
} }
function parseGitHubIssueOrPrUrl(raw: string | undefined): GitHubShorthandReference | null {
if (raw === undefined) return null;
let parsed: URL;
try {
parsed = new URL(raw);
} catch {
return null;
}
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return null;
if (parsed.hostname.toLowerCase() !== "github.com" && parsed.hostname.toLowerCase() !== "www.github.com") return null;
const parts = parsed.pathname.split("/").filter((part) => part.length > 0);
if (parts.length < 4) return null;
const [owner, repoName, kind, numberRaw] = parts;
if (!owner || !repoName || (kind !== "issues" && kind !== "pull")) return null;
if (!/^[1-9]\d*$/u.test(numberRaw ?? "")) return null;
return {
input: raw,
repo: `${owner}/${repoName}`,
number: Number(numberRaw),
source: "github-url",
urlKind: kind === "issues" ? "issue" : "pr",
};
}
function parseReadViewTarget(raw: string | undefined): GitHubShorthandReference | null {
return parseOwnerRepoNumberShorthand(raw) ?? parseGitHubIssueOrPrUrl(raw);
}
function readViewSupportedCommands(kind: "issue" | "pr", repo: string, number: number): string[] { function readViewSupportedCommands(kind: "issue" | "pr", repo: string, number: number): string[] {
return [ return [
`bun scripts/cli.ts gh ${kind} read ${number} --repo ${repo} --json ${readViewSupportedJsonFields(kind)}`, `bun scripts/cli.ts gh ${kind} view ${number} --repo ${repo} --json ${readViewSupportedJsonFields(kind)}`,
`bun scripts/cli.ts gh ${kind} view ${repo}#${number} --raw`, `bun scripts/cli.ts gh ${kind} view ${repo}#${number} --raw`,
`bun scripts/cli.ts gh ${kind} read ${number} --repo ${repo} --json ${readViewSupportedJsonFields(kind)} [compatibility alias]`,
]; ];
} }
@@ -981,28 +1109,50 @@ function resolveReadViewNumberReference(kind: "issue" | "pr", sub: "read" | "vie
const command = `${kind} ${sub}`; const command = `${kind} ${sub}`;
const targets = positionalArgs(args.slice(2)); const targets = positionalArgs(args.slice(2));
if (targets.length > 1) { if (targets.length > 1) {
return validationError(command, options.repo, `${command} accepts one number or owner/repo#number target; use --repo owner/name and --number N when passing options first`, { return validationError(command, options.repo, `${command} accepts one positional target: number, GitHub ${kind} URL, or owner/repo#number`, {
supportedCommands: [ supportedCommands: [
`bun scripts/cli.ts gh ${kind} read <number> --repo owner/name --json ${readViewSupportedJsonFields(kind)}`, `bun scripts/cli.ts gh ${kind} view <number> --repo owner/name --json ${readViewSupportedJsonFields(kind)}`,
`bun scripts/cli.ts gh ${kind} read owner/name#<number> --raw`, `bun scripts/cli.ts gh ${kind} view https://github.com/owner/name/${kind === "issue" ? "issues" : "pull"}/<number> --raw`,
`bun scripts/cli.ts gh ${kind} view owner/name#<number> --raw`,
], ],
}); });
} }
const raw = targets[0]; const raw = targets[0];
const numberAliasRaw = kind === "pr" ? optionValue(args, "--number") : undefined; if (optionWasProvided(args, "--number")) {
const shorthand = parseOwnerRepoNumberShorthand(raw); const numberAliasRaw = optionValue(args, "--number");
if (shorthand !== null) { const aliasNumber = parseNumberForCommand(options.repo, numberAliasRaw, command);
if (numberAliasRaw !== undefined) { if (typeof aliasNumber !== "number") return aliasNumber;
const aliasNumber = parseNumberForCommand(shorthand.repo, numberAliasRaw, command); if (raw !== undefined) {
if (typeof aliasNumber !== "number") return aliasNumber; const parsedRaw = parseNumberForCommand(options.repo, raw, command);
if (aliasNumber !== shorthand.number) { if (typeof parsedRaw !== "number") return parsedRaw;
return validationError(command, shorthand.repo, `${command} target ${shorthand.input} conflicts with --number ${aliasNumber}; use one PR number target.`, { if (parsedRaw !== aliasNumber) {
shorthand, return validationError(command, options.repo, `${command} positional number ${parsedRaw} conflicts with --number ${aliasNumber}; use one target number.`, {
positionalNumber: parsedRaw,
numberAlias: aliasNumber, numberAlias: aliasNumber,
supportedCommands: readViewSupportedCommands(kind, shorthand.repo, shorthand.number), supportedCommands: readViewSupportedCommands(kind, options.repo, aliasNumber),
}); });
} }
} }
return {
repo: options.repo,
number: aliasNumber,
shorthand: {
input: `--number ${aliasNumber}`,
repo: options.repo,
number: aliasNumber,
source: "number-option",
standardCommand: `bun scripts/cli.ts gh ${kind} view ${aliasNumber} --repo ${options.repo}`,
},
};
}
const shorthand = parseReadViewTarget(raw);
if (shorthand !== null) {
if (shorthand.urlKind !== undefined && shorthand.urlKind !== kind) {
return validationError(command, shorthand.repo, `${command} target ${shorthand.input} is a GitHub ${shorthand.urlKind} URL, not a ${kind} URL.`, {
shorthand,
supportedCommands: readViewSupportedCommands(shorthand.urlKind, shorthand.repo, shorthand.number),
});
}
const explicitRepo = optionValue(args, "--repo"); const explicitRepo = optionValue(args, "--repo");
if (explicitRepo !== undefined && explicitRepo !== shorthand.repo) { if (explicitRepo !== undefined && explicitRepo !== shorthand.repo) {
const message = `${command} target ${shorthand.input} resolves to repo ${shorthand.repo}, but --repo ${explicitRepo} was also provided. Use either the shorthand or a matching --repo, not both.`; const message = `${command} target ${shorthand.input} resolves to repo ${shorthand.repo}, but --repo ${explicitRepo} was also provided. Use either the shorthand or a matching --repo, not both.`;
@@ -1015,38 +1165,14 @@ function resolveReadViewNumberReference(kind: "issue" | "pr", sub: "read" | "vie
} }
return { repo: shorthand.repo, number: shorthand.number, shorthand }; return { repo: shorthand.repo, number: shorthand.number, shorthand };
} }
if (numberAliasRaw !== undefined) {
const aliasNumber = parseNumberForCommand(options.repo, numberAliasRaw, command);
if (typeof aliasNumber !== "number") return {
...aliasNumber,
supportedCommands: [
`bun scripts/cli.ts gh pr read --repo owner/name --number <number> --json ${readViewSupportedJsonFields("pr")}`,
"bun scripts/cli.ts gh pr read owner/name#<number> --raw",
],
};
if (raw !== undefined) {
const parsedRaw = parseNumberForCommand(options.repo, raw, command);
if (typeof parsedRaw !== "number") return parsedRaw;
if (parsedRaw !== aliasNumber) {
return validationError(command, options.repo, `${command} positional number ${parsedRaw} conflicts with --number ${aliasNumber}; use one PR number target.`, {
positionalNumber: parsedRaw,
numberAlias: aliasNumber,
supportedCommands: [
`bun scripts/cli.ts gh pr read ${aliasNumber} --repo ${options.repo} --json ${readViewSupportedJsonFields("pr")}`,
`bun scripts/cli.ts gh pr read --repo ${options.repo} --number ${aliasNumber} --full`,
],
});
}
}
return { repo: options.repo, number: aliasNumber };
}
const parsed = parseNumberForCommand(options.repo, raw, command); const parsed = parseNumberForCommand(options.repo, raw, command);
if (typeof parsed !== "number") { if (typeof parsed !== "number") {
return { return {
...parsed, ...parsed,
supportedCommands: [ supportedCommands: [
`bun scripts/cli.ts gh ${kind} read <number> --repo owner/name --json ${readViewSupportedJsonFields(kind)}`, `bun scripts/cli.ts gh ${kind} view <number> --repo owner/name --json ${readViewSupportedJsonFields(kind)}`,
`bun scripts/cli.ts gh ${kind} read owner/name#<number> --raw`, `bun scripts/cli.ts gh ${kind} view https://github.com/owner/name/${kind === "issue" ? "issues" : "pull"}/<number> --raw`,
`bun scripts/cli.ts gh ${kind} view owner/name#<number> --raw`,
], ],
}; };
} }
@@ -1068,6 +1194,30 @@ function readDisclosureOptions(options: GitHubOptions, shorthand: GitHubShorthan
...(options.full ? { full: true } : {}), ...(options.full ? { full: true } : {}),
fullDisclosure: options.raw || options.full, fullDisclosure: options.raw || options.full,
shorthand: shorthand ?? null, shorthand: shorthand ?? null,
...(shorthand?.source === "number-option" ? {
compatibilityHint: "--number is accepted for low-friction compatibility; standard gh syntax uses a positional number or URL.",
standardCommand: shorthand.standardCommand,
} : {}),
};
}
function numberOptionCompatibilityHint(resolved: GitHubResolvedNumberReference): Record<string, unknown> | null {
if (resolved.shorthand?.source !== "number-option") return null;
return {
acceptedOption: "--number",
compatibility: true,
message: "--number is accepted for low-friction compatibility; prefer the standard positional number target shown in standardCommand.",
standardCommand: resolved.shorthand.standardCommand ?? null,
};
}
async function withNumberOptionHint(result: GitHubCommandResult | Promise<GitHubCommandResult>, resolved: GitHubResolvedNumberReference): Promise<GitHubCommandResult> {
const output = await result;
const hint = numberOptionCompatibilityHint(resolved);
if (hint === null) return output;
return {
...output,
standardSyntaxHint: hint,
}; };
} }
@@ -1082,14 +1232,14 @@ function unknownGhOptionDetails(args: string[], option: string): Record<string,
helpCommand: "bun scripts/cli.ts gh help", helpCommand: "bun scripts/cli.ts gh help",
}; };
if ((top === "issue" || top === "pr") && (sub === "read" || sub === "view")) { if ((top === "issue" || top === "pr") && (sub === "read" || sub === "view")) {
const shorthand = parseOwnerRepoNumberShorthand(third); const shorthand = parseReadViewTarget(third);
const repo = shorthand?.repo ?? optionValue(args, "--repo") ?? "owner/name"; const repo = shorthand?.repo ?? optionValue(args, "--repo") ?? "owner/name";
const number = shorthand?.number ?? (third !== undefined && /^\d+$/u.test(third) ? Number(third) : 0); const number = shorthand?.number ?? (third !== undefined && /^\d+$/u.test(third) ? Number(third) : 0);
details.supportedCommands = number > 0 details.supportedCommands = number > 0
? readViewSupportedCommands(top, repo, number) ? readViewSupportedCommands(top, repo, number)
: [ : [
`bun scripts/cli.ts gh ${top} read <number> --repo owner/name --json ${readViewSupportedJsonFields(top)}`, `bun scripts/cli.ts gh ${top} view <number> --repo owner/name --json ${readViewSupportedJsonFields(top)}`,
`bun scripts/cli.ts gh ${top} read owner/name#<number> --raw`, `bun scripts/cli.ts gh ${top} view owner/name#<number> --raw`,
]; ];
} }
return details; return details;
@@ -2036,17 +2186,17 @@ function isGitHubError(value: unknown): value is GitHubErrorPayload {
function issueBodyReadCommands(repo: string, issueNumber: number): Record<string, string> { function issueBodyReadCommands(repo: string, issueNumber: number): Record<string, string> {
return { return {
body: `bun scripts/cli.ts gh issue read ${issueNumber} --repo ${repo} --json body`, body: `bun scripts/cli.ts gh issue view ${issueNumber} --repo ${repo} --json body`,
full: `bun scripts/cli.ts gh issue read ${issueNumber} --repo ${repo} --full`, full: `bun scripts/cli.ts gh issue view ${issueNumber} --repo ${repo} --full`,
raw: `bun scripts/cli.ts gh issue read ${issueNumber} --repo ${repo} --raw`, raw: `bun scripts/cli.ts gh issue view ${issueNumber} --repo ${repo} --raw`,
}; };
} }
function issueCommentReadCommands(repo: string, issueNumber: number): Record<string, string> { function issueCommentReadCommands(repo: string, issueNumber: number): Record<string, string> {
return { return {
comments: `bun scripts/cli.ts gh issue read ${issueNumber} --repo ${repo} --json comments`, comments: `bun scripts/cli.ts gh issue view ${issueNumber} --repo ${repo} --json comments`,
full: `bun scripts/cli.ts gh issue read ${issueNumber} --repo ${repo} --full`, full: `bun scripts/cli.ts gh issue view ${issueNumber} --repo ${repo} --full`,
raw: `bun scripts/cli.ts gh issue read ${issueNumber} --repo ${repo} --raw`, raw: `bun scripts/cli.ts gh issue view ${issueNumber} --repo ${repo} --raw`,
}; };
} }
@@ -2060,7 +2210,7 @@ function issueWriteDisclosure(options: GitHubOptions, repo: string, issueNumber:
dryRunBoundedPreview: dryRun, dryRunBoundedPreview: dryRun,
note: explicitFullDisclosure && !dryRun note: explicitFullDisclosure && !dryRun
? "The returned issue object includes the full body because --full or --raw was explicitly requested." ? "The returned issue object includes the full body because --full or --raw was explicitly requested."
: "Default issue write output omits full issue.body; use readCommands.full/raw or gh issue read --json body when full text is needed.", : "Default issue write output omits full issue.body; use readCommands.full/raw or gh issue view --json body when full text is needed.",
readCommands: issueBodyReadCommands(repo, issueNumber), readCommands: issueBodyReadCommands(repo, issueNumber),
}; };
} }
@@ -2122,7 +2272,7 @@ function issueLifecycleDisclosure(repo: string, issueNumber: number, dryRun: boo
fullBodyIncluded: false, fullBodyIncluded: false,
bodyOmitted: true, bodyOmitted: true,
dryRunBoundedPreview: dryRun, dryRunBoundedPreview: dryRun,
note: "Issue lifecycle write output omits full issue.body; use readCommands.full/raw or gh issue read --json body when full text is needed.", note: "Issue lifecycle write output omits full issue.body; use readCommands.full/raw or gh issue view --json body when full text is needed.",
readCommands: issueBodyReadCommands(repo, issueNumber), readCommands: issueBodyReadCommands(repo, issueNumber),
}; };
} }
@@ -6313,7 +6463,7 @@ async function prFiles(repo: string, token: string, number: number, limit: numbe
const nextLimit = totalFiles === null ? MAX_PR_FILES_LIMIT : Math.min(totalFiles, MAX_PR_FILES_LIMIT); const nextLimit = totalFiles === null ? MAX_PR_FILES_LIMIT : Math.min(totalFiles, MAX_PR_FILES_LIMIT);
const nextCommand = truncated const nextCommand = truncated
? `bun scripts/cli.ts gh pr files ${number} --repo ${repo} --limit ${nextLimit}` ? `bun scripts/cli.ts gh pr files ${number} --repo ${repo} --limit ${nextLimit}`
: `bun scripts/cli.ts gh pr read ${number} --repo ${repo} --json body,title,state,head,base`; : `bun scripts/cli.ts gh pr view ${number} --repo ${repo} --json body,title,state,head,base`;
return { return {
ok: true, ok: true,
command: commandName, command: commandName,
@@ -6384,42 +6534,42 @@ export function ghHelp(): unknown {
usage: [ usage: [
"bun scripts/cli.ts gh auth status [--repo owner/name]", "bun scripts/cli.ts gh auth status [--repo owner/name]",
"bun scripts/cli.ts gh issue list [owner/repo] [--state open|closed|all] [--limit N] [--search text] [--label label[,label...]]... [--repo owner/name] [--json number,title,state,closed,closedAt,url,updatedAt,createdAt,author,labels] [--raw|--full]", "bun scripts/cli.ts gh issue list [owner/repo] [--state open|closed|all] [--limit N] [--search text] [--label label[,label...]]... [--repo owner/name] [--json number,title,state,closed,closedAt,url,updatedAt,createdAt,author,labels] [--raw|--full]",
"bun scripts/cli.ts gh issue read <number|owner/repo#number> [--repo owner/name] [--json body,title,state,closed,closedAt,comments] [--raw|--full]", "bun scripts/cli.ts gh issue view <number|url|owner/repo#number> [--repo owner/name] [--number N compat] [--json body,title,state,closed,closedAt,comments] [--raw|--full]",
"bun scripts/cli.ts gh issue view <number|owner/repo#number> [--repo owner/name] [--raw|--full] [compatibility alias for issue read]", "bun scripts/cli.ts gh issue read <number|url|owner/repo#number> [--repo owner/name] [--number N compat] [--raw|--full] [compatibility alias for issue view]",
"bun scripts/cli.ts gh issue create --title <title> --body-file <file|-> [--label label[,label...]]... [--repo owner/name] [--dry-run]", "bun scripts/cli.ts gh issue create --title <title> --body-file <file|-> [--label label[,label...]]... [--repo owner/name] [--dry-run]",
"bun scripts/cli.ts gh issue update <number> --mode replace|append --body-file <file|-> [--title title] [--repo owner/name] [--dry-run] [--expect-updated-at ts|--expect-body-sha sha256] [--body-profile auto|code-queue-board|commander-brief] [--allow-short-body] [--full|--raw]", "bun scripts/cli.ts gh issue update <number> --mode replace|append --body-file <file|-> [--title title] [--repo owner/name] [--number N compat] [--dry-run] [--expect-updated-at ts|--expect-body-sha sha256] [--body-profile auto|code-queue-board|commander-brief] [--allow-short-body] [--full|--raw]",
"bun scripts/cli.ts gh issue edit <number> --body-file <file|-> [--full|--raw] [compat alias for issue update --mode replace]", "bun scripts/cli.ts gh issue edit <number> --body-file <file|-> [--repo owner/name] [--number N compat] [--full|--raw] [compat alias for issue update --mode replace]",
"bun scripts/cli.ts gh issue edit 24 --body-file <file> --notify-claudeqq-brief-diff [--dry-run]", "bun scripts/cli.ts gh issue edit 24 --body-file <file> --notify-claudeqq-brief-diff [--dry-run]",
"bun scripts/cli.ts gh issue comment create <number> --body-file <file|->|--body <short-text> [--repo owner/name] [--dry-run]", "bun scripts/cli.ts gh issue comment create <number> --body-file <file|->|--body <short-text> [--repo owner/name] [--number N compat] [--dry-run]",
"bun scripts/cli.ts gh issue comment delete <commentId> [--repo owner/name] [--dry-run]", "bun scripts/cli.ts gh issue comment delete <commentId> [--repo owner/name] [--number N compat] [--dry-run]",
"bun scripts/cli.ts gh issue close|reopen <number> [--repo owner/name] [--comment <short-text>|--comment-file <file|->] [--dry-run]", "bun scripts/cli.ts gh issue close|reopen <number> [--repo owner/name] [--number N compat] [--comment <short-text>|--comment-file <file|->] [--dry-run]",
"bun scripts/cli.ts gh issue stale-close [--repo owner/name] [--inactive-hours N] [--limit N] [--label label[,label...]]... [--dry-run]", "bun scripts/cli.ts gh issue stale-close [--repo owner/name] [--inactive-hours N] [--limit N] [--label label[,label...]]... [--dry-run]",
"bun scripts/cli.ts gh issue delete <number> [unsupported: use close]", "bun scripts/cli.ts gh issue delete <number> [unsupported: use close]",
"bun scripts/cli.ts gh issue scan-escape [--repo owner/name] [--limit N] [--dry-run]", "bun scripts/cli.ts gh issue scan-escape [--repo owner/name] [--limit N] [--dry-run]",
"bun scripts/cli.ts gh issue cleanup-plan [--repo owner/name] [--limit N] [--dry-run]", "bun scripts/cli.ts gh issue cleanup-plan [--repo owner/name] [--limit N] [--dry-run]",
"bun scripts/cli.ts gh issue board-audit [--repo owner/name] [--board-issue 20] [--limit N] [--known-meta-issue N[,N...]] [--ignore-issue N[,N...]] [--dry-run]", "bun scripts/cli.ts gh issue board-audit [--repo owner/name] [--board-issue 20] [--limit N] [--known-meta-issue N[,N...]] [--ignore-issue N[,N...]] [--dry-run]",
"bun scripts/cli.ts gh issue board-row list [--repo owner/name] --board-issue 20 [--state open|closed|all] [--dry-run]", "bun scripts/cli.ts gh issue board-row list [--repo owner/name] --board-issue 20 [--state open|closed|all] [--dry-run]",
"bun scripts/cli.ts gh issue board-row get <issueNumber> [--repo owner/name] --board-issue 20", "bun scripts/cli.ts gh issue board-row get <issueNumber> [--repo owner/name] [--number N compat] --board-issue 20",
"bun scripts/cli.ts gh issue board-row update <issueNumber> [--repo owner/name] --board-issue 20 --field progress|status|validation|branch|tasks|focus --value <text> [--dry-run] [--expect-updated-at ts|--expect-body-sha sha256]", "bun scripts/cli.ts gh issue board-row update <issueNumber> [--repo owner/name] [--number N compat] --board-issue 20 --field progress|status|validation|branch|tasks|focus --value <text> [--dry-run] [--expect-updated-at ts|--expect-body-sha sha256]",
"bun scripts/cli.ts gh issue board-row add <issueNumber> [--repo owner/name] --board-issue 20 --section open|closed --row-file <markdown-row-file> [--dry-run] [--expect-body-sha sha256]", "bun scripts/cli.ts gh issue board-row add <issueNumber> [--repo owner/name] [--number N compat] --board-issue 20 --section open|closed --row-file <markdown-row-file> [--dry-run] [--expect-body-sha sha256]",
"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 upsert <issueNumber> [--repo owner/name] [--number N compat] --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 move <issueNumber> [--repo owner/name] [--number N compat] --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 issue board-row delete <issueNumber> [--repo owner/name] [--number N compat] --board-issue 20 [--dry-run] [--expect-body-sha sha256]",
"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 preflight <prNumber|owner/repo#number> [--repo owner/name] [--number N compat] [--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 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 files <number> [--repo owner/name] [--number N compat] [--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 diff <number> --stat [--repo owner/name] [--number N compat] [--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|url|owner/repo#number> [--repo owner/name] [--number N compat] [--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 read <number|url|owner/repo#number> [--repo owner/name] [--number N compat] [--raw|--full] [compatibility alias for pr view]",
"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 preflight <number|owner/repo#number> [--repo owner/name] [--number N compat] [--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 closeout <number|owner/repo#number> [--repo owner/name] [--number N compat] [--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 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 edit <number> [--title title] [--body-file <file>|--body-file -|--body <text>] [--repo owner/name] [--number N compat] [--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]", "bun scripts/cli.ts gh pr update <number> --mode replace|append [--body-file <file>|--body-file -|--body <text>] [--title title] [--repo owner/name] [--number N compat] [--dry-run]",
"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 create <number> --body-file <file>|--body <text> [--repo owner/name] [--number N compat] [--dry-run]",
"bun scripts/cli.ts gh pr comment delete <commentId> [--repo owner/name] [--dry-run]", "bun scripts/cli.ts gh pr comment delete <commentId> [--repo owner/name] [--number N compat] [--dry-run]",
"bun scripts/cli.ts gh pr close|reopen <number> [--repo owner/name] [--dry-run]", "bun scripts/cli.ts gh pr close|reopen <number> [--repo owner/name] [--number N compat] [--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 merge <number> [--repo owner/name] [--number N compat] [--merge|--squash|--rebase] [--delete-branch] [--dry-run]",
"bun scripts/cli.ts gh pr delete <number> [unsupported: use close]", "bun scripts/cli.ts gh pr delete <number> [unsupported: use close]",
], ],
defaults: { repo: DEFAULT_REPO }, defaults: { repo: DEFAULT_REPO },
@@ -6429,7 +6579,7 @@ export function ghHelp(): unknown {
"issue list and pr list accept a single positional owner/repo as a compatibility alias for --repo owner/name. The positional repo and --repo must match if both are supplied; non-repo positionals fail structurally instead of falling back to the default repo.", "issue list and pr list accept a single positional owner/repo as a compatibility alias for --repo owner/name. The positional repo and --repo must match if both are supplied; non-repo positionals fail structurally instead of falling back to the default repo.",
"issue list defaults to --state open and bounded --limit 30; it paginates GitHub REST/Search pages internally when --limit exceeds GitHub's per-page cap and discloses pagination/rawCount/hasMore so operators do not mistake a single page for the full repository. --search uses GitHub Search Issues API with repo/type/state qualifiers for low-friction dedupe lookup before creating a new issue. Supported --json fields are number,title,state,closed,closedAt,url,updatedAt,createdAt,author,labels and unknown fields fail structurally.", "issue list defaults to --state open and bounded --limit 30; it paginates GitHub REST/Search pages internally when --limit exceeds GitHub's per-page cap and discloses pagination/rawCount/hasMore so operators do not mistake a single page for the full repository. --search uses GitHub Search Issues API with repo/type/state qualifiers for low-friction dedupe lookup before creating a new issue. Supported --json fields are number,title,state,closed,closedAt,url,updatedAt,createdAt,author,labels and unknown fields fail structurally.",
"PR list defaults to --state all for compatibility with earlier UniDesk CLI behavior; supported states are open, closed, and all.", "PR list defaults to --state all for compatibility with earlier UniDesk CLI behavior; supported states are open, closed, and all.",
"issue read is the canonical read path; view remains a compatibility alias. Read/view accept owner/repo#number shorthand and derive --repo unless an explicit conflicting --repo is supplied, which fails structurally with suggested commands. Read supports lifecycle fields closed/closedAt plus legacy --json field selection such as --json body and still exposes .data.issue.body for compatibility; unsupported fields fail structurally.", "issue view is the canonical GitHub CLI-compatible read path; read remains a UniDesk compatibility alias. View/read accept positional numbers, GitHub issue URLs, and owner/repo#number shorthand, deriving --repo unless an explicit conflicting --repo is supplied. --number is accepted on single issue/comment numeric target commands for low-friction compatibility and returns a standard syntax hint; list/create/scan-escape/cleanup-plan/board-audit/board-row list do not accept it. Comment delete treats --number as commentId, not an issue number. View supports lifecycle fields closed/closedAt plus legacy --json field selection such as --json body and still exposes .data.issue.body for compatibility; unsupported fields fail structurally.",
"--raw and --full are explicit full-disclosure aliases for gh issue list/read/view/update/edit and gh pr list/read/view. For issue writes, default success output omits full issue.body and returns bodyChars/bodySha/bodyPreview plus readCommands; --full|--raw includes the full returned issue body only on commands that explicitly support full disclosure.", "--raw and --full are explicit full-disclosure aliases for gh issue list/read/view/update/edit and gh pr list/read/view. For issue writes, default success output omits full issue.body and returns bodyChars/bodySha/bodyPreview plus readCommands; --full|--raw includes the full returned issue body only on commands that explicitly support full disclosure.",
"GitHub CLI output larger than 20 KiB is automatically written to /tmp/unidesk-cli-output/*.json; stdout stays bounded JSON with outputTruncated=true, the dump path, total bytes/lines, and head/tail previews.", "GitHub CLI output larger than 20 KiB is automatically written to /tmp/unidesk-cli-output/*.json; stdout stays bounded JSON with outputTruncated=true, the dump path, total bytes/lines, and head/tail previews.",
"issue create accepts --body-file <file|-> plus repeatable --label values and comma-separated labels; inline --body is intentionally unsupported for issue creation. Dry-run prints the parsed labels and non-dry-run sends them in the GitHub REST create-issue payload.", "issue create accepts --body-file <file|-> plus repeatable --label values and comma-separated labels; inline --body is intentionally unsupported for issue creation. Dry-run prints the parsed labels and non-dry-run sends them in the GitHub REST create-issue payload.",
@@ -6439,7 +6589,7 @@ export function ghHelp(): unknown {
"issue update --body-file accepts files or - for stdin, refuses literal null, blank, and too-short bodies by default. Use --allow-short-body only for intentional short writes; #20 requires its board heading, warns when HWLAB product/user issue routing appears in favor of pikasTech/HWLAB, and still rejects commander brief update sections; commander-brief requires its stable heading on legacy #24 plus daily rolling brief issues titled YYYY-MM-DD 指挥简报(北京时间).", "issue update --body-file accepts files or - for stdin, refuses literal null, blank, and too-short bodies by default. Use --allow-short-body only for intentional short writes; #20 requires its board heading, warns when HWLAB product/user issue routing appears in favor of pikasTech/HWLAB, and still rejects commander brief update sections; commander-brief requires its stable heading on legacy #24 plus daily rolling brief issues titled YYYY-MM-DD 指挥简报(北京时间).",
"issue update dry-run reports bounded bodyPreview/bodyPreviewLines, old/new body length slots, body SHA, required heading checks, literal \\n detection, shell-pollution signals, guard/concurrency summary, wouldPatch, and readCommands without printing an unbounded full body. Non-dry-run automatically reads current issue metadata before PATCH and returns oldBodySha/updatedAt; --expect-updated-at or --expect-body-sha remain available for explicit stale-cache protection.", "issue update dry-run reports bounded bodyPreview/bodyPreviewLines, old/new body length slots, body SHA, required heading checks, literal \\n detection, shell-pollution signals, guard/concurrency summary, wouldPatch, and readCommands without printing an unbounded full body. Non-dry-run automatically reads current issue metadata before PATCH and returns oldBodySha/updatedAt; --expect-updated-at or --expect-body-sha remain available for explicit stale-cache protection.",
"issue comment create accepts --body-file <file|-> for Markdown/generated content and --body only for short single-line text. Blank, multiline, shell-polluted, secret-like, and overlong inline bodies fail structurally.", "issue comment create accepts --body-file <file|-> for Markdown/generated content and --body only for short single-line text. Blank, multiline, shell-polluted, secret-like, and overlong inline bodies fail structurally.",
"issue close/reopen default success output is compact and omits full issue.body. Optional --comment <short-text> or --comment-file <file|-> posts a bounded lifecycle comment before the state change and aborts the state change if the comment POST fails. --comment-file is the recommended path for generated Markdown closeout evidence; --comment remains the short inline form. Use gh issue read <number> --json body or --full/--raw on read when full text is needed.", "issue close/reopen default success output is compact and omits full issue.body. Optional --comment <short-text> or --comment-file <file|-> posts a bounded lifecycle comment before the state change and aborts the state change if the comment POST fails. --comment-file is the recommended path for generated Markdown closeout evidence; --comment remains the short inline form. Use gh issue view <number> --json body or --full/--raw on view when full text is needed.",
"issue stale-close is the reusable lifecycle cleanup path for policies such as closing open issues inactive for more than 48 hours. It selects open issues by GitHub updatedAt older than observedAt - --inactive-hours, treats comments and state changes as activity, filters pull requests, supports --dry-run, and returns bounded candidate/closed/failure summaries without echoing full bodies.", "issue stale-close is the reusable lifecycle cleanup path for policies such as closing open issues inactive for more than 48 hours. It selects open issues by GitHub updatedAt older than observedAt - --inactive-hours, treats comments and state changes as activity, filters pull requests, supports --dry-run, and returns bounded candidate/closed/failure summaries without echoing full bodies.",
"For one-shot issue writes, pipe reviewed Markdown through stdin: cat body.md | bun scripts/cli.ts gh issue update <number> --repo owner/name --body-file - or gh issue comment create <number> --body-file -. When staging a body file from a shell, use a quoted heredoc such as cat <<'EOF' > /tmp/body.md so backticks and backslashes are not expanded before --body-file reads the file.", "For one-shot issue writes, pipe reviewed Markdown through stdin: cat body.md | bun scripts/cli.ts gh issue update <number> --repo owner/name --body-file - or gh issue comment create <number> --body-file -. When staging a body file from a shell, use a quoted heredoc such as cat <<'EOF' > /tmp/body.md so backticks and backslashes are not expanded before --body-file reads the file.",
"For JSON request bodies in other CLI namespaces, prefer --body-file or --body-stdin over long inline shell arguments. GitHub issue/PR Markdown writes use --body-file <file|-> for long or multiline content.", "For JSON request bodies in other CLI namespaces, prefer --body-file or --body-stdin over long inline shell arguments. GitHub issue/PR Markdown writes use --body-file <file|-> for long or multiline content.",
@@ -6453,10 +6603,10 @@ export function ghHelp(): unknown {
"comment delete is supported because GitHub supports deleting issue comments; issue/pr hard delete is unsupported and close is the lifecycle alternative.", "comment delete is supported because GitHub supports deleting issue comments; issue/pr hard delete is unsupported and close is the lifecycle alternative.",
"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 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 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 view is the canonical GitHub CLI-compatible read path; read remains a UniDesk compatibility alias. PR view/read accept positional numbers, GitHub PR URLs, and owner/repo#number shorthand, deriving --repo unless an explicit conflicting --repo is supplied. --number is accepted on single PR/comment numeric target commands for low-friction compatibility and returns a standard syntax hint; list/create do not accept it. PR comment delete treats --number as commentId, not a PR number. PR view/read 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 preflight/closeout accept the same owner/repo#number shorthand as PR view/read 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 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 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 an explicit read-only policy. Use --full or --raw to include all fetched status contexts; gh pr merge is the separate guarded write path.",
"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.", "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.",
], ],
}; };
@@ -6496,12 +6646,12 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
return validationError(command, options.repo, "--raw and --full are explicit full-disclosure aliases only for gh issue list/read/view/update/edit, gh pr list/read/view, and gh pr preflight/closeout.", { return validationError(command, options.repo, "--raw and --full are explicit full-disclosure aliases only for gh issue list/read/view/update/edit, gh pr list/read/view, and gh pr preflight/closeout.", {
supportedCommands: [ supportedCommands: [
"bun scripts/cli.ts gh issue list --repo owner/name --limit 200 --full", "bun scripts/cli.ts gh issue list --repo owner/name --limit 200 --full",
"bun scripts/cli.ts gh issue read owner/name#<number> --raw", "bun scripts/cli.ts gh issue view owner/name#<number> --raw",
"bun scripts/cli.ts gh issue read <number> --repo owner/name --json body,title,state,comments", "bun scripts/cli.ts gh issue view <number> --repo owner/name --json body,title,state,comments",
"cat body.md | bun scripts/cli.ts gh issue update <number> --repo owner/name --body-file - --full", "cat body.md | bun scripts/cli.ts gh issue update <number> --repo owner/name --body-file - --full",
"bun scripts/cli.ts gh pr list --repo owner/name --limit 100 --full", "bun scripts/cli.ts gh pr list --repo owner/name --limit 100 --full",
"bun scripts/cli.ts gh pr read owner/name#<number> --raw", "bun scripts/cli.ts gh pr view owner/name#<number> --raw",
`bun scripts/cli.ts gh pr read <number> --repo owner/name --json ${readViewSupportedJsonFields("pr")}`, `bun scripts/cli.ts gh pr view <number> --repo owner/name --json ${readViewSupportedJsonFields("pr")}`,
"bun scripts/cli.ts gh pr preflight <number> --repo owner/name --full", "bun scripts/cli.ts gh pr preflight <number> --repo owner/name --full",
], ],
}); });
@@ -6515,13 +6665,17 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
], ],
}); });
} }
if (optionWasProvided(args, "--number") && !(top === "pr" && isPrReadCommand(sub))) { if (optionWasProvided(args, "--number") && !allowsNumberTargetAlias(top, sub, third)) {
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh"; const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
return validationError(command, options.repo, "--number is only supported by gh pr read/view; use gh pr read --repo owner/name --number N.", { const standardViewCommand = top === "issue" || top === "pr" ? `gh ${top} view` : "gh issue/pr view";
return validationError(command, options.repo, `--number is only a compatibility alias for single numeric target commands; standard ${standardViewCommand} uses a positional number or URL target.`, {
supportedCommands: [ supportedCommands: [
"bun scripts/cli.ts gh pr read --repo owner/name --number <number> --full", "bun scripts/cli.ts gh issue view <number> --repo owner/name --json body,title,state",
"bun scripts/cli.ts gh pr read <number> --repo owner/name --json body,title,state,head,base", "bun scripts/cli.ts gh issue view https://github.com/owner/name/issues/<number> --raw",
"bun scripts/cli.ts gh pr view <number> --repo owner/name --json body,title,state,head,base",
"bun scripts/cli.ts gh pr view https://github.com/owner/name/pull/<number> --raw",
], ],
rejectedOption: "--number",
}); });
} }
if (optionWasProvided(args, "--mode") && !((top === "issue" && (sub === "update" || sub === "edit")) || (top === "pr" && (sub === "update" || sub === "edit")))) { if (optionWasProvided(args, "--mode") && !((top === "issue" && (sub === "update" || sub === "edit")) || (top === "pr" && (sub === "update" || sub === "edit")))) {
@@ -6586,32 +6740,32 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
if (top === "preflight") { if (top === "preflight") {
const resolved = resolvePositionalPrReference(args, 1, "preflight", options); const resolved = resolvePositionalPrReference(args, 1, "preflight", options);
if (isGitHubCommandResult(resolved)) return resolved; if (isGitHubCommandResult(resolved)) return resolved;
return prPreflight(resolved.repo, resolved.number, "preflight", options.full || options.raw); return withNumberOptionHint(prPreflight(resolved.repo, resolved.number, "preflight", options.full || options.raw), resolved);
} }
if (top === "issue") { if (top === "issue") {
if (sub === "delete") return unsupportedCommand("issue delete", options.repo, "GitHub REST does not support hard-deleting issues; use gh issue close for lifecycle deletion semantics."); if (sub === "delete") return unsupportedCommand("issue delete", options.repo, "GitHub REST does not support hard-deleting issues; use gh issue close for lifecycle deletion semantics.");
if (sub === "comment" && third === "delete") { if (sub === "comment" && third === "delete") {
const resolved = resolvePositionalIssueReference(args, 3, "issue comment delete", options); const resolved = resolvePositionalNumberReference("issue", args, 3, "issue comment delete", options);
if (isGitHubCommandResult(resolved)) return resolved; if (isGitHubCommandResult(resolved)) return resolved;
const commentId = resolved.number; const commentId = resolved.number;
if (typeof commentId !== "number") return commentId; if (typeof commentId !== "number") return commentId;
if (options.dryRun) return commentDelete(options.repo, "", "issue", commentId, true); if (options.dryRun) return withNumberOptionHint(commentDelete(resolved.repo, "", "issue", commentId, true), resolved);
const { token, probe } = resolveToken(true); const { token, probe } = resolveToken(true);
const missing = authRequired(options.repo, "issue comment delete", probe); const missing = authRequired(resolved.repo, "issue comment delete", probe);
if (missing !== null || token === null) return missing ?? authRequired(options.repo, "issue comment delete", { present: false, source: null, ghFallbackAttempted: true }); if (missing !== null || token === null) return missing ?? authRequired(resolved.repo, "issue comment delete", { present: false, source: null, ghFallbackAttempted: true });
return commentDelete(options.repo, token, "issue", commentId, false); return withNumberOptionHint(commentDelete(resolved.repo, token, "issue", commentId, false), resolved);
} }
if (sub === "comment" && third === "create") { if (sub === "comment" && third === "create") {
const resolved = resolvePositionalIssueReference(args, 3, "issue comment create", options); const resolved = resolvePositionalIssueReference(args, 3, "issue comment create", options);
if (isGitHubCommandResult(resolved)) return resolved; if (isGitHubCommandResult(resolved)) return resolved;
const issueNumber = resolved.number; const issueNumber = resolved.number;
if (typeof issueNumber !== "number") return issueNumber; if (typeof issueNumber !== "number") return issueNumber;
if (options.dryRun) return issueComment(options.repo, "", issueNumber, options); if (options.dryRun) return withNumberOptionHint(issueComment(resolved.repo, "", issueNumber, { ...options, repo: resolved.repo }), resolved);
const { token, probe } = resolveToken(true); const { token, probe } = resolveToken(true);
const missing = authRequired(options.repo, "issue comment create", probe); const missing = authRequired(resolved.repo, "issue comment create", probe);
if (missing !== null || token === null) return missing ?? authRequired(options.repo, "issue comment create", { present: false, source: null, ghFallbackAttempted: true }); if (missing !== null || token === null) return missing ?? authRequired(options.repo, "issue comment create", { present: false, source: null, ghFallbackAttempted: true });
return issueComment(options.repo, token, issueNumber, options); return withNumberOptionHint(issueComment(resolved.repo, token, issueNumber, { ...options, repo: resolved.repo }), resolved);
} }
if (sub === "scan-escape" || sub === "cleanup-plan") { if (sub === "scan-escape" || sub === "cleanup-plan") {
const { token, probe } = resolveToken(true); const { token, probe } = resolveToken(true);
@@ -6640,12 +6794,13 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
if (isGitHubCommandResult(resolvedBoardRow)) return resolvedBoardRow; if (isGitHubCommandResult(resolvedBoardRow)) return resolvedBoardRow;
const issueNumber = resolvedBoardRow.number; const issueNumber = resolvedBoardRow.number;
if (typeof issueNumber !== "number") return issueNumber; if (typeof issueNumber !== "number") return issueNumber;
if (action === "get") return issueBoardRowGet(options.repo, token, issueNumber, options); const boardRowOptions = { ...options, repo: resolvedBoardRow.repo };
if (action === "update") return issueBoardRowUpdate(options.repo, token, issueNumber, options); if (action === "get") return withNumberOptionHint(issueBoardRowGet(resolvedBoardRow.repo, token, issueNumber, boardRowOptions), resolvedBoardRow);
if (action === "add") return issueBoardRowAdd(options.repo, token, issueNumber, options); if (action === "update") return withNumberOptionHint(issueBoardRowUpdate(resolvedBoardRow.repo, token, issueNumber, boardRowOptions), resolvedBoardRow);
if (action === "upsert") return issueBoardRowUpsert(options.repo, token, issueNumber, options); if (action === "add") return withNumberOptionHint(issueBoardRowAdd(resolvedBoardRow.repo, token, issueNumber, boardRowOptions), resolvedBoardRow);
if (action === "move") return issueBoardRowMove(options.repo, token, issueNumber, options); if (action === "upsert") return withNumberOptionHint(issueBoardRowUpsert(resolvedBoardRow.repo, token, issueNumber, boardRowOptions), resolvedBoardRow);
return issueBoardRowDelete(options.repo, token, issueNumber, options); if (action === "move") return withNumberOptionHint(issueBoardRowMove(resolvedBoardRow.repo, token, issueNumber, boardRowOptions), resolvedBoardRow);
return withNumberOptionHint(issueBoardRowDelete(resolvedBoardRow.repo, token, issueNumber, boardRowOptions), resolvedBoardRow);
} }
if (options.dryRun) { if (options.dryRun) {
if (sub === "create") return issueCreate(options.repo, "", options); if (sub === "create") return issueCreate(options.repo, "", options);
@@ -6654,29 +6809,29 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
if (isGitHubCommandResult(issueEditRef)) return issueEditRef; if (isGitHubCommandResult(issueEditRef)) return issueEditRef;
const issueNumber = issueEditRef.number; const issueNumber = issueEditRef.number;
const { token } = resolveToken(false); const { token } = resolveToken(false);
return issueEdit(options.repo, token ?? "", issueNumber, options); return withNumberOptionHint(issueEdit(issueEditRef.repo, token ?? "", issueNumber, { ...options, repo: issueEditRef.repo }), issueEditRef);
} }
if (sub === "update") { if (sub === "update") {
const issueUpdateRef = resolvePositionalIssueReference(args, 2, "issue update", options); const issueUpdateRef = resolvePositionalIssueReference(args, 2, "issue update", options);
if (isGitHubCommandResult(issueUpdateRef)) return issueUpdateRef; if (isGitHubCommandResult(issueUpdateRef)) return issueUpdateRef;
const issueNumber = issueUpdateRef.number; const issueNumber = issueUpdateRef.number;
const { token } = resolveToken(false); const { token } = resolveToken(false);
return issueEdit(options.repo, token ?? "", issueNumber, options, "issue update"); return withNumberOptionHint(issueEdit(issueUpdateRef.repo, token ?? "", issueNumber, { ...options, repo: issueUpdateRef.repo }, "issue update"), issueUpdateRef);
} }
if (sub === "comment") { if (sub === "comment") {
const r = resolvePositionalIssueReference(args, 2, "issue comment", options); const r = resolvePositionalIssueReference(args, 2, "issue comment", options);
if (isGitHubCommandResult(r)) return r; if (isGitHubCommandResult(r)) return r;
return issueComment(r.repo, "", r.number, { ...options, repo: r.repo }); return withNumberOptionHint(issueComment(r.repo, "", r.number, { ...options, repo: r.repo }), r);
} }
if (sub === "close") { if (sub === "close") {
const r = resolvePositionalIssueReference(args, 2, "issue close", options); const r = resolvePositionalIssueReference(args, 2, "issue close", options);
if (isGitHubCommandResult(r)) return r; if (isGitHubCommandResult(r)) return r;
return issueState(r.repo, "", r.number, "closed", true, { ...options, repo: r.repo }); return withNumberOptionHint(issueState(r.repo, "", r.number, "closed", true, { ...options, repo: r.repo }), r);
} }
if (sub === "reopen") { if (sub === "reopen") {
const r = resolvePositionalIssueReference(args, 2, "issue reopen", options); const r = resolvePositionalIssueReference(args, 2, "issue reopen", options);
if (isGitHubCommandResult(r)) return r; if (isGitHubCommandResult(r)) return r;
return issueState(r.repo, "", r.number, "open", true, { ...options, repo: r.repo }); return withNumberOptionHint(issueState(r.repo, "", r.number, "open", true, { ...options, repo: r.repo }), r);
} }
} }
if (sub === "read" || sub === "view") { if (sub === "read" || sub === "view") {
@@ -6686,8 +6841,8 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
const missing = authRequired(resolved.repo, `issue ${sub}`, probe); const missing = authRequired(resolved.repo, `issue ${sub}`, probe);
if (missing !== null || token === null) return missing ?? authRequired(resolved.repo, `issue ${sub}`, { present: false, source: null, ghFallbackAttempted: true }); if (missing !== null || token === null) return missing ?? authRequired(resolved.repo, `issue ${sub}`, { present: false, source: null, ghFallbackAttempted: true });
const disclosure = readDisclosureOptions(options, resolved.shorthand); const disclosure = readDisclosureOptions(options, resolved.shorthand);
if (sub === "read") return issueRead(resolved.repo, token, resolved.number, issueReadJsonFields(options), "issue read", disclosure); if (sub === "read") return withNumberOptionHint(issueRead(resolved.repo, token, resolved.number, issueReadJsonFields(options), "issue read", disclosure), resolved);
return issueView(resolved.repo, token, resolved.number, issueReadJsonFields(options), disclosure); return withNumberOptionHint(issueView(resolved.repo, token, resolved.number, issueReadJsonFields(options), disclosure), resolved);
} }
const { token, probe } = resolveToken(true); const { token, probe } = resolveToken(true);
const missing = authRequired(options.repo, `issue ${sub ?? ""}`.trim(), probe); const missing = authRequired(options.repo, `issue ${sub ?? ""}`.trim(), probe);
@@ -6699,27 +6854,27 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
if (sub === "edit") { if (sub === "edit") {
const r = resolvePositionalIssueReference(args, 2, "issue edit", options); const r = resolvePositionalIssueReference(args, 2, "issue edit", options);
if (isGitHubCommandResult(r)) return r; if (isGitHubCommandResult(r)) return r;
return issueEdit(r.repo, token, r.number, { ...options, repo: r.repo }); return withNumberOptionHint(issueEdit(r.repo, token, r.number, { ...options, repo: r.repo }), r);
} }
if (sub === "update") { if (sub === "update") {
const r = resolvePositionalIssueReference(args, 2, "issue update", options); const r = resolvePositionalIssueReference(args, 2, "issue update", options);
if (isGitHubCommandResult(r)) return r; if (isGitHubCommandResult(r)) return r;
return issueEdit(r.repo, token, r.number, { ...options, repo: r.repo }, "issue update"); return withNumberOptionHint(issueEdit(r.repo, token, r.number, { ...options, repo: r.repo }, "issue update"), r);
} }
if (sub === "comment") { if (sub === "comment") {
const r = resolvePositionalIssueReference(args, 2, "issue comment", options); const r = resolvePositionalIssueReference(args, 2, "issue comment", options);
if (isGitHubCommandResult(r)) return r; if (isGitHubCommandResult(r)) return r;
return issueComment(r.repo, token, r.number, { ...options, repo: r.repo }); return withNumberOptionHint(issueComment(r.repo, token, r.number, { ...options, repo: r.repo }), r);
} }
if (sub === "close") { if (sub === "close") {
const r = resolvePositionalIssueReference(args, 2, "issue close", options); const r = resolvePositionalIssueReference(args, 2, "issue close", options);
if (isGitHubCommandResult(r)) return r; if (isGitHubCommandResult(r)) return r;
return issueState(r.repo, token, r.number, "closed", options.dryRun, { ...options, repo: r.repo }); return withNumberOptionHint(issueState(r.repo, token, r.number, "closed", options.dryRun, { ...options, repo: r.repo }), r);
} }
if (sub === "reopen") { if (sub === "reopen") {
const r = resolvePositionalIssueReference(args, 2, "issue reopen", options); const r = resolvePositionalIssueReference(args, 2, "issue reopen", options);
if (isGitHubCommandResult(r)) return r; if (isGitHubCommandResult(r)) return r;
return issueState(r.repo, token, r.number, "open", options.dryRun, { ...options, repo: r.repo }); return withNumberOptionHint(issueState(r.repo, token, r.number, "open", options.dryRun, { ...options, repo: r.repo }), r);
} }
} }
@@ -6739,7 +6894,7 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
const { token, probe } = resolveToken(true); const { token, probe } = resolveToken(true);
const missing = authRequired(resolved.repo, "pr diff --stat", probe); const missing = authRequired(resolved.repo, "pr diff --stat", probe);
if (missing !== null || token === null) return missing ?? authRequired(resolved.repo, "pr diff --stat", { present: false, source: null, ghFallbackAttempted: true }); if (missing !== null || token === null) return missing ?? authRequired(resolved.repo, "pr diff --stat", { present: false, source: null, ghFallbackAttempted: true });
return prFiles(resolved.repo, token, resolved.number, options.limit, "pr diff --stat"); return withNumberOptionHint(prFiles(resolved.repo, token, resolved.number, options.limit, "pr diff --stat"), resolved);
} }
if (sub === "files") { if (sub === "files") {
const resolved = resolvePositionalPrReference(args, 2, "pr files", options); const resolved = resolvePositionalPrReference(args, 2, "pr files", options);
@@ -6747,31 +6902,31 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
const { token, probe } = resolveToken(true); const { token, probe } = resolveToken(true);
const missing = authRequired(resolved.repo, "pr files", probe); const missing = authRequired(resolved.repo, "pr files", probe);
if (missing !== null || token === null) return missing ?? authRequired(resolved.repo, "pr files", { present: false, source: null, ghFallbackAttempted: true }); if (missing !== null || token === null) return missing ?? authRequired(resolved.repo, "pr files", { present: false, source: null, ghFallbackAttempted: true });
return prFiles(resolved.repo, token, resolved.number, options.limit, "pr files"); return withNumberOptionHint(prFiles(resolved.repo, token, resolved.number, options.limit, "pr files"), resolved);
} }
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 === "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") { if (sub === "preflight" || sub === "closeout") {
const resolved = resolvePositionalPrReference(args, 2, `pr ${sub}`, options); const resolved = resolvePositionalPrReference(args, 2, `pr ${sub}`, options);
if (isGitHubCommandResult(resolved)) return resolved; if (isGitHubCommandResult(resolved)) return resolved;
return prPreflight(resolved.repo, resolved.number, sub === "closeout" ? "pr closeout" : "pr preflight", options.full || options.raw); return withNumberOptionHint(prPreflight(resolved.repo, resolved.number, sub === "closeout" ? "pr closeout" : "pr preflight", options.full || options.raw), resolved);
} }
if (sub === "comment" && third === "delete") { if (sub === "comment" && third === "delete") {
const commentId = parseNumberForCommand(options.repo, args[3], "pr comment delete"); const resolved = resolvePositionalNumberReference("pr", args, 3, "pr comment delete", options);
if (typeof commentId !== "number") return commentId; if (isGitHubCommandResult(resolved)) return resolved;
if (options.dryRun) return commentDelete(options.repo, "", "pr", commentId, true); if (options.dryRun) return withNumberOptionHint(commentDelete(resolved.repo, "", "pr", resolved.number, true), resolved);
const { token, probe } = resolveToken(true); const { token, probe } = resolveToken(true);
const missing = authRequired(options.repo, "pr comment delete", probe); const missing = authRequired(resolved.repo, "pr comment delete", probe);
if (missing !== null || token === null) return missing ?? authRequired(options.repo, "pr comment delete", { present: false, source: null, ghFallbackAttempted: true }); if (missing !== null || token === null) return missing ?? authRequired(resolved.repo, "pr comment delete", { present: false, source: null, ghFallbackAttempted: true });
return commentDelete(options.repo, token, "pr", commentId, false); return withNumberOptionHint(commentDelete(resolved.repo, token, "pr", resolved.number, false), resolved);
} }
if (sub === "comment" && third === "create") { if (sub === "comment" && third === "create") {
const number = parseNumberForCommand(options.repo, args[3], "pr comment create"); const resolved = resolvePositionalPrReference(args, 3, "pr comment create", options);
if (typeof number !== "number") return number; if (isGitHubCommandResult(resolved)) return resolved;
if (options.dryRun) return prComment(options.repo, "", number, options); if (options.dryRun) return withNumberOptionHint(prComment(resolved.repo, "", resolved.number, { ...options, repo: resolved.repo }), resolved);
const { token, probe } = resolveToken(true); const { token, probe } = resolveToken(true);
const missing = authRequired(options.repo, "pr comment create", probe); const missing = authRequired(resolved.repo, "pr comment create", probe);
if (missing !== null || token === null) return missing ?? authRequired(options.repo, "pr comment create", { present: false, source: null, ghFallbackAttempted: true }); if (missing !== null || token === null) return missing ?? authRequired(options.repo, "pr comment create", { present: false, source: null, ghFallbackAttempted: true });
return prComment(options.repo, token, number, options); return withNumberOptionHint(prComment(resolved.repo, token, resolved.number, { ...options, repo: resolved.repo }), resolved);
} }
if (sub === "create") { if (sub === "create") {
if (options.dryRun) return prCreate(options.repo, "", options); if (options.dryRun) return prCreate(options.repo, "", options);
@@ -6782,35 +6937,35 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
} }
if (sub === "comment") { if (sub === "comment") {
if (options.dryRun) { if (options.dryRun) {
const number = parseNumberForCommand(options.repo, third, "pr comment"); const resolved = resolvePositionalPrReference(args, 2, "pr comment", options);
if (typeof number !== "number") return number; if (isGitHubCommandResult(resolved)) return resolved;
return prComment(options.repo, "", number, options); return withNumberOptionHint(prComment(resolved.repo, "", resolved.number, { ...options, repo: resolved.repo }), resolved);
} }
const { token, probe } = resolveToken(true); const { token, probe } = resolveToken(true);
const missing = authRequired(options.repo, "pr comment", probe); const missing = authRequired(options.repo, "pr comment", probe);
if (missing !== null || token === null) return missing ?? authRequired(options.repo, "pr comment", { present: false, source: null, ghFallbackAttempted: true }); if (missing !== null || token === null) return missing ?? authRequired(options.repo, "pr comment", { present: false, source: null, ghFallbackAttempted: true });
const number = parseNumberForCommand(options.repo, third, "pr comment"); const resolved = resolvePositionalPrReference(args, 2, "pr comment", options);
if (typeof number !== "number") return number; if (isGitHubCommandResult(resolved)) return resolved;
return prComment(options.repo, token, number, options); return withNumberOptionHint(prComment(resolved.repo, token, resolved.number, { ...options, repo: resolved.repo }), resolved);
} }
if (sub === "update" || sub === "edit") { if (sub === "update" || sub === "edit") {
const commandName = `pr ${sub}`; const commandName = `pr ${sub}`;
const number = parseNumberForCommand(options.repo, third, commandName); const resolved = resolvePositionalPrReference(args, 2, commandName, options);
if (typeof number !== "number") return number; if (isGitHubCommandResult(resolved)) return resolved;
if (options.dryRun && options.mode === "replace") return prUpdate(options.repo, "", number, options, commandName); if (options.dryRun && options.mode === "replace") return withNumberOptionHint(prUpdate(resolved.repo, "", resolved.number, { ...options, repo: resolved.repo }, commandName), resolved);
const { token, probe } = resolveToken(true); const { token, probe } = resolveToken(true);
const missing = authRequired(options.repo, commandName, probe); const missing = authRequired(resolved.repo, commandName, probe);
if (missing !== null || token === null) return missing ?? authRequired(options.repo, commandName, { present: false, source: null, ghFallbackAttempted: true }); if (missing !== null || token === null) return missing ?? authRequired(options.repo, commandName, { present: false, source: null, ghFallbackAttempted: true });
return prUpdate(options.repo, token, number, options, commandName); return withNumberOptionHint(prUpdate(resolved.repo, token, resolved.number, { ...options, repo: resolved.repo }, commandName), resolved);
} }
if (sub === "close" || sub === "reopen") { if (sub === "close" || sub === "reopen") {
const number = parseNumberForCommand(options.repo, third, `pr ${sub}`); const resolved = resolvePositionalPrReference(args, 2, `pr ${sub}`, options);
if (typeof number !== "number") return number; if (isGitHubCommandResult(resolved)) return resolved;
if (options.dryRun) return prState(options.repo, "", number, sub === "close" ? "closed" : "open", true); if (options.dryRun) return withNumberOptionHint(prState(resolved.repo, "", resolved.number, sub === "close" ? "closed" : "open", true), resolved);
const { token, probe } = resolveToken(true); const { token, probe } = resolveToken(true);
const missing = authRequired(options.repo, `pr ${sub}`, probe); const missing = authRequired(resolved.repo, `pr ${sub}`, probe);
if (missing !== null || token === null) return missing ?? authRequired(options.repo, `pr ${sub}`, { present: false, source: null, ghFallbackAttempted: true }); if (missing !== null || token === null) return missing ?? authRequired(options.repo, `pr ${sub}`, { present: false, source: null, ghFallbackAttempted: true });
return prState(options.repo, token, number, sub === "close" ? "closed" : "open", false); return withNumberOptionHint(prState(resolved.repo, token, resolved.number, sub === "close" ? "closed" : "open", false), resolved);
} }
if (sub === "merge") { if (sub === "merge") {
const resolved = resolvePositionalPrReference(args, 2, "pr merge", options); const resolved = resolvePositionalPrReference(args, 2, "pr merge", options);
@@ -6818,7 +6973,7 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
const { token, probe } = resolveToken(true); const { token, probe } = resolveToken(true);
const missing = authRequired(options.repo, "pr merge", probe); 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 }); if (missing !== null || token === null) return missing ?? authRequired(options.repo, "pr merge", { present: false, source: null, ghFallbackAttempted: true });
return prMerge(resolved.repo, token, resolved.number, options); return withNumberOptionHint(prMerge(resolved.repo, token, resolved.number, options), resolved);
} }
if (sub !== "list" && !isPrReadCommand(sub)) { 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, merge, comment create/delete, and unsupported 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.");
@@ -6830,8 +6985,8 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
const missing = authRequired(resolved.repo, `pr ${sub}`, probe); const missing = authRequired(resolved.repo, `pr ${sub}`, probe);
if (missing !== null || token === null) return missing ?? authRequired(resolved.repo, `pr ${sub}`, { present: false, source: null, ghFallbackAttempted: true }); if (missing !== null || token === null) return missing ?? authRequired(resolved.repo, `pr ${sub}`, { present: false, source: null, ghFallbackAttempted: true });
const disclosure = readDisclosureOptions(options, resolved.shorthand); const disclosure = readDisclosureOptions(options, resolved.shorthand);
if (sub === "read") return prRead(resolved.repo, token, resolved.number, prReadJsonFields(options), "pr read", disclosure); if (sub === "read") return withNumberOptionHint(prRead(resolved.repo, token, resolved.number, prReadJsonFields(options), "pr read", disclosure), resolved);
return prView(resolved.repo, token, resolved.number, prReadJsonFields(options), disclosure); return withNumberOptionHint(prView(resolved.repo, token, resolved.number, prReadJsonFields(options), disclosure), resolved);
} }
const { token, probe } = resolveToken(true); const { token, probe } = resolveToken(true);
const missing = authRequired(options.repo, `pr ${sub}`, probe); const missing = authRequired(options.repo, `pr ${sub}`, probe);