Files
pikasTech-unidesk/scripts/src/gh-route.ts
T

595 lines
24 KiB
TypeScript

import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Readable, Writable } from "node:stream";
import { runCommand } from "./command";
import { repoRoot } from "./config";
import { runApplyPatchV2, type ApplyPatchV2Executor, type ApplyPatchV2RemoteResult } from "./apply-patch-v2";
interface GhContentRoute {
repo: string;
area: "root" | "pr-list" | "issue-list" | "pr-item" | "issue-item" | "legacy-item";
number: number | null;
floor: number | null;
raw: string;
}
interface GhRouteDocument {
kind: "pr" | "issue";
repo: string;
number: number;
floor: number;
title: string;
url: string;
state: string;
commentCount: number;
body: string;
bodySha: string | null;
}
interface CommandResult {
ok: boolean;
exitCode: number | null;
stdout: string;
stderr: string;
parsed: Record<string, unknown>;
}
function record(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function nested(value: unknown, keys: string[]): unknown {
let current = value;
for (const key of keys) current = record(current)[key];
return current;
}
function cliJson(args: string[], timeoutMs = 80_000): CommandResult {
const result = runCommand(["bun", "scripts/cli.ts", ...args], repoRoot, { timeoutMs });
let parsed: Record<string, unknown> = {};
if (result.stdout.trim().length > 0) {
try {
parsed = record(JSON.parse(result.stdout) as unknown);
} catch {
parsed = {};
}
}
const ok = result.exitCode === 0 && parsed.ok !== false && nested(parsed, ["data", "ok"]) !== false;
return { ok, exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr, parsed };
}
function decodeSegment(segment: string): string {
try {
return decodeURIComponent(segment);
} catch {
throw new Error(`invalid percent-encoded gh route segment: ${segment}`);
}
}
export function isGhContentRoute(target: string | undefined): boolean {
return typeof target === "string" && target.startsWith("gh:");
}
export function parseGhContentRoute(target: string): GhContentRoute {
if (!target.startsWith("gh:/")) throw new Error("gh route must use gh:/owner/repo, for example: gh:/pikasTech/HWLAB/pr ls or gh:/pikasTech/HWLAB/pr/503/1 cat");
const parts = target.slice("gh:/".length).split("/").filter((part) => part.length > 0).map(decodeSegment);
if (parts.length < 2 || parts.length > 5) throw new Error("gh route supports gh:/owner/repo, gh:/owner/repo/pr, gh:/owner/repo/issue, gh:/owner/repo/pr/number[/floor], gh:/owner/repo/issue/number[/floor], and legacy gh:/owner/repo/number/floor");
const [owner, repoName, areaRaw, numberRaw, floorRaw] = parts;
if (!owner || !repoName) throw new Error("gh route requires non-empty owner and repo");
const repo = `${owner}/${repoName}`;
if (areaRaw === undefined) return { repo, area: "root", number: null, floor: null, raw: target };
if (areaRaw === "pr" || areaRaw === "prs" || areaRaw === "pull" || areaRaw === "pulls") {
if (numberRaw === undefined) return { repo, area: "pr-list", number: null, floor: null, raw: target };
const number = positiveRouteNumber(numberRaw, "PR number");
const floor = floorRaw === undefined ? null : positiveRouteNumber(floorRaw, "PR floor");
return { repo, area: "pr-item", number, floor, raw: target };
}
if (areaRaw === "issue" || areaRaw === "issues") {
if (numberRaw === undefined) return { repo, area: "issue-list", number: null, floor: null, raw: target };
const number = positiveRouteNumber(numberRaw, "issue number");
const floor = floorRaw === undefined ? null : positiveRouteNumber(floorRaw, "issue floor");
return { repo, area: "issue-item", number, floor, raw: target };
}
if (parts.length === 4) {
const number = positiveRouteNumber(areaRaw, "legacy issue/PR number");
const floor = positiveRouteNumber(numberRaw ?? "", "legacy floor");
return { repo, area: "legacy-item", number, floor, raw: target };
}
throw new Error("unsupported gh route; use gh:/owner/repo/pr[/number[/floor]] or gh:/owner/repo/issue[/number[/floor]]");
}
function positiveRouteNumber(raw: string, label: string): number {
const value = Number(raw);
if (!Number.isInteger(value) || value <= 0) throw new Error(`gh route ${label} must be a positive integer`);
return value;
}
function commandData(result: CommandResult): Record<string, unknown> {
return record(expandedParsedRoot(result).data);
}
function expandedParsedRoot(result: CommandResult): Record<string, unknown> {
const dumpPath = nested(result.parsed, ["data", "dump", "path"]);
if (typeof dumpPath === "string" && existsSync(dumpPath)) {
try {
return record(JSON.parse(readFileSync(dumpPath, "utf8")) as unknown);
} catch {
return result.parsed;
}
}
return result.parsed;
}
function readDocument(route: GhContentRoute): GhRouteDocument {
if (route.number === null) throw new Error("gh route document requires a PR or issue number");
if (route.floor !== 1) {
throw new Error("only floor 1 is currently supported; floor 1 maps to the issue/PR body");
}
const shouldReadPr = route.area === "pr-item" || route.area === "legacy-item";
const shouldReadIssue = route.area === "issue-item" || route.area === "legacy-item";
const pr = shouldReadPr ? cliJson(["gh", "pr", "read", String(route.number), "--repo", route.repo, "--json", "title,body,url,stateDetail,merged,mergedAt,mergeCommit,headRefName,baseRefName", "--raw"]) : null;
if (pr?.ok) {
const data = commandData(pr);
const prRecord = record(data.pullRequest);
const json = record(data.json);
const issueMeta = readIssueMetadata(route.repo, route.number);
return {
kind: "pr",
repo: route.repo,
number: route.number,
floor: route.floor,
title: String(json.title ?? prRecord.title ?? ""),
url: String(json.url ?? prRecord.url ?? ""),
state: issueMeta.state ?? String(json.stateDetail ?? prRecord.stateDetail ?? ""),
commentCount: issueMeta.commentCount ?? 0,
body: String(json.body ?? prRecord.body ?? ""),
bodySha: null,
};
}
if (!shouldReadIssue) {
throw new Error(`failed to read ${route.repo}#${route.number} as PR: ${pr?.stderr || pr?.stdout}`);
}
const issue = cliJson(["gh", "issue", "read", String(route.number), "--repo", route.repo, "--json", "title,body,state,updatedAt", "--raw"]);
if (!issue.ok) {
throw new Error(`failed to read ${route.repo}#${route.number} as ${route.area === "issue-item" ? "issue" : "PR or issue"}: pr=${pr?.stderr || pr?.stdout || "skipped"}; issue=${issue.stderr || issue.stdout}`);
}
const issueRecord = record(commandData(issue).issue);
const json = record(commandData(issue).json);
const commentCount = numberFromUnknown(issueRecord.commentCount) ?? commentsCount(commandData(issue).comments) ?? commentsCount(json.comments) ?? 0;
return {
kind: "issue",
repo: route.repo,
number: route.number,
floor: route.floor,
title: String(json.title ?? issueRecord.title ?? ""),
url: String(issueRecord.url ?? ""),
state: String(json.state ?? issueRecord.state ?? ""),
commentCount,
body: String(json.body ?? issueRecord.body ?? ""),
bodySha: typeof issueRecord.bodySha === "string" ? issueRecord.bodySha : null,
};
}
function numberFromUnknown(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function commentsCount(value: unknown): number | null {
return Array.isArray(value) ? value.length : null;
}
function readIssueMetadata(repo: string, number: number): { state: string | null; commentCount: number | null; bodyChars: number | null } {
const issue = cliJson(["gh", "issue", "read", String(number), "--repo", repo, "--json", "title,body,state,updatedAt,comments", "--raw"], 80_000);
if (!issue.ok) return { state: null, commentCount: null, bodyChars: null };
const data = commandData(issue);
const issueRecord = record(data.issue);
const json = record(data.json);
const body = String(json.body ?? issueRecord.body ?? "");
return {
state: typeof json.state === "string" ? json.state : typeof issueRecord.state === "string" ? issueRecord.state : null,
commentCount: numberFromUnknown(issueRecord.commentCount) ?? commentsCount(data.comments) ?? commentsCount(json.comments),
bodyChars: body.length,
};
}
function parseListOptions(args: string[]): { limit: number; full: boolean } {
const limitIndex = args.indexOf("--limit");
return {
limit: limitIndex >= 0 ? positiveRouteNumber(args[limitIndex + 1] ?? "", "--limit") : 30,
full: args.includes("--full"),
};
}
function listRoute(route: GhContentRoute, args: string[]): number {
const options = parseListOptions(args);
if (route.area === "root") {
printDirectory([
{ name: "pr/", type: "dir", description: "pull request directory" },
{ name: "issue/", type: "dir", description: "issue directory" },
]);
return 0;
}
if (route.area === "pr-list") {
const result = cliJson(["gh", "pr", "list", "--repo", route.repo, "--state", "all", "--limit", String(options.limit), "--json", "number,title,state,url,updatedAt,createdAt,author,headRefName,baseRefName,draft"], 80_000);
if (!result.ok) throw new Error(result.stderr || result.stdout || "gh pr list failed");
const prs = commandData(result).pullRequests;
const rows = Array.isArray(prs) ? prs.map((item) => prDirectoryEntry(route.repo, record(item), options.full)) : [];
printDirectory(rows);
return 0;
}
if (route.area === "issue-list") {
const result = cliJson(["gh", "issue", "list", "--repo", route.repo, "--state", "all", "--limit", String(options.limit), "--json", "number,title,state,url,updatedAt,createdAt,author"], 80_000);
if (!result.ok) throw new Error(result.stderr || result.stdout || "gh issue list failed");
const issues = commandData(result).issues;
const rows = Array.isArray(issues) ? issues.map((item) => issueDirectoryEntry(route.repo, record(item), options.full)) : [];
printDirectory(rows);
return 0;
}
const document = readDocument({ ...route, floor: 1 });
printDirectory([
{
name: "1",
type: "file",
kind: document.kind,
state: document.state,
floors: 1 + document.commentCount,
commentCount: document.commentCount,
bodyChars: document.body.length,
title: document.title,
url: document.url,
},
]);
return 0;
}
function prDirectoryEntry(repo: string, item: Record<string, unknown>, full: boolean): Record<string, unknown> {
const number = Number(item.number);
const meta = full && Number.isInteger(number) ? readIssueMetadata(repo, number) : null;
const commentCount = meta?.commentCount ?? null;
return {
name: `${item.number}/`,
type: "dir",
kind: "pr",
state: meta?.state ?? item.state ?? null,
draft: item.draft === true,
floors: commentCount === null ? 1 : 1 + commentCount,
commentCount,
bodyChars: meta?.bodyChars ?? null,
title: item.title ?? "",
url: item.url ?? `https://github.com/${repo}/pull/${item.number}`,
headRefName: item.headRefName ?? null,
baseRefName: item.baseRefName ?? null,
updatedAt: item.updatedAt ?? null,
};
}
function issueDirectoryEntry(repo: string, item: Record<string, unknown>, full: boolean): Record<string, unknown> {
const number = Number(item.number);
const meta = full && Number.isInteger(number) ? readIssueMetadata(repo, number) : null;
const commentCount = meta?.commentCount ?? null;
return {
name: `${item.number}/`,
type: "dir",
kind: "issue",
state: meta?.state ?? item.state ?? null,
floors: commentCount === null ? 1 : 1 + commentCount,
commentCount,
bodyChars: meta?.bodyChars ?? null,
title: item.title ?? "",
url: item.url ?? `https://github.com/${repo}/issues/${item.number}`,
updatedAt: item.updatedAt ?? null,
};
}
function printDirectory(rows: Array<Record<string, unknown>>): void {
for (const row of rows) {
const fields = [
String(row.name ?? ""),
`type=${String(row.type ?? "")}`,
row.kind === undefined ? null : `kind=${String(row.kind)}`,
row.state === undefined || row.state === null ? null : `state=${String(row.state)}`,
row.draft === undefined ? null : `draft=${String(row.draft)}`,
row.floors === undefined || row.floors === null ? null : `floors=${String(row.floors)}`,
row.commentCount === undefined || row.commentCount === null ? null : `commentCount=${String(row.commentCount)}`,
row.bodyChars === undefined || row.bodyChars === null ? null : `bodyChars=${String(row.bodyChars)}`,
row.updatedAt === undefined || row.updatedAt === null ? null : `updatedAt=${String(row.updatedAt)}`,
row.title === undefined || row.title === "" ? null : `title=${String(row.title)}`,
].filter((item): item is string => item !== null);
process.stdout.write(`${fields.join("\t")}\n`);
}
}
function readStdin(): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
process.stdin.on("data", (chunk: Buffer) => chunks.push(chunk));
process.stdin.on("error", reject);
process.stdin.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
});
}
function sha256Hex(value: Buffer): string {
return createHash("sha256").update(value).digest("hex");
}
function bodyVirtualPath(path: string): string | null {
const normalized = path.replace(/\\/gu, "/").replace(/^\.\/+/u, "");
if (normalized === "body.md" || normalized === "body" || normalized === "1.md") return "body.md";
return null;
}
function verifyVirtualWrite(target: string, buffer: Buffer, expectedBytes: string, expectedSha256: string): ApplyPatchV2RemoteResult | null {
const expectedByteCount = Number(expectedBytes);
if (!Number.isSafeInteger(expectedByteCount) || expectedByteCount < 0) {
return { exitCode: 2, stdout: "", stderr: `invalid expected byte count for ${target}\n` };
}
if (buffer.length !== expectedByteCount) {
return { exitCode: 23, stdout: "", stderr: `v2 virtual byte count mismatch for ${target}: expected=${expectedByteCount} actual=${buffer.length}\n` };
}
const actualSha256 = sha256Hex(buffer);
if (actualSha256 !== expectedSha256) {
return { exitCode: 24, stdout: "", stderr: `v2 virtual sha256 mismatch for ${target}: expected=${expectedSha256} actual=${actualSha256}\n` };
}
return null;
}
export class GhVirtualFileExecutor implements ApplyPatchV2Executor {
private readonly files = new Map<string, Buffer>();
private readonly temp = new Map<string, string>();
constructor(body: string) {
this.files.set("body.md", Buffer.from(body, "utf8"));
}
body(): string {
const body = this.files.get("body.md");
return body === undefined ? "" : body.toString("utf8");
}
async run(command: string[], input?: string): Promise<ApplyPatchV2RemoteResult> {
const operation = command[4] ?? "";
const args = command.slice(5);
try {
return this.runOperation(operation, args, input);
} catch (error) {
return { exitCode: 1, stdout: "", stderr: `${error instanceof Error ? error.message : String(error)}\n` };
}
}
private canonical(rawPath: string): string {
const path = bodyVirtualPath(rawPath);
if (path === null) throw new Error(`unsupported gh virtual file path: ${rawPath}; use body.md for floor 1`);
return path;
}
private file(path: string): Buffer {
const body = this.files.get(path);
if (body === undefined) throw new Error(`virtual file not found: ${path}`);
return body;
}
private runOperation(operation: string, args: string[], input?: string): ApplyPatchV2RemoteResult {
if (operation === "stat") {
const path = this.canonical(args[0] ?? "");
const body = this.file(path);
return { exitCode: 0, stdout: `${body.length} ${sha256Hex(body)}\n`, stderr: "" };
}
if (operation === "read-b64-block") {
const path = this.canonical(args[0] ?? "");
const blockIndex = Number(args[1] ?? "");
const blockSize = Number(args[2] ?? "");
if (!Number.isSafeInteger(blockIndex) || blockIndex < 0 || !Number.isSafeInteger(blockSize) || blockSize <= 0) {
return { exitCode: 2, stdout: "", stderr: "invalid read block args\n" };
}
const body = this.file(path);
const offset = blockIndex * blockSize;
return { exitCode: 0, stdout: body.subarray(offset, offset + blockSize).toString("base64"), stderr: "" };
}
if (operation === "write-b64-argv") {
const path = this.canonical(args[0] ?? "");
const expectedBytes = args[1] ?? "";
const expectedSha256 = args[2] ?? "";
const buffer = Buffer.from(args.slice(3).join(""), "base64");
const failure = verifyVirtualWrite(path, buffer, expectedBytes, expectedSha256);
if (failure !== null) return failure;
this.files.set(path, buffer);
return { exitCode: 0, stdout: "", stderr: "" };
}
if (operation === "write-b64-stdin") {
const path = this.canonical(args[0] ?? "");
const expectedBytes = args[1] ?? "";
const expectedSha256 = args[2] ?? "";
const buffer = Buffer.from((input ?? "").replace(/\s+/gu, ""), "base64");
const failure = verifyVirtualWrite(path, buffer, expectedBytes, expectedSha256);
if (failure !== null) return failure;
this.files.set(path, buffer);
return { exitCode: 0, stdout: "", stderr: "" };
}
if (operation === "write-b64-begin") {
this.canonical(args[0] ?? "");
const token = args[1] ?? "";
this.temp.set(token, "");
return { exitCode: 0, stdout: "", stderr: "" };
}
if (operation === "write-b64-append") {
this.canonical(args[0] ?? "");
const token = args[1] ?? "";
if (!this.temp.has(token)) return { exitCode: 2, stdout: "", stderr: "unknown virtual write token\n" };
this.temp.set(token, `${this.temp.get(token) ?? ""}${args[2] ?? ""}`);
return { exitCode: 0, stdout: "", stderr: "" };
}
if (operation === "write-b64-commit") {
const path = this.canonical(args[0] ?? "");
const token = args[1] ?? "";
const expectedBytes = args[2] ?? "";
const expectedSha256 = args[3] ?? "";
const encoded = this.temp.get(token);
if (encoded === undefined) return { exitCode: 2, stdout: "", stderr: "unknown virtual write token\n" };
const buffer = Buffer.from(encoded, "base64");
const failure = verifyVirtualWrite(path, buffer, expectedBytes, expectedSha256);
if (failure !== null) return failure;
this.temp.delete(token);
this.files.set(path, buffer);
return { exitCode: 0, stdout: "", stderr: "" };
}
if (operation === "delete") {
const path = this.canonical(args[0] ?? "");
if (!this.files.has(path)) return { exitCode: 1, stdout: "", stderr: `virtual file not found: ${path}\n` };
this.files.delete(path);
return { exitCode: 0, stdout: "", stderr: "" };
}
if (operation === "move") {
const from = this.canonical(args[0] ?? "");
const to = this.canonical(args[1] ?? "");
const body = this.file(from);
this.files.set(to, body);
this.files.delete(from);
return { exitCode: 0, stdout: "", stderr: "" };
}
return { exitCode: 2, stdout: "", stderr: `unsupported virtual apply-patch operation: ${operation}\n` };
}
}
function writableCapture(): { stream: Writable; text: () => string } {
const chunks: Buffer[] = [];
return {
stream: new Writable({
write(chunk, _encoding, callback) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
callback();
},
}),
text: () => Buffer.concat(chunks).toString("utf8"),
};
}
export async function applyPatchToGhBody(currentBody: string, patchText: string): Promise<{ body: string; applyPatchOutput: string; executor: GhVirtualFileExecutor }> {
const executor = new GhVirtualFileExecutor(currentBody);
const output = writableCapture();
const exitCode = await runApplyPatchV2({
executor,
stdin: Readable.from([patchText]),
stdout: output.stream,
argv: [],
});
if (exitCode !== 0) throw new Error(`apply-patch-v2 failed for gh virtual body: ${output.text()}`);
return { body: executor.body(), applyPatchOutput: output.text(), executor };
}
function writeTempBody(body: string): string {
const dir = mkdtempSync(join(tmpdir(), "unidesk-gh-route-"));
const path = join(dir, "body.md");
writeFileSync(path, body, "utf8");
return path;
}
function updateDocument(document: GhRouteDocument, nextBody: string, dryRun: boolean): CommandResult {
const bodyPath = writeTempBody(nextBody);
try {
if (document.kind === "pr") {
return cliJson(["gh", "pr", "update", String(document.number), "--repo", document.repo, "--mode", "replace", "--body-file", bodyPath, ...(dryRun ? ["--dry-run"] : [])], 100_000);
}
return cliJson([
"gh",
"issue",
"update",
String(document.number),
"--repo",
document.repo,
"--mode",
"replace",
"--body-file",
bodyPath,
...(document.bodySha !== null ? ["--expect-body-sha", document.bodySha] : []),
...(dryRun ? ["--dry-run"] : []),
], 100_000);
} finally {
rmSync(join(bodyPath, ".."), { recursive: true, force: true });
}
}
function printJson(payload: unknown): void {
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
}
function runRg(body: string, args: string[]): number {
if (args.length === 0) throw new Error("gh route rg requires a pattern");
const dir = mkdtempSync(join(tmpdir(), "unidesk-gh-route-rg-"));
try {
const bodyPath = join(dir, "body.md");
writeFileSync(bodyPath, body, "utf8");
const result = runCommand(["rg", "--color=never", ...args, bodyPath], repoRoot, { timeoutMs: 30_000 });
process.stdout.write(result.stdout.replaceAll(`${bodyPath}:`, ""));
process.stderr.write(result.stderr.replaceAll(`${bodyPath}:`, ""));
return result.exitCode ?? 1;
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
export async function runGhContentRoute(target: string, args: string[]): Promise<number> {
const route = parseGhContentRoute(target);
const operation = args[0] ?? "cat";
const opArgs = args.slice(1);
if (operation === "ls" || operation === "dir") {
return listRoute(route, opArgs);
}
const document = readDocument(route);
if (operation === "cat") {
process.stdout.write(document.body);
return 0;
}
if (operation === "rg") {
return runRg(document.body, opArgs);
}
if (operation === "patch-apply" || operation === "apply-patch") {
const dryRun = opArgs.includes("--dry-run");
const unsupported = opArgs.filter((arg) => arg !== "--dry-run");
if (unsupported.length > 0) throw new Error(`unsupported gh ${operation} args: ${unsupported.join(" ")}`);
const patchText = await readStdin();
const patchResult = await applyPatchToGhBody(document.body, patchText);
const nextBody = patchResult.body;
const update = updateDocument(document, nextBody, dryRun);
printJson({
ok: update.ok,
route,
target: {
kind: document.kind,
repo: document.repo,
number: document.number,
floor: document.floor,
title: document.title,
url: document.url,
},
dryRun,
bodyCharsBefore: document.body.length,
bodyCharsAfter: nextBody.length,
applyPatch: {
engine: "apply-patch-v2",
virtualFiles: ["body.md"],
output: patchResult.applyPatchOutput.trim(),
},
update: commandData(update),
});
return update.ok ? 0 : 1;
}
if (operation === "rm") {
printJson({
ok: false,
degradedReason: "unsupported-operation",
route,
message: "gh route floor 1 is the issue/PR body and cannot be removed safely; use patch-apply to delete reviewed sections.",
});
return 2;
}
printJson({
ok: false,
degradedReason: "unsupported-command",
route,
supported: ["cat", "rg", "patch-apply", "apply-patch", "rm"],
});
return 2;
}