fix: make auto-review and PR triage fail safely (#114745)

* fix: make auto-review and PR triage fail safely

* test: cover fail-closed PR merge review gates

* fix: preserve GitHub pending-check review semantics
This commit is contained in:
Peter Steinberger
2026-07-27 17:11:28 -04:00
committed by GitHub
parent 1c0ccdddcd
commit 6f7edb3695
17 changed files with 685 additions and 126 deletions
+57 -4
View File
@@ -294,11 +294,12 @@ function fakeGithub(options: {
>;
checksByRef?: Record<string, FakeCheckRun[]>;
workflowRunsById?: Record<number, FakeWorkflowRun>;
pullsGetByNumber?: Record<number, Record<string, unknown>>;
pullsGetByNumber?: Record<number, Record<string, unknown> | Array<Record<string, unknown>>>;
events?: Array<Record<string, unknown>>;
pageSize?: number;
}) {
const calls: FakeCall[] = [];
const pullsGetCallCounts = new Map<number, number>();
const record = (method: string, args: Record<string, unknown>) => {
calls.push({ method, args });
};
@@ -351,9 +352,13 @@ function fakeGithub(options: {
list: { endpointName: "pulls.list" },
get: (args: Record<string, unknown>) => {
record("pulls.get", args);
const match =
options.pullsGetByNumber?.[args.pull_number as number] ??
options.prs.find((entry) => entry.number === args.pull_number);
const pullNumber = args.pull_number as number;
const configured = options.pullsGetByNumber?.[pullNumber];
const callIndex = pullsGetCallCounts.get(pullNumber) ?? 0;
pullsGetCallCounts.set(pullNumber, callIndex + 1);
const match = Array.isArray(configured)
? configured[Math.min(callIndex, configured.length - 1)]
: (configured ?? options.prs.find((entry) => entry.number === pullNumber));
return Promise.resolve({ data: match });
},
update: (args: Record<string, unknown>) => {
@@ -544,6 +549,54 @@ describe("runPrCiSweeper", () => {
).toEqual([]);
});
it("does not spend the re-fire budget on PRs that change during revalidation", async () => {
const dropped = Array.from({ length: 11 }, (_, index) => ({
...pr(),
number: 200 + index,
state: "open",
head: { sha: index.toString(16).padStart(2, "0").repeat(20) },
}));
const pullsGetByNumber = Object.fromEntries(
dropped
.slice(0, 10)
.map((candidate) => [
candidate.number,
[candidate, { ...candidate, head: { sha: "f".repeat(40) } }],
]),
);
const { github, calls } = fakeGithub({ prs: dropped, runsBySha: {}, pullsGetByNumber });
const { core: loggedCore, logs } = recordingCore();
const results = await runPrCiSweeper({
github: github as never,
context: context as never,
core: loggedCore as never,
now: NOW,
});
expect(results.slice(0, 10)).toEqual(
dropped.slice(0, 10).map((candidate) => ({
number: candidate.number,
sha: candidate.head.sha.slice(0, 12),
action: "skip",
reason: "changed-during-sweep",
})),
);
expect(results.at(-1)).toEqual({
number: 210,
sha: "0a".repeat(6),
action: "refire",
reason: "ci-run-missing",
});
expect(calls.filter((call) => call.method === "pulls.update").map((call) => call.args)).toEqual(
[
{ owner: "openclaw", repo: "openclaw", pull_number: 210, state: "closed" },
{ owner: "openclaw", repo: "openclaw", pull_number: 210, state: "open" },
],
);
expect(logs.at(-1)).toContain("1 re-fire");
});
it("stops listing pages once creation dates cross the lookback", async () => {
const recent = { ...pr(), number: 30, state: "open", head: { sha: "7".repeat(40) } };
const oldA = {
+50 -3
View File
@@ -13,10 +13,12 @@ const describePosix = process.platform === "win32" ? describe.skip : describe;
type MergeScenario = {
auto?: boolean;
autoResult?: "enabled" | "inconclusive" | "unavailable";
checks?: "fail" | "green";
checks?: "fail" | "green" | "pending";
existingAutoMethod?: "" | "MERGE" | "REBASE" | "SQUASH";
mergeStateStatus?: string;
mergeable?: string;
recommendation?: "ready" | "needs_work";
reviewArtifacts?: "valid" | "invalid";
};
function runMerge(scenario: MergeScenario = {}) {
@@ -59,13 +61,27 @@ function runMerge(scenario: MergeScenario = {}) {
const checks =
scenario.checks === "fail"
? [{ name: "CI", bucket: "fail", state: "FAILURE" }]
: [{ name: "CI", bucket: "pass", state: "SUCCESS" }];
: scenario.checks === "pending"
? [{ name: "CI", bucket: "pending", state: "IN_PROGRESS" }]
: [{ name: "CI", bucket: "pass", state: "SUCCESS" }];
const shell = `
set -euo pipefail
source "$OPENCLAW_TEST_MERGE_SCRIPT"
enter_worktree() { :; }
require_artifact() { :; }
validate_review_artifact_data() {
if [ "$OPENCLAW_TEST_REVIEW_ARTIFACTS" != "valid" ]; then
echo 'review artifact validation failed' >&2
return 1
fi
}
require_ready_review_recommendation() {
if [ "$OPENCLAW_TEST_REVIEW_RECOMMENDATION" != "ready" ]; then
echo 'review recommendation is not ready' >&2
return 1
fi
}
verify_prep_branch_matches_prepared_head() { :; }
mark_pr_operation_side_effects_started() { :; }
mainline_drift_requires_sync() { return 1; }
@@ -90,7 +106,10 @@ gh() {
case "$1 $2" in
"pr checks")
case " $* " in
*" --json "*) printf '%s\\n' "$OPENCLAW_TEST_CHECKS_JSON" ;;
*" --json "*)
printf '%s\\n' "$OPENCLAW_TEST_CHECKS_JSON"
return "$OPENCLAW_TEST_CHECKS_EXIT_STATUS"
;;
esac
;;
"pr view")
@@ -161,6 +180,7 @@ merge_run 123 "$OPENCLAW_TEST_AUTO_REQUESTED"
OPENCLAW_TEST_AUTO_REQUESTED: scenario.auto ? "true" : "false",
OPENCLAW_TEST_AUTO_RESULT: scenario.autoResult ?? "enabled",
OPENCLAW_TEST_AUTO_STATE: autoState,
OPENCLAW_TEST_CHECKS_EXIT_STATUS: scenario.checks === "pending" ? "8" : "0",
OPENCLAW_TEST_CHECKS_JSON: JSON.stringify(checks),
OPENCLAW_TEST_DISABLED_AUTO_META: disabledAutoMeta,
OPENCLAW_TEST_GH_CALLS: calls,
@@ -169,6 +189,8 @@ merge_run 123 "$OPENCLAW_TEST_AUTO_REQUESTED"
OPENCLAW_TEST_MERGE_STATE_STATUS: scenario.mergeStateStatus ?? "BEHIND",
OPENCLAW_TEST_POST_AUTO_META: postAutoMeta,
OPENCLAW_TEST_PRE_AUTO_META: preAutoMeta,
OPENCLAW_TEST_REVIEW_ARTIFACTS: scenario.reviewArtifacts ?? "valid",
OPENCLAW_TEST_REVIEW_RECOMMENDATION: scenario.recommendation ?? "ready",
OPENCLAW_TEST_ROOT: root,
},
});
@@ -179,6 +201,22 @@ merge_run 123 "$OPENCLAW_TEST_AUTO_REQUESTED"
}
describePosix("scripts/pr merge-run", () => {
it("refuses to merge when review artifact validation fails", () => {
const result = runMerge({ reviewArtifacts: "invalid" });
expect(result.status).toBe(1);
expect(result.stderr).toContain("review artifact validation failed");
expect(result.calls).not.toContain("pr merge");
});
it("refuses to merge when the review recommendation is not ready", () => {
const result = runMerge({ recommendation: "needs_work" });
expect(result.status).toBe(1);
expect(result.stderr).toContain("review recommendation is not ready");
expect(result.calls).not.toContain("pr merge");
});
it("does not enable auto-merge when exact-head required CI is failing", () => {
const result = runMerge({ auto: true, checks: "fail" });
@@ -187,6 +225,15 @@ describePosix("scripts/pr merge-run", () => {
expect(result.calls).not.toContain("pr merge");
});
it("does not mistake pending required checks for a GitHub API failure", () => {
const result = runMerge({ auto: true, checks: "pending" });
expect(result.status).toBe(1);
expect(result.stdout).toContain("Required checks are still pending.");
expect(result.stderr).not.toContain("unable to verify the required GitHub checks");
expect(result.calls).not.toContain("pr merge");
});
it("fails a conflicting PR without attempting auto-merge", () => {
const result = runMerge({
auto: true,
+44
View File
@@ -535,6 +535,50 @@ describe("lease-retry gate stamp refresh", () => {
});
});
describe("prepare review readiness", () => {
it("rejects invalid review artifacts before any preparation side effects", () => {
const repoDir = makeTempDir("openclaw-pr-prepare-invalid-review-");
mkdirSync(join(repoDir, ".local"));
const result = runGatesBash(
[
"review_validate_artifacts() { echo 'invalid review artifacts'; return 1; }",
"require_ready_review_recommendation() { touch .local/readiness-called; }",
"mark_pr_operation_side_effects_started() { touch .local/side-effects; }",
"enter_worktree() { touch .local/worktree-entered; }",
"prepare_init 4242",
].join("\n"),
{ cwd: repoDir, sourcePrepareCore: true },
);
expect(result.status).toBe(1);
expect(result.stdout).toContain("invalid review artifacts");
expect(existsSync(join(repoDir, ".local", "readiness-called"))).toBe(false);
expect(existsSync(join(repoDir, ".local", "side-effects"))).toBe(false);
expect(existsSync(join(repoDir, ".local", "worktree-entered"))).toBe(false);
});
it("rejects a non-ready review before taking the operation lock past validation", () => {
const repoDir = makeTempDir("openclaw-pr-prepare-not-ready-");
mkdirSync(join(repoDir, ".local"));
const result = runGatesBash(
[
"review_validate_artifacts() { touch .local/review-validated; }",
"require_ready_review_recommendation() { echo 'review is not ready'; return 1; }",
"mark_pr_operation_side_effects_started() { touch .local/side-effects; }",
"enter_worktree() { touch .local/worktree-entered; }",
"prepare_init 4242",
].join("\n"),
{ cwd: repoDir, sourcePrepareCore: true },
);
expect(result.status).toBe(1);
expect(result.stdout).toContain("review is not ready");
expect(existsSync(join(repoDir, ".local", "review-validated"))).toBe(true);
expect(existsSync(join(repoDir, ".local", "side-effects"))).toBe(false);
expect(existsSync(join(repoDir, ".local", "worktree-entered"))).toBe(false);
});
});
describe("prepare sync-head transitions", () => {
it("publishes only appended fixups when main advances", () => {
const repoDir = makeSyncRepo({ needsRebase: true });
@@ -7,12 +7,19 @@ import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
const reviewScript = join(process.cwd(), "scripts/pr-lib/review.sh");
const reviewArtifactsScript = join(process.cwd(), "scripts/pr-lib/review-artifacts.mjs");
const mergeScript = join(process.cwd(), "scripts/pr-lib/merge.sh");
const describePosix = process.platform === "win32" ? describe.skip : describe;
function validReview() {
return {
recommendation: "NEEDS WORK",
findings: [],
findings: [] as Array<{
id: string;
title: string;
area: string;
fix: string;
severity: "BLOCKER" | "IMPORTANT" | "NIT";
}>,
nitSweep: {
performed: true,
status: "none",
@@ -41,7 +48,22 @@ function validReview() {
};
}
function runValidation(review: ReturnType<typeof validReview>) {
function validReadyReview() {
const review = validReview();
review.recommendation = "READY FOR /prepare-pr";
review.issueValidation.status = "valid";
return review;
}
function runValidation(
review: ReturnType<typeof validReview>,
options: {
files?: string[];
guardFailure?: boolean;
mode?: "pr" | "main";
orList?: boolean;
} = {},
) {
const fixtureRoot = tempDirs.make("openclaw-pr-review-validation-");
const localDir = join(fixtureRoot, ".local");
mkdirSync(localDir);
@@ -51,7 +73,10 @@ function runValidation(review: ReturnType<typeof validReview>) {
["A)", "B)", "C)", "D)", "E)", "F)", "G)", "H)", "I)", "J)"].join("\n"),
);
writeFileSync(join(localDir, "pr-meta.env"), "PR_URL=https://example.invalid/pr/42\n");
writeFileSync(join(localDir, "pr-meta.json"), '{"files":[]}\n');
writeFileSync(
join(localDir, "pr-meta.json"),
`${JSON.stringify({ files: (options.files ?? []).map((path) => ({ path })) })}\n`,
);
return spawnSync(
"bash",
@@ -63,9 +88,11 @@ function runValidation(review: ReturnType<typeof validReview>) {
'fixture_root="$2"',
'enter_worktree() { cd "$fixture_root"; }',
'require_artifact() { [ -s "$1" ]; }',
"review_guard() { :; }",
options.guardFailure
? "review_guard() { REVIEW_MODE=pr; echo 'review head guard failed'; return 1; }"
: `review_guard() { REVIEW_MODE=${options.mode ?? "pr"}; }`,
"print_review_stdout_summary() { :; }",
"review_validate_artifacts 42",
options.orList ? "review_validate_artifacts 42 || exit 1" : "review_validate_artifacts 42",
].join("\n"),
"pr-review-artifact-validation",
reviewScript,
@@ -75,6 +102,47 @@ function runValidation(review: ReturnType<typeof validReview>) {
);
}
function runMergeVerification(checks: "api-error" | "invalid-json" | "no-required" | "pending") {
const fixtureRoot = tempDirs.make("openclaw-pr-merge-verification-");
const localDir = join(fixtureRoot, ".local");
const head = "a".repeat(40);
mkdirSync(localDir);
writeFileSync(join(localDir, "prep.env"), `PREP_HEAD_SHA=${head}\n`);
const checksResponse =
checks === "api-error"
? "echo 'GitHub API unavailable' >&2; return 1"
: checks === "no-required"
? "echo \"no required checks reported on the 'review-branch' branch\" >&2; return 1"
: checks === "pending"
? `printf '%s\\n' '[{"name":"CI","bucket":"pending","state":"IN_PROGRESS"}]'; return 8`
: "printf '%s\\n' 'not valid JSON'";
return spawnSync(
"bash",
[
"-c",
[
"set -euo pipefail",
'source "$1"',
'fixture_root="$2"',
'enter_worktree() { cd "$fixture_root"; }',
'require_artifact() { [ -s "$1" ]; }',
"verify_prep_branch_matches_prepared_head() { :; }",
`pr_meta_json() { printf '%s\\n' '{"isDraft":false,"headRefOid":"${head}"}'; }`,
"mark_pr_operation_side_effects_started() { :; }",
"git() { :; }",
`gh() { case "$*" in *"--json name,bucket,state"*) ${checksResponse};; *) return 0;; esac; }`,
"merge_verify 42",
].join("\n"),
"pr-merge-verification",
mergeScript,
fixtureRoot,
],
{ encoding: "utf8" },
);
}
describePosix("scripts/pr review artifact validation", () => {
it("accepts a valid review artifact", () => {
const result = runValidation(validReview());
@@ -83,6 +151,146 @@ describePosix("scripts/pr review artifact validation", () => {
expect(result.stdout).toContain("review artifacts validated");
});
it("rejects validation from main-baseline mode", () => {
const result = runValidation(validReview(), { mode: "main" });
expect(result.status).toBe(1);
expect(result.stdout).toContain(
"Review artifact validation requires the reviewed PR head, not main-baseline mode.",
);
});
it("preserves head-guard failures when preparation calls validation in an OR-list", () => {
const result = runValidation(validReview(), { guardFailure: true, orList: true });
expect(result.status).toBe(1);
expect(result.stdout).toContain("review head guard failed");
expect(result.stdout).not.toContain("review artifacts validated");
});
it.each(["BLOCKER", "IMPORTANT"] as const)(
"rejects a ready review containing a %s finding",
(severity) => {
const review = validReadyReview();
review.findings.push({
id: "review-finding",
title: "Actionable review finding",
area: "runtime",
fix: "Resolve the finding before preparing the PR.",
severity,
});
const result = runValidation(review);
expect(result.status).toBe(1);
expect(result.stdout).toContain(
"READY FOR /prepare-pr cannot include BLOCKER or IMPORTANT findings",
);
},
);
it("keeps non-ready findings and failed proof valid for review triage", () => {
const review = validReview();
review.findings.push({
id: "review-finding",
title: "Actionable review finding",
area: "runtime",
fix: "Resolve the finding before preparing the PR.",
severity: "IMPORTANT",
});
review.tests.result = "fail";
const result = runValidation(review);
expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0);
});
it("rejects a ready review with failing proof", () => {
const review = validReadyReview();
review.tests.result = "fail";
const result = runValidation(review);
expect(result.status).toBe(1);
expect(result.stdout).toContain("READY FOR /prepare-pr cannot include failing tests");
});
it("permits documentation-only ready reviews without runtime tests", () => {
const review = validReadyReview();
review.tests.result = "not_run";
const result = runValidation(review, { files: ["docs/reference/example.md"] });
expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0);
});
it.each([
"packages/normalization-core/src/string-normalization.ts",
"packages/gateway-protocol/src/schema/approvals.ts",
"ui/src/app.ts",
])("requires behavioral review for core runtime path %s", (path) => {
const result = runValidation(validReview(), { files: [path] });
expect(result.status).toBe(1);
expect(result.stdout).toContain(
"runtime file changes require behavioralSweep.status=pass|needs_work",
);
expect(result.stdout).toContain("runtime file changes require at least one branch entry");
});
it("requires passing runtime proof for a ready review", () => {
const review = validReadyReview();
review.behavioralSweep.status = "pass";
review.behavioralSweep.branches.push({
path: "ui/src/app.ts",
decision: "verified",
outcome: "Behavior remains correct.",
});
review.tests.result = "not_run";
const result = runValidation(review, { files: ["ui/src/app.ts"] });
expect(result.status).toBe(1);
expect(result.stdout).toContain(
"READY FOR /prepare-pr on runtime changes requires passing tests",
);
});
it("rejects merge verification when GitHub cannot verify required checks", () => {
const result = runMergeVerification("api-error");
expect(result.status).toBe(1);
expect(result.stderr).toContain("unable to verify the required GitHub checks");
expect(result.stderr).toContain("GitHub API unavailable");
expect(result.stdout).not.toContain("merge-verify passed");
expect(result.stdout).not.toContain("No required checks configured");
});
it("preserves GitHub CLI behavior when a branch has no required checks", () => {
const result = runMergeVerification("no-required");
expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0);
expect(result.stdout).toContain("No required checks configured for this PR.");
expect(result.stdout).toContain("merge-verify passed for PR #42");
});
it("preserves GitHub CLI pending-check evidence from exit status eight", () => {
const result = runMergeVerification("pending");
expect(result.status).toBe(1);
expect(result.stdout).toContain("Required checks are still pending.");
expect(result.stderr).not.toContain("unable to verify the required GitHub checks");
expect(result.stdout).not.toContain("merge-verify passed");
});
it("rejects merge verification when GitHub returns malformed check evidence", () => {
const result = runMergeVerification("invalid-json");
expect(result.status).toBe(1);
expect(result.stderr).toContain("GitHub returned invalid required-check evidence");
expect(result.stdout).not.toContain("merge-verify passed");
});
it("reports the required branch entry shape without a raw jq error", () => {
const review = validReview();
review.behavioralSweep.branches = ["src/example.ts"];