1401 lines
47 KiB
JavaScript
1401 lines
47 KiB
JavaScript
#!/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 = [
|
|
/<noreply@anthropic\.com>/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[:/](?<slug>[^/]+\/[^/]+?)(?:\.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 || "<missing>"}.`);
|
|
}
|
|
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(/^(?<oid>[0-9a-f]{40}) (?<type>\S+) (?<size>\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 || "<unknown>"} already has deferred auto-merge enabled.`);
|
|
}
|
|
if (prDetails?.baseRefName !== "main") {
|
|
throw new Error(`PR #${number || "<unknown>"} 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 || "<missing>"} 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 || "<unknown>"}: ${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,
|
|
};
|