#!/usr/bin/env node const fs = require("fs"); const os = require("os"); const path = require("path"); const { spawnSync } = require("child_process"); const { findProjectRoot } = require("../lib/project-root"); const { parseRawDiff } = require("../lib/git-raw-diff"); const { classifyChangeRecords, classifyPathPolicy, } = require("../lib/workflow-contract"); const DEFAULT_POLL_SECONDS = 20; const BASE_BRANCH_MODIFIED_PATTERNS = [ /base branch was modified/i, /base branch has been modified/i, /branch was modified/i, ]; const REQUIRED_CHECKS = [ ["pr-policy", { label: "pr-policy", aliases: ["pr-policy"], appId: 15368 }], ["pr-evidence", { label: "pr-evidence", aliases: ["pr-evidence"], appId: 15368 }], ["source-validation", { label: "source-validation", aliases: ["source-validation"], appId: 15368 }], ["artifact-preview", { label: "artifact-preview", aliases: ["artifact-preview"], appId: 15368 }], ]; const REQUIRED_PROTECTION_CHECKS = new Map(REQUIRED_CHECKS.map(([name, spec]) => [name, spec.appId])); const GITHUB_ACTIONS_APP_ID = 15368; const SKILL_REVIEW_REQUIRED = [ "review", "Skill Review / review", "Skill Review & Optimize", "Skill Review & Optimize / review", ]; const MANUAL_REVIEW_REQUIRED = ["manual-review-required", "Skill Review / manual-review-required"]; const DISALLOWED_COAUTHOR_TRAILER_PATTERNS = [ //i, /:\s*claude\b/i, /:\s*claude\s+sonnet\b/i, ]; const FULL_SHA_PATTERN = /^[0-9a-f]{40}$/u; const EVIDENCE_SCHEMA_VERSION = 1; const EVIDENCE_TIMEOUT_MS = 120_000; const MAX_EVIDENCE_BYTES = 8 * 1024 * 1024; const APPROVAL_WORKFLOW_PATHS = new Set([ ".github/workflows/actionlint.yml", ".github/workflows/ci.yml", ".github/workflows/codeql.yml", ".github/workflows/dependency-review.yml", ".github/workflows/skill-review.yml", ]); function parseArgs(argv) { const args = { prs: null, pollSeconds: DEFAULT_POLL_SECONDS, dryRun: false, reviewedHeads: [], }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg === "--prs") { args.prs = argv[index + 1] || null; index += 1; } else if (arg === "--poll-seconds") { args.pollSeconds = Number(argv[index + 1]); index += 1; } else if (arg === "--dry-run") { args.dryRun = true; } else if (arg === "--reviewed-head") { const reviewedHead = String(argv[index + 1] || ""); if (!FULL_SHA_PATTERN.test(reviewedHead)) { throw new Error("--reviewed-head must be an exact 40-character lowercase commit SHA."); } args.reviewedHeads.push(reviewedHead); index += 1; } } if (typeof args.pollSeconds !== "number" || Number.isNaN(args.pollSeconds) || args.pollSeconds <= 0) { args.pollSeconds = DEFAULT_POLL_SECONDS; } return args; } function assertFullSha(value, label) { if (!FULL_SHA_PATTERN.test(String(value || ""))) { throw new Error(`${label} must be an exact 40-character lowercase commit SHA.`); } return value; } function readJson(filePath) { return JSON.parse(fs.readFileSync(filePath, "utf8")); } function readRepositorySlug(projectRoot) { const packageJson = readJson(path.join(projectRoot, "package.json")); const repository = packageJson.repository; const rawUrl = typeof repository === "string" ? repository : repository && typeof repository.url === "string" ? repository.url : null; if (!rawUrl) { throw new Error("package.json repository.url is required to resolve the GitHub slug."); } const match = rawUrl.match(/github\.com[:/](?[^/]+\/[^/]+?)(?:\.git)?$/i); if (!match?.groups?.slug) { throw new Error(`Could not derive a GitHub repo slug from repository url: ${rawUrl}`); } return match.groups.slug; } function runCommand(command, args, cwd, options = {}) { const result = spawnSync(command, args, { cwd, encoding: "utf8", input: options.input, stdio: options.capture ? ["pipe", "pipe", "pipe"] : options.input !== undefined ? ["pipe", "inherit", "inherit"] : ["inherit", "inherit", "inherit"], }); if (result.error) { throw result.error; } if (typeof result.status !== "number" || result.status !== 0) { const stderr = options.capture ? result.stderr.trim() : ""; throw new Error(stderr || `${command} ${args.join(" ")} failed with status ${result.status}`); } return options.capture ? result.stdout.trim() : ""; } function runCommandBuffer(command, args, cwd, options = {}) { const result = spawnSync(command, args, { cwd, encoding: null, input: options.input, maxBuffer: options.maxBuffer || 64 * 1024 * 1024, stdio: ["pipe", "pipe", "pipe"], }); if (result.error) { throw result.error; } if (typeof result.status !== "number" || result.status !== 0) { const stderr = Buffer.isBuffer(result.stderr) ? result.stderr.toString("utf8").trim() : ""; throw new Error(stderr || `${command} ${args.join(" ")} failed with status ${result.status}`); } if (!Buffer.isBuffer(result.stdout)) { throw new Error(`${command} did not return a raw byte buffer.`); } return result.stdout; } function resolveMergeBase(projectRoot, baseOid, headOid, dependencies = {}) { const execute = dependencies.runCommand || runCommand; const mergeBase = execute("git", ["merge-base", baseOid, headOid], projectRoot, { capture: true }); return assertFullSha(mergeBase, "Pull request merge-base SHA"); } function normalizeEvidenceRecord(record) { return JSON.stringify({ status: String(record?.status || ""), old_path: record?.old_path ?? null, new_path: record?.new_path ?? null, old_mode: String(record?.old_mode || ""), new_mode: String(record?.new_mode || ""), old_oid: String(record?.old_oid || ""), new_oid: String(record?.new_oid || ""), similarity: record?.similarity ?? null, }); } function isSkillContentRecord(record) { return [record?.old_path, record?.new_path] .filter((filePath) => typeof filePath === "string" && filePath) .some((filePath) => ["canonical_skill", "skill_support"].includes(classifyPathPolicy(filePath).kind)); } function assertValidSnapshot(snapshot, label) { if (!snapshot || typeof snapshot !== "object") { throw new Error(`${label} snapshot is missing.`); } const counts = snapshot?.audit?.counts; for (const severity of ["error", "warning", "info"]) { if (!Number.isInteger(counts?.[severity]) || counts[severity] < 0) { throw new Error(`${label} audit count ${severity} is missing or invalid.`); } } } function validateChangedSkillEvidence(report, expected) { if (!report || typeof report !== "object" || Array.isArray(report)) { throw new Error("Changed-skill evidence must be a JSON object."); } if (report.schema_version !== EVIDENCE_SCHEMA_VERSION) { throw new Error(`Changed-skill evidence schema must be ${EVIDENCE_SCHEMA_VERSION}.`); } for (const [field, value] of [ ["base_oid", expected.mergeBaseOid], ["head_oid", expected.headOid], ["base_ref", expected.mergeBaseOid], ["head_ref", expected.headOid], ]) { if (report[field] !== value) { throw new Error(`Changed-skill evidence ${field} does not match the validated tuple.`); } } if (typeof report.blocking !== "boolean") { throw new Error("Changed-skill evidence blocking must be boolean."); } if (!Array.isArray(report.reasons) || report.reasons.some((reason) => typeof reason !== "string")) { throw new Error("Changed-skill evidence reasons must be an array of strings."); } if (!Array.isArray(report.changes)) { throw new Error("Changed-skill evidence changes must be an array."); } const evidenceRecords = []; for (const [index, change] of report.changes.entries()) { if (!change || typeof change !== "object" || !Array.isArray(change.records) || !change.records.length) { throw new Error(`Changed-skill evidence change ${index} has no covered Git records.`); } const type = String(change.change_type || ""); if (type === "modified" || type === "renamed") { assertValidSnapshot(change.before, `change ${index} before`); assertValidSnapshot(change.after, `change ${index} after`); } else if (type === "added" || type === "copied") { assertValidSnapshot(change.after, `change ${index} after`); } else if (type === "deleted") { assertValidSnapshot(change.before, `change ${index} before`); if (change.after !== null) { throw new Error(`Changed-skill evidence deletion ${index} must not contain an after snapshot.`); } } else { throw new Error(`Changed-skill evidence change ${index} has unknown type ${type || ""}.`); } evidenceRecords.push(...change.records.map(normalizeEvidenceRecord)); } const rawRecords = expected.rawRecords.filter(isSkillContentRecord).map(normalizeEvidenceRecord).sort(); evidenceRecords.sort(); if (new Set(evidenceRecords).size !== evidenceRecords.length) { throw new Error("Changed-skill evidence contains duplicate Git records."); } if (rawRecords.length !== evidenceRecords.length || rawRecords.some((record, index) => record !== evidenceRecords[index])) { throw new Error("Changed-skill evidence does not cover the exact skill-content Git diff."); } if (report.blocking !== (report.reasons.length > 0)) { throw new Error("Changed-skill evidence blocking flag disagrees with its reasons."); } return report; } function resolveIsolatedPython() { const candidates = [["python3"], ["python"], ["py", "-3"]]; for (const candidate of candidates) { const [command, ...baseArgs] = candidate; const probe = spawnSync(command, [...baseArgs, "-I", "-c", "import sys; raise SystemExit(0 if sys.version_info[0] == 3 else 1)"], { encoding: "utf8", shell: false, timeout: 10_000, env: { PATH: process.env.PATH || "", SYSTEMROOT: process.env.SYSTEMROOT || "" }, }); if (!probe.error && probe.status === 0) { return candidate; } } throw new Error("Unable to find an isolated Python 3 interpreter for trusted evidence."); } function recomputeChangedSkillEvidence( projectRoot, { evaluatorOid, mergeBaseOid, headOid, rawRecords }, dependencies = {}, ) { assertFullSha(evaluatorOid, "Trusted evaluator SHA"); assertFullSha(mergeBaseOid, "Evidence merge-base SHA"); assertFullSha(headOid, "Evidence head SHA"); const executeBuffer = dependencies.runCommandBuffer || runCommandBuffer; const spawn = dependencies.spawnSync || spawnSync; const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "aas-trusted-evidence-")); try { const archive = executeBuffer( "git", ["archive", "--format=tar", evaluatorOid, "--", "tools/scripts"], projectRoot, { maxBuffer: 64 * 1024 * 1024 }, ); const extract = spawn("tar", ["-xf", "-", "-C", temporary], { input: archive, encoding: "utf8", shell: false, timeout: 30_000, maxBuffer: 1024 * 1024, }); if (extract.error || extract.status !== 0) { throw new Error(`Could not materialize trusted evaluator: ${extract.error?.message || extract.stderr || "tar failed"}`); } const scriptsDir = path.join(temporary, "tools", "scripts"); const scriptPath = path.join(scriptsDir, "changed_skill_evidence.py"); const outputPath = path.join(temporary, "changed-skills.json"); const [python, ...pythonBaseArgs] = dependencies.pythonCommand || resolveIsolatedPython(); const bootstrap = [ "import runpy,sys", "scripts=sys.argv[1]", "script=sys.argv[2]", "sys.path.insert(0,scripts)", "sys.argv=[script,*sys.argv[3:]]", "runpy.run_path(script,run_name='__main__')", ].join(";"); const environment = { PATH: process.env.PATH || "", SYSTEMROOT: process.env.SYSTEMROOT || "", TMPDIR: temporary, TEMP: temporary, TMP: temporary, HOME: temporary, LANG: "C.UTF-8", LC_ALL: "C.UTF-8", PYTHONDONTWRITEBYTECODE: "1", PYTHONHASHSEED: "0", }; const result = spawn( python, [ ...pythonBaseArgs, "-I", "-c", bootstrap, scriptsDir, scriptPath, "--repo", projectRoot, "--base", mergeBaseOid, "--head", headOid, "--output", outputPath, ], { cwd: projectRoot, encoding: "utf8", shell: false, timeout: EVIDENCE_TIMEOUT_MS, maxBuffer: 2 * 1024 * 1024, env: environment, }, ); if (result.error) { throw new Error(`Trusted changed-skill evaluator failed: ${result.error.message}`); } if (![0, 1].includes(result.status)) { throw new Error(`Trusted changed-skill evaluator exited unexpectedly (${result.status ?? result.signal ?? "unknown"}).`); } const stat = fs.statSync(outputPath); if (!stat.isFile() || stat.size <= 0 || stat.size > MAX_EVIDENCE_BYTES) { throw new Error("Trusted changed-skill evidence output is missing, empty, or oversized."); } const report = validateChangedSkillEvidence(readJson(outputPath), { mergeBaseOid, headOid, rawRecords, }); if ((result.status === 1) !== report.blocking) { throw new Error("Trusted changed-skill evaluator exit status disagrees with evidence blocking state."); } return report; } finally { fs.rmSync(temporary, { recursive: true, force: true }); } } function fetchPullRequestObjects(projectRoot, baseOid, headOid, dependencies = {}) { const execute = dependencies.runCommand || runCommand; assertFullSha(baseOid, "Pull request base SHA"); assertFullSha(headOid, "Pull request head SHA"); execute( "git", ["fetch", "--no-tags", "--no-write-fetch-head", "origin", baseOid, headOid], projectRoot, ); execute("git", ["cat-file", "-e", `${baseOid}^{commit}`], projectRoot); execute("git", ["cat-file", "-e", `${headOid}^{commit}`], projectRoot); } function readRawChangeRecords(projectRoot, baseOid, headOid, dependencies = {}) { const executeBuffer = dependencies.runCommandBuffer || runCommandBuffer; assertFullSha(baseOid, "Pull request base SHA"); assertFullSha(headOid, "Pull request head SHA"); const raw = executeBuffer( "git", ["diff", "--raw", "--no-abbrev", "-z", "-M", "--find-copies-harder", baseOid, headOid, "--"], projectRoot, ); return parseRawDiff(raw); } function resolveBlobSizes(projectRoot, records, dependencies = {}) { const execute = dependencies.runCommand || runCommand; const objectIds = [...new Set(records.flatMap((record) => [record.old_oid, record.new_oid]))] .filter((oid) => FULL_SHA_PATTERN.test(String(oid || "")) && !/^0+$/u.test(oid)); if (!objectIds.length) { throw new Error("Raw Git diff did not contain any materialized blob object IDs."); } const stdout = execute( "git", ["cat-file", "--batch-check=%(objectname) %(objecttype) %(objectsize)"], projectRoot, { capture: true, input: `${objectIds.join("\n")}\n` }, ); const sizes = new Map(); for (const line of String(stdout || "").split(/\r?\n/u).filter(Boolean)) { const match = line.match(/^(?[0-9a-f]{40}) (?\S+) (?\d+)$/u); if (!match?.groups || !objectIds.includes(match.groups.oid)) { throw new Error(`Unexpected git cat-file response: ${line}`); } if (match.groups.type !== "blob") { throw new Error(`Object ${match.groups.oid} is ${match.groups.type}, not a blob.`); } const size = Number(match.groups.size); if (!Number.isSafeInteger(size) || size < 0) { throw new Error(`Object ${match.groups.oid} has an invalid size.`); } sizes.set(match.groups.oid, size); } for (const oid of objectIds) { if (!sizes.has(oid)) { throw new Error(`git cat-file did not return metadata for ${oid}.`); } } return sizes; } function runGhJson(projectRoot, args, options = {}) { const stdout = runCommand( "gh", [...args, "--json", options.jsonFields || ""].filter(Boolean), projectRoot, { capture: true, input: options.input }, ); return JSON.parse(stdout || "null"); } function runGhApiJson(projectRoot, args, options = {}) { const ghArgs = ["api", ...args]; if (options.paginate) { ghArgs.push("--paginate"); } if (options.slurp) { ghArgs.push("--slurp"); } const stdout = runCommand("gh", ghArgs, projectRoot, { capture: true, input: options.input }); return JSON.parse(stdout || "null"); } function flattenGhSlurpPayload(payload) { if (!Array.isArray(payload)) { return []; } const flattened = []; for (const page of payload) { if (Array.isArray(page)) { flattened.push(...page); } else if (page && typeof page === "object") { flattened.push(page); } } return flattened; } function ensureOnMainAndClean(projectRoot) { const branch = runCommand("git", ["rev-parse", "--abbrev-ref", "HEAD"], projectRoot, { capture: true, }); if (branch !== "main") { throw new Error(`merge-batch must run from main. Current branch: ${branch}`); } const status = runCommand( "git", ["status", "--porcelain", "--untracked-files=no"], projectRoot, { capture: true }, ); if (status) { throw new Error("merge-batch requires a clean tracked working tree before starting."); } } function ensureTrustedMain(projectRoot, dependencies = {}) { const execute = dependencies.runCommand || runCommand; ensureOnMainAndClean(projectRoot); execute("git", ["fetch", "--no-tags", "origin", "main"], projectRoot); const localHead = assertFullSha( execute("git", ["rev-parse", "HEAD"], projectRoot, { capture: true }), "Local main SHA", ); const remoteHead = assertFullSha( execute("git", ["rev-parse", "origin/main"], projectRoot, { capture: true }), "origin/main SHA", ); if (localHead !== remoteHead) { throw new Error(`merge-batch requires local main to equal origin/main (${localHead} != ${remoteHead}).`); } return localHead; } function parsePrList(prs) { if (!prs) { throw new Error("Usage: merge_batch.cjs --prs 450,449,446,451"); } const parsed = prs .split(/[\s,]+/) .map((value) => Number.parseInt(value, 10)) .filter((value) => Number.isInteger(value) && value > 0); if (!parsed.length) { throw new Error("No valid PR numbers were provided."); } return [...new Set(parsed)]; } function extractSummaryBlock(body) { const text = String(body || "").replace(/\r\n/g, "\n").trim(); if (!text) { return ""; } const sectionMatch = text.match(/^\s*##\s+/m); if (!sectionMatch) { return text; } const prefix = text.slice(0, sectionMatch.index).trimEnd(); return prefix; } function stripDisallowedCoauthorTrailers(body) { return String(body || "") .replace(/\r\n/g, "\n") .split("\n") .filter((line) => { if (!/^\s*co-authored-by:/i.test(line)) { return true; } return !DISALLOWED_COAUTHOR_TRAILER_PATTERNS.some((pattern) => pattern.test(line)); }) .join("\n") .replace(/\n{3,}/g, "\n\n") .trim(); } function buildSquashMergeSubject(prDetails) { return `${String(prDetails.title || `PR #${prDetails.number}`).trim()} (#${prDetails.number})`; } function buildSquashMergeBody(prDetails) { const summary = extractSummaryBlock(prDetails.body); return stripDisallowedCoauthorTrailers(summary); } function loadPullRequestDetails(projectRoot, repoSlug, prNumber) { const details = runGhJson(projectRoot, ["pr", "view", String(prNumber)], { jsonFields: [ "body", "autoMergeRequest", "baseRefName", "baseRefOid", "mergeStateStatus", "mergeable", "number", "title", "headRefOid", "headRefName", "headRepository", "url", ].join(","), }); return details; } function pullRequestTuple(prDetails) { const number = Number(prDetails?.number); if (prDetails?.autoMergeRequest) { throw new Error(`PR #${number || ""} already has deferred auto-merge enabled.`); } if (prDetails?.baseRefName !== "main") { throw new Error(`PR #${number || ""} must target main.`); } return { baseOid: assertFullSha(prDetails?.baseRefOid, `PR #${number} base SHA`), headOid: assertFullSha(prDetails?.headRefOid, `PR #${number} head SHA`), }; } function globMatchesRef(pattern, refName) { if (pattern === "~ALL" || pattern === "~DEFAULT_BRANCH") { return true; } if (/[\[\]\\]/u.test(String(pattern || ""))) { return null; } const escaped = String(pattern || "") .replace(/[.+^${}()|[\]\\]/gu, "\\$&") .replace(/\*\*/gu, "\u0000") .replace(/\*/gu, "[^/]*") .replace(/\?/gu, "[^/]") .replace(/\u0000/gu, ".*"); return new RegExp(`^${escaped}$`, "u").test(refName); } function rulesetAppliesToMain(ruleset) { if (String(ruleset?.enforcement || "").toLowerCase() !== "active" || ruleset?.target !== "branch") { return false; } const refCondition = ruleset?.conditions?.ref_name; if (!refCondition) { return true; } const refName = "refs/heads/main"; const excludeMatches = Array.isArray(refCondition.exclude) ? refCondition.exclude.map((pattern) => globMatchesRef(pattern, refName)) : []; if (excludeMatches.includes(null)) { return true; } const excluded = excludeMatches.includes(true); if (excluded) { return false; } const includes = Array.isArray(refCondition.include) ? refCondition.include : []; const includeMatches = includes.map((pattern) => globMatchesRef(pattern, refName)); return includes.length === 0 || includeMatches.includes(null) || includeMatches.includes(true); } function assertUnchangedTuple(prDetails, expected, phase, prNumber) { const actual = pullRequestTuple(prDetails); if (actual.baseOid !== expected.baseOid || actual.headOid !== expected.headOid) { throw new Error( `PR #${prNumber} base/head changed ${phase}: expected ${expected.baseOid}/${expected.headOid}, ` + `received ${actual.baseOid}/${actual.headOid}. Rerun merge:batch.`, ); } return actual; } function validateEffectiveMainProtection(protection, rulesets = []) { const required = protection?.required_status_checks; const checks = Array.isArray(required?.checks) ? required.checks : []; const configured = new Map(checks.map((check) => [String(check?.context || ""), Number(check?.app_id)])); const invalidChecks = [...REQUIRED_PROTECTION_CHECKS].filter( ([context, appId]) => configured.get(context) !== appId, ); if (required?.strict !== true || invalidChecks.length > 0) { throw new Error( "main protection must require the exact app-bound strict checks: " + [...REQUIRED_PROTECTION_CHECKS.keys()].join(", "), ); } if (protection?.enforce_admins?.enabled !== true) { throw new Error("main protection must apply to administrators."); } if (!protection?.required_pull_request_reviews) { throw new Error("main protection must require changes through pull requests."); } if (Number(protection.required_pull_request_reviews.required_approving_review_count) !== 0) { throw new Error("main protection must not require routine approving reviews."); } const bypassAllowances = protection.required_pull_request_reviews.bypass_pull_request_allowances || {}; if (["users", "teams", "apps"].some((key) => Array.isArray(bypassAllowances[key]) && bypassAllowances[key].length > 0)) { throw new Error("main pull-request protection must not have bypass allowances."); } if (protection?.allow_force_pushes?.enabled !== false || protection?.allow_deletions?.enabled !== false) { throw new Error("main protection must disable force pushes and branch deletion."); } const applicableRulesets = rulesets.filter(rulesetAppliesToMain); const bypassing = applicableRulesets.filter((ruleset) => Array.isArray(ruleset?.bypass_actors) && ruleset.bypass_actors.length > 0, ); if (bypassing.length) { throw new Error(`Cannot prove latest-base enforcement because rulesets have bypass actors: ${bypassing.map((item) => item.id).join(", ")}.`); } const mergeQueues = applicableRulesets.filter((ruleset) => Array.isArray(ruleset?.rules) && ruleset.rules.some((rule) => rule?.type === "merge_queue"), ); if (mergeQueues.length) { throw new Error(`merge-batch does not support deferred merge queues: ${mergeQueues.map((item) => item.id).join(", ")}.`); } return true; } function loadEffectiveMainProtection(projectRoot, repoSlug, dependencies = {}) { const api = dependencies.runGhApiJson || runGhApiJson; const protection = api(projectRoot, [`repos/${repoSlug}/branches/main/protection`]); const pages = api( projectRoot, [`repos/${repoSlug}/rulesets?includes_parents=true&per_page=100`], { paginate: true, slurp: true }, ); const summaries = flattenGhSlurpPayload(pages); const rulesets = summaries .filter((ruleset) => Number.isInteger(Number(ruleset?.id))) .map((ruleset) => api(projectRoot, [`repos/${repoSlug}/rulesets/${Number(ruleset.id)}`])); return { protection, rulesets }; } function assertEffectiveMainProtection(projectRoot, repoSlug, dependencies = {}) { const load = dependencies.loadEffectiveMainProtection || loadEffectiveMainProtection; let state; try { state = load(projectRoot, repoSlug, dependencies); } catch (error) { throw new Error(`Cannot prove effective main protection: ${error.message}`); } validateEffectiveMainProtection(state?.protection, state?.rulesets); } function getRequiredCheckAliases(prDetails, options = {}) { const aliases = REQUIRED_CHECKS.map(([, value]) => value); if (prDetails.hasSkillChanges) { aliases.push({ label: "review", aliases: SKILL_REVIEW_REQUIRED, appId: GITHUB_ACTIONS_APP_ID, acceptedConclusions: ["success"], alternatives: options.allowManualReview ? [{ aliases: MANUAL_REVIEW_REQUIRED, appId: GITHUB_ACTIONS_APP_ID, acceptedConclusions: ["success"], }] : [], }); } return aliases; } function mergeableIsConflict(prDetails) { const mergeable = String(prDetails.mergeable || "").toUpperCase(); const mergeState = String(prDetails.mergeStateStatus || "").toUpperCase(); return mergeable === "CONFLICTING" || mergeState === "DIRTY"; } function selectLatestCheckRuns(checkRuns) { const byName = new Map(); for (const run of checkRuns) { const name = String(run?.name || ""); if (!name) { continue; } const previous = byName.get(name); if (!previous) { byName.set(name, run); continue; } // Completion time is not creation order: an older failed run may finish // after a newly-created pending run for the same head SHA. const currentKey = run.created_at || run.started_at || run.completed_at || ""; const previousKey = previous.created_at || previous.started_at || previous.completed_at || ""; if (currentKey > previousKey || (currentKey === previousKey && Number(run.id || 0) > Number(previous.id || 0))) { byName.set(name, run); } } return byName; } function checkRunMatchesAliases(checkRun, aliases, appId) { const name = String(checkRun?.name || ""); return Number(checkRun?.app?.id) === Number(appId) && aliases.some((alias) => name === alias); } function normalizeRequiredCheckSpec(requiredCheck) { if (Array.isArray(requiredCheck)) { return { label: requiredCheck[0], aliases: requiredCheck, acceptedConclusions: ["success"], alternatives: [], blockingAliases: [], appId: GITHUB_ACTIONS_APP_ID, }; } if (!requiredCheck || typeof requiredCheck !== "object" || !Array.isArray(requiredCheck.aliases)) { throw new Error("Required check specification is malformed."); } return { label: String(requiredCheck.label || requiredCheck.aliases[0] || "check"), aliases: requiredCheck.aliases, acceptedConclusions: requiredCheck.acceptedConclusions || ["success"], alternatives: Array.isArray(requiredCheck.alternatives) ? requiredCheck.alternatives : [], blockingAliases: Array.isArray(requiredCheck.blockingAliases) ? requiredCheck.blockingAliases : [], appId: Number(requiredCheck.appId || GITHUB_ACTIONS_APP_ID), }; } function summarizeCheckCandidate(latestRuns, aliases, acceptedConclusions, appId = GITHUB_ACTIONS_APP_ID) { const candidates = latestRuns.filter((run) => checkRunMatchesAliases(run, aliases, appId)); if (!candidates.length) { return { state: "missing", conclusion: null, run: null }; } const successful = candidates.find((run) => ( String(run?.status || "").toLowerCase() === "completed" && acceptedConclusions.includes(String(run?.conclusion || "").toLowerCase()) )); if (successful) { return { state: "success", conclusion: String(successful.conclusion || "").toLowerCase(), run: successful, }; } const pending = candidates.find((run) => String(run?.status || "").toLowerCase() !== "completed"); if (pending) { return { state: "pending", conclusion: String(pending.conclusion || "").toLowerCase(), run: pending, }; } const failed = candidates.find((run) => { const conclusion = String(run?.conclusion || "").toLowerCase(); return conclusion && conclusion !== "skipped"; }); if (failed) { return { state: "failed", conclusion: String(failed.conclusion || "").toLowerCase(), run: failed, }; } return { state: "missing", conclusion: "skipped", run: candidates[0], }; } function summarizeRequiredCheckRuns(checkRuns, requiredAliases) { const latestByName = selectLatestCheckRuns(checkRuns); const summaries = []; const latestRuns = [...latestByName.values()]; for (const requiredCheck of requiredAliases) { const spec = normalizeRequiredCheckSpec(requiredCheck); const blocker = summarizeCheckCandidate(latestRuns, spec.blockingAliases, [], spec.appId); if (blocker.state === "failed" || blocker.state === "pending") { summaries.push({ label: spec.label, ...blocker }); continue; } const primary = summarizeCheckCandidate(latestRuns, spec.aliases, spec.acceptedConclusions, spec.appId); if (primary.state === "success" || primary.state === "failed" || primary.state === "pending") { summaries.push({ label: spec.label, ...primary }); continue; } let alternativeSummary = null; for (const alternative of spec.alternatives) { const candidate = summarizeCheckCandidate( latestRuns, alternative.aliases || [], alternative.acceptedConclusions || ["success"], Number(alternative.appId || spec.appId), ); if (candidate.state === "success") { alternativeSummary = candidate; break; } if (!alternativeSummary || candidate.state === "pending" || candidate.state === "failed") { alternativeSummary = candidate; } } if (!spec.alternatives.length && primary.conclusion === "skipped") { summaries.push({ label: spec.label, ...primary, state: "failed" }); } else { summaries.push({ label: spec.label, ...(alternativeSummary || primary) }); } } return summaries; } function formatCheckSummary(summaries) { return summaries .map((summary) => { if (summary.state === "success") { return `${summary.label}: ${summary.conclusion || "success"}`; } if (summary.state === "pending") { return `${summary.label}: pending (${summary.conclusion || "in progress"})`; } if (summary.state === "failed") { return `${summary.label}: failed (${summary.conclusion || "unknown"})`; } return `${summary.label}: missing`; }) .join(", "); } function listActionRequiredRuns(projectRoot, repoSlug, headSha) { const payload = runGhApiJson(projectRoot, [ `repos/${repoSlug}/actions/runs?head_sha=${headSha}&status=action_required&per_page=100`, ]); const runs = (Array.isArray(payload?.workflow_runs) ? payload.workflow_runs : []) .filter((run) => Number.isInteger(Number(run?.id))); const seen = new Set(); return runs.filter((run) => { const id = Number(run.id); if (seen.has(id)) { return false; } seen.add(id); return true; }); } function listWorkflowDefinitions(projectRoot, repoSlug) { const payload = runGhApiJson(projectRoot, [ `repos/${repoSlug}/actions/workflows?per_page=100`, ]); return Array.isArray(payload?.workflows) ? payload.workflows : []; } function validateActionRequiredRuns( runs, workflows, prNumber, headSha, allowedWorkflowPaths = APPROVAL_WORKFLOW_PATHS, prIdentity = null, ) { const workflowById = new Map( workflows .filter((workflow) => Number.isInteger(Number(workflow?.id))) .map((workflow) => [Number(workflow.id), workflow]), ); const validated = []; for (const run of runs) { const runId = Number(run?.id); const workflowId = Number(run?.workflow_id); const runPath = typeof run?.path === "string" ? run.path : ""; const workflow = workflowById.get(workflowId); const prNumbers = Array.isArray(run?.pull_requests) ? run.pull_requests.map((entry) => Number(entry?.number)).filter(Number.isInteger) : []; const reasons = []; if (!Number.isInteger(runId) || runId <= 0) { reasons.push("missing run ID"); } if (!workflow || !Number.isInteger(workflowId)) { reasons.push("workflow ID is not present in the trusted workflow inventory"); } if (!allowedWorkflowPaths.has(runPath)) { reasons.push(`workflow path ${runPath || ""} is not allowlisted`); } if (workflow && (workflow.path !== runPath || !allowedWorkflowPaths.has(workflow.path))) { reasons.push("workflow ID/path mapping does not match the allowlist"); } if (workflow && workflow.state !== "active") { reasons.push(`workflow is not active (${workflow.state || "missing state"})`); } if (run?.event !== "pull_request") { reasons.push(`event is ${run?.event || "missing"}, not pull_request`); } if (run?.head_sha !== headSha) { reasons.push("head SHA does not match the captured pull request head"); } if (!prNumbers.includes(prNumber)) { const identityValues = [ run?.head_branch, run?.head_repository?.full_name, run?.repository?.full_name, prIdentity?.headRefName, prIdentity?.headRepository, prIdentity?.baseRepository, ]; const canBindEmptyMetadata = Array.isArray(run?.pull_requests) && run.pull_requests.length === 0 && identityValues.every((value) => typeof value === "string" && value.length > 0) && run?.head_branch === prIdentity.headRefName && run?.head_repository?.full_name === prIdentity.headRepository && run?.repository?.full_name === prIdentity.baseRepository; if (!canBindEmptyMetadata) { reasons.push(`pull request metadata does not contain #${prNumber} and the exact fork identity does not match`); } } if (reasons.length) { throw new Error(`Refusing workflow run ${runId || ""}: ${reasons.join("; ")}.`); } validated.push(run); } return validated; } function approveWorkflowRun(projectRoot, repoSlug, run) { runCommand( "gh", ["api", "-X", "POST", `repos/${repoSlug}/actions/runs/${run.id}/approve`], projectRoot, ); } function isSameRepositoryPullRequest(repoSlug, prDetails) { const headRepository = prDetails?.headRepository?.nameWithOwner; return typeof headRepository === "string" && headRepository.toLowerCase() === String(repoSlug || "").toLowerCase(); } function approveActionRequiredRuns(projectRoot, repoSlug, prDetails, options = {}) { const prNumber = Number(prDetails?.number); const tuple = pullRequestTuple(prDetails); const { baseOid, headOid } = tuple; const dependencies = options.dependencies || {}; const fetchObjects = dependencies.fetchPullRequestObjects || fetchPullRequestObjects; const getMergeBase = dependencies.resolveMergeBase || resolveMergeBase; const readRecords = dependencies.readRawChangeRecords || readRawChangeRecords; const getSizes = dependencies.resolveBlobSizes || resolveBlobSizes; const classifyRecords = dependencies.classifyChangeRecords || classifyChangeRecords; const recomputeEvidence = dependencies.recomputeChangedSkillEvidence || recomputeChangedSkillEvidence; const getCurrentDetails = dependencies.loadPullRequestDetails || loadPullRequestDetails; const getRuns = dependencies.listActionRequiredRuns || listActionRequiredRuns; const getWorkflows = dependencies.listWorkflowDefinitions || listWorkflowDefinitions; const approveRun = dependencies.approveWorkflowRun || approveWorkflowRun; if (!Number.isInteger(prNumber) || prNumber <= 0) { throw new Error("Pull request number is required for workflow approval."); } fetchObjects(projectRoot, baseOid, headOid, dependencies); const mergeBaseOid = getMergeBase(projectRoot, baseOid, headOid, dependencies); const records = readRecords(projectRoot, mergeBaseOid, headOid, dependencies); const sameRepository = isSameRepositoryPullRequest(repoSlug, prDetails); const preliminaryPolicy = classifyRecords(records, { requireBlobSizes: false }); if (!sameRepository && !preliminaryPolicy?.approvalSafe) { const reasons = Array.isArray(preliminaryPolicy?.reasons) && preliminaryPolicy.reasons.length ? preliminaryPolicy.reasons.slice(0, 12).join(", ") : "unclassified local diff"; throw new Error(`PR #${prNumber} local base-to-head diff is not fork-approval-safe: ${reasons}.`); } const blobSizes = getSizes(projectRoot, records, dependencies); const policy = classifyRecords(records, { blobSizes }); if (!sameRepository && !policy?.approvalSafe) { const reasons = Array.isArray(policy?.reasons) && policy.reasons.length ? policy.reasons.slice(0, 12).join(", ") : "unclassified local diff"; throw new Error(`PR #${prNumber} local base-to-head diff is not fork-approval-safe: ${reasons}.`); } const evaluatorOid = assertFullSha( options.evaluatorOid || dependencies.getEvaluatorOid?.(projectRoot), "Trusted evaluator SHA", ); const evidence = recomputeEvidence( projectRoot, { evaluatorOid, mergeBaseOid, headOid, rawRecords: records }, dependencies, ); if (evidence.blocking) { const reasons = evidence.reasons.slice(0, 12).join(", ") || "unspecified regression"; throw new Error(`PR #${prNumber} trusted changed-skill evidence is blocking: ${reasons}.`); } const reviewedHeads = new Set(options.reviewedHeads || []); if (policy.requiresHumanReview && !reviewedHeads.has(headOid)) { throw new Error( `PR #${prNumber} changes canonical skill content. Re-run with --reviewed-head ${headOid} after reviewing that exact full SHA.`, ); } const workflows = getWorkflows(projectRoot, repoSlug); const excludedRunIds = new Set((options.excludedRunIds || []).map(Number)); const runs = getRuns(projectRoot, repoSlug, headOid) .filter((run) => !excludedRunIds.has(Number(run?.id))); const validatedRuns = validateActionRequiredRuns( runs, workflows, prNumber, headOid, options.allowedWorkflowPaths || APPROVAL_WORKFLOW_PATHS, { headRefName: prDetails.headRefName, headRepository: prDetails.headRepository?.nameWithOwner, baseRepository: repoSlug, }, ); assertUnchangedTuple( getCurrentDetails(projectRoot, repoSlug, prNumber), tuple, "before approvals", prNumber, ); if (!options.dryRun) { for (const run of validatedRuns) { approveRun(projectRoot, repoSlug, run); } } assertUnchangedTuple( getCurrentDetails(projectRoot, repoSlug, prNumber), tuple, "after approvals", prNumber, ); return { tuple, evaluatorOid, mergeBaseOid, evidence, records, policy, sameRepository, runs: validatedRuns, approvedRuns: options.dryRun ? [] : validatedRuns, }; } function listCheckRuns(projectRoot, repoSlug, headSha) { const payload = runGhApiJson(projectRoot, [ `repos/${repoSlug}/commits/${headSha}/check-runs?per_page=100`, ]); return Array.isArray(payload?.check_runs) ? payload.check_runs : []; } async function waitForRequiredChecks( projectRoot, repoSlug, headSha, requiredAliases, pollSeconds, maxAttempts = 180, ) { for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { const checkRuns = listCheckRuns(projectRoot, repoSlug, headSha); const summaries = summarizeRequiredCheckRuns(checkRuns, requiredAliases); const pending = summaries.filter((summary) => summary.state === "pending" || summary.state === "missing"); const failed = summaries.filter((summary) => summary.state === "failed"); console.log(`[merge-batch] Checks for ${headSha}: ${formatCheckSummary(summaries)}`); if (failed.length) { throw new Error( `Required checks failed for ${headSha}: ${failed.map((item) => `${item.label} (${item.conclusion || "failed"})`).join(", ")}`, ); } if (!pending.length) { return summaries; } await new Promise((resolve) => setTimeout(resolve, pollSeconds * 1000)); } throw new Error(`Timed out waiting for required checks on ${headSha}.`); } function mergePullRequestImmediately(projectRoot, repoSlug, prDetails, dependencies = {}) { const execute = dependencies.runCommand || runCommand; const headOid = assertFullSha(prDetails?.headRefOid, `PR #${prDetails?.number} head SHA`); const payload = JSON.stringify({ merge_method: "squash", sha: headOid, commit_title: buildSquashMergeSubject(prDetails), commit_message: buildSquashMergeBody(prDetails), }); const stdout = execute( "gh", ["api", `repos/${repoSlug}/pulls/${prDetails.number}/merge`, "-X", "PUT", "--input", "-"], projectRoot, { capture: true, input: payload }, ); let response; try { response = JSON.parse(stdout || "null"); } catch (error) { throw new Error(`PR #${prDetails.number} merge endpoint returned invalid JSON: ${error.message}`); } if (response?.merged !== true || !FULL_SHA_PATTERN.test(String(response?.sha || ""))) { throw new Error( `PR #${prDetails.number} was not merged immediately: ${response?.message || "merge endpoint returned merged=false"}.`, ); } return response; } function isRetryableMergeError(error) { const message = String(error?.message || error || ""); return BASE_BRANCH_MODIFIED_PATTERNS.some((pattern) => pattern.test(message)); } function gitCheckoutMain(projectRoot) { runCommand("git", ["checkout", "main"], projectRoot); } function gitPullMain(projectRoot) { runCommand("git", ["pull", "--ff-only", "origin", "main"], projectRoot); } async function mergePullRequest(projectRoot, repoSlug, prNumber, options) { const prDetails = loadPullRequestDetails(projectRoot, repoSlug, prNumber); console.log(`[merge-batch] PR #${prNumber}: ${prDetails.title}`); if (mergeableIsConflict(prDetails)) { throw new Error(`PR #${prNumber} is in conflict state; resolve conflicts on the PR branch before merging.`); } const approval = approveActionRequiredRuns(projectRoot, repoSlug, prDetails, { dryRun: options.dryRun, evaluatorOid: options.evaluatorOid, reviewedHeads: options.reviewedHeads, excludedRunIds: [], dependencies: options.approvalDependencies, }); const headSha = prDetails.headRefOid; const approvedRuns = approval.approvedRuns; // The Skill Review workflow is path-filtered to SKILL.md. Supporting skill // content still requires exact-head human attestation, but has no review // check run to wait for. prDetails.hasSkillChanges = approval.policy.canonicalSkillChanges.length > 0; if (approvedRuns.length) { console.log( `[merge-batch] PR #${prNumber}: approved ${approvedRuns.length} fork run(s) waiting on action_required.`, ); } const requiredCheckAliases = getRequiredCheckAliases(prDetails, { allowManualReview: approval.policy.requiresHumanReview && new Set(options.reviewedHeads || []).has(headSha), }); if (!options.dryRun) { await waitForRequiredChecks(projectRoot, repoSlug, headSha, requiredCheckAliases, options.pollSeconds); } if (options.dryRun) { console.log(`[merge-batch] PR #${prNumber}: dry run complete, skipping merge and post-merge sync.`); return { prNumber, merged: false, approvedRuns: [], followUp: { changed: false }, }; } assertUnchangedTuple( loadPullRequestDetails(projectRoot, repoSlug, prNumber), approval.tuple, "immediately before merge", prNumber, ); assertEffectiveMainProtection( projectRoot, repoSlug, options.protectionDependencies, ); mergePullRequestImmediately(projectRoot, repoSlug, prDetails, options.mergeDependencies); const merged = true; console.log(`[merge-batch] PR #${prNumber}: merged.`); gitCheckoutMain(projectRoot); gitPullMain(projectRoot); const followUp = { changed: false, delegatedToCanonicalSync: true }; console.log(`[merge-batch] PR #${prNumber}: canonical artifacts and contributor credits delegated to the protected bot PR lane.`); return { prNumber, merged, approvedRuns: approvedRuns.map((run) => run.id), followUp, }; } async function runBatch(projectRoot, prNumbers, options = {}) { const repoSlug = readRepositorySlug(projectRoot); const results = []; const evaluatorOid = ensureTrustedMain(projectRoot, options.mainDependencies); if (!options.dryRun) { assertEffectiveMainProtection(projectRoot, repoSlug, options.protectionDependencies); } for (const prNumber of prNumbers) { const result = await mergePullRequest(projectRoot, repoSlug, prNumber, { ...options, evaluatorOid, }); results.push(result); } return results; } async function main() { const args = parseArgs(process.argv.slice(2)); const projectRoot = findProjectRoot(__dirname); const prNumbers = parsePrList(args.prs); if (args.dryRun) { console.log(`[merge-batch] Dry run for PRs: ${prNumbers.join(", ")}`); } const results = await runBatch(projectRoot, prNumbers, { dryRun: args.dryRun, pollSeconds: args.pollSeconds, reviewedHeads: args.reviewedHeads, }); console.log( `[merge-batch] Completed ${results.length} PR(s): ${results.map((result) => `#${result.prNumber}`).join(", ")}`, ); } if (require.main === module) { main().catch((error) => { console.error(`[merge-batch] ${error.message}`); process.exit(1); }); } module.exports = { approvalWorkflowPaths: APPROVAL_WORKFLOW_PATHS, approveActionRequiredRuns, approveWorkflowRun, assertEffectiveMainProtection, assertFullSha, assertUnchangedTuple, baseBranchModifiedPatterns: BASE_BRANCH_MODIFIED_PATTERNS, buildSquashMergeBody, buildSquashMergeSubject, checkRunMatchesAliases, ensureOnMainAndClean, ensureTrustedMain, extractSummaryBlock, formatCheckSummary, fetchPullRequestObjects, getRequiredCheckAliases, gitCheckoutMain, gitPullMain, isRetryableMergeError, listActionRequiredRuns, listCheckRuns, listWorkflowDefinitions, loadEffectiveMainProtection, loadPullRequestDetails, mergePullRequest, mergePullRequestImmediately, mergeableIsConflict, parseArgs, parsePrList, parseRawDiff, pullRequestTuple, readRawChangeRecords, readRepositorySlug, recomputeChangedSkillEvidence, resolveMergeBase, rulesetAppliesToMain, runCommand, runCommandBuffer, runBatch, selectLatestCheckRuns, stripDisallowedCoauthorTrailers, summarizeRequiredCheckRuns, validateActionRequiredRuns, validateChangedSkillEvidence, validateEffectiveMainProtection, waitForRequiredChecks, resolveBlobSizes, };