import { resolve } from "node:path"; import { execProcess } from "./process"; import { guardRemotePrGitignoredSymlinks } from "./workspace-probe/integrated-land-gates"; type PrReviewDecision = "APPROVED" | "CHANGES_REQUESTED" | "REVIEW_REQUIRED"; interface PullRequestState { state: "merged" | "open"; url?: string; sha?: string; number?: number; title?: string; reviewDecision?: PrReviewDecision; statusCheckRollup?: unknown[]; mergeable?: string; mergeStateStatus?: string; } function prReviewDecision(value: unknown): PrReviewDecision | undefined { return value === "APPROVED" || value === "CHANGES_REQUESTED" || value === "REVIEW_REQUIRED" ? value : undefined; } export async function prMergedState(cwd: string, branch: string | undefined): Promise { if (!branch) return undefined; const proc = await execProcess(["gh", "pr", "view", branch, "--json", "state,url,mergeCommit,number,title,reviewDecision,statusCheckRollup,mergeable,mergeStateStatus"], { cwd, env: process.env, }); if (!proc.ok) return undefined; try { const data = JSON.parse(proc.stdout) as { state?: string; url?: string; mergeCommit?: { oid?: string } | null; number?: number; title?: unknown; reviewDecision?: unknown; statusCheckRollup?: unknown; mergeable?: unknown; mergeStateStatus?: unknown; }; if (!data.state) return undefined; const sha = data.mergeCommit?.oid; const number = typeof data.number === "number" && Number.isSafeInteger(data.number) ? data.number : undefined; const title = typeof data.title === "string" && data.title.trim() ? data.title.trim() : undefined; const reviewDecision = prReviewDecision(data.reviewDecision); const statusCheckRollup = Array.isArray(data.statusCheckRollup) ? data.statusCheckRollup : undefined; const mergeable = typeof data.mergeable === "string" ? data.mergeable : undefined; const mergeStateStatus = typeof data.mergeStateStatus === "string" ? data.mergeStateStatus : undefined; return { state: data.state === "MERGED" ? "merged" : "open", url: data.url, ...(sha ? { sha } : {}), ...(number ? { number } : {}), ...(title ? { title } : {}), ...(reviewDecision ? { reviewDecision } : {}), ...(statusCheckRollup ? { statusCheckRollup } : {}), ...(mergeable ? { mergeable } : {}), ...(mergeStateStatus ? { mergeStateStatus } : {}), }; } catch { return undefined; } } export async function armWorkspacePrAutoMerge(input: { id?: string; repoRoot?: string; worktreePath?: string; branch?: string; prNumber?: number; prUrl?: string; }): Promise<{ workspaceId?: string; status: "merge_planned"; autoMergeArmed: boolean; prNumber?: number; prUrl?: string; error?: string }> { const cwd = input.worktreePath ? resolve(input.worktreePath) : input.repoRoot ? resolve(input.repoRoot) : undefined; if (!cwd) return { workspaceId: input.id, status: "merge_planned", autoMergeArmed: false, error: "worktreePath or repoRoot required" }; const target = input.prNumber ? String(input.prNumber) : input.prUrl ?? input.branch; if (!target) return { workspaceId: input.id, status: "merge_planned", autoMergeArmed: false, error: "prNumber, prUrl, or branch required" }; const guard = await guardRemotePrGitignoredSymlinks(cwd, target); if (!guard.ok) return { workspaceId: input.id, status: "merge_planned", autoMergeArmed: false, prNumber: input.prNumber, prUrl: input.prUrl, error: guard.error }; const proc = await execProcess(["gh", "pr", "merge", target, "--auto", "--merge"], { cwd, env: process.env, }); if (!proc.ok) { return { workspaceId: input.id, status: "merge_planned", autoMergeArmed: false, prNumber: input.prNumber, prUrl: input.prUrl, error: proc.stderr || proc.stdout || "gh pr merge failed", }; } return { workspaceId: input.id, status: "merge_planned", autoMergeArmed: true, prNumber: input.prNumber, prUrl: input.prUrl }; } export async function mergeWorkspacePr(input: { id?: string; repoRoot?: string; worktreePath?: string; branch?: string; prNumber?: number; prUrl?: string; }): Promise<{ workspaceId?: string; status: "merge_planned"; relayMerged: boolean; prNumber?: number; prUrl?: string; error?: string }> { const cwd = input.worktreePath ? resolve(input.worktreePath) : input.repoRoot ? resolve(input.repoRoot) : undefined; if (!cwd) return { workspaceId: input.id, status: "merge_planned", relayMerged: false, error: "worktreePath or repoRoot required" }; const target = input.prNumber ? String(input.prNumber) : input.prUrl ?? input.branch; if (!target) return { workspaceId: input.id, status: "merge_planned", relayMerged: false, error: "prNumber, prUrl, or branch required" }; const guard = await guardRemotePrGitignoredSymlinks(cwd, target); if (!guard.ok) return { workspaceId: input.id, status: "merge_planned", relayMerged: false, prNumber: input.prNumber, prUrl: input.prUrl, error: guard.error }; const proc = await execProcess(["gh", "pr", "merge", target, "--merge"], { cwd, env: process.env, }); if (!proc.ok) { return { workspaceId: input.id, status: "merge_planned", relayMerged: false, prNumber: input.prNumber, prUrl: input.prUrl, error: proc.stderr || proc.stdout || "gh pr merge failed", }; } return { workspaceId: input.id, status: "merge_planned", relayMerged: true, prNumber: input.prNumber, prUrl: input.prUrl }; } export async function refreshWorkspacePrBranch(input: { id?: string; repoRoot?: string; worktreePath?: string; branch?: string; prNumber?: number; prUrl?: string; }): Promise<{ workspaceId?: string; status: "merge_planned"; prRefreshed: boolean; prNumber?: number; prUrl?: string; error?: string }> { const cwd = input.worktreePath ? resolve(input.worktreePath) : input.repoRoot ? resolve(input.repoRoot) : undefined; if (!cwd) return { workspaceId: input.id, status: "merge_planned", prRefreshed: false, error: "worktreePath or repoRoot required" }; const target = input.prNumber ? String(input.prNumber) : input.prUrl ?? input.branch; if (!target) return { workspaceId: input.id, status: "merge_planned", prRefreshed: false, error: "prNumber, prUrl, or branch required" }; // #1145 round-5 BLOCKER-3 — `update-branch` merges the CURRENT remote base into an // already-armed PR's head. An arm can be clean (base had no ignore rule for a path head // carries) and a refresh can still poison it: base gains the rule afterward, this merges it // in, required checks re-run clean, and the already-armed auto-merge lands the ignore+symlink // with no further gate in between. Guard the CURRENT remote head/base merge tree before ever // triggering the update, same primitive `armWorkspacePrAutoMerge`/`mergeWorkspacePr` use. const guard = await guardRemotePrGitignoredSymlinks(cwd, target); if (!guard.ok) return { workspaceId: input.id, status: "merge_planned", prRefreshed: false, prNumber: input.prNumber, prUrl: input.prUrl, error: guard.error }; const proc = await execProcess(["gh", "pr", "update-branch", target], { cwd, env: process.env, }); if (!proc.ok) { return { workspaceId: input.id, status: "merge_planned", prRefreshed: false, prNumber: input.prNumber, prUrl: input.prUrl, error: proc.stderr || proc.stdout || "gh pr update-branch failed", }; } return { workspaceId: input.id, status: "merge_planned", prRefreshed: true, prNumber: input.prNumber, prUrl: input.prUrl }; }