671 lines
27 KiB
TypeScript
671 lines
27 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>;
|
|
}
|
|
|
|
type GhListState = "open" | "closed" | "all";
|
|
|
|
interface GhListOptions {
|
|
limit: number;
|
|
full: boolean;
|
|
state: GhListState;
|
|
search: string | null;
|
|
labels: string[];
|
|
}
|
|
|
|
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 documentRoute(route: GhContentRoute): GhContentRoute {
|
|
if ((route.area === "pr-item" || route.area === "issue-item" || route.area === "legacy-item") && route.floor === null) {
|
|
return { ...route, floor: 1 };
|
|
}
|
|
return route;
|
|
}
|
|
|
|
function isPatchOperation(operation: string): boolean {
|
|
return operation === "patch-apply" || operation === "apply-patch" || operation === "apply_patch";
|
|
}
|
|
|
|
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 parseListState(raw: string): GhListState {
|
|
if (raw === "open" || raw === "closed" || raw === "all") return raw;
|
|
throw new Error("gh route list --state must be open, closed, or all");
|
|
}
|
|
|
|
function optionValue(args: string[], index: number, name: string): { value: string; nextIndex: number } {
|
|
const arg = args[index] ?? "";
|
|
if (arg.startsWith(`${name}=`)) return { value: arg.slice(name.length + 1), nextIndex: index };
|
|
const value = args[index + 1];
|
|
if (value === undefined || value.length === 0) throw new Error(`gh route list ${name} requires a value`);
|
|
return { value, nextIndex: index + 1 };
|
|
}
|
|
|
|
function parseLabels(raw: string): string[] {
|
|
const labels = raw.split(",").map((item) => item.trim()).filter((item) => item.length > 0);
|
|
if (labels.length === 0) throw new Error("gh route issue ls --label requires at least one non-empty label");
|
|
return labels;
|
|
}
|
|
|
|
function parseListOptions(args: string[], kind: "pr" | "issue"): GhListOptions {
|
|
const options: GhListOptions = { limit: 30, full: false, state: "all", search: null, labels: [] };
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const arg = args[index] ?? "";
|
|
if (arg === "--full") {
|
|
options.full = true;
|
|
continue;
|
|
}
|
|
if (arg === "--limit" || arg.startsWith("--limit=")) {
|
|
const parsed = optionValue(args, index, "--limit");
|
|
options.limit = positiveRouteNumber(parsed.value, "--limit");
|
|
index = parsed.nextIndex;
|
|
continue;
|
|
}
|
|
if (arg === "--state" || arg.startsWith("--state=")) {
|
|
const parsed = optionValue(args, index, "--state");
|
|
options.state = parseListState(parsed.value);
|
|
index = parsed.nextIndex;
|
|
continue;
|
|
}
|
|
if (arg === "--search" || arg.startsWith("--search=")) {
|
|
if (kind !== "issue") throw new Error("gh route --search is only supported for issue ls");
|
|
const parsed = optionValue(args, index, "--search");
|
|
options.search = parsed.value;
|
|
index = parsed.nextIndex;
|
|
continue;
|
|
}
|
|
if (arg === "--label" || arg.startsWith("--label=")) {
|
|
if (kind !== "issue") throw new Error("gh route --label is only supported for issue ls");
|
|
const parsed = optionValue(args, index, "--label");
|
|
options.labels.push(...parseLabels(parsed.value));
|
|
index = parsed.nextIndex;
|
|
continue;
|
|
}
|
|
throw new Error(`unsupported gh route ${kind} ls arg: ${arg}`);
|
|
}
|
|
return options;
|
|
}
|
|
|
|
function listRoute(route: GhContentRoute, args: string[]): number {
|
|
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 options = parseListOptions(args, "pr");
|
|
const result = cliJson(["gh", "pr", "list", "--repo", route.repo, "--state", options.state, "--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 options = parseListOptions(args, "issue");
|
|
const issueArgs = ["gh", "issue", "list", "--repo", route.repo, "--state", options.state, "--limit", String(options.limit), "--json", "number,title,state,url,updatedAt,createdAt,author"];
|
|
if (options.search !== null) issueArgs.push("--search", options.search);
|
|
for (const label of options.labels) issueArgs.push("--label", label);
|
|
const result = cliJson(issueArgs, 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.url === undefined || row.url === null || row.url === "" ? null : `url=${String(row.url)}`,
|
|
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(documentRoute(route));
|
|
if (operation === "cat") {
|
|
process.stdout.write(document.body);
|
|
return 0;
|
|
}
|
|
if (operation === "rg") {
|
|
return runRg(document.body, opArgs);
|
|
}
|
|
if (isPatchOperation(operation)) {
|
|
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 apply-patch to delete reviewed sections.",
|
|
});
|
|
return 2;
|
|
}
|
|
printJson({
|
|
ok: false,
|
|
degradedReason: "unsupported-command",
|
|
route,
|
|
supported: ["cat", "rg", "patch-apply", "apply-patch", "apply_patch", "rm"],
|
|
});
|
|
return 2;
|
|
}
|