fix: 修复 CLI repo 与状态别名解析

This commit is contained in:
Codex
2026-05-24 02:15:28 +00:00
parent 2257c986eb
commit 2a386a17da
7 changed files with 307 additions and 27 deletions
@@ -1,3 +1,4 @@
import { spawnSync } from "node:child_process";
import { codexTasksQueryForTest } from "./src/code-queue";
type JsonRecord = Record<string, unknown>;
@@ -148,17 +149,20 @@ export function runCodeQueueCommanderViewContract(): JsonRecord {
const fetchCommanderLimit8 = (path: string): JsonRecord => noisyCommanderFixture(path, commanderLimit8Requests);
const fetchNoisy = (path: string): JsonRecord => noisyCommanderFixture(path);
const commander = codexTasksQueryForTest(["--view", "commander", "--limit", "260"], fetchCommander);
const commanderTerminalAliases = codexTasksQueryForTest(["--view", "commander", "--status", "completed,cancelled", "--limit", "8"], fetchNoisy);
const supervisor = codexTasksQueryForTest(["--view", "supervisor", "--limit", "260"], fetchNoisy);
const full = codexTasksQueryForTest(["--view", "full", "--limit", "260"], fetchNoisy);
const commanderLimit8 = codexTasksQueryForTest(["--view", "commander", "--limit", "8"], fetchCommanderLimit8);
const fullLimit8 = codexTasksQueryForTest(["--view", "full", "--limit", "8"], fetchNoisy);
const unreadLimit8 = codexTasksQueryForTest(["--unread", "--limit", "8"], fetchNoisy);
const commanderBody = JSON.stringify(commander);
const commanderTerminalAliasBody = JSON.stringify(commanderTerminalAliases);
const commanderLimit8Body = JSON.stringify(commanderLimit8);
const fullLimit8Body = JSON.stringify(fullLimit8);
const unreadLimit8Body = JSON.stringify(unreadLimit8);
const fullBody = JSON.stringify(full);
const commanderView = asRecord(asRecord(commander).commander);
const commanderTerminalAliasView = asRecord(asRecord(commanderTerminalAliases).commander);
const commanderLimit8View = asRecord(asRecord(commanderLimit8).commander);
const supervisorView = asRecord(asRecord(supervisor).supervisor);
const filters = asRecord(commanderView.filters);
@@ -178,6 +182,10 @@ export function runCodeQueueCommanderViewContract(): JsonRecord {
const recentCompletedSection = asRecord(sections.recentCompleted);
const recentIds = asArray(recentCompletedSection.items).map((item) => String(asRecord(item).id ?? ""));
const terminalIds = asArray(terminalUnreadSection.items).map((item) => String(asRecord(item).id ?? ""));
const terminalAliasFilters = asRecord(commanderTerminalAliasView.filters);
const terminalAliasSections = asRecord(commanderTerminalAliasView.sections);
const terminalAliasRecentCompleted = asRecord(terminalAliasSections.recentCompleted);
const terminalAliasCommands = asRecord(commanderTerminalAliasView.commands);
const activeItems = asArray(activeRunners.items).map(asRecord);
const runningRisk = attentionItems.find((item) => item.id === "task-running-risk") ?? {};
const limit8ActiveRunners = asRecord(commanderLimit8View.activeRunners);
@@ -220,6 +228,22 @@ export function runCodeQueueCommanderViewContract(): JsonRecord {
assertCondition(recentIds.length === 3, "recentCompleted commander section should be independently capped", { recentIds });
assertCondition(terminalUnreadSection.returned === 0 && asArray(terminalUnreadSection.items).length === 0, "default commander terminal unread section should omit item details", terminalUnreadSection);
assertCondition(String(asRecord(terminalUnreadSection.commands).unread ?? "").includes("codex unread"), "terminal unread section should point to codex unread drill-down", terminalUnreadSection);
assertCondition(JSON.stringify(terminalAliasFilters.status) === JSON.stringify(["succeeded", "canceled"]), "completed/cancelled status aliases should normalize to succeeded/canceled", terminalAliasFilters);
assertCondition(!commanderTerminalAliasBody.includes("task-failed-unread"), "completed/cancelled aliases should filter out failed tasks", { commanderTerminalAliasBody });
assertCondition(String(terminalAliasCommands.refresh ?? "").includes("--status succeeded,canceled"), "normalized status aliases should be preserved in drill-down commands", terminalAliasCommands);
assertCondition(asArray(terminalAliasRecentCompleted.items).some((item) => asRecord(item).id === "task-recent-read-docs"), "completed alias should include read succeeded tasks in recent completed", terminalAliasRecentCompleted);
const badStatus = spawnSync("bun", ["scripts/cli.ts", "codex", "tasks", "--status", "done", "--limit", "1"], {
cwd: process.cwd(),
encoding: "utf8",
});
const badStatusJson = JSON.parse(badStatus.stdout) as JsonRecord;
assertCondition(badStatus.status !== 0, "unsupported codex tasks --status should fail", { status: badStatus.status, stdout: badStatus.stdout, stderr: badStatus.stderr });
const badStatusError = asRecord(badStatusJson.error);
assertCondition(badStatusError.degradedReason === "validation-failed", "unsupported status should return structured validation error", badStatusError);
assertCondition(Array.isArray(badStatusError.supported) && asArray(badStatusError.supported).includes("succeeded"), "unsupported status should list supported values", badStatusError);
assertCondition(Array.isArray(badStatusError.aliases) && asArray(badStatusError.aliases).includes("completed->succeeded") && asArray(badStatusError.aliases).includes("cancelled->canceled"), "unsupported status should list common aliases", badStatusError);
assertCondition(!badStatus.stdout.includes("stack") && !badStatus.stdout.includes("at parseTasksOptions"), "expected codex tasks parameter errors should not print stack traces by default", { stdout: badStatus.stdout });
assertCondition(asRecord(supervisorView.completedUnread).count === 3 && asRecord(supervisorView.recentCompleted).count === 5, "supervisor view should remain available and keep separate unread/recent sections", supervisorView);
assertCondition(commanderLimit8Body.length < 16_000, "commander --limit 8 output should stay compact for polling", { chars: commanderLimit8Body });
assertCondition(asRecord(commanderLimit8View.filters).requestedLimit === 8, "commander --limit 8 should preserve requested limit disclosure", commanderLimit8View);
@@ -244,6 +268,8 @@ export function runCodeQueueCommanderViewContract(): JsonRecord {
"deterministic classifier emits requested categories",
"drilldown commands are present without prompt/final-response flood",
"commander --limit 8 omits terminal unread details and prompt previews",
"codex tasks --status completed,cancelled aliases normalize to succeeded,canceled",
"codex tasks invalid --status returns compact structured suggestions without stack noise",
"full and unread drill-down paths still expose details",
"recent completed does not duplicate terminal unread",
"supervisor/full views remain available",
@@ -270,6 +270,21 @@ async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockReque
updated_at: "2026-05-20T03:10:00Z",
},
];
const hwlabIssueList = [
{
id: 7001,
number: 7,
title: "HWLAB generic issue fixture",
body: "HWLAB issue list body fixture",
state: "open",
html_url: "https://github.com/pikasTech/HWLAB/issues/7",
comments: 0,
user: { login: "tester" },
labels: [],
created_at: "2026-05-20T02:30:00Z",
updated_at: "2026-05-20T03:30:00Z",
},
];
const scanIssues = [
{
id: 2501,
@@ -505,6 +520,10 @@ async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockReque
sendJson(res, 200, issueList);
return;
}
if (req.method === "GET" && req.url === "/repos/pikasTech/HWLAB/issues?state=open&per_page=2") {
sendJson(res, 200, hwlabIssueList);
return;
}
if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/issues?state=all&per_page=3") {
sendJson(res, 200, issueList);
return;
@@ -654,6 +673,20 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
assertCondition(listDefaultStateData.state === "open", "issue list should keep default state=open", listDefaultStateData);
assertCondition(mock.requests.some((request) => request.method === "GET" && request.url === "/repos/pikasTech/unidesk/issues?state=open&per_page=2"), "issue list default should query state=open", mock.requests);
const positionalRepoList = await runCli(["gh", "issue", "list", "pikasTech/HWLAB", "--state", "open", "--limit", "2", "--json", "number,title,state,url"], env);
assertCondition(positionalRepoList.status === 0, "issue list positional owner/repo should succeed", positionalRepoList.json ?? { stdout: positionalRepoList.stdout });
const positionalRepoListData = dataOf(positionalRepoList.json ?? {});
assertCondition(positionalRepoListData.repo === "pikasTech/HWLAB", "issue list positional repo should become the actual request repo", positionalRepoListData);
const positionalRepoIssues = positionalRepoListData.issues as JsonRecord[];
assertCondition(Array.isArray(positionalRepoIssues) && positionalRepoIssues[0]?.number === 7, "issue list positional repo should return HWLAB fixture issue", positionalRepoListData);
assertCondition(mock.requests.some((request) => request.method === "GET" && request.url === "/repos/pikasTech/HWLAB/issues?state=open&per_page=2"), "issue list positional repo should query derived repo REST path", mock.requests);
const positionalRepoConflict = await runCli(["gh", "issue", "list", "pikasTech/HWLAB", "--repo", "pikasTech/unidesk", "--state", "open"], env);
assertCondition(positionalRepoConflict.status !== 0, "issue list conflicting positional repo and --repo should fail", positionalRepoConflict.json ?? { stdout: positionalRepoConflict.stdout });
const positionalRepoConflictData = failedDataOf(positionalRepoConflict.json ?? {});
assertCondition(positionalRepoConflictData.degradedReason === "validation-failed", "issue list repo conflict should be validation-failed", positionalRepoConflictData);
assertCondition(String((positionalRepoConflictData.details as JsonRecord)?.message ?? "").includes("positional repo pikasTech/HWLAB"), "issue list repo conflict should name positional repo", positionalRepoConflictData);
const listDefaultFields = await runCli(["gh", "issue", "list", "--repo", "pikasTech/unidesk", "--state", "all", "--limit", "3"], env);
assertCondition(listDefaultFields.status === 0, "issue list should support default fields", listDefaultFields.json ?? { stdout: listDefaultFields.stdout });
const listDefaultData = dataOf(listDefaultFields.json ?? {});
@@ -1494,6 +1527,7 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
"issue read/view accept owner/repo#number shorthand and reject conflicting --repo",
"issue read/view --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",
+33
View File
@@ -149,6 +149,10 @@ async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockReque
sendJson(res, 200, [pullRequest]);
return;
}
if (req.method === "GET" && req.url === "/repos/pikasTech/HWLAB/pulls?state=open&per_page=4") {
sendJson(res, 200, [shorthandPullRequest]);
return;
}
if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/pulls?state=closed&per_page=4") {
sendJson(res, 200, [mergedPullRequest]);
return;
@@ -330,6 +334,20 @@ export async function runGhCliPrContract(): Promise<JsonRecord> {
assertCondition(!("body" in listOpenPrs[0]), "pr list --json should keep progressive disclosure", listOpenPrs[0]);
assertCondition(mock.requests.some((request) => request.method === "GET" && request.url === "/repos/pikasTech/unidesk/pulls?state=open&per_page=4"), "pr list --state open should query REST state=open", mock.requests);
const positionalRepoList = await runCli(["gh", "pr", "list", "pikasTech/HWLAB", "--state", "open", "--limit", "4", "--json", "number,title,state,url"], env);
assertCondition(positionalRepoList.status === 0, "pr list positional owner/repo should succeed", positionalRepoList.json ?? { stdout: positionalRepoList.stdout });
const positionalRepoListData = dataOf(positionalRepoList.json ?? {});
assertCondition(positionalRepoListData.repo === "pikasTech/HWLAB", "pr list positional repo should become the actual request repo", positionalRepoListData);
const positionalRepoPrs = positionalRepoListData.pullRequests as JsonRecord[];
assertCondition(Array.isArray(positionalRepoPrs) && positionalRepoPrs[0]?.number === 7, "pr list positional repo should return HWLAB fixture PR", positionalRepoListData);
assertCondition(mock.requests.some((request) => request.method === "GET" && request.url === "/repos/pikasTech/HWLAB/pulls?state=open&per_page=4"), "pr list positional repo should query derived repo REST path", mock.requests);
const positionalRepoConflict = await runCli(["gh", "pr", "list", "pikasTech/HWLAB", "--repo", "pikasTech/unidesk", "--state", "open"], env);
assertCondition(positionalRepoConflict.status !== 0, "pr list conflicting positional repo and --repo should fail", positionalRepoConflict.json ?? { stdout: positionalRepoConflict.stdout });
const positionalRepoConflictData = failedDataOf(positionalRepoConflict.json ?? {});
assertCondition(positionalRepoConflictData.degradedReason === "validation-failed", "pr list repo conflict should be validation-failed", positionalRepoConflictData);
assertCondition(String((positionalRepoConflictData.details as JsonRecord)?.message ?? "").includes("positional repo pikasTech/HWLAB"), "pr list repo conflict should name positional repo", positionalRepoConflictData);
const listClosed = await runCli(["gh", "pr", "list", "--repo", "pikasTech/unidesk", "--state", "closed", "--limit", "4", "--json", "number,state,url"], env);
assertCondition(listClosed.status === 0, "pr list should support --state closed", listClosed.json ?? { stdout: listClosed.stdout });
const listClosedData = dataOf(listClosed.json ?? {});
@@ -359,6 +377,19 @@ export async function runGhCliPrContract(): Promise<JsonRecord> {
const selected = readData.json as JsonRecord;
assertCondition(selected.body === "PR body" && selected.title === "contract PR", "pr read --json should select fields", readData);
const readNumberAlias = await runCli(["gh", "pr", "read", "--repo", "pikasTech/HWLAB", "--number", "7", "--json", "body,title,state,head,base"], env);
assertCondition(readNumberAlias.status === 0, "pr read should accept --number alias", readNumberAlias.json ?? { stdout: readNumberAlias.stdout });
const readNumberAliasData = dataOf(readNumberAlias.json ?? {});
assertCondition(readNumberAliasData.repo === "pikasTech/HWLAB", "pr read --number should preserve explicit repo", readNumberAliasData);
const readNumberAliasPr = readNumberAliasData.pullRequest as JsonRecord;
assertCondition(readNumberAliasPr.number === 7 && readNumberAliasPr.url === "https://github.com/pikasTech/HWLAB/pull/7", "pr read --number should read the requested PR", readNumberAliasData);
assertCondition(mock.requests.some((request) => request.method === "GET" && request.url === "/repos/pikasTech/HWLAB/pulls/7"), "pr read --number should call explicit repo REST path", mock.requests);
const numberAliasUnsupported = await runCli(["gh", "pr", "list", "--repo", "pikasTech/unidesk", "--number", "7"], env);
assertCondition(numberAliasUnsupported.status !== 0, "--number should not be silently ignored outside pr read/view", numberAliasUnsupported.json ?? { stdout: numberAliasUnsupported.stdout });
const numberAliasUnsupportedData = failedDataOf(numberAliasUnsupported.json ?? {});
assertCondition(numberAliasUnsupportedData.degradedReason === "validation-failed", "unsupported --number should be validation-failed", numberAliasUnsupportedData);
const openLifecycle = await runCli(["gh", "pr", "read", "42", "--repo", "pikasTech/unidesk", "--json", "state,stateDetail,closed,closedAt,merged,mergedAt,mergeCommit,headRefName,baseRefName"], env);
assertCondition(openLifecycle.status === 0, "open pr lifecycle fields should succeed through REST", openLifecycle.json ?? { stdout: openLifecycle.stdout });
const openLifecycleData = dataOf(openLifecycle.json ?? {});
@@ -674,6 +705,8 @@ export async function runGhCliPrContract(): Promise<JsonRecord> {
checks: [
"gh help lists pr create/comment",
"pr list/read/view work through REST with token and no gh binary dependency",
"pr list positional owner/repo targets the requested repo and conflicting --repo fails",
"pr read supports --number alias without silently ignoring it elsewhere",
"pr read/view accept owner/repo#number shorthand and reject conflicting --repo",
"pr read/view --raw is explicit full disclosure",
"pr list rejects closeout fields and points to pr view",
+65 -12
View File
@@ -59,6 +59,30 @@ const defaultSteerRetryAttempts = 2;
const maxSteerRetryAttempts = 3;
const defaultSteerRetryDelayMs = 750;
const maxSteerRetryDelayMs = 5_000;
const codexTaskStatuses = ["queued", "running", "judging", "retry_wait", "succeeded", "failed", "canceled"] as const;
const codexTerminalTaskStatuses = ["succeeded", "failed", "canceled"] as const;
const codexTaskStatusAliases: Record<string, string> = {
completed: "succeeded",
complete: "succeeded",
success: "succeeded",
successful: "succeeded",
succeeded: "succeeded",
cancelled: "canceled",
canceled: "canceled",
failed: "failed",
failure: "failed",
errored: "failed",
error: "failed",
running: "running",
active: "running",
judging: "judging",
judge: "judging",
queued: "queued",
pending: "queued",
retrying: "retry_wait",
retry_wait: "retry_wait",
"retry-wait": "retry_wait",
};
interface CodexTaskOptions {
detail: boolean;
@@ -1176,6 +1200,45 @@ function assertKnownOptions(args: string[], spec: { flags?: string[]; valueOptio
}
}
function normalizeCodexStatusFilter(raw: string, allowedValues: readonly string[], command: string): string[] {
const requested = raw.split(/[,\s]+/u).map((item) => item.trim()).filter(Boolean);
if (requested.length === 0) throw new Error(`${command} --status requires at least one status value`);
const allowed = new Set(allowedValues);
const normalized: string[] = [];
const unsupported: string[] = [];
for (const value of requested) {
const key = value.toLowerCase();
const canonical = codexTaskStatusAliases[key] ?? key;
if (!allowed.has(canonical)) {
unsupported.push(value);
continue;
}
if (!normalized.includes(canonical)) normalized.push(canonical);
}
if (unsupported.length > 0) {
const aliasHints = Object.entries(codexTaskStatusAliases)
.filter(([alias, canonical]) => alias !== canonical && allowed.has(canonical))
.map(([alias, canonical]) => `${alias}->${canonical}`);
const detail = {
ok: false,
degradedReason: "validation-failed",
command,
option: "--status",
unsupported,
supported: allowedValues,
aliases: aliasHints,
examples: [
`bun scripts/cli.ts ${command} --status ${allowedValues.join(",")}`,
command === "codex tasks"
? "bun scripts/cli.ts codex tasks --status completed,cancelled --view commander"
: "bun scripts/cli.ts codex unread --status completed,cancelled",
],
};
throw new Error(`${command} unsupported --status value: ${unsupported.join(", ")}; ${JSON.stringify(detail)}`);
}
return normalized;
}
function textView(text: string, full: boolean, maxChars: number): Record<string, unknown> {
const truncated = !full && text.length > maxChars;
return {
@@ -2674,12 +2737,7 @@ function parseTasksOptions(args: string[]): CodexTasksOptions {
const statusRaw = optionValue(args, ["--status"]);
const statusFilter = statusRaw === undefined
? null
: statusRaw.split(/[,\s]+/u).map((item) => item.trim()).filter(Boolean);
if (statusFilter !== null) {
const supported = new Set(["queued", "running", "judging", "retry_wait", "succeeded", "failed", "canceled"]);
const unsupported = statusFilter.filter((status) => !supported.has(status));
if (unsupported.length > 0) throw new Error(`unsupported --status value: ${unsupported.join(", ")}; supported: ${Array.from(supported).join(", ")}`);
}
: normalizeCodexStatusFilter(statusRaw, codexTaskStatuses, "codex tasks");
const requestedLimit = positiveIntegerOption(args, ["--limit"], defaultTasksLimit);
return {
queueId: optionValue(args, ["--queue", "--queue-id"]),
@@ -2727,12 +2785,7 @@ function parseUnreadOptions(args: string[]): CodexUnreadOptions {
const statusRaw = optionValue(optionArgs, ["--status"]);
const statusFilter = statusRaw === undefined
? null
: statusRaw.split(/[,\s]+/u).map((item) => item.trim()).filter(Boolean);
if (statusFilter !== null) {
const supported = new Set(["succeeded", "failed", "canceled"]);
const unsupported = statusFilter.filter((status) => !supported.has(status));
if (unsupported.length > 0) throw new Error(`unsupported --status value for codex unread: ${unsupported.join(", ")}; supported terminal statuses: ${Array.from(supported).join(", ")}`);
}
: normalizeCodexStatusFilter(statusRaw, codexTerminalTaskStatuses, "codex unread");
const requestedLimit = positiveIntegerOption(optionArgs, ["--limit"], defaultTasksLimit);
const action: CodexUnreadAction = subcommand === "mark-read" || hasFlag(optionArgs, "--mark-read") ? "mark-read" : "summary";
const explicitDryRun = hasFlag(optionArgs, "--dry-run");
+106 -10
View File
@@ -28,6 +28,8 @@ const ISSUE_LIST_STATES = ["open", "closed", "all"] as const;
const BODY_UPDATE_MODES = ["replace", "append"] as const;
const BOARD_MUTATION_SECTIONS = ["open", "closed"] as const;
const BOARD_GITHUB_STATUSES = ["OPEN", "CLOSED"] as const;
const GH_VALUE_OPTIONS = 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", "--number"]);
const GH_FLAG_OPTIONS = new Set(["--dry-run", "--draft", "--notify-claudeqq-brief-diff", "--allow-short-body", "--raw", "--full", "--stat"]);
const MIN_SAFE_BODY_SCAN_CHARS = MIN_SAFE_ISSUE_BODY_CHARS;
const ISSUE_SCAN_MAX_FINDINGS = 60;
const ISSUE_BODY_PROFILES = {
@@ -678,13 +680,11 @@ 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", "--raw", "--full", "--stat"]);
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (!arg.startsWith("--")) continue;
if (flagOptions.has(arg)) continue;
if (valueOptions.has(arg)) {
if (GH_FLAG_OPTIONS.has(arg)) continue;
if (GH_VALUE_OPTIONS.has(arg)) {
index += 1;
continue;
}
@@ -692,13 +692,52 @@ function validateKnownOptions(args: string[]): void {
}
}
function positionalArgs(args: string[]): string[] {
const positionals: string[] = [];
for (let index = 0; index < args.length; index += 1) {
const arg = args[index] ?? "";
if (arg.startsWith("--")) {
if (GH_VALUE_OPTIONS.has(arg)) index += 1;
continue;
}
positionals.push(arg);
}
return positionals;
}
function isOwnerRepo(value: string): boolean {
return /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(value);
}
function resolveRepoOption(args: string[]): string {
const [top, sub] = args;
const explicitRepo = optionValue(args, "--repo");
if ((top === "issue" || top === "pr") && sub === "list") {
const positionals = positionalArgs(args.slice(2));
if (positionals.length > 1) {
throw new Error(`gh ${top} list accepts at most one positional owner/repo; use --repo owner/name`);
}
const positionalRepo = positionals[0];
if (positionalRepo !== undefined) {
if (!isOwnerRepo(positionalRepo)) {
throw new Error(`gh ${top} list positional argument must be owner/repo; use --repo owner/name`);
}
if (explicitRepo !== undefined && explicitRepo !== positionalRepo) {
throw new Error(`gh ${top} list received positional repo ${positionalRepo} and --repo ${explicitRepo}; use one repo target, for example --repo ${positionalRepo}`);
}
return positionalRepo;
}
}
return explicitRepo ?? DEFAULT_REPO;
}
function parseOptions(args: string[]): GitHubOptions {
validateKnownOptions(args);
const [top, sub] = args;
const requestedJsonFields = commaListOption(args, "--json");
const limitMax = top === "pr" && (sub === "files" || sub === "diff") ? MAX_PR_FILES_LIMIT : 100;
return {
repo: optionValue(args, "--repo") ?? DEFAULT_REPO,
repo: resolveRepoOption(args),
dryRun: hasFlag(args, "--dry-run"),
raw: hasFlag(args, "--raw"),
full: hasFlag(args, "--full"),
@@ -774,10 +813,32 @@ function readViewSupportedJsonFields(kind: "issue" | "pr"): string {
: "body,title,state,stateDetail,closed,closedAt,merged,mergedAt,mergeCommit,head,base,draft,headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup";
}
function resolveReadViewNumberReference(kind: "issue" | "pr", sub: "read" | "view", raw: string | undefined, options: GitHubOptions, args: string[]): GitHubResolvedNumberReference | GitHubCommandResult {
function resolveReadViewNumberReference(kind: "issue" | "pr", sub: "read" | "view", _raw: string | undefined, options: GitHubOptions, args: string[]): GitHubResolvedNumberReference | GitHubCommandResult {
const command = `${kind} ${sub}`;
const targets = positionalArgs(args.slice(2));
if (targets.length > 1) {
return validationError(command, options.repo, `${command} accepts one number or owner/repo#number target; use --repo owner/name and --number N when passing options first`, {
supportedCommands: [
`bun scripts/cli.ts gh ${kind} read <number> --repo owner/name --json ${readViewSupportedJsonFields(kind)}`,
`bun scripts/cli.ts gh ${kind} read owner/name#<number> --raw`,
],
});
}
const raw = targets[0];
const numberAliasRaw = kind === "pr" ? optionValue(args, "--number") : undefined;
const shorthand = parseOwnerRepoNumberShorthand(raw);
if (shorthand !== null) {
if (numberAliasRaw !== undefined) {
const aliasNumber = parseNumberForCommand(shorthand.repo, numberAliasRaw, command);
if (typeof aliasNumber !== "number") return aliasNumber;
if (aliasNumber !== shorthand.number) {
return validationError(command, shorthand.repo, `${command} target ${shorthand.input} conflicts with --number ${aliasNumber}; use one PR number target.`, {
shorthand,
numberAlias: aliasNumber,
supportedCommands: readViewSupportedCommands(kind, shorthand.repo, shorthand.number),
});
}
}
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.`;
@@ -790,6 +851,31 @@ function resolveReadViewNumberReference(kind: "issue" | "pr", sub: "read" | "vie
}
return { repo: shorthand.repo, number: shorthand.number, shorthand };
}
if (numberAliasRaw !== undefined) {
const aliasNumber = parseNumberForCommand(options.repo, numberAliasRaw, command);
if (typeof aliasNumber !== "number") return {
...aliasNumber,
supportedCommands: [
`bun scripts/cli.ts gh pr read --repo owner/name --number <number> --json ${readViewSupportedJsonFields("pr")}`,
"bun scripts/cli.ts gh pr read owner/name#<number> --raw",
],
};
if (raw !== undefined) {
const parsedRaw = parseNumberForCommand(options.repo, raw, command);
if (typeof parsedRaw !== "number") return parsedRaw;
if (parsedRaw !== aliasNumber) {
return validationError(command, options.repo, `${command} positional number ${parsedRaw} conflicts with --number ${aliasNumber}; use one PR number target.`, {
positionalNumber: parsedRaw,
numberAlias: aliasNumber,
supportedCommands: [
`bun scripts/cli.ts gh pr read ${aliasNumber} --repo ${options.repo} --json ${readViewSupportedJsonFields("pr")}`,
`bun scripts/cli.ts gh pr read --repo ${options.repo} --number ${aliasNumber} --full`,
],
});
}
}
return { repo: options.repo, number: aliasNumber };
}
const parsed = parseNumberForCommand(options.repo, raw, command);
if (typeof parsed !== "number") {
return {
@@ -5521,7 +5607,7 @@ export function ghHelp(): unknown {
output: "json",
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 list [owner/repo] [--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|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]",
@@ -5543,10 +5629,10 @@ 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 preflight <prNumber> [--repo owner/name] [--full|--raw] [compatibility alias for gh pr preflight]",
"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 list [owner/repo] [--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 files <number> [--repo owner/name] [--limit N]",
"bun scripts/cli.ts gh pr diff <number> --stat [--repo owner/name] [--limit N] [compatibility alias for pr files; no raw diff]",
"bun scripts/cli.ts gh pr read <number|owner/repo#number> [--repo owner/name] [--json body,title,state,stateDetail,closed,closedAt,merged,mergedAt,mergeCommit,head,base,draft,headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup] [--raw|--full]",
"bun scripts/cli.ts gh pr read <number|owner/repo#number> [--repo owner/name] [--number N] [--json body,title,state,stateDetail,closed,closedAt,merged,mergedAt,mergeCommit,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 preflight <number> [--repo owner/name] [--full|--raw]",
"bun scripts/cli.ts gh pr closeout <number> [--repo owner/name] [--full|--raw] [compatibility alias for pr preflight]",
@@ -5562,6 +5648,7 @@ export function ghHelp(): unknown {
notes: [
"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 and pr list accept a single positional owner/repo as a compatibility alias for --repo owner/name. The positional repo and --repo must match if both are supplied; non-repo positionals fail structurally instead of falling back to the default repo.",
"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/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.",
@@ -5586,7 +5673,7 @@ export function ghHelp(): unknown {
"comment delete is supported because GitHub supports deleting issue comments; issue/pr hard delete is unsupported and close is the lifecycle alternative.",
"PR files is the canonical compact changed-file/stat summary. It uses GitHub REST, returns bounded file rows, additions/deletions/changes when available, truncation metadata, and a next command for full details. Raw diff patches are not emitted by default; gh pr diff <number> --stat is a compatibility alias for the same JSON summary.",
"PR edit/update PATCHes /repos/{owner}/{repo}/pulls/{number} through REST only, never GitHub Projects Classic GraphQL/projectCards, and returns low-noise JSON with repo, PR number, changedFields, url, and body size/SHA metadata instead of echoing the full body.",
"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 REST closeout fields stateDetail, closed, closedAt, merged, mergedAt, mergeCommit, headRefName, and baseRefName; mergeable, mergeStateStatus, and statusCheckRollup are fetched through GitHub GraphQL only when requested or when --raw/--full requests full disclosure, and closeoutMetadata makes GraphQL errors plus UNKNOWN/null metadata explicit.",
"PR read is the canonical read path; view remains a compatibility alias. PR read/view accept owner/repo#number shorthand and --number N as a compatibility alias for the positional PR number; shorthand derives --repo unless an explicit conflicting --repo is supplied, which fails structurally with suggested commands. PR read/view supports REST closeout fields stateDetail, closed, closedAt, merged, mergedAt, mergeCommit, headRefName, and baseRefName; mergeable, mergeStateStatus, and statusCheckRollup are fetched through GitHub GraphQL only when requested or when --raw/--full requests full disclosure, and closeoutMetadata makes GraphQL errors plus UNKNOWN/null metadata explicit.",
"PR list does not fetch mergeability or statusCheckRollup; request those closeout fields with gh pr view <number> --json headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup.",
"PR preflight is a low-noise read-only closeout helper. It combines redacted auth capability, PR branch/state metadata, mergeability, mergeStateStatus, compact status check counts, and the explicit UniDesk REST CLI no-merge policy. Use --full or --raw to include all fetched status contexts.",
"PR create/edit/update/comment are safe-write operations with dry-run planning; merge is intentionally unsupported in this phase.",
@@ -5644,6 +5731,15 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
],
});
}
if (optionWasProvided(args, "--number") && !(top === "pr" && isPrReadCommand(sub))) {
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
return validationError(command, options.repo, "--number is only supported by gh pr read/view; use gh pr read --repo owner/name --number N.", {
supportedCommands: [
"bun scripts/cli.ts gh pr read --repo owner/name --number <number> --full",
"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" || sub === "edit")))) {
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/edit");
+40 -3
View File
@@ -34,13 +34,50 @@ export function emitJson<T>(command: string, data: T, ok = true): void {
}
export function emitError(command: string, error: unknown): void {
const payload = error instanceof Error
? { name: error.name, message: error.message, stack: error.stack ?? null }
: { message: String(error) };
const payload = normalizeErrorPayload(command, error);
const envelope: JsonEnvelope<never> = { ok: false, command, error: payload };
safeStdoutWrite(renderEnvelope(command, envelope));
}
function normalizeErrorPayload(command: string, error: unknown): Record<string, unknown> {
const message = error instanceof Error ? error.message : String(error);
const parsed = parseStructuredErrorMessage(command, message);
if (parsed !== null) return parsed;
if (shouldSuppressStack(command) || shouldSuppressStack(commandPrefixFromMessage(message))) {
return { message };
}
if (error instanceof Error) {
const debug = process.env.UNIDESK_CLI_DEBUG === "1" || process.env.UNIDESK_CLI_FULL_ERROR === "1" || process.env.UNIDESK_CLI_RAW_ERROR === "1";
return { name: error.name, message, stack: error.stack ?? null, ...(debug ? { debug: true } : {}) };
}
return { message };
}
function parseStructuredErrorMessage(command: string, message: string): Record<string, unknown> | null {
if (!shouldSuppressStack(command) && !shouldSuppressStack(commandPrefixFromMessage(message))) return null;
const jsonStart = message.indexOf("{");
if (jsonStart === -1) return null;
try {
const parsed = JSON.parse(message.slice(jsonStart)) as unknown;
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
const record = parsed as Record<string, unknown>;
return { message: message.slice(0, jsonStart).trim().replace(/[;:]$/u, ""), ...record };
}
} catch {
return null;
}
return null;
}
function commandPrefixFromMessage(message: string): string {
return message.split(/\s+/u).slice(0, 2).join(" ");
}
function shouldSuppressStack(prefix: string): boolean {
if (process.env.UNIDESK_CLI_DEBUG === "1" || process.env.UNIDESK_CLI_FULL_ERROR === "1" || process.env.UNIDESK_CLI_RAW_ERROR === "1") return false;
return prefix === "codex tasks" || prefix.startsWith("codex tasks ") || prefix === "codex unread" || prefix.startsWith("codex unread ");
}
function renderEnvelope<T>(command: string, envelope: JsonEnvelope<T>): string {
const fullText = `${JSON.stringify(envelope, null, 2)}\n`;
if (!shouldDumpLargeOutput(command, fullText)) return fullText;