feat: 支持 gh issue stdin 写入
This commit is contained in:
@@ -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",
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user