Files
pikasTech-unidesk/scripts/src/gh/options.ts
T
2026-06-26 00:16:53 +08:00

372 lines
18 KiB
TypeScript

// 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<string>();
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<number>();
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<T extends string>(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<T extends string>(command: string, requested: string[] | undefined, allowedFields: readonly T[]): T[] | undefined {
if (requested === undefined) return undefined;
const allowed = new Set<string>(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<string>(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 <number> --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 <number> --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"),
};
}