fix: relax gh number target compatibility

This commit is contained in:
Codex
2026-06-05 00:18:29 +00:00
parent 316fdedc3f
commit 384fd613ff
6 changed files with 425 additions and 202 deletions
+53 -17
View File
@@ -649,8 +649,8 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
const usage = Array.isArray(helpData.usage) ? helpData.usage.map((value) => String(value)) : [];
const notes = Array.isArray(helpData.notes) ? helpData.notes.map((value) => String(value)) : [];
assertCondition(usage.some((line) => line.includes("gh issue list")), "gh help should list issue list", { usage });
assertCondition(usage.some((line) => line.includes("gh issue read")), "gh help should list issue read", { usage });
assertCondition(usage.some((line) => line.includes("gh issue view")), "gh help should list issue view", { usage });
assertCondition(usage.some((line) => line.includes("gh issue view") && line.includes("number|url|owner/repo#number")), "gh help should list standard issue view target forms", { usage });
assertCondition(usage.some((line) => line.includes("gh issue read") && line.includes("compatibility alias for issue view")), "gh help should list issue read compatibility alias", { usage });
assertCondition(usage.some((line) => line.includes("gh issue comment create") && line.includes("--body <short-text>")), "gh help should list short inline issue comment body", { usage });
assertCondition(usage.some((line) => line.includes("owner/repo#number") && line.includes("--raw|--full")), "gh help should document issue shorthand and raw/full disclosure", { usage });
assertCondition(usage.some((line) => line.includes("gh issue board-row list")), "gh help should list board-row list", { usage });
@@ -660,9 +660,10 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
assertCondition(usage.some((line) => line.includes("gh issue board-row move")), "gh help should list board-row move", { usage });
assertCondition(usage.some((line) => line.includes("gh issue board-row delete")), "gh help should list board-row delete", { usage });
assertCondition(usage.some((line) => line.includes("gh issue list") && line.includes("--search text")), "gh help should list issue list search", { usage });
assertCondition(notes.some((line) => line.includes("canonical read path")), "gh help should state issue read is canonical", { notes });
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("issue view is the canonical")), "gh help should state issue view is canonical", { notes });
assertCondition(notes.some((line) => line.includes("read remains") && line.includes("compatibility alias")), "gh help should state issue read is alias", { notes });
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("board-row update changes one table cell")), "gh help should describe board-row update safety", { notes });
@@ -1256,12 +1257,33 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
assertCondition(!("comments" in selectedJson), "--json body should not imply comments field", selectedJson);
const viewBody = await runCli(["gh", "issue", "view", "20", "--repo", "pikasTech/unidesk", "--json", "body"], env);
assertCondition(viewBody.status === 0, "issue view alias should succeed", viewBody.json ?? { stdout: viewBody.stdout });
assertCondition(viewBody.status === 0, "issue view should succeed as canonical read path", viewBody.json ?? { stdout: viewBody.stdout });
const viewBodyData = dataOf(viewBody.json ?? {});
const viewIssue = viewBodyData.issue as JsonRecord;
assertCondition(typeof viewIssue.body === "string" && viewIssue.body.includes("## 看板(OPEN"), "issue view alias should keep .data.issue.body readable", viewBodyData);
assertCondition(typeof viewIssue.body === "string" && viewIssue.body.includes("## 看板(OPEN"), "issue view should keep .data.issue.body readable", viewBodyData);
const viewSelectedJson = viewBodyData.json as JsonRecord;
assertCondition(typeof viewSelectedJson.body === "string" && viewSelectedJson.body === readIssue.body, "issue view alias should preserve selected json body", viewBodyData);
assertCondition(typeof viewSelectedJson.body === "string" && viewSelectedJson.body === readIssue.body, "issue view should preserve selected json body", viewBodyData);
const issueUrlView = await runCli(["gh", "issue", "view", "https://github.com/pikasTech/HWLAB/issues/7", "--json", "body,title,state"], env);
assertCondition(issueUrlView.status === 0, "issue view should accept GitHub issue URL target", issueUrlView.json ?? { stdout: issueUrlView.stdout });
const issueUrlViewData = dataOf(issueUrlView.json ?? {});
assertCondition(issueUrlViewData.repo === "pikasTech/HWLAB", "issue URL target should derive repo", issueUrlViewData);
assertCondition((issueUrlViewData.issue as JsonRecord).number === 7, "issue URL target should derive issue number", issueUrlViewData);
const issueUrlDisclosure = issueUrlViewData.disclosure as JsonRecord;
assertCondition(issueUrlDisclosure.shorthand && (issueUrlDisclosure.shorthand as JsonRecord).source === "github-url", "issue URL target should be disclosed", issueUrlDisclosure);
const issuePrUrlMismatch = await runCli(["gh", "issue", "view", "https://github.com/pikasTech/HWLAB/pull/7", "--json", "body"], env);
assertCondition(issuePrUrlMismatch.status !== 0, "issue view should reject PR URLs", issuePrUrlMismatch.json ?? { stdout: issuePrUrlMismatch.stdout });
const issuePrUrlMismatchData = failedDataOf(issuePrUrlMismatch.json ?? {});
assertCondition(failureMessageOf(issuePrUrlMismatchData).includes("GitHub pr URL"), "issue view PR URL mismatch should be explicit", issuePrUrlMismatchData);
const issueNumberOption = await runCli(["gh", "issue", "view", "--repo", "pikasTech/HWLAB", "--number", "7", "--json", "body"], env);
assertCondition(issueNumberOption.status === 0, "issue view should accept --number compatibility alias", issueNumberOption.json ?? { stdout: issueNumberOption.stdout });
const issueNumberOptionData = dataOf(issueNumberOption.json ?? {});
assertCondition(issueNumberOptionData.repo === "pikasTech/HWLAB", "issue view --number should preserve explicit repo", issueNumberOptionData);
assertCondition((issueNumberOptionData.issue as JsonRecord).number === 7, "issue view --number should read the requested issue", issueNumberOptionData);
const issueNumberOptionHint = issueNumberOptionData.standardSyntaxHint as JsonRecord;
assertCondition(issueNumberOptionHint.compatibility === true && String(issueNumberOptionHint.standardCommand ?? "").includes("gh issue view 7 --repo pikasTech/HWLAB"), "issue view --number should return standard syntax hint", issueNumberOptionHint);
const shorthandRaw = await runCli(["gh", "issue", "view", "pikasTech/HWLAB#7", "--raw"], env);
assertCondition(shorthandRaw.status === 0, "issue view should accept owner/repo#number shorthand with --raw", shorthandRaw.json ?? { stdout: shorthandRaw.stdout });
@@ -1281,7 +1303,7 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
assertCondition(shorthandConflictData.degradedReason === "validation-failed", "conflicting --repo should be validation-failed", shorthandConflictData);
assertCondition(String(shorthandConflictData.message ?? "").includes("resolves to repo pikasTech/HWLAB"), "conflict message should name the derived repo", shorthandConflictData);
const issueConflictCommands = shorthandConflictData.supportedCommands as string[];
assertCondition(Array.isArray(issueConflictCommands) && issueConflictCommands.some((command) => command === "bun scripts/cli.ts gh issue read 7 --repo pikasTech/HWLAB --json body,title,state,closed,closedAt,comments,number,url,author,createdAt,updatedAt"), "conflict should include the exact supported issue read command", shorthandConflictData);
assertCondition(Array.isArray(issueConflictCommands) && issueConflictCommands.some((command) => command === "bun scripts/cli.ts gh issue view 7 --repo pikasTech/HWLAB --json body,title,state,closed,closedAt,comments,number,url,author,createdAt,updatedAt"), "conflict should include the exact supported issue view command", shorthandConflictData);
const rawIssueList = await runCli(["gh", "issue", "list", "--raw"], env);
assertCondition(rawIssueList.status === 0, "issue list --raw should be a supported explicit list disclosure path", rawIssueList.json ?? { stdout: rawIssueList.stdout });
@@ -1576,11 +1598,17 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
const replaceDisclosure = replaceData.disclosure as JsonRecord;
const replaceReadCommands = replaceData.readCommands as JsonRecord;
assertCondition(replaceDisclosure.bodyOmitted === true && replaceDisclosure.dryRunBoundedPreview === true, "issue update dry-run should disclose compact body policy", replaceDisclosure);
assertCondition(typeof replaceReadCommands.full === "string" && String(replaceReadCommands.full).includes("gh issue read 20"), "issue update dry-run should expose full body drill-down", replaceReadCommands);
assertCondition(typeof replaceReadCommands.full === "string" && String(replaceReadCommands.full).includes("gh issue view 20"), "issue update dry-run should expose full body drill-down", replaceReadCommands);
const replaceWouldPatch = replaceData.wouldPatch as JsonRecord;
assertCondition(typeof replaceWouldPatch.bodySha === "string" && String(replaceWouldPatch.bodySha).length === 64, "issue update dry-run should include wouldPatch body sha", replaceWouldPatch);
assertCondition(Number(replaceWouldPatch.bodyChars ?? 0) === Number(replaceData.bodyChars ?? 0), "issue update dry-run wouldPatch should include final body chars", replaceWouldPatch);
const replaceNumberDryRun = await runCli(["gh", "issue", "update", "--number", "20", "--repo", "pikasTech/unidesk", "--mode", "replace", "--body-file", safeFile, "--dry-run"], env);
assertCondition(replaceNumberDryRun.status === 0, "issue update should accept --number compatibility alias", replaceNumberDryRun.json ?? { stdout: replaceNumberDryRun.stdout });
const replaceNumberData = dataOf(replaceNumberDryRun.json ?? {});
const replaceNumberHint = replaceNumberData.standardSyntaxHint as JsonRecord;
assertCondition(String(replaceNumberHint.standardCommand ?? "").includes("gh issue update 20 --repo pikasTech/unidesk"), "issue update --number should return standard syntax hint", replaceNumberHint);
const compactLongBody = Array.from({ length: 260 }, (_, index) => `compact-success-line-${String(index + 1).padStart(4, "0")} ${"x".repeat(80)}`).join("\n");
const compactLongFile = join(tmp, "compact-long-body.md");
writeFileSync(compactLongFile, compactLongBody, "utf8");
@@ -1604,7 +1632,7 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
const compactDisclosure = compactUpdateData.disclosure as JsonRecord;
assertCondition(compactDisclosure.bodyOmitted === true && compactDisclosure.fullBodyIncluded === false && compactDisclosure.defaultCompact === true, "compact update disclosure should be explicit", compactDisclosure);
const compactCommands = compactUpdateData.readCommands as JsonRecord;
assertCondition(String(compactCommands.body ?? "").includes("gh issue read 7 --repo pikasTech/HWLAB --json body"), "compact update should expose body read command", compactCommands);
assertCondition(String(compactCommands.body ?? "").includes("gh issue view 7 --repo pikasTech/HWLAB --json body"), "compact update should expose body view command", compactCommands);
assertCondition(String(compactCommands.full ?? "").includes("--full") && String(compactCommands.raw ?? "").includes("--raw"), "compact update should expose full/raw drill-down", compactCommands);
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) });
@@ -1655,7 +1683,7 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
assertCondition(Number(inlineDryRunData.bodyChars ?? 0) === inlineBody.length && typeof inlineDryRunData.bodySha === "string", "inline issue comment dry-run should expose bodyChars/bodySha", inlineDryRunData);
assertCondition(String(inlineDryRunData.bodyPreview ?? "") === inlineBody, "inline issue comment dry-run should provide bounded preview for short text", inlineDryRunData);
const inlineDryRunReadCommands = inlineDryRunData.readCommands as JsonRecord;
assertCondition(String(inlineDryRunReadCommands.comments ?? "").includes("gh issue read 36") && String(inlineDryRunReadCommands.comments ?? "").includes("--json comments"), "inline issue comment dry-run should expose comment read command", inlineDryRunReadCommands);
assertCondition(String(inlineDryRunReadCommands.comments ?? "").includes("gh issue view 36") && String(inlineDryRunReadCommands.comments ?? "").includes("--json comments"), "inline issue comment dry-run should expose comment view command", inlineDryRunReadCommands);
const inlineDryRunWriteCount = mock.requests.slice(inlineDryRunRequestCountBefore).filter((request) => request.method === "POST" && request.url.includes("/comments")).length;
assertCondition(inlineDryRunWriteCount === 0, "inline issue comment dry-run must not POST GitHub", { requests: mock.requests.slice(inlineDryRunRequestCountBefore) });
@@ -1751,6 +1779,13 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
const commentDeleteDryRunData = dataOf(commentDeleteDryRun.json ?? {});
assertCondition(commentDeleteDryRunData.command === "issue comment delete" && commentDeleteDryRunData.planned === true, "comment delete dry-run should plan DELETE", commentDeleteDryRunData);
const commentDeleteNumberDryRun = await runCli(["gh", "issue", "comment", "delete", "--number", "9001", "--repo", "pikasTech/unidesk", "--dry-run"], env);
assertCondition(commentDeleteNumberDryRun.status === 0, "issue comment delete should accept --number commentId compatibility alias", commentDeleteNumberDryRun.json ?? { stdout: commentDeleteNumberDryRun.stdout });
const commentDeleteNumberData = dataOf(commentDeleteNumberDryRun.json ?? {});
assertCondition(commentDeleteNumberData.commentId === 9001 && commentDeleteNumberData.standardSyntaxHint, "issue comment delete --number should return commentId and standard syntax hint", commentDeleteNumberData);
const commentDeleteNumberHint = commentDeleteNumberData.standardSyntaxHint as JsonRecord;
assertCondition(String(commentDeleteNumberHint.standardCommand ?? "").includes("gh issue comment delete 9001 --repo pikasTech/unidesk"), "issue comment delete --number should point to positional commentId syntax", commentDeleteNumberHint);
const commentDelete = await runCli(["gh", "issue", "comment", "delete", "9001", "--repo", "pikasTech/unidesk"], env);
assertCondition(commentDelete.status === 0, "issue comment delete should succeed", commentDelete.json ?? { stdout: commentDelete.stdout });
const commentDeleteData = dataOf(commentDelete.json ?? {});
@@ -1764,15 +1799,16 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
return {
ok: true,
checks: [
"issue read --json body preserves .data.issue.body",
"issue view remains a compatibility alias",
"issue read/view accept owner/repo#number shorthand and reject conflicting --repo",
"issue read/view --raw is explicit full disclosure",
"issue view --json body preserves .data.issue.body",
"issue read remains a compatibility alias",
"issue view/read accept GitHub URL and owner/repo#number targets and reject conflicting --repo",
"issue single numeric target commands accept --number compatibility with a standard syntax hint",
"issue view/read --raw is explicit full disclosure",
"issue list supports state/limit/json with stable selected fields",
"issue list positional owner/repo targets the requested repo and conflicting --repo fails",
"acceptance issue list command succeeds under mock GitHub",
"issue list default fields include labels and filter pull requests",
"large gh issue read output is dumped to a temp file with bounded stdout and head/tail metadata",
"large gh issue view/read output is dumped to a temp file with bounded stdout and head/tail metadata",
"issue scan-escape classifies pollution, explanatory mentions, and body risks",
"issue cleanup-plan remains dry-run with body/comment cleanup suggestions",
"issue board-audit returns read-only board structure, disables OPEN/CLOSED coverage validation, and keeps compatibility fields empty without writes",