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

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;
}