fix(cli): support gh read shorthand raw

This commit is contained in:
Codex
2026-05-23 05:11:05 +00:00
parent 3b554e6154
commit d0c762da06
6 changed files with 251 additions and 24 deletions
+154 -19
View File
@@ -300,6 +300,8 @@ interface GitHubTokenProbe {
interface GitHubOptions {
repo: string;
dryRun: boolean;
raw: boolean;
full: boolean;
limit: number;
boardIssue: number;
knownMetaIssues: number[];
@@ -332,6 +334,18 @@ interface GitHubOptions {
boardRowUpsertValues: BoardRowUpsertValues;
}
interface GitHubShorthandReference {
input: string;
repo: string;
number: number;
}
interface GitHubResolvedNumberReference {
repo: string;
number: number;
shorthand?: GitHubShorthandReference;
}
interface IssueProfileValidationContext {
issueTitle?: string | null;
issueBody?: string | null;
@@ -626,7 +640,7 @@ function parseBoardRowUpsertValues(args: string[]): BoardRowUpsertValues {
function validateKnownOptions(args: string[]): void {
const valueOptions = new Set(["--repo", "--limit", "--board-issue", "--known-meta-issue", "--ignore-issue", "--title", "--body-file", "--body", "--base", "--head", "--json", "--state", "--mode", "--expect-updated-at", "--expect-body-sha", "--body-profile", "--label", "--field", "--value", "--section", "--to", "--status", "--row-file", "--category", "--branch", "--tasks", "--summary", "--focus", "--validation", "--progress"]);
const flagOptions = new Set(["--dry-run", "--draft", "--notify-claudeqq-brief-diff", "--allow-short-body"]);
const flagOptions = new Set(["--dry-run", "--draft", "--notify-claudeqq-brief-diff", "--allow-short-body", "--raw", "--full"]);
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (!arg.startsWith("--")) continue;
@@ -646,6 +660,8 @@ function parseOptions(args: string[]): GitHubOptions {
return {
repo: optionValue(args, "--repo") ?? DEFAULT_REPO,
dryRun: hasFlag(args, "--dry-run"),
raw: hasFlag(args, "--raw"),
full: hasFlag(args, "--full"),
limit: positiveIntegerOption(args, "--limit", top === "issue" && sub === "board-audit" ? 100 : 30, 100),
boardIssue: positiveIntegerSingleOption(args, "--board-issue", CODE_QUEUE_BOARD_TARGET_ISSUE),
knownMetaIssues: positiveIntegerValuesOption(args, "--known-meta-issue"),
@@ -694,6 +710,94 @@ function parseNumberForCommand(repo: string, raw: string | undefined, label: str
}
}
function parseOwnerRepoNumberShorthand(raw: string | undefined): GitHubShorthandReference | null {
if (raw === undefined) return null;
const match = /^([^/#\s]+)\/([^/#\s]+)#([1-9]\d*)$/u.exec(raw);
if (match === null) return null;
return {
input: raw,
repo: `${match[1]}/${match[2]}`,
number: Number(match[3]),
};
}
function readViewSupportedCommands(kind: "issue" | "pr", repo: string, number: number): string[] {
const jsonFields = kind === "issue"
? "body,title,state,comments"
: "body,title,state,head,base,draft,headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup";
return [
`bun scripts/cli.ts gh ${kind} read ${number} --repo ${repo} --json ${jsonFields}`,
`bun scripts/cli.ts gh ${kind} view ${repo}#${number} --raw`,
];
}
function resolveReadViewNumberReference(kind: "issue" | "pr", sub: "read" | "view", raw: string | undefined, options: GitHubOptions, args: string[]): GitHubResolvedNumberReference | GitHubCommandResult {
const command = `${kind} ${sub}`;
const shorthand = parseOwnerRepoNumberShorthand(raw);
if (shorthand !== null) {
const explicitRepo = optionValue(args, "--repo");
if (explicitRepo !== undefined && explicitRepo !== shorthand.repo) {
const message = `${command} target ${shorthand.input} resolves to repo ${shorthand.repo}, but --repo ${explicitRepo} was also provided. Use either the shorthand or a matching --repo, not both.`;
return validationError(command, explicitRepo, message, {
message,
shorthand,
explicitRepo,
supportedCommands: readViewSupportedCommands(kind, shorthand.repo, shorthand.number),
});
}
return { repo: shorthand.repo, number: shorthand.number, shorthand };
}
const parsed = parseNumberForCommand(options.repo, raw, command);
if (typeof parsed !== "number") {
return {
...parsed,
supportedCommands: [
`bun scripts/cli.ts gh ${kind} read <number> --repo owner/name --json ${kind === "issue" ? "body,title,state,comments" : "body,title,state,head,base"}`,
`bun scripts/cli.ts gh ${kind} read owner/name#<number> --raw`,
],
};
}
return { repo: options.repo, number: parsed };
}
function issueReadJsonFields(options: GitHubOptions): IssueViewJsonField[] | undefined {
return options.raw || options.full ? ISSUE_VIEW_JSON_FIELDS.slice() : options.jsonFields;
}
function prReadJsonFields(options: GitHubOptions): PrReadJsonField[] | undefined {
return options.raw || options.full ? PR_READ_JSON_FIELDS.slice() : options.prJsonFields;
}
function readDisclosureOptions(options: GitHubOptions, shorthand: GitHubShorthandReference | undefined): Record<string, unknown> | null {
if (!options.raw && !options.full && shorthand === undefined) return null;
return {
...(options.raw ? { raw: true } : {}),
...(options.full ? { full: true } : {}),
fullDisclosure: options.raw || options.full,
shorthand: shorthand ?? null,
};
}
function unknownGhOptionDetails(args: string[], option: string): Record<string, unknown> {
const [top, sub, third] = args;
const details: Record<string, unknown> = {
unsupportedOption: option,
helpCommand: "bun scripts/cli.ts gh help",
};
if ((top === "issue" || top === "pr") && (sub === "read" || sub === "view")) {
const shorthand = parseOwnerRepoNumberShorthand(third);
const repo = shorthand?.repo ?? optionValue(args, "--repo") ?? "owner/name";
const number = shorthand?.number ?? (third !== undefined && /^\d+$/u.test(third) ? Number(third) : 0);
details.supportedCommands = number > 0
? readViewSupportedCommands(top, repo, number)
: [
`bun scripts/cli.ts gh ${top} read <number> --repo owner/name --json ${top === "issue" ? "body,title,state,comments" : "body,title,state,head,base"}`,
`bun scripts/cli.ts gh ${top} read owner/name#<number> --raw`,
];
}
return details;
}
function readBodyFile(path: string | undefined, command: string): string {
if (path === undefined) throw new Error(`${command} requires --body-file <file>`);
if (!existsSync(path)) throw new Error(`body file not found: ${path}`);
@@ -3838,7 +3942,7 @@ function selectedIssueJson(issue: GitHubIssue, comments: GitHubComment[] | null,
return selected;
}
async function issueRead(repo: string, token: string, issueNumber: number, jsonFields: IssueViewJsonField[] | undefined, commandName = "issue read"): Promise<GitHubCommandResult> {
async function issueRead(repo: string, token: string, issueNumber: number, jsonFields: IssueViewJsonField[] | undefined, commandName = "issue read", disclosure: Record<string, unknown> | null = null): Promise<GitHubCommandResult> {
const issue = await getIssue(token, repo, issueNumber);
if (isGitHubError(issue)) return commandError(commandName, repo, issue, { issueNumber });
const needsComments = jsonFields === undefined || jsonFields.includes("comments");
@@ -3848,6 +3952,7 @@ async function issueRead(repo: string, token: string, issueNumber: number, jsonF
ok: true,
command: commandName,
repo,
...(disclosure === null ? {} : { disclosure }),
issue: issueSummary(issue),
codeQueueBoardHint: codeQueueBoardCommanderBriefHint(issueNumber, issue.body ?? ""),
...(comments === null ? {} : { comments: comments.map(commentSummary) }),
@@ -3861,8 +3966,8 @@ async function issueRead(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 issueView(repo: string, token: string, issueNumber: number, jsonFields: IssueViewJsonField[] | undefined, disclosure: Record<string, unknown> | null = null): Promise<GitHubCommandResult> {
return issueRead(repo, token, issueNumber, jsonFields, "issue view", disclosure);
}
async function issueList(repo: string, token: string, state: IssueListState, limit: number, jsonFields: IssueListJsonField[] | undefined): Promise<GitHubCommandResult> {
@@ -4680,7 +4785,7 @@ async function prList(repo: string, token: string, state: PrListState, limit: nu
};
}
async function prRead(repo: string, token: string, number: number, jsonFields: PrReadJsonField[] | undefined, commandName = "pr read"): Promise<GitHubCommandResult> {
async function prRead(repo: string, token: string, number: number, jsonFields: PrReadJsonField[] | undefined, commandName = "pr read", disclosure: Record<string, unknown> | null = null): Promise<GitHubCommandResult> {
const { owner, name } = repoParts(repo);
const pr = await githubRequest<GitHubPullRequest>(token, "GET", `/repos/${owner}/${name}/pulls/${number}`);
if (isGitHubError(pr)) return commandError(commandName, repo, pr, { number });
@@ -4692,13 +4797,14 @@ async function prRead(repo: string, token: string, number: number, jsonFields: P
ok: true,
command: commandName,
repo,
...(disclosure === null ? {} : { disclosure }),
pullRequest: summary,
...(jsonFields === undefined ? {} : { jsonFields, json: selectedPrJson(selectionSummary, jsonFields) }),
};
}
async function prView(repo: string, token: string, number: number, jsonFields: PrReadJsonField[] | undefined): Promise<GitHubCommandResult> {
return prRead(repo, token, number, jsonFields, "pr view");
async function prView(repo: string, token: string, number: number, jsonFields: PrReadJsonField[] | undefined, disclosure: Record<string, unknown> | null = null): Promise<GitHubCommandResult> {
return prRead(repo, token, number, jsonFields, "pr view", disclosure);
}
export function ghHelp(): unknown {
@@ -4708,8 +4814,8 @@ 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 read <number> [--repo owner/name] [--json body,title,state,comments]",
"bun scripts/cli.ts gh issue view <number> [--repo owner/name] [compatibility alias for issue read]",
"bun scripts/cli.ts gh issue read <number|owner/repo#number> [--repo owner/name] [--json body,title,state,comments] [--raw|--full]",
"bun scripts/cli.ts gh issue view <number|owner/repo#number> [--repo owner/name] [--raw|--full] [compatibility alias for issue read]",
"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]",
@@ -4729,8 +4835,8 @@ export function ghHelp(): unknown {
"bun scripts/cli.ts gh issue board-row move <issueNumber> [--repo owner/name] --board-issue 20 --to open|closed [--status OPEN|CLOSED] [--dry-run] [--expect-body-sha sha256]",
"bun scripts/cli.ts gh issue board-row delete <issueNumber> [--repo owner/name] --board-issue 20 [--dry-run] [--expect-body-sha sha256]",
"bun scripts/cli.ts gh pr list [--repo owner/name] [--state open|closed|all] [--limit N] [--json number,title,state,url,updatedAt,createdAt,author,head,base,draft]",
"bun scripts/cli.ts gh pr read <number> [--repo owner/name] [--json body,title,state,head,base,draft,headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup]",
"bun scripts/cli.ts gh pr view <number> [--repo owner/name] [compatibility alias for pr read]",
"bun scripts/cli.ts gh pr read <number|owner/repo#number> [--repo owner/name] [--json body,title,state,head,base,draft,headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup] [--raw|--full]",
"bun scripts/cli.ts gh pr view <number|owner/repo#number> [--repo owner/name] [--raw|--full] [compatibility alias for pr read]",
"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]",
@@ -4744,7 +4850,8 @@ export function ghHelp(): unknown {
"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.",
"PR list defaults to --state all for compatibility with earlier UniDesk CLI behavior; supported states are open, closed, and all.",
"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 read is the canonical read path; view remains a compatibility alias. Read/view accept owner/repo#number shorthand and derive --repo unless an explicit conflicting --repo is supplied, which fails structurally with suggested commands. Read supports legacy --json field selection such as --json body and still exposes .data.issue.body for compatibility; unsupported fields fail structurally.",
"--raw and --full are explicit full-disclosure aliases for gh issue read/view and gh pr read/view. They request the full supported read/view JSON field set while keeping default command output structured JSON.",
"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.",
@@ -4762,7 +4869,7 @@ export function ghHelp(): unknown {
"issue edit 24 --notify-claudeqq-brief-diff remains the legacy #24 notification helper: it 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 read is the canonical read path; view remains a compatibility alias. PR read/view supports closeout fields headRefName, baseRefName, mergeable, mergeStateStatus, and statusCheckRollup; mergeability and status rollup are fetched through GitHub GraphQL only when requested.",
"PR read is the canonical read path; view remains a compatibility alias. PR read/view accept owner/repo#number shorthand and derive --repo unless an explicit conflicting --repo is supplied, which fails structurally with suggested commands. PR read/view supports closeout fields headRefName, baseRefName, mergeable, mergeStateStatus, and statusCheckRollup; mergeability and status rollup are fetched through GitHub GraphQL only when requested or when --raw/--full requests full disclosure.",
"PR create/update/comment are safe-write operations with dry-run planning; merge is intentionally unsupported in this phase.",
],
};
@@ -4778,8 +4885,9 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
const message = error instanceof Error ? error.message : String(error);
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
const repo = optionValue(args, "--repo") ?? DEFAULT_REPO;
return message.startsWith("unknown gh option:")
? unsupportedCommand(command, repo, message)
const unknownOption = /^unknown gh option:\s+(.+)$/u.exec(message)?.[1];
return unknownOption !== undefined
? unsupportedCommand(command, repo, message, unknownGhOptionDetails(args, unknownOption))
: validationError(command, repo, message);
}
if (options.notifyClaudeQqBriefDiff && !(top === "issue" && sub === "edit")) {
@@ -4796,6 +4904,17 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
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, "--raw") || optionWasProvided(args, "--full")) && !((top === "issue" && isIssueReadCommand(sub)) || (top === "pr" && isPrReadCommand(sub)))) {
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
return validationError(command, options.repo, "--raw and --full are explicit full-disclosure aliases only for gh issue read/view and gh pr read/view.", {
supportedCommands: [
"bun scripts/cli.ts gh issue read owner/name#<number> --raw",
"bun scripts/cli.ts gh issue read <number> --repo owner/name --json body,title,state,comments",
"bun scripts/cli.ts gh pr read owner/name#<number> --raw",
"bun scripts/cli.ts gh pr read <number> --repo owner/name --json body,title,state,head,base",
],
});
}
if (optionWasProvided(args, "--mode") && !((top === "issue" && (sub === "update" || sub === "edit")) || (top === "pr" && sub === "update"))) {
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
return validationError(command, options.repo, "--mode is only supported by gh issue update/edit and gh pr update");
@@ -4911,13 +5030,21 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
if (sub === "close") return issueState(options.repo, "", parseNumber(third, "issue close"), "closed", true);
if (sub === "reopen") return issueState(options.repo, "", parseNumber(third, "issue reopen"), "open", true);
}
if (sub === "read" || sub === "view") {
const resolved = resolveReadViewNumberReference("issue", sub, third, options, args);
if ("ok" in resolved && resolved.ok === false) return resolved;
const { token, probe } = resolveToken(true);
const missing = authRequired(resolved.repo, `issue ${sub}`, probe);
if (missing !== null || token === null) return missing ?? authRequired(resolved.repo, `issue ${sub}`, { present: false, source: null, ghFallbackAttempted: true });
const disclosure = readDisclosureOptions(options, resolved.shorthand);
if (sub === "read") return issueRead(resolved.repo, token, resolved.number, issueReadJsonFields(options), "issue read", disclosure);
return issueView(resolved.repo, token, resolved.number, issueReadJsonFields(options), disclosure);
}
const { token, probe } = resolveToken(true);
const missing = authRequired(options.repo, `issue ${sub ?? ""}`.trim(), probe);
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);
if (sub === "update") return issueEdit(options.repo, token, parseNumber(third, "issue update"), options, "issue update");
@@ -4990,12 +5117,20 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
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.");
}
if (sub === "read" || sub === "view") {
const resolved = resolveReadViewNumberReference("pr", sub, third, options, args);
if ("ok" in resolved && resolved.ok === false) return resolved;
const { token, probe } = resolveToken(true);
const missing = authRequired(resolved.repo, `pr ${sub}`, probe);
if (missing !== null || token === null) return missing ?? authRequired(resolved.repo, `pr ${sub}`, { present: false, source: null, ghFallbackAttempted: true });
const disclosure = readDisclosureOptions(options, resolved.shorthand);
if (sub === "read") return prRead(resolved.repo, token, resolved.number, prReadJsonFields(options), "pr read", disclosure);
return prView(resolved.repo, token, resolved.number, prReadJsonFields(options), disclosure);
}
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.prListState, options.limit, options.prListJsonFields);
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);
}
return unsupportedCommand(args.join(" ") || "gh", options.repo, "Unsupported gh command", { help: ghHelp() });