// SPEC: PJ2026-010606 GitHub入口 draft-2026-06-25-gh-split // Moved mechanically from scripts/src/gh.ts:614-978. import { BOARD_GITHUB_STATUSES, BOARD_MUTATION_SECTIONS, BOARD_ROW_FIELDS, BODY_UPDATE_MODES, CODE_QUEUE_BOARD_TARGET_ISSUE, DEFAULT_REPO, DEFAULT_STALE_CLOSE_INACTIVE_HOURS, GH_FLAG_OPTIONS, GH_VALUE_OPTIONS, ISSUE_LIST_JSON_FIELDS, ISSUE_LIST_STATES, ISSUE_VIEW_JSON_FIELDS, MAX_ISSUE_LIST_LIMIT, MAX_PR_FILES_LIMIT, MAX_STALE_CLOSE_INACTIVE_HOURS, PR_CLOSEOUT_JSON_FIELDS, PR_CLOSEOUT_VIEW_JSON, PR_LIST_JSON_FIELDS, PR_READ_JSON_FIELDS } from "./types"; import type { BoardGithubStatus, BoardMutationSection, BoardRowField, BoardRowUpsertValues, BodyUpdateMode, GitHubOptions, IssueBodyProfileOption, IssueListJsonField, IssueListState, IssueViewJsonField, PrListJsonField, PrListState, PrReadJsonField, PullRequestMergeMethod, RepoVisibility } from "./types"; export function optionValue(args: string[], name: string): string | undefined { const index = args.indexOf(name); if (index === -1) return undefined; const value = args[index + 1]; if (value === undefined || value.length === 0 || value.startsWith("--")) throw new Error(`${name} requires a value`); return value; } export function optionValues(args: string[], name: string): string[] { const values: string[] = []; for (let index = 0; index < args.length; index += 1) { if (args[index] !== name) continue; const value = args[index + 1]; if (value === undefined || value.length === 0 || value.startsWith("--")) throw new Error(`${name} requires a value`); values.push(value); index += 1; } return values; } export function hasFlag(args: string[], name: string): boolean { return args.includes(name); } export function optionWasProvided(args: string[], name: string): boolean { return args.includes(name); } export function commaListOption(args: string[], name: string): string[] | undefined { const raw = optionValue(args, name); if (raw === undefined) return undefined; const values = raw.split(",").map((value) => value.trim()).filter((value) => value.length > 0); if (values.length === 0) throw new Error(`${name} requires at least one comma-separated field`); return values; } export function labelsOption(args: string[]): string[] { const rawValues = optionValues(args, "--label"); const labels: string[] = []; const seen = new Set(); for (const raw of rawValues) { const parts = raw.split(",").map((value) => value.trim()).filter((value) => value.length > 0); if (parts.length === 0) throw new Error("--label requires at least one non-empty label"); for (const label of parts) { if (seen.has(label)) continue; labels.push(label); seen.add(label); } } return labels; } export function positiveIntegerValuesOption(args: string[], name: string): number[] { const values: number[] = []; const seen = new Set(); for (const raw of optionValues(args, name)) { const parts = raw.split(",").map((value) => value.trim()).filter((value) => value.length > 0); if (parts.length === 0) throw new Error(`${name} requires at least one positive integer`); for (const part of parts) { const value = Number(part.replace(/^#/u, "")); if (!Number.isInteger(value) || value <= 0) throw new Error(`${name} values must be positive issue numbers`); if (seen.has(value)) continue; values.push(value); seen.add(value); } } return values; } export function positiveIntegerSingleOption(args: string[], name: string, defaultValue: number): number { const raw = optionValue(args, name); if (raw === undefined) return defaultValue; const value = Number(raw.replace(/^#/u, "")); if (!Number.isInteger(value) || value <= 0) throw new Error(`${name} must be a positive issue number`); return value; } export function positiveIntegerOption(args: string[], name: string, defaultValue: number, maxValue: number): number { const raw = optionValue(args, name); if (raw === undefined) return defaultValue; const value = Number(raw); if (!Number.isInteger(value) || value <= 0) throw new Error(`${name} must be a positive integer`); return Math.min(value, maxValue); } export function positiveNumberOption(args: string[], name: string, defaultValue: number, maxValue: number): number { const raw = optionValue(args, name); if (raw === undefined) return defaultValue; const value = Number(raw); if (!Number.isFinite(value) || value <= 0) throw new Error(`${name} must be a positive number`); return Math.min(value, maxValue); } export function validateEnumValue(name: string, raw: string, allowedValues: readonly T[]): T { if ((allowedValues as readonly string[]).includes(raw)) return raw as T; throw new Error(`unsupported ${name} ${raw}; supported values: ${allowedValues.join(",")}`); } export function validateJsonFields(command: string, requested: string[] | undefined, allowedFields: readonly T[]): T[] | undefined { if (requested === undefined) return undefined; const allowed = new Set(allowedFields); const unsupported = requested.filter((field) => !allowed.has(field)); if (unsupported.length > 0) { throw new Error(`unsupported ${command} --json field(s): ${unsupported.join(", ")}; supported fields: ${allowedFields.join(",")}`); } return requested as T[]; } export function parseIssueViewJsonFields(requested: string[] | undefined): IssueViewJsonField[] | undefined { return validateJsonFields("gh issue read/view", requested, ISSUE_VIEW_JSON_FIELDS); } export function parseIssueListJsonFields(requested: string[] | undefined): IssueListJsonField[] | undefined { return validateJsonFields("gh issue list", requested, ISSUE_LIST_JSON_FIELDS); } export function parsePrListJsonFields(requested: string[] | undefined): PrListJsonField[] | undefined { if (requested !== undefined) { const listFields = new Set(PR_LIST_JSON_FIELDS); const closeoutFields = requested.filter((field) => (PR_CLOSEOUT_JSON_FIELDS as readonly string[]).includes(field)); const unsupported = requested.filter((field) => !listFields.has(field)); if (closeoutFields.length > 0) { throw new Error(`unsupported gh pr list --json closeout field(s): ${closeoutFields.join(", ")}; use gh pr view --json ${PR_CLOSEOUT_VIEW_JSON} for mergeability/statusCheckRollup metadata; pr list supported fields: ${PR_LIST_JSON_FIELDS.join(",")}`); } if (unsupported.length > 0) { throw new Error(`unsupported gh pr list --json field(s): ${unsupported.join(", ")}; supported fields: ${PR_LIST_JSON_FIELDS.join(",")}; closeout metadata requires gh pr view --json ${PR_CLOSEOUT_VIEW_JSON}`); } } return validateJsonFields("gh pr list", requested, PR_LIST_JSON_FIELDS); } export function parsePrReadJsonFields(requested: string[] | undefined): PrReadJsonField[] | undefined { return validateJsonFields("gh pr read/view", requested, PR_READ_JSON_FIELDS); } export function isIssueReadCommand(sub: string | undefined): boolean { return sub === "read" || sub === "view"; } export function isPrReadCommand(sub: string | undefined): boolean { return sub === "read" || sub === "view"; } export function allowsNumberTargetAlias(top: string | undefined, sub: string | undefined, third: string | undefined): boolean { if (top === "preflight") return true; if (top === "issue") { if (sub === "read" || sub === "view" || sub === "edit" || sub === "update" || sub === "patch" || sub === "close" || sub === "reopen" || sub === "delete") return true; if (sub === "comment") return true; if (sub === "attachment" && (third === "list" || third === "download")) return true; if (sub === "board-row" && ["get", "update", "add", "move", "delete", "upsert"].includes(third ?? "")) return true; return false; } if (top === "pr") { if (sub === "read" || sub === "view" || sub === "files" || sub === "diff" || sub === "preflight" || sub === "closeout" || sub === "edit" || sub === "update" || sub === "close" || sub === "reopen" || sub === "merge" || sub === "delete") return true; if (sub === "comment") return true; } return false; } export function parseIssueListState(args: string[]): IssueListState { const raw = optionValue(args, "--state") ?? "open"; if ((ISSUE_LIST_STATES as readonly string[]).includes(raw)) return raw as IssueListState; throw new Error(`unsupported gh issue list --state ${raw}; supported states: ${ISSUE_LIST_STATES.join(",")}`); } export function parsePrListState(args: string[]): PrListState { const raw = optionValue(args, "--state") ?? "all"; if ((ISSUE_LIST_STATES as readonly string[]).includes(raw)) return raw as PrListState; throw new Error(`unsupported gh pr list --state ${raw}; supported states: ${ISSUE_LIST_STATES.join(",")}`); } export function parseBodyUpdateMode(args: string[]): BodyUpdateMode { const raw = optionValue(args, "--mode") ?? "replace"; if ((BODY_UPDATE_MODES as readonly string[]).includes(raw)) return raw as BodyUpdateMode; throw new Error(`unsupported --mode ${raw}; supported modes: ${BODY_UPDATE_MODES.join(",")}`); } export function parsePullRequestMergeMethod(args: string[]): PullRequestMergeMethod { const selected = ["merge", "squash", "rebase"].filter((method) => hasFlag(args, `--${method}`)); if (selected.length > 1) throw new Error("choose only one PR merge method flag: --merge, --squash, or --rebase"); return (selected[0] ?? "merge") as PullRequestMergeMethod; } export function parseIssueBodyProfile(args: string[]): IssueBodyProfileOption { const raw = optionValue(args, "--body-profile") ?? "auto"; if (raw === "auto" || raw === "code-queue-board" || raw === "commander-brief") return raw; throw new Error(`unsupported --body-profile ${raw}; supported profiles: auto, code-queue-board, commander-brief`); } export function parseBoardMutationSection(args: string[], name: "--section" | "--to"): BoardMutationSection | undefined { const raw = optionValue(args, name); if (raw === undefined) return undefined; return validateEnumValue(name, raw, BOARD_MUTATION_SECTIONS); } export function parseBoardGithubStatus(args: string[]): BoardGithubStatus | undefined { const raw = optionValue(args, "--status"); if (raw === undefined) return undefined; return validateEnumValue("--status", raw, BOARD_GITHUB_STATUSES); } export function parseBoardRowField(args: string[]): BoardRowField | undefined { const raw = optionValue(args, "--field"); if (raw === undefined) return undefined; return validateEnumValue("--field", raw, BOARD_ROW_FIELDS); } export function parseBoardRowUpsertValues(args: string[]): BoardRowUpsertValues { return { category: optionValue(args, "--category"), branch: optionValue(args, "--branch"), tasks: optionValue(args, "--tasks"), summary: optionValue(args, "--summary"), focus: optionValue(args, "--focus"), validation: optionValue(args, "--validation"), progress: optionValue(args, "--progress"), status: parseBoardGithubStatus(args), }; } export function parseRepoVisibility(args: string[]): RepoVisibility | undefined { const privateRequested = hasFlag(args, "--private"); const publicRequested = hasFlag(args, "--public"); if (privateRequested && publicRequested) throw new Error("gh repo create accepts either --private or --public, not both"); if (privateRequested) return "private"; if (publicRequested) return "public"; return undefined; } export function validateKnownOptions(args: string[]): void { for (let index = 0; index < args.length; index += 1) { const arg = args[index]; if (!arg.startsWith("--")) continue; if (GH_FLAG_OPTIONS.has(arg)) continue; if (GH_VALUE_OPTIONS.has(arg)) { index += 1; continue; } throw new Error(`unknown gh option: ${arg}`); } } export 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; } export function isOwnerRepo(value: string): boolean { return /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(value); } export function resolveRepoOption(args: string[]): string { const [top, sub] = args; const explicitRepo = optionValue(args, "--repo"); if (top === "repo" && (sub === "view" || sub === "create")) { const positionals = positionalArgs(args.slice(2)); if (positionals.length > 1) { throw new Error(`gh repo ${sub} accepts at most one positional owner/repo target`); } const positionalRepo = positionals[0]; if (positionalRepo !== undefined) { if (!isOwnerRepo(positionalRepo)) { throw new Error(`gh repo ${sub} positional argument must be owner/repo`); } if (explicitRepo !== undefined && explicitRepo !== positionalRepo) { throw new Error(`gh repo ${sub} received positional repo ${positionalRepo} and --repo ${explicitRepo}; use one repo target`); } return positionalRepo; } } 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; } export function stdinAliasFileOption(args: string[], fileOption: string, stdinFlag: string): string | undefined { const file = optionValue(args, fileOption); const stdin = hasFlag(args, stdinFlag); if (file !== undefined && stdin) { throw new Error(`${fileOption} and ${stdinFlag} are mutually exclusive; use one body source`); } return stdin ? "-" : file; } export 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 : top === "issue" && (sub === "list" || sub === "stale-close" || sub === "scan-escape" || sub === "cleanup-plan") ? MAX_ISSUE_LIST_LIMIT : 100; return { repo: resolveRepoOption(args), dryRun: hasFlag(args, "--dry-run"), raw: hasFlag(args, "--raw"), full: hasFlag(args, "--full"), limit: positiveIntegerOption(args, "--limit", top === "issue" && sub === "board-audit" ? 100 : top === "issue" && sub === "stale-close" ? MAX_ISSUE_LIST_LIMIT : 30, limitMax), inactiveHours: positiveNumberOption(args, "--inactive-hours", DEFAULT_STALE_CLOSE_INACTIVE_HOURS, MAX_STALE_CLOSE_INACTIVE_HOURS), boardIssue: positiveIntegerSingleOption(args, "--board-issue", CODE_QUEUE_BOARD_TARGET_ISSUE), knownMetaIssues: positiveIntegerValuesOption(args, "--known-meta-issue"), ignoredIssues: positiveIntegerValuesOption(args, "--ignore-issue"), draft: hasFlag(args, "--draft"), notifyClaudeQqBriefDiff: hasFlag(args, "--notify-claudeqq-brief-diff"), allowShortBody: hasFlag(args, "--allow-short-body"), labels: labelsOption(args), search: optionValue(args, "--search"), titlePrefix: optionValue(args, "--title-prefix")?.trim(), title: optionValue(args, "--title"), body: optionValue(args, "--body"), bodyFile: stdinAliasFileOption(args, "--body-file", "--body-stdin"), bodyPatchFile: stdinAliasFileOption(args, "--body-patch-file", "--body-patch-stdin"), comment: optionValue(args, "--comment"), commentFile: stdinAliasFileOption(args, "--comment-file", "--comment-stdin"), repoDescription: optionValue(args, "--description"), repoVisibility: parseRepoVisibility(args), repoAutoInit: hasFlag(args, "--auto-init"), base: optionValue(args, "--base"), head: optionValue(args, "--head"), jsonFields: top === "issue" && isIssueReadCommand(sub) ? parseIssueViewJsonFields(requestedJsonFields) : undefined, issueListJsonFields: top === "issue" && sub === "list" ? parseIssueListJsonFields(requestedJsonFields) : undefined, prListJsonFields: top === "pr" && sub === "list" ? parsePrListJsonFields(requestedJsonFields) : undefined, prJsonFields: top === "pr" && isPrReadCommand(sub) ? parsePrReadJsonFields(requestedJsonFields) : undefined, listState: top === "issue" && (sub === "list" || sub === "board-row" && args[2] === "list") ? parseIssueListState(args) : "open", prListState: top === "pr" && sub === "list" ? parsePrListState(args) : "all", mode: parseBodyUpdateMode(args), expectUpdatedAt: optionValue(args, "--expect-updated-at"), expectBodySha: optionValue(args, "--expect-body-sha"), bodyProfile: parseIssueBodyProfile(args), boardRowField: parseBoardRowField(args), boardRowValue: optionValue(args, "--value"), boardRowFile: optionValue(args, "--row-file"), boardSection: parseBoardMutationSection(args, "--section"), boardMoveTo: parseBoardMutationSection(args, "--to"), boardGithubStatus: parseBoardGithubStatus(args), boardRowUpsertValues: parseBoardRowUpsertValues(args), mergeMethod: parsePullRequestMergeMethod(args), deleteBranch: hasFlag(args, "--delete-branch"), attachmentSelector: optionValue(args, "--attachment"), outputPath: optionValue(args, "--output"), }; }