Files
pikasTech-unidesk/scripts/src/gh/pr-merge-closeout.ts
T
2026-07-03 11:00:11 +00:00

171 lines
8.5 KiB
TypeScript

// SPEC: PJ2026-01060703 GitHub PR merge closeout.
// Responsibility: guarded local/node worktree closeout after a successful PR merge.
import { relative, resolve, sep } from "node:path";
import { repoRoot } from "../config";
import { runCommand, type CommandResult } from "../command";
import type { GitHubOptions, GitHubPullRequest } from "./types";
interface WorktreeEntry {
path: string;
branch: string | null;
head: string | null;
}
export function runPrMergeCloseout(repo: string, pr: GitHubPullRequest, options: GitHubOptions): Record<string, unknown> {
const headRef = pr.head?.ref ?? null;
const baseRef = pr.base?.ref ?? null;
const localEnabled = options.localCloseout;
return {
ok: true,
valuesPrinted: false,
headRef,
baseRef,
localWorktree: localEnabled ? cleanupHeadWorktree(headRef, options.dryRun) : skipped("local-closeout-disabled"),
mainWorktree: localEnabled ? syncLocalMainWorktree(repo, baseRef, options.dryRun) : skipped("local-closeout-disabled"),
nodeSyncs: syncRequestedNodes(repo, baseRef, options.syncNodes, options.dryRun),
};
}
function cleanupHeadWorktree(headRef: string | null, dryRun: boolean): Record<string, unknown> {
if (headRef === null || headRef.length === 0) return skipped("head-ref-missing");
const list = git(["worktree", "list", "--porcelain"], 10_000);
if (list.exitCode !== 0) return failed("worktree-list-failed", list);
const entries = parseWorktreeList(list.stdout);
const matches = entries.filter((entry) => entry.branch === `refs/heads/${headRef}` && isManagedTaskWorktree(entry.path));
if (matches.length === 0) return skipped("local-head-worktree-not-found", { headRef });
const removals = matches.map((entry) => removeWorktree(entry.path, dryRun));
const branchDelete = deleteLocalBranch(headRef, dryRun);
return {
ok: removals.every((item) => item.ok === true || item.planned === true) && (branchDelete.ok === true || branchDelete.planned === true || branchDelete.skipped === true),
headRef,
worktrees: removals,
branchDelete,
};
}
function removeWorktree(path: string, dryRun: boolean): Record<string, unknown> {
const status = git(["-C", path, "status", "--porcelain"], 10_000);
if (status.exitCode !== 0) return failed("worktree-status-failed", status, { path });
if (status.stdout.trim().length > 0) return skipped("worktree-dirty", { path, porcelainLines: status.stdout.trim().split(/\r?\n/u).slice(0, 20) });
if (dryRun) return { planned: true, action: "git-worktree-remove", path };
const removed = git(["worktree", "remove", path], 30_000);
return removed.exitCode === 0 ? { ok: true, action: "git-worktree-remove", path } : failed("worktree-remove-failed", removed, { path });
}
function deleteLocalBranch(headRef: string, dryRun: boolean): Record<string, unknown> {
const list = git(["branch", "--list", headRef], 10_000);
if (list.exitCode !== 0) return failed("branch-list-failed", list, { headRef });
if (list.stdout.trim().length === 0) return skipped("local-branch-not-found", { headRef });
if (dryRun) return { planned: true, action: "git-branch-delete", branch: headRef };
const deleted = git(["branch", "-d", headRef], 20_000);
return deleted.exitCode === 0 ? { ok: true, action: "git-branch-delete", branch: headRef } : failed("branch-delete-failed", deleted, { branch: headRef });
}
function syncLocalMainWorktree(repo: string, baseRef: string | null, dryRun: boolean): Record<string, unknown> {
if (baseRef === null || baseRef.length === 0) return skipped("base-ref-missing");
const remote = git(["remote", "get-url", "origin"], 10_000);
if (remote.exitCode !== 0) return failed("remote-read-failed", remote);
if (!remoteMatchesRepo(remote.stdout.trim(), repo)) return skipped("local-repo-mismatch", { repo, origin: redactRemote(remote.stdout.trim()) });
const current = git(["rev-parse", "--abbrev-ref", "HEAD"], 10_000);
if (current.exitCode !== 0) return failed("current-branch-read-failed", current);
const currentBranch = current.stdout.trim();
if (currentBranch !== baseRef) return skipped("main-worktree-on-different-branch", { currentBranch, baseRef });
if (dryRun) return { planned: true, action: "git-fetch-merge-ff-only", path: repoRoot, branch: baseRef };
const status = git(["status", "--porcelain"], 10_000);
if (status.exitCode !== 0) return failed("main-worktree-status-failed", status);
const hadDirty = status.stdout.trim().length > 0;
const stash = hadDirty ? git(["stash", "push", "-u", "-m", `unidesk-gh-pr-merge-closeout ${repo} ${baseRef}`], 30_000) : null;
if (stash !== null && stash.exitCode !== 0) return failed("main-worktree-stash-failed", stash, { branch: baseRef });
const fetched = git(["fetch", "origin", baseRef], 60_000);
const merged = fetched.exitCode === 0 ? git(["merge", "--ff-only", `origin/${baseRef}`], 60_000) : null;
const applied = stash !== null ? git(["stash", "apply"], 60_000) : null;
const ok = fetched.exitCode === 0 && merged?.exitCode === 0 && (applied === null || applied.exitCode === 0);
return {
ok,
action: "git-fetch-merge-ff-only",
path: repoRoot,
branch: baseRef,
dirtyPreservedByStash: hadDirty,
fetch: commandSummary(fetched),
merge: merged === null ? null : commandSummary(merged),
stashApply: applied === null ? null : commandSummary(applied),
};
}
function syncRequestedNodes(repo: string, baseRef: string | null, nodes: string[], dryRun: boolean): Record<string, unknown>[] {
return nodes.map((node) => syncRequestedNode(repo, baseRef, node, dryRun));
}
function syncRequestedNode(repo: string, baseRef: string | null, node: string, dryRun: boolean): Record<string, unknown> {
const command = nodeSyncCommand(repo, baseRef, node);
if (command === null) return skipped("node-sync-unsupported", { repo, baseRef, node });
if (dryRun) return { planned: true, node, command: command.join(" ") };
const result = runCommand(command, repoRoot, { timeoutMs: 120_000 });
return result.exitCode === 0 ? { ok: true, node, command: command.join(" ") } : failed("node-sync-failed", result, { node, command: command.join(" ") });
}
function nodeSyncCommand(repo: string, baseRef: string | null, node: string): string[] | null {
if (repo.toLowerCase() === "pikastech/hwlab" && baseRef === "v0.3") {
return ["bun", "scripts/cli.ts", "hwlab", "nodes", "control-plane", "source-workspace", "sync", "--node", node.toUpperCase(), "--lane", "v03", "--confirm"];
}
return null;
}
function parseWorktreeList(text: string): WorktreeEntry[] {
const entries: WorktreeEntry[] = [];
let current: WorktreeEntry | null = null;
for (const line of text.split(/\r?\n/u)) {
if (line.startsWith("worktree ")) {
if (current !== null) entries.push(current);
current = { path: line.slice("worktree ".length), branch: null, head: null };
} else if (line.startsWith("branch ") && current !== null) {
current.branch = line.slice("branch ".length);
} else if (line.startsWith("HEAD ") && current !== null) {
current.head = line.slice("HEAD ".length);
}
}
if (current !== null) entries.push(current);
return entries;
}
function isManagedTaskWorktree(path: string): boolean {
const rel = relative(repoRoot, resolve(path));
return rel === ".worktree" || rel.startsWith(`.worktree${sep}`);
}
function git(args: string[], timeoutMs: number): CommandResult {
return runCommand(["git", ...args], repoRoot, { timeoutMs });
}
function remoteMatchesRepo(remote: string, repo: string): boolean {
const normalized = remote
.replace(/^git@github\.com:/u, "")
.replace(/^https:\/\/github\.com\//u, "")
.replace(/\.git$/u, "")
.toLowerCase();
return normalized === repo.toLowerCase();
}
function redactRemote(remote: string): string {
return remote.replace(/:\/\/[^/@]+@/u, "://<redacted>@");
}
function skipped(reason: string, details: Record<string, unknown> = {}): Record<string, unknown> {
return { ok: true, skipped: true, skippedReason: reason, ...details };
}
function failed(reason: string, result: CommandResult, details: Record<string, unknown> = {}): Record<string, unknown> {
return { ok: false, degradedReason: reason, ...details, command: result.command.join(" "), exitCode: result.exitCode, timedOut: result.timedOut, stderrTail: tail(result.stderr), stdoutTail: tail(result.stdout) };
}
function commandSummary(result: CommandResult): Record<string, unknown> {
return { exitCode: result.exitCode, timedOut: result.timedOut, stderrTail: tail(result.stderr), stdoutTail: tail(result.stdout) };
}
function tail(text: string): string {
return text.trim().split(/\r?\n/u).slice(-8).join("\n").slice(-1000);
}