edc437cdd8
Co-authored-by: Codex <codex@noreply.local>
372 lines
18 KiB
TypeScript
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"),
|
|
};
|
|
}
|