feat: add heredoc body input to gh cli

This commit is contained in:
Codex
2026-06-06 01:27:52 +00:00
parent 879facb933
commit 0bae3af934
5 changed files with 113 additions and 65 deletions
+32 -8
View File
@@ -665,7 +665,7 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
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("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-stdin") && line.includes("--body only for short single-line text")), "gh help should document issue comment heredoc 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 });
@@ -785,7 +785,7 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
assertCondition(scanFindings.some((finding) => finding.issueNumber === 53 && finding.kind === "null-body" && finding.classification === "risk"), "null body should be guarded as risk", scanFindings);
const cleanupSuggestions = scanData.cleanupSuggestions as JsonRecord[];
assertCondition(Array.isArray(cleanupSuggestions), "scan should expose cleanupSuggestions", scanData);
assertCondition(cleanupSuggestions.some((suggestion) => suggestion.issueNumber === 51 && suggestion.type === "issue-body" && typeof suggestion.bodyId === "string" && suggestion.action === "rewrite-issue-body-with-body-file"), "issue body cleanup suggestion should use body-file rewrite with body id", cleanupSuggestions);
assertCondition(cleanupSuggestions.some((suggestion) => suggestion.issueNumber === 51 && suggestion.type === "issue-body" && typeof suggestion.bodyId === "string" && suggestion.action === "rewrite-issue-body-with-body-stdin"), "issue body cleanup suggestion should use heredoc/stdin rewrite with body id", cleanupSuggestions);
assertCondition(cleanupSuggestions.some((suggestion) => suggestion.commentId === 5101 && suggestion.type === "comment-body" && String(suggestion.bodyId ?? "").includes("comment:5101") && suggestion.action === "review-comment-manually"), "comment cleanup suggestion should be manual review with body id", cleanupSuggestions);
assertCondition(cleanupSuggestions.every((suggestion) => suggestion.issueNumber !== 52), "explanatory mention should not create cleanup suggestion", cleanupSuggestions);
const scanPatchCount = mock.requests.filter((request) => request.method === "PATCH" || request.method === "DELETE" || request.method === "POST").length;
@@ -1471,7 +1471,7 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
"",
"## 常驻观察与长期建议",
"",
"- 保持滚动简报正文只通过 body-file 更新。",
"- 保持滚动简报正文只通过 heredoc/stdin 更新。",
"",
"## 更新 2026-05-21 15:18 北京时间",
"",
@@ -1570,10 +1570,16 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
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 issueCreateBodyStdin = await runCli(["gh", "issue", "create", "--repo", "pikasTech/unidesk", "--title", "body-stdin issue create", "--body-stdin", "--dry-run"], env, issueCreateStdinBody);
assertCondition(issueCreateBodyStdin.status === 0, "issue create dry-run should accept --body-stdin", issueCreateBodyStdin.json ?? { stdout: issueCreateBodyStdin.stdout });
const issueCreateBodyStdinData = dataOf(issueCreateBodyStdin.json ?? {});
const issueCreateBodyStdinSource = issueCreateBodyStdinData.bodySource as JsonRecord;
assertCondition(issueCreateBodyStdinSource.kind === "stdin" && issueCreateBodyStdinSource.path === "-", "issue create --body-stdin should expose stdin source", issueCreateBodyStdinData);
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);
assertCondition(failureMessageOf(issueCreateInlineData).includes("does not support --body"), "issue create inline --body should point to body-stdin", 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 });
@@ -1729,6 +1735,17 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
const closePatchPayload = JSON.parse(closeWriteRequests[1]?.body ?? "{}") as JsonRecord;
assertCondition(closeCommentPayload.body === closeComment && closePatchPayload.state === "closed", "issue close --comment payloads should preserve comment and state", { closeCommentPayload, closePatchPayload });
const reopenCommentStdinBody = "reopen heredoc comment\n\n- keeps `code`\n";
const reopenStdinRequestCountBefore = mock.requests.length;
const reopenStdin = await runCli(["gh", "issue", "reopen", "20", "--repo", "pikasTech/unidesk", "--comment-stdin", "--dry-run"], env, reopenCommentStdinBody);
assertCondition(reopenStdin.status === 0, "issue reopen --comment-stdin dry-run should succeed", reopenStdin.json ?? { stdout: reopenStdin.stdout });
const reopenStdinData = dataOf(reopenStdin.json ?? {});
const reopenStdinComment = reopenStdinData.comment as JsonRecord;
const reopenStdinCommentSource = (reopenStdinComment.bodySource ?? {}) as JsonRecord;
assertCondition(reopenStdinCommentSource.kind === "stdin" && reopenStdinCommentSource.path === "-", "issue reopen --comment-stdin should expose stdin source", reopenStdinComment);
const reopenStdinWriteCount = mock.requests.slice(reopenStdinRequestCountBefore).filter((request) => request.method === "POST" || request.method === "PATCH").length;
assertCondition(reopenStdinWriteCount === 0, "issue reopen --comment-stdin dry-run must not write GitHub", { requests: mock.requests.slice(reopenStdinRequestCountBefore) });
const closeCommentWrongCommand = await runCli(["gh", "issue", "comment", "create", "36", "--repo", "pikasTech/unidesk", "--comment", closeComment, "--dry-run"], env);
assertCondition(closeCommentWrongCommand.status !== 0, "--comment outside issue close/reopen should fail structurally", closeCommentWrongCommand.json ?? { stdout: closeCommentWrongCommand.stdout });
const closeCommentWrongData = failedDataOf(closeCommentWrongCommand.json ?? {});
@@ -1737,7 +1754,7 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
const missingCommentBody = await runCli(["gh", "issue", "comment", "create", "36", "--repo", "pikasTech/unidesk", "--dry-run"], env);
assertCondition(missingCommentBody.status !== 0, "issue comment create without body source should fail", missingCommentBody.json ?? { stdout: missingCommentBody.stdout });
const missingCommentBodyData = failedDataOf(missingCommentBody.json ?? {});
assertCondition(missingCommentBodyData.degradedReason === "validation-failed" && failureMessageOf(missingCommentBodyData).includes("requires --body-file <file> or --body <text>"), "missing issue comment body should be structured validation failure", missingCommentBodyData);
assertCondition(missingCommentBodyData.degradedReason === "validation-failed" && failureMessageOf(missingCommentBodyData).includes("requires --body-stdin"), "missing issue comment body should be structured validation failure", missingCommentBodyData);
const mutualCommentBody = await runCli(["gh", "issue", "comment", "create", "36", "--repo", "pikasTech/unidesk", "--body", "inline", "--body-file", appendFile, "--dry-run"], env);
assertCondition(mutualCommentBody.status !== 0, "issue comment create with body and body-file should fail", mutualCommentBody.json ?? { stdout: mutualCommentBody.stdout });
@@ -1752,7 +1769,7 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
const multilineInlineComment = await runCli(["gh", "issue", "comment", "create", "36", "--repo", "pikasTech/unidesk", "--body", "line1\nline2", "--dry-run"], env);
assertCondition(multilineInlineComment.status !== 0, "multiline inline issue comment body should fail", multilineInlineComment.json ?? { stdout: multilineInlineComment.stdout });
const multilineInlineCommentData = failedDataOf(multilineInlineComment.json ?? {});
assertCondition(failureMessageOf(multilineInlineCommentData).includes("single-line text only"), "multiline inline issue comment body should point to body-file", multilineInlineCommentData);
assertCondition(failureMessageOf(multilineInlineCommentData).includes("single-line text only"), "multiline inline issue comment body should point to body-stdin", multilineInlineCommentData);
const pollutedInlineComment = await runCli(["gh", "issue", "comment", "create", "36", "--repo", "pikasTech/unidesk", "--body", "literal \\n pollution", "--dry-run"], env);
assertCondition(pollutedInlineComment.status !== 0, "polluted inline issue comment body should fail", pollutedInlineComment.json ?? { stdout: pollutedInlineComment.stdout });
@@ -1774,6 +1791,12 @@ export async function runGhCliIssueGuardContract(): Promise<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 bodyStdinComment = await runCli(["gh", "issue", "comment", "create", "36", "--repo", "pikasTech/unidesk", "--body-stdin", "--dry-run"], env, stdinCommentBody);
assertCondition(bodyStdinComment.status === 0, "issue comment --body-stdin should be supported", bodyStdinComment.json ?? { stdout: bodyStdinComment.stdout });
const bodyStdinCommentData = dataOf(bodyStdinComment.json ?? {});
const bodyStdinCommentSource = bodyStdinCommentData.bodySource as JsonRecord;
assertCondition(bodyStdinCommentSource.kind === "stdin" && bodyStdinCommentSource.path === "-" && bodyStdinCommentData.source === "stdin", "issue comment --body-stdin should expose stdin source", bodyStdinCommentData);
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 });
const commentDeleteDryRunData = dataOf(commentDeleteDryRun.json ?? {});
@@ -1819,7 +1842,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, supports stdin, rejects inline --body, and exposes request plan",
"issue create dry-run parses repeated/comma labels, supports --body-stdin and compatible --body-file -, 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,closed,closedAt,comments selection",
@@ -1838,8 +1861,9 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
"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 close/reopen supports --comment-stdin dry-run without writes",
"issue comment create supports short inline --body dry-run and write with bounded output",
"issue comment create supports stdin and still rejects missing, blank, multiline inline, polluted inline, secret-like inline, and mixed body sources",
"issue comment create supports --body-stdin and compatible --body-file -, 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",
],