edc437cdd8
Co-authored-by: Codex <codex@noreply.local>
374 lines
18 KiB
TypeScript
374 lines
18 KiB
TypeScript
// 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} <number> --repo ${repo}`,
|
|
`bun scripts/cli.ts gh ${label} --repo ${repo} <number>`,
|
|
],
|
|
});
|
|
}
|
|
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} <number> --repo ${options.repo}`,
|
|
`bun scripts/cli.ts gh ${label} --number <number> --repo ${options.repo}`,
|
|
`bun scripts/cli.ts gh ${label} ${options.repo}#<number>`,
|
|
],
|
|
});
|
|
}
|
|
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} <number> --repo ${options.repo}`,
|
|
`bun scripts/cli.ts gh ${label} --number <number> --repo ${options.repo}`,
|
|
`bun scripts/cli.ts gh ${label} ${options.repo}#<number>`,
|
|
],
|
|
});
|
|
}
|
|
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} <number> --repo ${options.repo}`,
|
|
`bun scripts/cli.ts gh ${label} --number <number> --repo ${options.repo}`,
|
|
`bun scripts/cli.ts gh ${label} ${options.repo}#<number>`,
|
|
],
|
|
});
|
|
}
|
|
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} <number> --repo ${options.repo}`,
|
|
`bun scripts/cli.ts gh ${label} --pr <number> --repo ${options.repo}`,
|
|
`bun scripts/cli.ts gh ${label} --number <number> --repo ${options.repo}`,
|
|
`bun scripts/cli.ts gh ${label} ${options.repo}#<number>`,
|
|
],
|
|
});
|
|
}
|
|
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} <number> --repo ${options.repo}`,
|
|
`bun scripts/cli.ts gh ${label} --pr <number> --repo ${options.repo}`,
|
|
`bun scripts/cli.ts gh ${label} --number <number> --repo ${options.repo}`,
|
|
`bun scripts/cli.ts gh ${label} ${options.repo}#<number>`,
|
|
],
|
|
});
|
|
}
|
|
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} <number> --repo ${options.repo}`,
|
|
`bun scripts/cli.ts gh ${label} --number <number> --repo ${options.repo}`,
|
|
`bun scripts/cli.ts gh ${label} ${options.repo}#<number>`,
|
|
],
|
|
});
|
|
}
|
|
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} <number> --repo ${options.repo}`,
|
|
...(allowNumberOption ? [`bun scripts/cli.ts gh ${label} --number <number> --repo ${options.repo}`] : []),
|
|
`bun scripts/cli.ts gh ${label} ${options.repo}#<number>`,
|
|
],
|
|
});
|
|
}
|
|
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 <number> --repo owner/name --json ${readViewSupportedJsonFields(kind)}`,
|
|
`bun scripts/cli.ts gh ${kind} view https://github.com/owner/name/${kind === "issue" ? "issues" : "pull"}/<number> --raw`,
|
|
`bun scripts/cli.ts gh ${kind} view owner/name#<number> --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 <number> --repo owner/name --json ${readViewSupportedJsonFields(kind)}`,
|
|
`bun scripts/cli.ts gh ${kind} view https://github.com/owner/name/${kind === "issue" ? "issues" : "pull"}/<number> --raw`,
|
|
`bun scripts/cli.ts gh ${kind} view owner/name#<number> --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<string, unknown> | 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<string, unknown> | 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<GitHubCommandResult>, resolved: GitHubResolvedNumberReference): Promise<GitHubCommandResult> {
|
|
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<string, unknown> {
|
|
const [top, sub, third] = args;
|
|
const details: Record<string, unknown> = {
|
|
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 <number> --repo owner/name --json ${readViewSupportedJsonFields(top)}`,
|
|
`bun scripts/cli.ts gh ${top} view owner/name#<number> --raw`,
|
|
];
|
|
}
|
|
return details;
|
|
}
|