// SPEC: PJ2026-010606 GitHub入口 draft-2026-06-25-gh-split // Moved mechanically from scripts/src/gh.ts:980-1344. import { validationError } from "./client"; import { optionValue, optionWasProvided, positionalArgs } from "./options"; import { ISSUE_VIEW_JSON_FIELDS, PR_READ_JSON_FIELDS } from "./types"; import type { GitHubCommandResult, GitHubOptions, GitHubResolvedNumberReference, GitHubShorthandReference, IssueViewJsonField, PrReadJsonField } from "./types"; export function parseNumber(raw: string | undefined, label: string): number { if (raw === undefined) throw new Error(`${label} requires a number`); const value = Number(raw); if (!Number.isInteger(value) || value <= 0) throw new Error(`${label} must be a positive integer`); return value; } export function parseNumberForCommand(repo: string, raw: string | undefined, label: string): number | GitHubCommandResult { try { return parseNumber(raw, label); } catch (error) { return validationError(label, repo, error instanceof Error ? error.message : String(error)); } } export 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); } export function resolvedNumberOptionReference(kind: "issue" | "pr", command: string, repo: string, raw: string): GitHubResolvedNumberReference | GitHubCommandResult { const number = parseNumberForCommand(repo, raw, command); if (typeof number !== "number") return number; return { repo, number, shorthand: { input: `--number ${number}`, repo, number, source: "number-option", standardCommand: `bun scripts/cli.ts gh ${command} ${number} --repo ${repo}`, urlKind: kind, }, }; } export function resolvePositionalNumberReference(kind: "issue" | "pr", args: string[], startIndex: number, label: string, options: GitHubOptions): GitHubResolvedNumberReference | GitHubCommandResult { const targets = positionalArgs(args.slice(startIndex)); const numberOption = optionValue(args, "--number"); if (targets.length > 0 && numberOption !== undefined) { return validationError(label, options.repo, `${label} accepts either a positional numeric target or --number, not both`, { supportedCommands: [ `bun scripts/cli.ts gh ${label} --repo ${options.repo}`, `bun scripts/cli.ts gh ${label} --number --repo ${options.repo}`, `bun scripts/cli.ts gh ${label} ${options.repo}#`, ], }); } if (numberOption !== undefined) return resolvedNumberOptionReference(kind, label, options.repo, numberOption); if (targets.length !== 1) { return validationError(label, options.repo, `${label} requires exactly one positive integer or owner/repo#number positional argument`, { supportedCommands: [ `bun scripts/cli.ts gh ${label} --repo ${options.repo}`, `bun scripts/cli.ts gh ${label} --number --repo ${options.repo}`, `bun scripts/cli.ts gh ${label} ${options.repo}#`, ], }); } const shorthand = parseOwnerRepoNumberShorthand(targets[0]); if (shorthand !== null) { const explicitRepo = optionValue(args, "--repo"); if (explicitRepo !== undefined && explicitRepo !== shorthand.repo) { return validationError(label, explicitRepo, `${label} target ${shorthand.input} resolves to repo ${shorthand.repo}, but --repo ${explicitRepo} was also provided.`, { shorthand, explicitRepo, }); } return { repo: shorthand.repo, number: shorthand.number, shorthand }; } const number = parseNumberForCommand(options.repo, targets[0], label); if (typeof number !== "number") return number; return { repo: options.repo, number }; } export function resolvePositionalPrReference(args: string[], startIndex: number, label: string, options: GitHubOptions): GitHubResolvedNumberReference | GitHubCommandResult { const targets = positionalArgs(args.slice(startIndex)); const prOption = optionValue(args, "--pr"); const numberOption = optionValue(args, "--number"); if (prOption !== undefined && numberOption !== undefined) { return validationError(label, options.repo, `${label} accepts either --pr or --number as a compatibility alias, not both`, { supportedCommands: [ `bun scripts/cli.ts gh ${label} --repo ${options.repo}`, `bun scripts/cli.ts gh ${label} --number --repo ${options.repo}`, `bun scripts/cli.ts gh ${label} ${options.repo}#`, ], }); } if (targets.length > 0 && (prOption !== undefined || numberOption !== undefined)) { return validationError(label, options.repo, `${label} accepts either a positional PR target or a compatibility number option, not both`, { supportedCommands: [ `bun scripts/cli.ts gh ${label} --repo ${options.repo}`, `bun scripts/cli.ts gh ${label} --pr --repo ${options.repo}`, `bun scripts/cli.ts gh ${label} --number --repo ${options.repo}`, `bun scripts/cli.ts gh ${label} ${options.repo}#`, ], }); } if (numberOption !== undefined) return resolvedNumberOptionReference("pr", label, options.repo, numberOption); const effectiveTargets = prOption !== undefined ? [prOption] : targets; if (effectiveTargets.length !== 1) { return validationError(label, options.repo, `${label} requires exactly one positive integer or owner/repo#number positional argument`, { supportedCommands: [ `bun scripts/cli.ts gh ${label} --repo ${options.repo}`, `bun scripts/cli.ts gh ${label} --pr --repo ${options.repo}`, `bun scripts/cli.ts gh ${label} --number --repo ${options.repo}`, `bun scripts/cli.ts gh ${label} ${options.repo}#`, ], }); } const shorthand = parseOwnerRepoNumberShorthand(effectiveTargets[0]); if (shorthand !== null) { const explicitRepo = optionValue(args, "--repo"); if (explicitRepo !== undefined && explicitRepo !== shorthand.repo) { return validationError(label, explicitRepo, `${label} target ${shorthand.input} resolves to repo ${shorthand.repo}, but --repo ${explicitRepo} was also provided.`, { shorthand, explicitRepo, }); } return { repo: shorthand.repo, number: shorthand.number, shorthand }; } const number = parseNumberForCommand(options.repo, effectiveTargets[0], label); if (typeof number !== "number") return number; return { repo: options.repo, number }; } export function resolvePositionalIssueReference(args: string[], startIndex: number, label: string, options: GitHubOptions, allowNumberOption = true): GitHubResolvedNumberReference | GitHubCommandResult { const targets = positionalArgs(args.slice(startIndex)); const numberOption = allowNumberOption ? optionValue(args, "--number") : undefined; if (targets.length > 0 && numberOption !== undefined) { return validationError(label, options.repo, `${label} accepts either a positional issue target or --number, not both`, { supportedCommands: [ `bun scripts/cli.ts gh ${label} --repo ${options.repo}`, `bun scripts/cli.ts gh ${label} --number --repo ${options.repo}`, `bun scripts/cli.ts gh ${label} ${options.repo}#`, ], }); } if (numberOption !== undefined) return resolvedNumberOptionReference("issue", label, options.repo, numberOption); if (targets.length !== 1) { return validationError(label, options.repo, `${label} requires exactly one positive integer or owner/repo#number positional argument`, { supportedCommands: [ `bun scripts/cli.ts gh ${label} --repo ${options.repo}`, ...(allowNumberOption ? [`bun scripts/cli.ts gh ${label} --number --repo ${options.repo}`] : []), `bun scripts/cli.ts gh ${label} ${options.repo}#`, ], }); } const shorthand = parseOwnerRepoNumberShorthand(targets[0]); if (shorthand !== null) { const explicitRepo = optionValue(args, "--repo"); if (explicitRepo !== undefined && explicitRepo !== shorthand.repo) { return validationError(label, explicitRepo, `${label} target ${shorthand.input} resolves to repo ${shorthand.repo}, but --repo ${explicitRepo} was also provided.`, { shorthand, explicitRepo, }); } return { repo: shorthand.repo, number: shorthand.number, shorthand }; } const number = parseNumberForCommand(options.repo, targets[0], label); if (typeof number !== "number") return number; return { repo: options.repo, number }; } export function parseOwnerRepoNumberShorthand(raw: string | undefined): GitHubShorthandReference | null { if (raw === undefined) return null; const match = /^([^/#\s]+)\/([^/#\s]+)#([1-9]\d*)$/u.exec(raw); if (match === null) return null; return { input: raw, repo: `${match[1]}/${match[2]}`, number: Number(match[3]), source: "owner-repo-number", }; } export function parseGitHubIssueOrPrUrl(raw: string | undefined): GitHubShorthandReference | null { if (raw === undefined) return null; let parsed: URL; try { parsed = new URL(raw); } catch { return null; } if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return null; if (parsed.hostname.toLowerCase() !== "github.com" && parsed.hostname.toLowerCase() !== "www.github.com") return null; const parts = parsed.pathname.split("/").filter((part) => part.length > 0); if (parts.length < 4) return null; const [owner, repoName, kind, numberRaw] = parts; if (!owner || !repoName || (kind !== "issues" && kind !== "pull")) return null; if (!/^[1-9]\d*$/u.test(numberRaw ?? "")) return null; return { input: raw, repo: `${owner}/${repoName}`, number: Number(numberRaw), source: "github-url", urlKind: kind === "issues" ? "issue" : "pr", }; } export function parseReadViewTarget(raw: string | undefined): GitHubShorthandReference | null { return parseOwnerRepoNumberShorthand(raw) ?? parseGitHubIssueOrPrUrl(raw); } export function readViewSupportedCommands(kind: "issue" | "pr", repo: string, number: number): string[] { return [ `bun scripts/cli.ts gh ${kind} view ${number} --repo ${repo} --json ${readViewSupportedJsonFields(kind)}`, `bun scripts/cli.ts gh ${kind} view ${repo}#${number} --raw`, `bun scripts/cli.ts gh ${kind} read ${number} --repo ${repo} --json ${readViewSupportedJsonFields(kind)} [compatibility alias]`, ]; } export function readViewSupportedJsonFields(kind: "issue" | "pr"): string { return kind === "issue" ? ISSUE_VIEW_JSON_FIELDS.join(",") : "body,title,state,stateDetail,closed,closedAt,merged,mergedAt,mergeCommit,head,base,draft,headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup"; } export 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 positional target: number, GitHub ${kind} URL, or owner/repo#number`, { supportedCommands: [ `bun scripts/cli.ts gh ${kind} view --repo owner/name --json ${readViewSupportedJsonFields(kind)}`, `bun scripts/cli.ts gh ${kind} view https://github.com/owner/name/${kind === "issue" ? "issues" : "pull"}/ --raw`, `bun scripts/cli.ts gh ${kind} view owner/name# --raw`, ], }); } const raw = targets[0]; if (optionWasProvided(args, "--number")) { const numberAliasRaw = optionValue(args, "--number"); const aliasNumber = parseNumberForCommand(options.repo, numberAliasRaw, command); if (typeof aliasNumber !== "number") return aliasNumber; 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 target number.`, { positionalNumber: parsedRaw, numberAlias: aliasNumber, supportedCommands: readViewSupportedCommands(kind, options.repo, aliasNumber), }); } } return { repo: options.repo, number: aliasNumber, shorthand: { input: `--number ${aliasNumber}`, repo: options.repo, number: aliasNumber, source: "number-option", standardCommand: `bun scripts/cli.ts gh ${kind} view ${aliasNumber} --repo ${options.repo}`, }, }; } const shorthand = parseReadViewTarget(raw); if (shorthand !== null) { if (shorthand.urlKind !== undefined && shorthand.urlKind !== kind) { return validationError(command, shorthand.repo, `${command} target ${shorthand.input} is a GitHub ${shorthand.urlKind} URL, not a ${kind} URL.`, { shorthand, supportedCommands: readViewSupportedCommands(shorthand.urlKind, 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.`; return validationError(command, explicitRepo, message, { message, shorthand, explicitRepo, supportedCommands: readViewSupportedCommands(kind, shorthand.repo, shorthand.number), }); } return { repo: shorthand.repo, number: shorthand.number, shorthand }; } const parsed = parseNumberForCommand(options.repo, raw, command); if (typeof parsed !== "number") { return { ...parsed, supportedCommands: [ `bun scripts/cli.ts gh ${kind} view --repo owner/name --json ${readViewSupportedJsonFields(kind)}`, `bun scripts/cli.ts gh ${kind} view https://github.com/owner/name/${kind === "issue" ? "issues" : "pull"}/ --raw`, `bun scripts/cli.ts gh ${kind} view owner/name# --raw`, ], }; } return { repo: options.repo, number: parsed }; } export function issueReadJsonFields(options: GitHubOptions): IssueViewJsonField[] | undefined { return options.jsonFields ?? (options.raw || options.full ? ISSUE_VIEW_JSON_FIELDS.slice() : undefined); } export function prReadJsonFields(options: GitHubOptions): PrReadJsonField[] | undefined { return options.prJsonFields ?? (options.raw || options.full ? PR_READ_JSON_FIELDS.slice() : undefined); } export function readDisclosureOptions(options: GitHubOptions, shorthand: GitHubShorthandReference | undefined): Record | null { if (!options.raw && !options.full && shorthand === undefined) return null; return { ...(options.raw ? { raw: true } : {}), ...(options.full ? { full: true } : {}), fullDisclosure: options.raw || options.full, shorthand: shorthand ?? null, ...(shorthand?.source === "number-option" ? { compatibilityHint: "--number is accepted for low-friction compatibility; standard gh syntax uses a positional number or URL.", standardCommand: shorthand.standardCommand, } : {}), }; } export function numberOptionCompatibilityHint(resolved: GitHubResolvedNumberReference): Record | null { if (resolved.shorthand?.source !== "number-option") return null; return { acceptedOption: "--number", compatibility: true, message: "--number is accepted for low-friction compatibility; prefer the standard positional number target shown in standardCommand.", standardCommand: resolved.shorthand.standardCommand ?? null, }; } export async function withNumberOptionHint(result: GitHubCommandResult | Promise, resolved: GitHubResolvedNumberReference): Promise { const output = await result; const hint = numberOptionCompatibilityHint(resolved); if (hint === null) return output; return { ...output, standardSyntaxHint: hint, }; } export function isGitHubCommandResult(value: unknown): value is GitHubCommandResult { return typeof (value as { ok?: unknown }).ok === "boolean"; } export function unknownGhOptionDetails(args: string[], option: string): Record { const [top, sub, third] = args; const details: Record = { unsupportedOption: option, helpCommand: "bun scripts/cli.ts gh help", }; if ((top === "issue" || top === "pr") && (sub === "read" || sub === "view")) { const shorthand = parseReadViewTarget(third); const repo = shorthand?.repo ?? optionValue(args, "--repo") ?? "owner/name"; const number = shorthand?.number ?? (third !== undefined && /^\d+$/u.test(third) ? Number(third) : 0); details.supportedCommands = number > 0 ? readViewSupportedCommands(top, repo, number) : [ `bun scripts/cli.ts gh ${top} view --repo owner/name --json ${readViewSupportedJsonFields(top)}`, `bun scripts/cli.ts gh ${top} view owner/name# --raw`, ]; } return details; }