feat: 支持 gh issue stdin 写入

This commit is contained in:
Codex
2026-05-29 09:04:06 +00:00
parent 93477a3330
commit 93d305b0f6
4 changed files with 81 additions and 38 deletions
+42 -9
View File
@@ -660,7 +660,7 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
assertCondition(notes.some((line) => line.includes("compatibility alias")), "gh help should state issue view 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("--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 only for short single-line text")), "gh help should document issue comment 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 upsert updates an existing row")), "gh help should describe board-row upsert safety", { notes });
assertCondition(notes.some((line) => line.includes("board-row add/move/delete are row-scoped")), "gh help should describe board-row row mutation safety", { notes });
@@ -1502,6 +1502,22 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
const issueCreatePayload = JSON.parse(issueCreateRequest?.body ?? "{}") as JsonRecord;
assertCondition(Array.isArray(issueCreatePayload.labels) && (issueCreatePayload.labels as unknown[]).join(",") === "cli,infra,ops", "issue create REST payload should include labels", issueCreatePayload);
const issueCreateStdinBody = "# stdin issue create\n\n- preserves `code`\n";
const issueCreateStdinRequestCountBefore = mock.requests.length;
const issueCreateStdin = await runCli(["gh", "issue", "create", "--repo", "pikasTech/unidesk", "--title", "stdin issue create", "--body-file", "-", "--dry-run"], env, issueCreateStdinBody);
assertCondition(issueCreateStdin.status === 0, "issue create dry-run should accept --body-file - stdin", issueCreateStdin.json ?? { stdout: issueCreateStdin.stdout });
const issueCreateStdinData = dataOf(issueCreateStdin.json ?? {});
const issueCreateStdinSource = issueCreateStdinData.bodySource as JsonRecord;
assertCondition(issueCreateStdinSource.kind === "stdin" && issueCreateStdinSource.path === "-", "issue create stdin dry-run should expose stdin source", issueCreateStdinData);
assertCondition(issueCreateStdinData.containsBackticks === true && issueCreateStdinData.containsLiteralBackslashN === false, "issue create stdin should preserve Markdown signals", issueCreateStdinData);
const issueCreateStdinWriteCount = mock.requests.slice(issueCreateStdinRequestCountBefore).filter((request) => request.method === "POST" && request.url === "/repos/pikasTech/unidesk/issues").length;
assertCondition(issueCreateStdinWriteCount === 0, "issue create stdin dry-run must not POST GitHub", { requests: mock.requests.slice(issueCreateStdinRequestCountBefore) });
const issueCreateInline = await runCli(["gh", "issue", "create", "--repo", "pikasTech/unidesk", "--title", "inline rejected", "--body", "inline body", "--dry-run"], env);
assertCondition(issueCreateInline.status !== 0, "issue create inline --body should fail", issueCreateInline.json ?? { stdout: issueCreateInline.stdout });
const issueCreateInlineData = failedDataOf(issueCreateInline.json ?? {});
assertCondition(failureMessageOf(issueCreateInlineData).includes("does not support --body"), "issue create inline --body should point to body-file", issueCreateInlineData);
const issueCreateMissingLabel = await runCli(["gh", "issue", "create", "--repo", "pikasTech/unidesk", "--title", "bad label", "--body-file", appendFile, "--label", "missing-label"], env);
assertCondition(issueCreateMissingLabel.status !== 0, "issue create missing label should fail structurally", issueCreateMissingLabel.json ?? { stdout: issueCreateMissingLabel.stdout });
const missingLabelData = failedDataOf(issueCreateMissingLabel.json ?? {});
@@ -1547,7 +1563,7 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
assertCondition(typeof compactIssue.bodySha === "string" && String(compactIssue.bodySha).length === 64, "compact issue summary should include bodySha", compactIssue);
assertCondition(String(compactIssue.bodyPreview ?? "").includes("compact-success-line-0001") && !String(compactIssue.bodyPreview ?? "").includes("compact-success-line-0260"), "compact issue summary should include only bounded preview", compactIssue);
const compactConcurrency = compactUpdateData.concurrency as JsonRecord;
assertCondition(compactConcurrency.checked === false && compactConcurrency.expectBodySha === null, "compact update should still report concurrency summary", compactConcurrency);
assertCondition(compactConcurrency.checked === true && typeof compactConcurrency.oldBodySha === "string" && compactConcurrency.expectBodySha === null, "compact update should automatically read old issue metadata before PATCH", compactConcurrency);
const compactGuard = compactUpdateData.guard as JsonRecord;
assertCondition(compactGuard.ok === true && typeof compactGuard.bodySha === "string", "compact update should keep guard/body sha summary", compactGuard);
const compactDisclosure = compactUpdateData.disclosure as JsonRecord;
@@ -1558,6 +1574,20 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
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) });
const stdinIssueBody = "# Code Queue\n\n## 看板(OPEN\n\n- stdin issue body keeps `code`.\n\n| s | t |\n| --- | --- |\n| 5 | 6 |\n";
const stdinUpdateRequestCountBefore = mock.requests.length;
const stdinUpdate = await runCli(["gh", "issue", "update", "20", "--repo", "pikasTech/unidesk", "--mode", "replace", "--body-file", "-"], env, stdinIssueBody);
assertCondition(stdinUpdate.status === 0, "issue update should accept --body-file - stdin", stdinUpdate.json ?? { stdout: stdinUpdate.stdout, stderr: stdinUpdate.stderr });
const stdinUpdateData = dataOf(stdinUpdate.json ?? {});
const stdinUpdateBodySource = stdinUpdateData.bodySource as JsonRecord;
assertCondition(stdinUpdateBodySource.kind === "stdin" && stdinUpdateBodySource.path === "-", "stdin issue update should report stdin bodySource", stdinUpdateData);
const stdinUpdateConcurrency = stdinUpdateData.concurrency as JsonRecord;
assertCondition(stdinUpdateConcurrency.checked === true && typeof stdinUpdateConcurrency.oldBodySha === "string", "stdin issue update should automatically read current issue metadata before PATCH", stdinUpdateConcurrency);
const stdinUpdatePatch = mock.requests.slice(stdinUpdateRequestCountBefore).find((request) => request.method === "PATCH" && request.url === "/repos/pikasTech/unidesk/issues/20");
assertCondition(stdinUpdatePatch !== undefined, "stdin issue update should PATCH issue body", { requests: mock.requests.slice(stdinUpdateRequestCountBefore) });
const stdinUpdatePayload = JSON.parse(stdinUpdatePatch?.body ?? "{}") as JsonRecord;
assertCondition(stdinUpdatePayload.body === stdinIssueBody, "stdin issue update should preserve exact stdin Markdown", stdinUpdatePayload);
const explicitFullBody = "# compact full body\n\nThis body is intentionally short enough to avoid global dump while still proving explicit disclosure.\n";
const explicitFullFile = join(tmp, "explicit-full-body.md");
writeFileSync(explicitFullFile, explicitFullBody, "utf8");
@@ -1640,10 +1670,13 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
stderr: secretInlineComment.stderr,
});
const stdinInlineComment = await runCli(["gh", "issue", "comment", "create", "36", "--repo", "pikasTech/unidesk", "--body-file", "-", "--dry-run"], env, "stdin body");
assertCondition(stdinInlineComment.status !== 0, "issue comment body stdin should remain unsupported", stdinInlineComment.json ?? { stdout: stdinInlineComment.stdout });
const stdinInlineCommentData = failedDataOf(stdinInlineComment.json ?? {});
assertCondition(failureMessageOf(stdinInlineCommentData).includes("does not support --body-file - stdin"), "issue comment stdin rejection should be explicit", stdinInlineCommentData);
const stdinCommentBody = "stdin comment line 1\n\n- keeps `code`\n";
const stdinComment = await runCli(["gh", "issue", "comment", "create", "36", "--repo", "pikasTech/unidesk", "--body-file", "-", "--dry-run"], env, stdinCommentBody);
assertCondition(stdinComment.status === 0, "issue comment body stdin should be supported", stdinComment.json ?? { stdout: stdinComment.stdout });
const stdinCommentData = dataOf(stdinComment.json ?? {});
const stdinCommentSource = stdinCommentData.bodySource as JsonRecord;
assertCondition(stdinCommentSource.kind === "stdin" && stdinCommentSource.path === "-" && stdinCommentData.source === "stdin", "stdin issue comment dry-run should expose stdin source", stdinCommentData);
assertCondition(stdinCommentData.containsBackticks === true && stdinCommentData.containsLiteralBackslashN === false, "stdin issue comment should preserve Markdown signals", stdinCommentData);
const commentDeleteDryRun = await runCli(["gh", "issue", "comment", "delete", "9001", "--repo", "pikasTech/unidesk", "--dry-run"], env);
assertCondition(commentDeleteDryRun.status === 0, "issue comment delete dry-run should succeed", commentDeleteDryRun.json ?? { stdout: commentDeleteDryRun.stdout });
@@ -1682,7 +1715,7 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
"issue board-row update rejects literal backslash-n cell values",
"issue board-row update escapes markdown table pipes and performs guarded PATCH with --expect-body-sha",
"issue board-row move is supported, defaults to dry-run, and can migrate OPEN rows into CLOSED",
"issue create dry-run parses repeated/comma labels and exposes request plan",
"issue create dry-run parses repeated/comma labels, supports stdin, rejects inline --body, and exposes request plan",
"issue create sends labels through REST and preserves GitHub validation errors for missing labels",
"issue list unsupported fields and states fail structurally",
"issue read supports body,title,state,comments selection",
@@ -1698,11 +1731,11 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
"dry-run reports old/new body safety and does not PATCH",
"multiline Markdown and backticks are not polluted",
"expect-updated-at stale write protection blocks PATCH",
"issue update replace/append modes preserve Markdown",
"issue update replace/append modes preserve Markdown and support stdin with automatic current issue metadata checks",
"issue update non-dry-run success defaults to compact output without full issue.body and exposes bodySha plus drill-down commands",
"issue update --full explicitly includes full issue.body",
"issue comment create supports short inline --body dry-run and write with bounded output",
"issue comment create still rejects missing, blank, multiline, polluted, secret-like, stdin, and mixed body sources",
"issue comment create supports stdin and still rejects missing, blank, multiline inline, polluted inline, secret-like inline, and mixed body sources",
"issue comment create/delete follows CRUD shape",
"issue hard delete is structurally unsupported",
],
+35 -25
View File
@@ -943,12 +943,6 @@ function unknownGhOptionDetails(args: string[], option: string): Record<string,
return details;
}
function readBodyFile(path: string | undefined, command: string): string {
if (path === undefined) throw new Error(`${command} requires --body-file <file>`);
if (!existsSync(path)) throw new Error(`body file not found: ${path}`);
return readFileSync(path, "utf8");
}
function readMarkdownBodyFileOrStdin(path: string): { body: string; bodySource: Record<string, unknown> } {
if (path === "-") {
return { body: readFileSync(0, "utf8"), bodySource: { kind: "stdin", path: "-" } };
@@ -994,7 +988,6 @@ function readIssueCommentBody(options: GitHubOptions): { body: string; bodySourc
throw new Error("issue comment create accepts only one body source: --body-file or --body");
}
if (options.bodyFile !== undefined) {
if (options.bodyFile === "-") throw new Error("issue comment create does not support --body-file - stdin; write Markdown to a file and pass --body-file <file>");
return readMarkdownBodyFileOrStdin(options.bodyFile);
}
if (options.body === undefined) throw new Error("issue comment create requires --body-file <file> or --body <text>");
@@ -5026,8 +5019,16 @@ async function issueBoardAudit(repo: string, token: string, options: GitHubOptio
async function issueCreate(repo: string, token: string, options: GitHubOptions): Promise<GitHubCommandResult> {
if (options.title === undefined) throw new Error("issue create requires --title <title>");
const body = readBodyFile(options.bodyFile, "issue create");
const bodySource = { kind: "body-file", path: options.bodyFile ?? null };
if (options.body !== undefined) {
return validationError("issue create", repo, "issue create does not support --body; use --body-file <file|-> for Markdown", {
title: options.title,
bodySource: "inline",
});
}
const { body, bodySource } = readMarkdownBody({
...options,
body: undefined,
}, "issue create");
const labels = options.labels;
if (options.dryRun) {
return {
@@ -5060,7 +5061,16 @@ async function issueCreate(repo: string, token: string, options: GitHubOptions):
}
async function issueEdit(repo: string, token: string, issueNumber: number, options: GitHubOptions, commandName = "issue edit"): Promise<GitHubCommandResult> {
const body = readBodyFile(options.bodyFile, commandName);
if (options.bodyFile === undefined) {
return validationError(commandName, repo, `${commandName} requires --body-file <file|->`, { issueNumber });
}
let bodyInput: { body: string; bodySource: Record<string, unknown> };
try {
bodyInput = readMarkdownBodyFileOrStdin(options.bodyFile);
} catch (error) {
return validationError(commandName, repo, error instanceof Error ? error.message : String(error), { issueNumber });
}
const { body, bodySource } = bodyInput;
const needsProfileMetadata = issueProfileNeedsMetadata(issueNumber, options.bodyProfile);
if (options.mode === "replace" && !needsProfileMetadata) {
const bodyGuard = validateIssueBodyGuard(repo, issueNumber, body, options);
@@ -5077,7 +5087,7 @@ async function issueEdit(repo: string, token: string, issueNumber: number, optio
}
const needsReadBeforeEdit = options.mode === "append"
|| needsProfileMetadata && token.length > 0
|| !options.dryRun && (options.notifyClaudeQqBriefDiff || options.expectUpdatedAt !== undefined || options.expectBodySha !== undefined);
|| !options.dryRun;
if (needsReadBeforeEdit) {
const issue = await getIssue(token, repo, issueNumber);
if (isGitHubError(issue)) return commandError(commandName, repo, issue, { issueNumber, phase: "read-before-update" });
@@ -5146,7 +5156,7 @@ async function issueEdit(repo: string, token: string, issueNumber: number, optio
disclosure: issueWriteDisclosure(options, repo, issueNumber, true),
readCommands: issueBodyReadCommands(repo, issueNumber),
guard,
update: bodyUpdatePlan(commandName, repo, issueNumber, options.mode, body, { kind: "body-file", path: options.bodyFile ?? null }, oldIssue?.body ?? null),
update: bodyUpdatePlan(commandName, repo, issueNumber, options.mode, body, bodySource, oldIssue?.body ?? null),
bodyOnlySafety: {
oldBody: dryRunOldBody,
newBody: {
@@ -5165,7 +5175,7 @@ async function issueEdit(repo: string, token: string, issueNumber: number, optio
wouldPatch: {
issueNumber,
title: options.title ?? null,
bodyFromFile: options.bodyFile,
bodySource,
mode: options.mode,
bodyChars: finalBody.length,
bodySha: bodySha(finalBody),
@@ -5197,6 +5207,7 @@ async function issueEdit(repo: string, token: string, issueNumber: number, optio
repo,
issue: issueSummary(issue, { includeBody: options.raw || options.full }),
mode: options.mode,
bodySource,
guard,
concurrency,
disclosure: issueWriteDisclosure(options, repo, issueNumber, false),
@@ -5869,11 +5880,11 @@ export function ghHelp(): unknown {
"bun scripts/cli.ts gh issue list [owner/repo] [--state open|closed|all] [--limit N] [--repo owner/name] [--json number,title,state,url,updatedAt,createdAt,author,labels]",
"bun scripts/cli.ts gh issue read <number|owner/repo#number> [--repo owner/name] [--json body,title,state,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 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 edit <number> --body-file <file> [--full|--raw] [compat alias for issue update --mode replace]",
"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 edit <number> --body-file <file|-> [--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 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] [--dry-run]",
"bun scripts/cli.ts gh issue comment delete <commentId> [--repo owner/name] [--dry-run]",
"bun scripts/cli.ts gh issue close|reopen <number> [--repo owner/name] [--dry-run]",
"bun scripts/cli.ts gh issue delete <number> [unsupported: use close]",
@@ -5914,16 +5925,15 @@ export function ghHelp(): unknown {
"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 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 read/view/update/edit and gh pr 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.",
"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 repeatable --label values and comma-separated labels; 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.",
"--body-file is the recommended source for Markdown bodies so real newlines, backticks, and tables are read as file bytes instead of shell arguments.",
"update defaults to --mode replace; --mode append reads the current body and appends file bytes so real newlines, backticks, and Markdown tables are preserved.",
"issue edit is a compatibility alias for issue update --mode replace.",
"issue update --body-file 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 can use --expect-updated-at or --expect-body-sha for stale-cache protection.",
"issue comment create accepts --body only for short single-line text. Blank, multiline, shell-polluted, secret-like, and overlong inline bodies fail structurally; use --body-file for Markdown, generated content, or long comments.",
"Issue body stdin is intentionally unsupported in this CLI; issue comment create also rejects --body-file - stdin. Write generated Markdown to a file and pass --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 Markdown writes intentionally use --body-file for long or multiline content; PR edit/update also accepts --body-file - for stdin when a runner already has reviewed Markdown on stdin.",
"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 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.",
"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.",
"issue scan-escape classifies literal \\n findings as suspected-pollution, explanatory-mention, or risk, and emits cleanupSuggestions with body/comment ids plus diff-like previews. cleanup-plan is an alias that remains dry-run/read-only.",
"issue board-audit is read-only and defaults to repo pikasTech/unidesk plus board issue #20. It reads only the board issue body, returns body size/SHA and parsed Markdown board sections, and no longer validates GitHub open/closed issue coverage against OPEN/CLOSED tables. The legacy coverage fields remain present as empty arrays/zero counts for compatibility.",
"issue board-row list/get reuse the board-audit table parser and are read-only. board-row update changes one table cell by issue number, returns old/new row, body SHA, body guard and request plan, and defaults to dry-run unless --expect-updated-at or --expect-body-sha is supplied for the guarded PATCH. Field aliases map status and validation to the 验收状态 column, tasks to 相关 Code Queue 任务, and focus to 当前关注点.",
@@ -5977,7 +5987,7 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
supportedCommands: [
"bun scripts/cli.ts gh issue read 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 update <number> --repo owner/name --body-file <file> --expect-body-sha <sha> --full",
"cat body.md | bun scripts/cli.ts gh issue update <number> --repo owner/name --body-file - --full",
"bun scripts/cli.ts gh pr read owner/name#<number> --raw",
`bun scripts/cli.ts gh pr read <number> --repo owner/name --json ${readViewSupportedJsonFields("pr")}`,
"bun scripts/cli.ts gh pr preflight <number> --repo owner/name --full",