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