mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 17:53:39 +00:00
162 lines
6.1 KiB
JavaScript
162 lines
6.1 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { execFileSync } from "node:child_process";
|
|
import { copyFileSync, lstatSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { isDirectRunUrl } from "../lib/direct-run.mjs";
|
|
import {
|
|
createReviewArtifactTemplate,
|
|
renderReviewMarkdown,
|
|
validateReviewArtifacts,
|
|
} from "./review-artifacts.mjs";
|
|
|
|
const gitExecutable = process.env.OPENCLAW_PR_GIT || process.env.GIT_EXEC || "git";
|
|
const git = (...args) => execFileSync(gitExecutable, args, { encoding: "utf8" }).trim();
|
|
|
|
function readRegular(path) {
|
|
if (!lstatSync(path).isFile()) {
|
|
throw new Error(`Expected a regular review file: ${path}`);
|
|
}
|
|
return readFileSync(path, "utf8");
|
|
}
|
|
|
|
function incomingReview(pr, incoming, jsonOid) {
|
|
const reviewPath = ".local/review.json";
|
|
const review = JSON.parse(readRegular(reviewPath));
|
|
const prMeta = JSON.parse(readRegular(".local/pr-meta.json"));
|
|
const violations = validateReviewArtifacts({ review, prMeta });
|
|
if (violations.length) {
|
|
throw new Error(violations.join("\n"));
|
|
}
|
|
if (prMeta.number !== pr || prMeta.headRefOid !== incoming) {
|
|
throw new Error("Correction preparation does not match the incoming PR review.");
|
|
}
|
|
if (git("hash-object", "--no-filters", reviewPath) !== jsonOid) {
|
|
throw new Error(
|
|
"Incoming review changed after correction admission. Retain evidence and re-review.",
|
|
);
|
|
}
|
|
const findings = review.findings.filter((item) =>
|
|
["BLOCKER", "IMPORTANT"].includes(item.severity),
|
|
);
|
|
if (review.recommendation !== "NEEDS WORK" || findings.length === 0) {
|
|
throw new Error("Correction admission requires NEEDS WORK with actionable findings.");
|
|
}
|
|
if (new Set(findings.map((item) => item.id)).size !== findings.length) {
|
|
throw new Error("Correction admission requires distinct finding IDs.");
|
|
}
|
|
return { prMeta, findings };
|
|
}
|
|
|
|
function candidateMetadata(prMeta, incoming, head) {
|
|
if (!/^[0-9a-f]{40}$/u.test(head) || head === incoming || git("rev-parse", "HEAD") !== head) {
|
|
throw new Error("Review a committed correction, not the unchanged incoming head.");
|
|
}
|
|
execFileSync(gitExecutable, ["merge-base", "--is-ancestor", incoming, head]);
|
|
execFileSync(gitExecutable, ["diff", "--quiet", "HEAD", "--"]);
|
|
if (git("ls-files", "--others", "--exclude-standard")) {
|
|
throw new Error("Commit or preserve untracked source before reviewing the correction.");
|
|
}
|
|
// Include both the incoming scope and every fixup path. Candidate metadata is
|
|
// derived in memory; the live incoming-head metadata is never rewritten.
|
|
const changed = execFileSync(gitExecutable, ["diff", "--name-only", "-z", incoming, head], {
|
|
encoding: "utf8",
|
|
// The complete path set is required; a fixed capture cap rejects large corrections.
|
|
maxBuffer: Infinity,
|
|
})
|
|
.split("\0")
|
|
.filter(Boolean);
|
|
const paths = new Set([...prMeta.files.map((file) => file.path), ...changed]);
|
|
return { ...prMeta, headRefOid: head, files: [...paths].map((path) => ({ path })) };
|
|
}
|
|
|
|
function runCorrectionReview(command, pr, incoming, head, jsonOid) {
|
|
if (
|
|
!["init", "validate"].includes(command) ||
|
|
!Number.isSafeInteger(pr) ||
|
|
pr < 1 ||
|
|
!/^[0-9a-f]{40}$/u.test(incoming)
|
|
) {
|
|
throw new Error("Invalid correction-review command or PR number.");
|
|
}
|
|
const { prMeta, findings } = incomingReview(pr, incoming, jsonOid);
|
|
const candidate = candidateMetadata(prMeta, incoming, head);
|
|
const jsonPath = ".local/correction-review.json";
|
|
const incomingJsonPath = ".local/correction-incoming-review.json";
|
|
if (command === "init") {
|
|
const existing = [jsonPath, incomingJsonPath].filter((path) =>
|
|
lstatSync(path, { throwIfNoEntry: false }),
|
|
);
|
|
for (const path of existing) {
|
|
readRegular(path);
|
|
}
|
|
if (existing.length) {
|
|
const archive = mkdtempSync(".local/correction-review-retained.");
|
|
for (const path of existing) {
|
|
copyFileSync(path, join(archive, path.split("/").at(-1)));
|
|
}
|
|
}
|
|
const review = createReviewArtifactTemplate({ number: pr, headSha: head });
|
|
review.correction = {
|
|
incomingHeadSha: incoming,
|
|
incomingReviewJsonOid: jsonOid,
|
|
resolvedFindings: findings.map(({ id }) => ({ id, resolution: "" })),
|
|
};
|
|
copyFileSync(".local/review.json", incomingJsonPath);
|
|
writeFileSync(jsonPath, `${JSON.stringify(review, null, 2)}\n`);
|
|
return;
|
|
}
|
|
const review = JSON.parse(readRegular(jsonPath));
|
|
const violations = validateReviewArtifacts({
|
|
review,
|
|
prMeta: candidate,
|
|
});
|
|
if (violations.length) {
|
|
throw new Error(violations.join("\n"));
|
|
}
|
|
readRegular(incomingJsonPath);
|
|
if (
|
|
review.correction?.incomingReviewJsonOid !== jsonOid ||
|
|
git("hash-object", "--no-filters", incomingJsonPath) !== jsonOid
|
|
) {
|
|
throw new Error("Correction approval does not bind these exact incoming review bytes.");
|
|
}
|
|
if (review.recommendation !== "READY FOR /prepare-pr") {
|
|
throw new Error(
|
|
"The exact correction needs an independent READY review before gates or publication.",
|
|
);
|
|
}
|
|
const resolved = review.correction?.resolvedFindings;
|
|
if (
|
|
review.correction?.incomingHeadSha !== incoming ||
|
|
!Array.isArray(resolved) ||
|
|
resolved.length !== findings.length ||
|
|
new Set(resolved.map((item) => item?.id)).size !== findings.length ||
|
|
!findings.every(({ id }) =>
|
|
resolved.some(
|
|
(item) =>
|
|
item?.id === id &&
|
|
typeof item.resolution === "string" &&
|
|
item.resolution.trim().length > 0,
|
|
),
|
|
)
|
|
) {
|
|
throw new Error("The correction review must resolve every incoming BLOCKER/IMPORTANT finding.");
|
|
}
|
|
process.stdout.write(renderReviewMarkdown(review));
|
|
}
|
|
|
|
if (isDirectRunUrl(process.argv[1], import.meta.url)) {
|
|
try {
|
|
const [command, pr, incoming, head, jsonOid, ...extra] = process.argv.slice(2);
|
|
if (extra.length || !jsonOid) {
|
|
throw new Error(
|
|
"Expected command, PR, incoming/candidate heads and incoming review JSON object ID.",
|
|
);
|
|
}
|
|
runCorrectionReview(command, Number(pr), incoming, head, jsonOid);
|
|
} catch (error) {
|
|
console.error(error instanceof Error ? error.message : String(error));
|
|
process.exitCode = 1;
|
|
}
|
|
}
|