projectclownfish/scripts/post-flight.mjs
2026-04-26 12:13:19 -07:00

387 lines
13 KiB
JavaScript

#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { assertAllowedOwner, hasSecuritySignalText, parseArgs, parseJob, repoRoot, validateJob } from "./lib.mjs";
const PASSING_CHECK_CONCLUSIONS = new Set(["SUCCESS", "SKIPPED", "NEUTRAL"]);
const CLEAN_MERGE_STATES = new Set(["CLEAN", "HAS_HOOKS"]);
const FIX_PR_ACTIONS = new Set(["open_fix_pr", "repair_contributor_branch"]);
const FIX_PR_READY_STATUSES = new Set(["opened", "pushed"]);
const DEFAULT_IGNORED_CHECKS = ["auto-response", "Labeler", "Stale"];
const args = parseArgs(process.argv.slice(2));
const jobPath = args._[0];
const resultPathArg = args._[1];
const latest = Boolean(args.latest);
const dryRun = Boolean(args["dry-run"] || process.env.CLOWNFISH_POST_FLIGHT_DRY_RUN === "1");
if (!jobPath) {
console.error("usage: node scripts/post-flight.mjs <job.md> [result.json] [--latest] [--dry-run]");
process.exit(2);
}
if (!resultPathArg && !latest) {
console.error("result path is required unless --latest is set");
process.exit(2);
}
const job = parseJob(jobPath);
const errors = validateJob(job);
if (errors.length > 0) {
console.error(errors.join("\n"));
process.exit(1);
}
assertAllowedOwner(job.frontmatter.repo, process.env.CLOWNFISH_ALLOWED_OWNER);
if (!["execute", "autonomous"].includes(job.frontmatter.mode)) {
throw new Error("refusing post-flight: job frontmatter mode is not execute or autonomous");
}
if (process.env.CLOWNFISH_ALLOW_EXECUTE !== "1") {
throw new Error("refusing post-flight: CLOWNFISH_ALLOW_EXECUTE must be 1");
}
const resultPath = resultPathArg ? path.resolve(resultPathArg) : findLatestResultPath();
const result = JSON.parse(fs.readFileSync(resultPath, "utf8"));
if (result.repo !== job.frontmatter.repo) {
throw new Error(`result repo ${result.repo} does not match job repo ${job.frontmatter.repo}`);
}
if (result.cluster_id !== job.frontmatter.cluster_id) {
throw new Error(`result cluster ${result.cluster_id} does not match job cluster ${job.frontmatter.cluster_id}`);
}
if (!["execute", "autonomous"].includes(result.mode)) {
throw new Error(`refusing post-flight: result mode is ${result.mode}`);
}
const fixReport = readSiblingJson(resultPath, "fix-execution-report.json");
const report = {
repo: result.repo,
cluster_id: result.cluster_id,
dry_run: dryRun,
result_path: path.relative(repoRoot(), resultPath),
post_flight_at: new Date().toISOString(),
actions: [],
};
if (!fixReport) {
report.actions.push({ action: "post_flight", status: "skipped", reason: "no fix-execution-report.json" });
writeReport(report, resultPath);
process.exit(0);
}
for (const action of fixReport.actions ?? []) {
if (!FIX_PR_ACTIONS.has(String(action.action ?? ""))) continue;
report.actions.push(finalizeFixPr(action));
}
if (report.actions.length === 0) {
report.actions.push({ action: "post_flight", status: "skipped", reason: "no ProjectClownfish fix PR actions to finalize" });
}
writeReport(report, resultPath);
function finalizeFixPr(action) {
const base = {
action: "finalize_fix_pr",
source_action: action.action,
source_status: action.status,
target: action.pr_url ?? action.target ?? null,
};
if (!FIX_PR_READY_STATUSES.has(String(action.status ?? ""))) {
return { ...base, status: "skipped", reason: `fix PR action status is ${action.status ?? "missing"}` };
}
const parsed = parsePullRequestUrl(action.pr_url ?? action.target);
if (!parsed || parsed.repo !== result.repo) {
return { ...base, status: "blocked", reason: "fix PR URL is missing or outside target repo" };
}
const policyBlock = validateMergePolicy();
if (policyBlock) return { ...base, status: "blocked", pr: `#${parsed.number}`, reason: policyBlock };
const pull = fetchPullRequest(result.repo, parsed.number);
const view = fetchPullRequestView(result.repo, parsed.number);
const prBase = { ...base, pr: `#${parsed.number}`, title: view.title ?? pull.title ?? null };
const mergedAt = pull.merged_at ?? view.mergedAt ?? null;
if (mergedAt) {
return {
...prBase,
status: "executed",
reason: "already merged",
merged_at: mergedAt,
merge_commit_sha: pull.merge_commit_sha ?? view.mergeCommit?.oid ?? null,
};
}
const mergeBlock = validateMergeableFixPr({ pull, view, preflight: action.merge_preflight });
if (mergeBlock) {
return {
...prBase,
status: "blocked",
reason: mergeBlock,
mergeable: view.mergeable ?? null,
merge_state_status: view.mergeStateStatus ?? null,
review_decision: view.reviewDecision ?? null,
};
}
if (dryRun) {
return {
...prBase,
status: "planned",
reason: "dry run",
merge_method: "squash",
};
}
ghWithRetry(["pr", "merge", String(parsed.number), "--repo", result.repo, "--squash"]);
const merged = fetchPullRequest(result.repo, parsed.number);
return {
...prBase,
status: "executed",
reason: "merged by ProjectClownfish post-flight",
merged_at: merged.merged_at ?? null,
merge_commit_sha: merged.merge_commit_sha ?? null,
merge_method: "squash",
};
}
function validateMergePolicy() {
if (!job.frontmatter.allowed_actions.includes("merge")) return "job does not allow merge";
if ((job.frontmatter.blocked_actions ?? []).includes("merge")) return "merge is blocked by job frontmatter";
if (job.frontmatter.allow_merge !== true) return "merge requires allow_merge: true";
return "";
}
function validateMergeableFixPr({ pull, view, preflight }) {
if (pull.state !== "open") return `pull request is ${pull.state}`;
if (pull.draft || view.isDraft) return "pull request is draft";
if (String(view.baseRefName ?? pull.base?.ref ?? "") !== "main") return "pull request base is not main";
if (hasSecuritySignalText(pull.title, pull.body, pull.labels ?? [])) {
return "security-sensitive PR requires central security triage";
}
if (view.mergeable !== "MERGEABLE") return `mergeable state is ${view.mergeable || "unknown"}`;
if (!CLEAN_MERGE_STATES.has(String(view.mergeStateStatus ?? ""))) {
return `merge state status is ${view.mergeStateStatus || "unknown"}`;
}
if (["CHANGES_REQUESTED", "REVIEW_REQUIRED"].includes(String(view.reviewDecision ?? ""))) {
return `review decision is ${view.reviewDecision}`;
}
const preflightBlock = validateMergePreflight(preflight);
if (preflightBlock) return preflightBlock;
const threadBlock = validateResolvedReviewThreads(result.repo, pull.number);
if (threadBlock) return threadBlock;
const checkBlock = validateStatusChecks(view.statusCheckRollup ?? []);
if (checkBlock) return checkBlock;
return "";
}
function validateMergePreflight(preflight) {
if (!preflight || typeof preflight !== "object") return "merge_preflight is missing";
if (preflight.security_status !== "cleared") return "security preflight is not cleared";
if (!Array.isArray(preflight.security_evidence) || preflight.security_evidence.length === 0) {
return "security preflight evidence is missing";
}
if (preflight.comments_status !== "resolved") return "review comments are not resolved";
if (!Array.isArray(preflight.comments_evidence) || preflight.comments_evidence.length === 0) {
return "review comments resolution evidence is missing";
}
if (preflight.bot_comments_status !== "resolved") return "review-bot comments are not resolved";
if (!Array.isArray(preflight.bot_comments_evidence) || preflight.bot_comments_evidence.length === 0) {
return "review-bot comment resolution evidence is missing";
}
if (!Array.isArray(preflight.validation_commands) || preflight.validation_commands.length === 0) {
return "merge validation commands are missing";
}
const codexReview = preflight.codex_review;
if (!codexReview || codexReview.command !== "/review") return "Codex /review preflight is missing";
if (!["passed", "clean"].includes(codexReview.status)) return `Codex /review status is ${codexReview.status || "missing"}`;
if (codexReview.findings_addressed !== true) return "Codex /review findings are not addressed";
if (!Array.isArray(codexReview.evidence) || codexReview.evidence.length === 0) {
return "Codex /review evidence is missing";
}
return "";
}
function validateStatusChecks(checks) {
if (!Array.isArray(checks) || checks.length === 0) return "no PR checks found";
const ignored = ignoredCheckNames();
const blockers = [];
for (const check of checks) {
const name = String(check.name ?? check.context ?? "unknown check");
if (ignored.has(name)) continue;
const status = String(check.status ?? check.state ?? "").toUpperCase();
const conclusion = String(check.conclusion ?? "").toUpperCase();
if (status && !["COMPLETED", "SUCCESS"].includes(status)) {
blockers.push(`${name}: ${status}`);
continue;
}
if (conclusion && !PASSING_CHECK_CONCLUSIONS.has(conclusion)) {
blockers.push(`${name}: ${conclusion}`);
}
}
if (blockers.length > 0) return `checks are not clean: ${blockers.slice(0, 5).join(", ")}`;
return "";
}
function ignoredCheckNames() {
const configured = String(process.env.CLOWNFISH_POST_FLIGHT_IGNORE_CHECKS ?? DEFAULT_IGNORED_CHECKS.join(","));
return new Set(
configured
.split(",")
.map((item) => item.trim())
.filter(Boolean),
);
}
function validateResolvedReviewThreads(repo, number) {
const [owner, name] = repo.split("/");
const query = `
query($owner: String!, $name: String!, $number: Int!) {
repository(owner: $owner, name: $name) {
pullRequest(number: $number) {
reviewThreads(first: 100) {
pageInfo { hasNextPage }
nodes {
isResolved
path
line
comments(first: 1) {
nodes {
url
author { login }
body
}
}
}
}
}
}
}
`;
const data = ghJson([
"api",
"graphql",
"-f",
`owner=${owner}`,
"-f",
`name=${name}`,
"-F",
`number=${number}`,
"-f",
`query=${query}`,
]);
const threads = data?.data?.repository?.pullRequest?.reviewThreads;
if (threads?.pageInfo?.hasNextPage) return "too many review threads to prove resolved";
const unresolved = (threads?.nodes ?? []).filter((thread) => thread && !thread.isResolved);
if (unresolved.length === 0) return "";
const examples = unresolved
.slice(0, 3)
.map((thread) => thread.comments?.nodes?.[0]?.url ?? `${thread.path}:${thread.line ?? "?"}`);
return `unresolved review threads remain: ${examples.join(", ")}`;
}
function fetchPullRequest(repo, number) {
return ghJson(["api", `repos/${repo}/pulls/${number}`]);
}
function fetchPullRequestView(repo, number) {
return ghJson([
"pr",
"view",
String(number),
"--repo",
repo,
"--json",
[
"baseRefName",
"isDraft",
"mergeable",
"mergeCommit",
"mergeStateStatus",
"mergedAt",
"reviewDecision",
"state",
"statusCheckRollup",
"title",
"updatedAt",
"url",
].join(","),
]);
}
function findLatestResultPath() {
const runsRoot = path.join(repoRoot(), ".projectclownfish", "runs");
if (!fs.existsSync(runsRoot)) throw new Error("no run directory exists");
const candidates = [];
for (const runName of fs.readdirSync(runsRoot)) {
const candidate = path.join(runsRoot, runName, "result.json");
if (fs.existsSync(candidate)) candidates.push({ path: candidate, mtimeMs: fs.statSync(candidate).mtimeMs });
}
candidates.sort((left, right) => right.mtimeMs - left.mtimeMs);
if (!candidates[0]) throw new Error("no result.json files found");
return candidates[0].path;
}
function readSiblingJson(resultPath, name) {
const file = path.join(path.dirname(resultPath), name);
if (!fs.existsSync(file)) return null;
return JSON.parse(fs.readFileSync(file, "utf8"));
}
function writeReport(report, resultPath) {
const reportPath = path.join(path.dirname(resultPath), "post-flight-report.json");
fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`);
console.log(JSON.stringify(report, null, 2));
}
function parsePullRequestUrl(value) {
const match = String(value ?? "").match(/^https:\/\/github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)(?:[/?#].*)?$/i);
if (!match) return null;
return { repo: match[1], number: Number(match[2]) };
}
function ghJson(ghArgs) {
const text = ghWithRetry(ghArgs);
return JSON.parse(text || "null");
}
function ghWithRetry(ghArgs, attempts = 6) {
let lastError;
for (let attempt = 0; attempt < attempts; attempt += 1) {
try {
return execFileSync("gh", ghArgs, {
cwd: repoRoot(),
encoding: "utf8",
env: process.env,
maxBuffer: 64 * 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
}).trim();
} catch (error) {
lastError = error;
if (!shouldRetryGh(error) || attempt === attempts - 1) throw error;
sleepMs(Math.min(120_000, 10_000 * 2 ** attempt));
}
}
throw lastError;
}
function shouldRetryGh(error) {
const stderr = String(error?.stderr ?? "");
const message = `${error instanceof Error ? error.message : String(error)}\n${stderr}`;
return (
message.includes("was submitted too quickly") ||
message.includes("secondary rate") ||
message.includes("API rate limit exceeded")
);
}
function sleepMs(milliseconds) {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds);
}