diff --git a/scripts/gh-cli-pr-contract-test.ts b/scripts/gh-cli-pr-contract-test.ts index e91ccbdd..0e4cbe81 100644 --- a/scripts/gh-cli-pr-contract-test.ts +++ b/scripts/gh-cli-pr-contract-test.ts @@ -517,6 +517,12 @@ export async function runGhCliPrContract(): Promise { assertCondition(aliasPreflightData.command === "preflight", "top-level gh preflight should report alias command", aliasPreflightData); assertCondition((aliasPreflightData.policy as JsonRecord).mergesPr === false, "top-level gh preflight alias must not merge", aliasPreflightData); + const optionsFirstPreflight = await runCli(["gh", "pr", "preflight", "--repo", "pikasTech/unidesk", "42"], env); + assertCondition(optionsFirstPreflight.status === 0, "pr preflight should accept PR number after --repo", optionsFirstPreflight.json ?? { stdout: optionsFirstPreflight.stdout }); + const optionsFirstPreflightData = dataOf(optionsFirstPreflight.json ?? {}); + assertCondition(optionsFirstPreflightData.command === "pr preflight", "options-first pr preflight should report command", optionsFirstPreflightData); + assertCondition((optionsFirstPreflightData.pullRequest as JsonRecord).number === 42, "options-first pr preflight should read the requested PR", optionsFirstPreflightData); + const fullPreflight = await runCli(["gh", "pr", "preflight", "42", "--repo", "pikasTech/unidesk", "--full"], env); assertCondition(fullPreflight.status === 0, "pr preflight --full should succeed", fullPreflight.json ?? { stdout: fullPreflight.stdout }); const fullPreflightData = dataOf(fullPreflight.json ?? {}); @@ -736,6 +742,7 @@ export async function runGhCliPrContract(): Promise { "pr read unsupported fields fail structurally with supported closeout fields listed", "pr preflight exposes redacted auth plus compact merge/status closeout metadata", "top-level gh preflight alias works for commander closeout", + "pr preflight accepts the PR number after options", "pr preflight --full is the explicit status-context disclosure path", "pr create dry-run exposes planned operation", "pr comment dry-run preserves markdown text", diff --git a/scripts/gh-cli-pr-files-contract-test.ts b/scripts/gh-cli-pr-files-contract-test.ts index cd995870..43b7be84 100644 --- a/scripts/gh-cli-pr-files-contract-test.ts +++ b/scripts/gh-cli-pr-files-contract-test.ts @@ -196,6 +196,14 @@ export async function runGhCliPrFilesContract(): Promise { assertCondition(mock.requests.some((request) => request.method === "GET" && request.url === "/repos/pikasTech/unidesk/pulls/42/files?per_page=1&page=1"), "files endpoint should use bounded per_page", mock.requests); checks.push("gh pr files returns bounded REST file/stat JSON without raw patches"); + mock.requests.length = 0; + const filesOptionsFirst = await runCli(["gh", "pr", "files", "--repo", "pikasTech/unidesk", "--limit", "1", "42"], env); + assertCondition(filesOptionsFirst.status === 0, "gh pr files should accept PR number after options", filesOptionsFirst.json ?? { stdout: filesOptionsFirst.stdout, stderr: filesOptionsFirst.stderr }); + const filesOptionsFirstData = dataOf(filesOptionsFirst.json ?? {}); + assertCondition(filesOptionsFirstData.command === "pr files" && filesOptionsFirstData.filesReturned === 1, "options-first pr files should return compact file summary", filesOptionsFirstData); + assertCondition(mock.requests.some((request) => request.method === "GET" && request.url === "/repos/pikasTech/unidesk/pulls/42/files?per_page=1&page=1"), "options-first pr files should query the requested PR number", mock.requests); + checks.push("gh pr files accepts the PR number after options"); + mock.requests.length = 0; const stat = await runCli(["gh", "pr", "diff", "42", "--stat", "--repo", "pikasTech/unidesk", "--limit", "2"], env); assertCondition(stat.status === 0, "gh pr diff --stat should succeed", stat.json ?? { stdout: stat.stdout, stderr: stat.stderr }); @@ -207,6 +215,14 @@ export async function runGhCliPrFilesContract(): Promise { assertCondition(statRows.every((file) => file.patch === undefined), "diff --stat file rows must not include patch", statRows); checks.push("gh pr diff --stat is a compact summary alias"); + mock.requests.length = 0; + const statOptionsFirst = await runCli(["gh", "pr", "diff", "--repo", "pikasTech/unidesk", "--stat", "--limit", "2", "42"], env); + assertCondition(statOptionsFirst.status === 0, "gh pr diff --stat should accept PR number after options", statOptionsFirst.json ?? { stdout: statOptionsFirst.stdout, stderr: statOptionsFirst.stderr }); + const statOptionsFirstData = dataOf(statOptionsFirst.json ?? {}); + assertCondition(statOptionsFirstData.command === "pr diff --stat" && statOptionsFirstData.filesReturned === 2, "options-first pr diff --stat should return compact file summary", statOptionsFirstData); + assertCondition(mock.requests.some((request) => request.method === "GET" && request.url === "/repos/pikasTech/unidesk/pulls/42/files?per_page=2&page=1"), "options-first pr diff --stat should query the requested PR number", mock.requests); + checks.push("gh pr diff --stat accepts the PR number after options"); + const rawDiff = await runCli(["gh", "pr", "diff", "42", "--repo", "pikasTech/unidesk"], env); assertCondition(rawDiff.status !== 0, "gh pr diff without --stat should fail closed", rawDiff.json ?? { stdout: rawDiff.stdout, stderr: rawDiff.stderr }); const rawData = failedDataOf(rawDiff.json ?? {}); diff --git a/scripts/src/gh.ts b/scripts/src/gh.ts index 6542395d..b2efc06e 100644 --- a/scripts/src/gh.ts +++ b/scripts/src/gh.ts @@ -801,6 +801,19 @@ function parseNumberForCommand(repo: string, raw: string | undefined, label: str } } +function parsePositionalNumberForCommand(repo: string, args: string[], startIndex: number, label: string): number | GitHubCommandResult { + const targets = positionalArgs(args.slice(startIndex)); + if (targets.length !== 1) { + return validationError(label, repo, `${label} requires exactly one positive integer positional argument`, { + supportedCommands: [ + `bun scripts/cli.ts gh ${label} --repo ${repo}`, + `bun scripts/cli.ts gh ${label} --repo ${repo} `, + ], + }); + } + return parseNumberForCommand(repo, targets[0], label); +} + function parseOwnerRepoNumberShorthand(raw: string | undefined): GitHubShorthandReference | null { if (raw === undefined) return null; const match = /^([^/#\s]+)\/([^/#\s]+)#([1-9]\d*)$/u.exec(raw); @@ -5900,12 +5913,12 @@ export function ghHelp(): unknown { "bun scripts/cli.ts gh issue board-row delete [--repo owner/name] --board-issue 20 [--dry-run] [--expect-body-sha sha256]", "bun scripts/cli.ts gh preflight [--repo owner/name] [--full|--raw] [compatibility alias for gh pr preflight]", "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 [--repo owner/name] [--limit N]", - "bun scripts/cli.ts gh pr diff --stat [--repo owner/name] [--limit N] [compatibility alias for pr files; no raw diff]", + "bun scripts/cli.ts gh pr files [--repo owner/name] [--limit N] [number may appear before or after options]", + "bun scripts/cli.ts gh pr diff --stat [--repo owner/name] [--limit N] [number may appear before or after options; compatibility alias for pr files; no raw diff]", "bun scripts/cli.ts gh pr read [--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 [--repo owner/name] [--raw|--full] [compatibility alias for pr read]", - "bun scripts/cli.ts gh pr preflight [--repo owner/name] [--full|--raw]", - "bun scripts/cli.ts gh pr closeout [--repo owner/name] [--full|--raw] [compatibility alias for pr preflight]", + "bun scripts/cli.ts gh pr preflight [--repo owner/name] [--full|--raw] [number may appear before or after options]", + "bun scripts/cli.ts gh pr closeout [--repo owner/name] [--full|--raw] [number may appear before or after options; compatibility alias for pr preflight]", "bun scripts/cli.ts gh pr create --title --body-file <file>|--body <text> --base <branch> --head <branch> [--repo owner/name] [--draft] [--dry-run]", "bun scripts/cli.ts gh pr edit <number> [--title title] [--body-file <file>|--body-file -|--body <text>] [--repo owner/name] [--dry-run]", "bun scripts/cli.ts gh pr update <number> --mode replace|append [--body-file <file>|--body-file -|--body <text>] [--title title] [--repo owner/name] [--dry-run]", @@ -6060,7 +6073,7 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult if (top === "auth" && sub === "status") return authStatus(options.repo); if (top === "preflight") { - const number = parseNumberForCommand(options.repo, sub, "preflight"); + const number = parsePositionalNumberForCommand(options.repo, args, 1, "preflight"); if (typeof number !== "number") return number; return prPreflight(options.repo, number, "preflight", options.full || options.raw); } @@ -6158,7 +6171,7 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult if (top === "pr") { if (sub === "diff") { - const number = parseNumberForCommand(options.repo, third, "pr diff"); + const number = parsePositionalNumberForCommand(options.repo, args, 2, "pr diff"); if (typeof number !== "number") return number; if (!optionWasProvided(args, "--stat")) { return unsupportedCommand("pr diff", options.repo, "Raw PR diff output is intentionally unsupported by UniDesk CLI; use gh pr diff <number> --stat or gh pr files for a bounded REST file/stat summary.", { @@ -6175,7 +6188,7 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult return prFiles(options.repo, token, number, options.limit, "pr diff --stat"); } if (sub === "files") { - const number = parseNumberForCommand(options.repo, third, "pr files"); + const number = parsePositionalNumberForCommand(options.repo, args, 2, "pr files"); if (typeof number !== "number") return number; const { token, probe } = resolveToken(true); const missing = authRequired(options.repo, "pr files", probe); @@ -6184,7 +6197,7 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult } if (sub === "delete") return unsupportedCommand("pr delete", options.repo, "GitHub REST does not support hard-deleting pull requests; use gh pr close for lifecycle deletion semantics."); if (sub === "preflight" || sub === "closeout") { - const number = parseNumberForCommand(options.repo, third, `pr ${sub}`); + const number = parsePositionalNumberForCommand(options.repo, args, 2, `pr ${sub}`); if (typeof number !== "number") return number; return prPreflight(options.repo, number, sub === "closeout" ? "pr closeout" : "pr preflight", options.full || options.raw); }