fix: unify gh read and CRUD semantics

This commit is contained in:
Codex
2026-05-21 03:36:17 +00:00
parent 21b4c7800b
commit 94e9467c30
7 changed files with 83 additions and 50 deletions
+26 -17
View File
@@ -20,9 +20,9 @@ function assertCondition(condition: unknown, message: string, detail: unknown =
function runCli(args: string[], env: Record<string, string> = {}): Promise<{ status: number | null; stdout: string; stderr: string; json: JsonRecord | null }> {
return new Promise((resolve, reject) => {
const child = spawn("bun", ["scripts/cli.ts", ...args], {
cwd: process.cwd(),
env: { ...process.env, ...env },
});
cwd: process.cwd(),
env: { ...process.env, ...env },
});
const stdoutChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
child.stdout.on("data", (chunk) => stdoutChunks.push(Buffer.from(chunk)));
@@ -567,23 +567,31 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
assertCondition(badStateData.degradedReason === "validation-failed", "issue list unsupported state should be validation-failed", badStateData);
assertCondition(badStateData.runnerDisposition === "business-failed", "issue list unsupported state should be business-failed", badStateData);
const viewBody = await runCli(["gh", "issue", "view", "20", "--repo", "pikasTech/unidesk", "--json", "body"], env);
assertCondition(viewBody.status === 0, "issue view --json body should succeed", viewBody.json ?? { stdout: viewBody.stdout });
const viewBodyData = dataOf(viewBody.json ?? {});
const issue = viewBodyData.issue as JsonRecord;
assertCondition(typeof issue.body === "string" && issue.body.includes("## 看板(OPEN"), ".data.issue.body should remain readable", viewBodyData);
const selectedJson = viewBodyData.json as JsonRecord;
assertCondition(typeof selectedJson.body === "string" && selectedJson.body === issue.body, "selected json body should match issue body", viewBodyData);
const readBody = await runCli(["gh", "issue", "read", "20", "--repo", "pikasTech/unidesk", "--json", "body"], env);
assertCondition(readBody.status === 0, "issue read --json body should succeed", readBody.json ?? { stdout: readBody.stdout });
const readBodyData = dataOf(readBody.json ?? {});
const readIssue = readBodyData.issue as JsonRecord;
assertCondition(typeof readIssue.body === "string" && readIssue.body.includes("## 看板(OPEN"), ".data.issue.body should remain readable", readBodyData);
const selectedJson = readBodyData.json as JsonRecord;
assertCondition(typeof selectedJson.body === "string" && selectedJson.body === readIssue.body, "selected json body should match issue body", readBodyData);
assertCondition(!("comments" in selectedJson), "--json body should not imply comments field", selectedJson);
const viewFields = await runCli(["gh", "issue", "view", "20", "--repo", "pikasTech/unidesk", "--json", "body,title,state,comments"], env);
assertCondition(viewFields.status === 0, "common --json field selection should succeed", viewFields.json ?? { stdout: viewFields.stdout });
const viewFieldsData = dataOf(viewFields.json ?? {});
const fieldsJson = viewFieldsData.json as JsonRecord;
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 });
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);
const viewSelectedJson = viewBodyData.json as JsonRecord;
assertCondition(typeof viewSelectedJson.body === "string" && viewSelectedJson.body === readIssue.body, "issue view alias should preserve selected json body", viewBodyData);
const readFields = await runCli(["gh", "issue", "read", "20", "--repo", "pikasTech/unidesk", "--json", "body,title,state,comments"], env);
assertCondition(readFields.status === 0, "common --json field selection should succeed", readFields.json ?? { stdout: readFields.stdout });
const readFieldsData = dataOf(readFields.json ?? {});
const fieldsJson = readFieldsData.json as JsonRecord;
assertCondition(fieldsJson.title === "长期总看板", "selected json title should be exposed", fieldsJson);
assertCondition(Array.isArray(fieldsJson.comments) && fieldsJson.comments.length === 1, "selected json comments should be exposed", fieldsJson);
const unsupported = await runCli(["gh", "issue", "view", "20", "--repo", "pikasTech/unidesk", "--json", "body,unknown"], env);
const unsupported = await runCli(["gh", "issue", "read", "20", "--repo", "pikasTech/unidesk", "--json", "body,unknown"], env);
assertCondition(unsupported.status !== 0, "unsupported --json field should fail", unsupported.json ?? { stdout: unsupported.stdout });
const unsupportedData = failedDataOf(unsupported.json ?? {});
assertCondition(unsupportedData.degradedReason === "validation-failed", "unsupported --json should be validation-failed", unsupportedData);
@@ -715,7 +723,8 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
return {
ok: true,
checks: [
"issue view --json body preserves .data.issue.body",
"issue read --json body preserves .data.issue.body",
"issue view remains a compatibility alias",
"issue list supports state/limit/json with stable selected fields",
"acceptance issue list command succeeds under mock GitHub",
"issue list default fields include labels and filter pull requests",
@@ -725,7 +734,7 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
"issue create dry-run parses repeated/comma labels 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 view supports body,title,state,comments selection",
"issue read supports body,title,state,comments selection",
"unsupported --json fields fail structurally",
"issue edit --body-file rejects literal null",
"#20/#24 body profile guards reject missing headings or wrong profile",
+13 -6
View File
@@ -159,13 +159,20 @@ export async function runGhCliPrContract(): Promise<JsonRecord> {
assertCondition(Array.isArray(pullRequests) && pullRequests.length === 1, "pr list should return pullRequests", listData);
assertCondition(pullRequests[0]?.number === 42 && pullRequests[0]?.base && pullRequests[0]?.head, "pr list should expose PR summary", pullRequests[0]);
const read = await runCli(["gh", "pr", "read", "42", "--repo", "pikasTech/unidesk", "--json", "body,title,state,head,base"], env);
assertCondition(read.status === 0, "pr read should succeed through REST", read.json ?? { stdout: read.stdout });
const readData = dataOf(read.json ?? {});
const pullRequest = readData.pullRequest as JsonRecord;
assertCondition(pullRequest.number === 42 && pullRequest.url === "https://github.com/pikasTech/unidesk/pull/42", "pr read should expose PR details", readData);
const selected = readData.json as JsonRecord;
assertCondition(selected.body === "PR body" && selected.title === "contract PR", "pr read --json should select fields", readData);
const view = await runCli(["gh", "pr", "view", "42", "--repo", "pikasTech/unidesk", "--json", "body,title,state,head,base"], env);
assertCondition(view.status === 0, "pr view should succeed through REST", view.json ?? { stdout: view.stdout });
assertCondition(view.status === 0, "pr view alias should succeed through REST", view.json ?? { stdout: view.stdout });
const viewData = dataOf(view.json ?? {});
const pullRequest = viewData.pullRequest as JsonRecord;
assertCondition(pullRequest.number === 42 && pullRequest.url === "https://github.com/pikasTech/unidesk/pull/42", "pr view should expose PR details", viewData);
const selected = viewData.json as JsonRecord;
assertCondition(selected.body === "PR body" && selected.title === "contract PR", "pr view --json should select fields", viewData);
assertCondition((viewData.pullRequest as JsonRecord).number === 42, "pr view alias should expose PR details", viewData);
const viewSelected = viewData.json as JsonRecord;
assertCondition(viewSelected.body === "PR body" && viewSelected.title === "contract PR", "pr view alias should preserve selected fields", viewData);
const preflight = await runBun([
"scripts/code-queue-pr-preflight-example.ts",
@@ -295,7 +302,7 @@ export async function runGhCliPrContract(): Promise<JsonRecord> {
ok: true,
checks: [
"gh help lists pr create/comment",
"pr list/view work through REST with token and no gh binary dependency",
"pr list/read/view work through REST with token and no gh binary dependency",
"pr create dry-run exposes planned operation",
"pr comment dry-run preserves markdown text",
"pr update replace/append and close/reopen are available",
+37 -20
View File
@@ -444,7 +444,7 @@ function validateJsonFields<T extends string>(command: string, requested: string
}
function parseIssueViewJsonFields(requested: string[] | undefined): IssueViewJsonField[] | undefined {
return validateJsonFields("gh issue view", requested, ISSUE_VIEW_JSON_FIELDS);
return validateJsonFields("gh issue read/view", requested, ISSUE_VIEW_JSON_FIELDS);
}
function parseIssueListJsonFields(requested: string[] | undefined): IssueListJsonField[] | undefined {
@@ -455,6 +455,14 @@ function parsePrJsonFields(command: string, requested: string[] | undefined): Pr
return validateJsonFields(command, requested, PR_JSON_FIELDS);
}
function isIssueReadCommand(sub: string | undefined): boolean {
return sub === "read" || sub === "view";
}
function isPrReadCommand(sub: string | undefined): boolean {
return sub === "read" || sub === "view";
}
function parseIssueListState(args: string[]): IssueListState {
const raw = optionValue(args, "--state") ?? "open";
if ((ISSUE_LIST_STATES as readonly string[]).includes(raw)) return raw as IssueListState;
@@ -508,9 +516,9 @@ function parseOptions(args: string[]): GitHubOptions {
bodyFile: optionValue(args, "--body-file"),
base: optionValue(args, "--base"),
head: optionValue(args, "--head"),
jsonFields: top === "issue" && sub === "view" ? parseIssueViewJsonFields(requestedJsonFields) : undefined,
jsonFields: top === "issue" && isIssueReadCommand(sub) ? parseIssueViewJsonFields(requestedJsonFields) : undefined,
issueListJsonFields: top === "issue" && sub === "list" ? parseIssueListJsonFields(requestedJsonFields) : undefined,
prJsonFields: top === "pr" && (sub === "view" || sub === "list") ? parsePrJsonFields(`gh pr ${sub}`, requestedJsonFields) : undefined,
prJsonFields: top === "pr" && (isPrReadCommand(sub) || sub === "list") ? parsePrJsonFields(`gh pr ${sub}`, requestedJsonFields) : undefined,
listState: parseIssueListState(args),
mode: parseBodyUpdateMode(args),
expectUpdatedAt: optionValue(args, "--expect-updated-at"),
@@ -1975,15 +1983,15 @@ function selectedIssueJson(issue: GitHubIssue, comments: GitHubComment[] | null,
return selected;
}
async function issueView(repo: string, token: string, issueNumber: number, jsonFields: IssueViewJsonField[] | undefined): Promise<GitHubCommandResult> {
async function issueRead(repo: string, token: string, issueNumber: number, jsonFields: IssueViewJsonField[] | undefined, commandName = "issue read"): Promise<GitHubCommandResult> {
const issue = await getIssue(token, repo, issueNumber);
if (isGitHubError(issue)) return commandError("issue view", repo, issue, { issueNumber });
if (isGitHubError(issue)) return commandError(commandName, repo, issue, { issueNumber });
const needsComments = jsonFields === undefined || jsonFields.includes("comments");
const comments = needsComments ? await listIssueComments(token, repo, issueNumber) : null;
if (isGitHubError(comments)) return commandError("issue view", repo, comments, { issueNumber, issue: issueSummary(issue) });
if (isGitHubError(comments)) return commandError(commandName, repo, comments, { issueNumber, issue: issueSummary(issue) });
return {
ok: true,
command: "issue view",
command: commandName,
repo,
issue: issueSummary(issue),
...(comments === null ? {} : { comments: comments.map(commentSummary) }),
@@ -1997,6 +2005,10 @@ async function issueView(repo: string, token: string, issueNumber: number, jsonF
};
}
async function issueView(repo: string, token: string, issueNumber: number, jsonFields: IssueViewJsonField[] | undefined): Promise<GitHubCommandResult> {
return issueRead(repo, token, issueNumber, jsonFields, "issue view");
}
async function issueList(repo: string, token: string, state: IssueListState, limit: number, jsonFields: IssueListJsonField[] | undefined): Promise<GitHubCommandResult> {
const rawIssues = await listIssues(token, repo, state, limit);
if (isGitHubError(rawIssues)) return commandError("issue list", repo, rawIssues, { state, limit });
@@ -2872,19 +2884,23 @@ async function prList(repo: string, token: string, limit: number, jsonFields: Pr
};
}
async function prView(repo: string, token: string, number: number, jsonFields: PrJsonField[] | undefined): Promise<GitHubCommandResult> {
async function prRead(repo: string, token: string, number: number, jsonFields: PrJsonField[] | undefined, commandName = "pr read"): Promise<GitHubCommandResult> {
const { owner, name } = repoParts(repo);
const pr = await githubRequest<GitHubPullRequest>(token, "GET", `/repos/${owner}/${name}/pulls/${number}`);
if (isGitHubError(pr)) return commandError("pr view", repo, pr, { number });
if (isGitHubError(pr)) return commandError(commandName, repo, pr, { number });
return {
ok: true,
command: "pr view",
command: commandName,
repo,
pullRequest: prSummary(pr),
...(jsonFields === undefined ? {} : { jsonFields, json: selectedPrJson(pr, jsonFields) }),
};
}
async function prView(repo: string, token: string, number: number, jsonFields: PrJsonField[] | undefined): Promise<GitHubCommandResult> {
return prRead(repo, token, number, jsonFields, "pr view");
}
export function ghHelp(): unknown {
return {
command: "gh",
@@ -2892,7 +2908,7 @@ export function ghHelp(): unknown {
usage: [
"bun scripts/cli.ts gh auth status [--repo owner/name]",
"bun scripts/cli.ts gh issue list [--state open|closed|all] [--limit N] [--repo owner/name] [--json number,title,state,url,updatedAt,createdAt,author,labels]",
"bun scripts/cli.ts gh issue view <number> [--repo owner/name] [--json body,title,state,comments]",
"bun scripts/cli.ts gh issue read|view <number> [--repo owner/name] [--json body,title,state,comments]",
"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]",
"bun scripts/cli.ts gh issue edit <number> --body-file <file> [compat alias for issue update --mode replace]",
@@ -2905,7 +2921,7 @@ export function ghHelp(): unknown {
"bun scripts/cli.ts gh issue cleanup-plan [--repo owner/name] [--limit N] [--dry-run]",
"bun scripts/cli.ts gh issue board-audit [--repo owner/name] [--board-issue 20] [--limit N] [--known-meta-issue N[,N...]] [--ignore-issue N[,N...]] [--dry-run]",
"bun scripts/cli.ts gh pr list [--repo owner/name] [--limit N] [--json number,title,state,url,updatedAt,createdAt,author,head,base,draft]",
"bun scripts/cli.ts gh pr view <number> [--repo owner/name] [--json body,title,state,head,base,draft]",
"bun scripts/cli.ts gh pr read|view <number> [--repo owner/name] [--json body,title,state,head,base,draft]",
"bun scripts/cli.ts gh pr create --title <title> --body-file <file>|--body <text> --base <branch> --head <branch> [--repo owner/name] [--draft] [--dry-run]",
"bun scripts/cli.ts gh pr update <number> --mode replace|append --body-file <file>|--body <text> [--title title] [--repo owner/name] [--dry-run]",
"bun scripts/cli.ts gh pr comment create <number> --body-file <file>|--body <text> [--repo owner/name] [--dry-run]",
@@ -2918,7 +2934,7 @@ export function ghHelp(): unknown {
"Issue and PR create/read/update/comment/close/reopen use GitHub REST and do not require the gh binary when GH_TOKEN or GITHUB_TOKEN is present.",
"Token values are never printed; auth status reports only token source and presence.",
"issue list defaults to --state open and bounded --limit 30; supported --json fields are number,title,state,url,updatedAt,createdAt,author,labels and unknown fields fail structurally.",
"issue view supports legacy --json field selection such as --json body and still exposes .data.issue.body for compatibility; unsupported fields fail structurally.",
"issue read is the canonical read path; view remains a compatibility alias. Read supports legacy --json field selection such as --json body and still exposes .data.issue.body for compatibility; unsupported fields fail structurally.",
"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.",
"--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.",
@@ -2933,7 +2949,7 @@ export function ghHelp(): unknown {
"issue edit 24 --notify-claudeqq-brief-diff reads the old issue body, PATCHes the new body, and sends only newly added '## ... ' sections to ClaudeQQ; ClaudeQQ failure does not roll back GitHub.",
"Commander brief ClaudeQQ defaults to private target 645275593 through backend-core /api/microservices/claudeqq/proxy; UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_* env vars can override target, base URL, timeout, and enabled state.",
"comment delete is supported because GitHub supports deleting issue comments; issue/pr hard delete is unsupported and close is the lifecycle alternative.",
"PR create/update/comment are safe-write operations with dry-run planning; merge is intentionally unsupported in this phase.",
"PR read is the canonical read path; view remains a compatibility alias. PR create/update/comment are safe-write operations with dry-run planning; merge is intentionally unsupported in this phase.",
],
};
}
@@ -2960,10 +2976,10 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
return validationError(command, options.repo, "--state is only supported by gh issue list");
}
if (optionWasProvided(args, "--json") && !(top === "issue" && (sub === "view" || sub === "list"))) {
if (optionWasProvided(args, "--json") && !(top === "issue" && (isIssueReadCommand(sub) || sub === "list"))) {
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
if (!(top === "pr" && (sub === "view" || sub === "list"))) {
return validationError(command, options.repo, "--json field selection is only supported by gh issue view/list and gh pr view/list");
if (!(top === "pr" && (isPrReadCommand(sub) || sub === "list"))) {
return validationError(command, options.repo, "--json field selection is only supported by gh issue read/view/list and gh pr read/view/list");
}
}
if (optionWasProvided(args, "--mode") && !((top === "issue" && (sub === "update" || sub === "edit")) || (top === "pr" && sub === "update"))) {
@@ -3039,6 +3055,7 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
if (missing !== null || token === null) return missing ?? authRequired(options.repo, `issue ${sub ?? ""}`.trim(), { present: false, source: null, ghFallbackAttempted: true });
if (sub === "list") return issueList(options.repo, token, options.listState, options.limit, options.issueListJsonFields);
if (sub === "read") return issueRead(options.repo, token, parseNumber(third, "issue read"), options.jsonFields);
if (sub === "view") return issueView(options.repo, token, parseNumber(third, "issue view"), options.jsonFields);
if (sub === "create") return issueCreate(options.repo, token, options);
if (sub === "edit") return issueEdit(options.repo, token, parseNumber(third, "issue edit"), options);
@@ -3046,7 +3063,6 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
if (sub === "comment") return issueComment(options.repo, token, parseNumber(third, "issue comment"), options);
if (sub === "close") return issueState(options.repo, token, parseNumber(third, "issue close"), "closed", options.dryRun);
if (sub === "reopen") return issueState(options.repo, token, parseNumber(third, "issue reopen"), "open", options.dryRun);
if (sub === "delete") return unsupportedCommand("issue delete", options.repo, "GitHub REST does not support hard-deleting issues; use gh issue close for lifecycle deletion semantics.");
}
if (top === "pr") {
@@ -3110,13 +3126,14 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
if (sub === "merge") {
return unsupportedCommand("pr merge", options.repo, "PR merge is intentionally unsupported in this phase; use create/comment/read only.");
}
if (sub !== "list" && sub !== "view") {
return unsupportedCommand(`pr ${sub ?? ""}`.trim(), options.repo, "PR supported commands are list, view, create, update, close, reopen, comment create/delete, and unsupported merge/delete.");
if (sub !== "list" && !isPrReadCommand(sub)) {
return unsupportedCommand(`pr ${sub ?? ""}`.trim(), options.repo, "PR supported commands are list, read/view, create, update, close, reopen, comment create/delete, and unsupported merge/delete.");
}
const { token, probe } = resolveToken(true);
const missing = authRequired(options.repo, `pr ${sub}`, probe);
if (missing !== null || token === null) return missing ?? authRequired(options.repo, `pr ${sub}`, { present: false, source: null, ghFallbackAttempted: true });
if (sub === "list") return prList(options.repo, token, options.limit, options.prJsonFields);
if (sub === "read") return prRead(options.repo, token, parseNumber(third, "pr read"), options.prJsonFields);
return prView(options.repo, token, parseNumber(third, "pr view"), options.prJsonFields);
}