fix: 修复 CLI repo 与状态别名解析
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user