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
+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",