openclaw/test/scripts/security-review-script.test.ts
Josh Avant 35263bd17a
fix(ci): stop obsolete security reviews after PR lifecycle changes (#160890)
* fix(ci): stop obsolete security reviews after PR lifecycle changes

* fix(ci): preserve security review evidence after merges
2026-09-28 21:34:29 -05:00

1703 lines
66 KiB
TypeScript

import { spawnSync } from "node:child_process";
import { readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
const head = "a".repeat(40);
const landed = "c".repeat(40);
const pullPath = "/repos/openclaw/openclaw/pulls/7";
const actions = "/repos/openclaw/openclaw/actions";
const pr = {
number: 7,
state: "open",
draft: false,
created_at: "2026-01-01T00:00:00Z",
user: { id: 1, login: "maintainer", type: "User" },
changed_files: 2,
head: { sha: head, ref: "change", repo: { id: 2 } },
base: { sha: "b".repeat(40), ref: "main", repo: { id: 1 } },
};
const lifecycleChanges = [
{ name: "closed PR", changedPr: { ...pr, state: "closed" } },
{ name: "draft PR", changedPr: { ...pr, draft: true } },
{ name: "target branch", changedPr: { ...pr, base: { ...pr.base, ref: "stable" } } },
];
const mergedPr = { ...pr, state: "closed", merged: true };
const rollout = {
number: 152415,
state: "closed",
merged: true,
merged_at: "2025-12-01T00:00:00Z",
merge_commit_sha: landed,
base: { ref: "main", repo: { full_name: "openclaw/openclaw" } },
};
const run = {
id: 10,
run_attempt: 1,
event: "pull_request",
path: ".github/workflows/ci.yml",
head_sha: head,
head_branch: "change",
repository: { id: 1 },
status: "completed",
};
const jobs = {
total_count: 1,
jobs: [{ name: "openclaw/ci-gate", status: "completed", conclusion: "success" }],
};
const rolePath = "GET /repos/openclaw/openclaw/collaborators/maintainer/permission";
const statusPath = `POST /repos/openclaw/openclaw/statuses/${head}`;
const runsPath = `GET ${actions}/workflows/ci.yml/runs`;
const jobsPath = `GET ${actions}/runs/10/attempts/1/jobs`;
const files = [
{ filename: "src/gateway/auth.ts", status: "modified" },
{ filename: "pnpm-workspace.yaml", status: "modified" },
];
const historyPath = `GET /repos/openclaw/openclaw/commits/${head}/statuses`;
const otherReview = {
context: "openclaw/ci-gate",
description: "PR #8: Security review has not completed",
creator: { login: "github-actions[bot]", type: "Bot" },
};
function evaluate(routes: Record<string, unknown> = {}, mode = "enforce", deadline?: number) {
const root = tempDirs.make("security-review-");
const logPath = path.join(root, "requests.jsonl");
const fixturePath = path.join(root, "fixture.json");
const eventPath = path.join(root, "event.json");
const environmentPath = path.join(root, "environment");
writeFileSync(environmentPath, "");
writeFileSync(logPath, "");
// The resolver selects the PR for CI-completion events as well as PR/comments.
writeFileSync(
eventPath,
JSON.stringify({ repository: { default_branch: "main" }, workflow_run: { id: 10 } }),
);
writeFileSync(
fixturePath,
JSON.stringify({
logPath,
clock: true,
routes: {
[`GET ${pullPath}`]: pr,
[`GET /repos/openclaw/openclaw/commits/${head}/statuses`]: [],
[`GET ${pullPath}/files`]: files,
"GET /repos/openclaw/openclaw/pulls/152415": rollout,
"GET /repos/openclaw/openclaw/issues/7/comments": [],
"GET /repos/openclaw/openclaw/issues/7/labels": [],
[rolePath]: { role_name: "maintain" },
[runsPath]: { total_count: 1, workflow_runs: [run] },
[jobsPath]: jobs,
[`GET ${actions}/runs/10`]: run,
...routes,
},
}),
);
const result = spawnSync(
process.execPath,
[
"--import",
path.resolve("test/fixtures/github-guard-fetch.mjs"),
path.resolve("scripts/github/security-review.mjs"),
],
{
encoding: "utf8",
env: {
GITHUB_TOKEN: "fixture-token",
OPENCLAW_DEPENDENCY_GUARD_AUTOSCRUB_TOKEN: "fixture-autoscrub-token",
...(deadline === undefined
? {}
: { OPENCLAW_SECURITY_REVIEW_DEADLINE_MS: String(deadline) }),
GITHUB_EVENT_PATH: eventPath,
GITHUB_ENV: environmentPath,
GITHUB_REPOSITORY: "openclaw/openclaw",
GITHUB_RUN_ID: "123",
OPENCLAW_SECURITY_REVIEW_PR_NUMBER: "7",
OPENCLAW_SECURITY_REVIEW_HEAD_SHA: head,
OPENCLAW_SECURITY_REVIEW_MODE: mode,
OPENCLAW_GUARD_TEST_FIXTURE: fixturePath,
},
},
);
const requests = readFileSync(logPath, "utf8")
.trim()
.split("\n")
.filter(Boolean)
.map(
(line) =>
JSON.parse(line) as {
method: string;
delay?: number;
path: string;
body?: { context?: string; state?: string; body?: string };
},
);
return {
...result,
environment: readFileSync(environmentPath, "utf8"),
requests,
waits: requests.filter((entry) => entry.method === "WAIT").map((entry) => entry.delay!),
combined: requests
.filter((entry) => entry.body?.context === "openclaw/ci-gate")
.map((entry) => entry.body?.state),
reviews: requests.filter((entry) => entry.body?.context?.endsWith("-review")),
};
}
describe("combined security review entry point", () => {
it("recovers a temporary HTTP 500 without failing the review", () => {
const result = evaluate({
[`GET ${pullPath}`]: { responses: [{ httpError: 500 }, pr] },
});
expect(result.status, result.stderr).toBe(0);
expect(result.waits).toEqual([1_000]);
expect(result.combined.at(-1)).toBe("success");
expect(result.reviews.filter((entry) => entry.body?.state === "success")).toHaveLength(2);
});
it.each([
{ route: `GET ${pullPath}`, response: pr, requestTimeout: "fetch" },
{ route: rolePath, response: { role_name: "maintain" }, requestTimeout: "body" },
{ route: jobsPath, response: jobs, requestTimeout: "fetch" },
])(
"restarts evaluation after a $requestTimeout deadline on $route",
({ route, response, requestTimeout }) => {
const result = evaluate({ [route]: { responses: [{ requestTimeout }, response] } });
expect(result.status, result.stderr).toBe(0);
expect(result.waits).toEqual([1_000]);
const afterWait = result.requests.slice(
result.requests.findIndex((entry) => entry.method === "WAIT") + 1,
);
expect(afterWait[0]).toMatchObject({ method: "GET", path: pullPath });
expect(result.combined.at(-1)).toBe("success");
},
);
it("recovers the dependency-graph read deadline without granting contributor approval", () => {
const graphPath = `/repos/openclaw/openclaw/dependency-graph/compare/${pr.base.sha}...${head}`;
const result = evaluate({
[rolePath]: { role_name: "read" },
[`GET ${pullPath}/files`]: [files[0], { filename: "pnpm-lock.yaml", status: "modified" }],
[`GET ${graphPath}`]: { responses: [{ requestTimeout: "fetch" }, []] },
});
expect(result.status, result.stderr).toBe(0);
expect(result.waits).toEqual([1_000]);
expect(result.requests.filter((entry) => entry.path === graphPath)).toHaveLength(2);
expect(result.combined.at(-1)).toBe("failure");
expect(result.reviews.some((entry) => entry.body?.state === "success")).toBe(false);
expect(result.stdout).toContain("awaiting maintainer approval");
});
it("rereads author authority after a read deadline instead of retaining approval", () => {
const result = evaluate({
[jobsPath]: { responses: [{ requestTimeout: "fetch" }, jobs] },
[rolePath]: {
settlesAt: "2026-01-02T00:00:30Z",
before: { role_name: "maintain" },
after: { role_name: "read" },
},
});
expect(result.status, result.stderr).toBe(0);
expect(result.waits).toEqual([1_000]);
const afterWait = result.requests.slice(
result.requests.findIndex((entry) => entry.method === "WAIT") + 1,
);
expect(afterWait[0]).toMatchObject({ method: "GET", path: pullPath });
expect(afterWait.some((entry) => entry.body?.state === "success")).toBe(false);
expect(result.combined.at(-1)).toBe("failure");
});
it("recovers a lockfile read deadline before submitting one cleanup commit", () => {
const result = evaluate(
{
[`GET ${pullPath}`]: {
...pr,
changed_files: 1,
head: { ...pr.head, repo: { id: 1, full_name: "openclaw/openclaw" } },
},
[`GET ${pullPath}/files`]: [{ filename: "pnpm-lock.yaml", status: "modified" }],
[rolePath]: { role_name: "read" },
[`GET /repos/openclaw/openclaw/dependency-graph/compare/${pr.base.sha}...${head}`]: [],
[`GET /repos/openclaw/openclaw/compare/${pr.base.sha}...${head}`]: {
base_commit: { sha: pr.base.sha },
merge_base_commit: { sha: pr.base.sha },
},
[`GET /repos/openclaw/openclaw/contents/pnpm-lock.yaml?ref=${pr.base.sha}`]: {
responses: [
{ requestTimeout: "fetch" },
{
type: "file",
encoding: "base64",
content: Buffer.from("lockfileVersion: '9.0'\n").toString("base64"),
},
],
},
"POST /graphql": { data: { createCommitOnBranch: { commit: { oid: "e".repeat(40) } } } },
},
"autoscrub",
);
expect(result.status, result.stderr).toBe(0);
expect(result.waits).toEqual([1_000]);
const afterWait = result.requests.slice(
result.requests.findIndex((entry) => entry.method === "WAIT") + 1,
);
expect(afterWait[0]).toMatchObject({ method: "GET", path: pullPath });
expect(result.requests.filter((entry) => entry.path === "/graphql")).toHaveLength(1);
});
it.each([
{
name: "HTTP permission denial",
response: { httpError: 403, message: "Resource not accessible by integration" },
unavailable: true,
},
{
name: "GraphQL permission denial",
response: {
errors: [
{
type: "FORBIDDEN",
path: ["createCommitOnBranch"],
message: "Resource not accessible by integration",
},
],
},
unavailable: true,
},
{ name: "unknown HTTP 403", response: { httpError: 403 }, unavailable: false },
{ name: "invalid credentials", response: { httpError: 401 }, unavailable: false },
{ name: "missing repository", response: { httpError: 404 }, unavailable: false },
{
name: "stale head",
response: { errors: [{ type: "STALE_DATA", message: "Expected branch head to match" }] },
unavailable: false,
},
{
name: "mixed GraphQL errors",
response: {
errors: [{ type: "FORBIDDEN", path: ["createCommitOnBranch"] }, { type: "INTERNAL" }],
},
unavailable: false,
},
{
name: "commit response field denial",
response: {
errors: [{ type: "FORBIDDEN", path: ["createCommitOnBranch", "commit", "oid"] }],
},
unavailable: false,
},
{
name: "partial commit response",
response: {
data: { createCommitOnBranch: { commit: { oid: "e".repeat(40) } } },
errors: [{ type: "FORBIDDEN", path: ["createCommitOnBranch"] }],
},
unavailable: false,
},
{
name: "base read permission denial",
response: { httpError: 403, message: "Resource not accessible by integration" },
unavailable: false,
baseReadDenied: true,
},
])("keeps cleanup $name separate from dependency approval", (scenario) => {
const { response, unavailable } = scenario;
const baseReadDenied = "baseReadDenied" in scenario && scenario.baseReadDenied;
const routes = {
[`GET ${pullPath}`]: {
...pr,
changed_files: 1,
maintainer_can_modify: true,
head: { ...pr.head, repo: { id: 2, full_name: "contributor/openclaw" } },
},
[`GET ${pullPath}/files`]: [{ filename: "pnpm-lock.yaml", status: "modified" }],
[rolePath]: { role_name: "read" },
[`GET /repos/openclaw/openclaw/dependency-graph/compare/${pr.base.sha}...${head}`]: [],
[`GET /repos/openclaw/openclaw/compare/${pr.base.sha}...${head}`]: {
base_commit: { sha: pr.base.sha },
merge_base_commit: { sha: pr.base.sha },
},
[`GET /repos/openclaw/openclaw/contents/pnpm-lock.yaml`]: baseReadDenied
? response
: {
type: "file",
encoding: "base64",
content: Buffer.from("lockfileVersion: '9.0'\n").toString("base64"),
},
"POST /graphql": response,
};
const cleanup = evaluate(routes, "autoscrub");
expect(cleanup.status, cleanup.stderr).toBe(unavailable ? 0 : 1);
expect(cleanup.requests.filter((entry) => entry.path === "/graphql")).toHaveLength(
baseReadDenied ? 0 : 1,
);
expect(cleanup.reviews.some((entry) => entry.body?.state === "success")).toBe(false);
expect(cleanup.waits).toEqual([]);
if (unavailable) {
const notice = cleanup.requests.find(
(entry) => entry.method === "POST" && entry.path.endsWith("/comments"),
);
expect(notice?.body?.body).toContain("Automatic lockfile cleanup is best effort.");
const enforcement = evaluate(routes);
expect(enforcement.status, enforcement.stderr).toBe(0);
expect(enforcement.combined.at(-1)).toBe("failure");
expect(
enforcement.reviews.findLast(
(entry) => entry.body?.context === "openclaw/dependency-review",
)?.body?.state,
).toBe("failure");
expect(enforcement.stdout).toContain("Automatic lockfile cleanup is best effort.");
} else {
expect(cleanup.combined.at(-1)).toBe("failure");
expect(cleanup.stderr).toContain("autoscrub failed");
}
});
it("preserves a failed cleanup mutation when the PR then closes", () => {
const cleanupPr = {
...pr,
changed_files: 1,
head: { ...pr.head, repo: { id: 1, full_name: "openclaw/openclaw" } },
};
const result = evaluate(
{
[`GET ${pullPath}`]: {
responses: [
cleanupPr,
cleanupPr,
cleanupPr,
cleanupPr,
{ ...cleanupPr, state: "closed" },
],
},
[`GET ${pullPath}/files`]: [{ filename: "pnpm-lock.yaml", status: "modified" }],
[rolePath]: { role_name: "read" },
[`GET /repos/openclaw/openclaw/dependency-graph/compare/${pr.base.sha}...${head}`]: [],
[`GET /repos/openclaw/openclaw/compare/${pr.base.sha}...${head}`]: {
base_commit: { sha: pr.base.sha },
merge_base_commit: { sha: pr.base.sha },
},
[`GET /repos/openclaw/openclaw/contents/pnpm-lock.yaml`]: {
type: "file",
encoding: "base64",
content: Buffer.from("lockfileVersion: '9.0'\n").toString("base64"),
},
"POST /graphql": { httpError: 500 },
},
"autoscrub",
);
expect(result.status).toBe(1);
expect(result.stderr).toContain("Dependency lockfile autoscrub failed");
expect(result.stderr).toContain("Fixture API failure");
expect(result.requests.filter((entry) => entry.path === "/graphql")).toHaveLength(1);
expect(result.combined).not.toContain("success");
});
it("does not replay a failed notice write when a sibling read later times out", () => {
const commentPath = "/repos/openclaw/openclaw/issues/7/comments";
const result = evaluate({
[`POST ${commentPath}`]: { httpError: 500 },
[rolePath]: {
responses: [
{ role_name: "maintain" },
{ role_name: "maintain" },
{ requestTimeout: "fetch" },
],
},
});
expect(result.status).toBe(1);
expect(result.waits).toEqual([]);
expect(
result.requests.filter((entry) => entry.method === "POST" && entry.path === commentPath),
).toHaveLength(1);
expect(result.combined.at(-1)).toBe("failure");
expect(result.stderr).toContain(`GitHub API POST ${commentPath} failed: 500`);
});
it("bounds persistent read deadlines without granting approval", () => {
const result = evaluate({ [rolePath]: { requestTimeout: "fetch" } });
expect(result.status).toBe(1);
expect(result.waits).toEqual([1_000, 2_000, 4_000]);
expect(result.requests.filter((entry) => `GET ${entry.path}` === rolePath)).toHaveLength(4);
expect(result.stderr).toContain("recovery budget exhausted");
expect(result.requests.some((entry) => entry.body?.state === "success")).toBe(false);
});
it.each([
{ name: "status", route: statusPath },
{ name: "comment", route: "POST /repos/openclaw/openclaw/issues/7/comments" },
])("does not restart evaluation after an uncertain $name write deadline", ({ route }) => {
const result = evaluate({
[`GET ${pullPath}`]: { ...pr, changed_files: 1 },
[`GET ${pullPath}/files`]: [files[1]],
[route]: { requestTimeout: "body" },
});
expect(result.status).toBe(1);
expect(result.waits).toEqual([]);
expect(
result.requests.filter((entry) => `${entry.method} ${entry.path}` === route),
).toHaveLength(1);
expect(result.stderr).toContain("exceeded timeout 30000ms");
expect(result.combined).not.toContain("success");
});
it("exhausts HTTP 500 retries without publishing approval", () => {
const result = evaluate({ [`GET ${pullPath}`]: { httpError: 500 } });
expect(result.status).toBe(1);
expect(result.waits).toEqual([1_000, 2_000, 4_000]);
expect(result.requests.filter((entry) => entry.path === pullPath)).toHaveLength(4);
expect(result.stderr).toContain(`GitHub API GET ${pullPath} failed: 500`);
expect(
result.requests.every((entry) => entry.method === "GET" || entry.method === "WAIT"),
).toBe(true);
});
it.each([
{ name: "partial file list", initialPr: pr, initialFiles: files.slice(0, 1) },
{ name: "stale file count", initialPr: { ...pr, changed_files: 3 }, initialFiles: files },
])("recovers a $name before evaluating either guard", ({ initialPr, initialFiles }) => {
const result = evaluate({
[`GET ${pullPath}`]: { responses: [initialPr, pr] },
[`GET ${pullPath}/files`]: { responses: [initialFiles, files] },
});
expect(result.status, result.stderr).toBe(0);
expect(result.waits).toEqual([30_000, 30_000]);
expect(result.requests.filter((entry) => entry.path === `${pullPath}/files`)).toHaveLength(2);
expect(result.combined.at(-1)).toBe("success");
expect(result.reviews.filter((entry) => entry.body?.state === "success")).toHaveLength(2);
});
it.each(["detect", "enforce"])(
"recovers diff data that takes longer than seven seconds to settle in %s mode",
(mode) => {
const result = evaluate(
{
[`GET ${pullPath}`]: {
settlesAt: "2026-01-02T00:00:30Z",
before: { ...pr, changed_files: 3146 },
after: pr,
},
[`GET ${pullPath}/files`]: {
settlesAt: "2026-01-02T00:00:30Z",
before: files.slice(0, 1),
after: files,
},
},
mode,
);
expect(result.status, result.stderr).toBe(0);
expect(result.waits.some((delay) => delay >= 30_000)).toBe(true);
expect(result.requests.filter((entry) => entry.path === `${pullPath}/files`)).toHaveLength(2);
if (mode === "enforce") {
expect(result.combined.at(-1)).toBe("success");
expect(result.reviews.filter((entry) => entry.body?.state === "success")).toHaveLength(2);
}
},
);
it.each([
{ phase: "before dependency approval", stableReads: 2 },
{ phase: "between guards", stableReads: 3 },
{ phase: "before combined success", stableReads: 5 },
])("restarts the complete review when the file count changes $phase", ({ stableReads }) => {
const changed = { ...pr, changed_files: 3 };
const result = evaluate({
[`GET ${pullPath}`]: {
responses: [...Array.from({ length: stableReads }, () => ({ ...pr })), changed],
},
[`GET ${pullPath}/files`]: {
responses: [files, [...files, { filename: "src/secrets/store.ts", status: "modified" }]],
},
[rolePath]: {
settlesAt: "2026-01-02T00:00:30Z",
before: { role_name: "maintain" },
after: { role_name: "read" },
},
});
expect(result.status, result.stderr).toBe(0);
expect(result.waits).toEqual([30_000, 30_000]);
const afterWait = result.requests.slice(
result.requests.findIndex((entry) => entry.method === "WAIT") + 1,
);
expect(afterWait[0]).toMatchObject({ method: "GET", path: pullPath });
expect(afterWait.some((entry) => entry.body?.state === "success")).toBe(false);
expect(result.combined.at(-1)).toBe("failure");
expect(afterWait.map((entry) => entry.body?.body ?? "").join("\n")).toContain(
"src/secrets/store.ts",
);
});
it("restarts incomplete pagination and still requires approval for recovered sensitive files", () => {
const firstPage = Array.from({ length: 100 }, (_, index) => ({
filename: `docs/example-${index}.md`,
status: "modified",
}));
const result = evaluate({
[`GET ${pullPath}`]: { ...pr, changed_files: 102 },
[`GET ${pullPath}/files`]: { responses: [firstPage, [], firstPage, files] },
[rolePath]: { role_name: "read" },
});
expect(result.status, result.stderr).toBe(0);
expect(result.waits).toEqual([30_000, 30_000]);
expect(result.combined.at(-1)).toBe("failure");
expect(result.reviews.some((entry) => entry.body?.state === "success")).toBe(false);
const notices = result.requests.map((entry) => entry.body?.body ?? "").join("\n");
expect(notices).toContain("/allow-dependencies-change");
expect(notices).toContain("/allow-security-sensitive-change");
});
it.each([
{ name: "head", changedPr: { ...pr, head: { ...pr.head, sha: "d".repeat(40) } }, status: 0 },
...lifecycleChanges.map(({ name, changedPr }) => ({ name, changedPr, status: 0 })),
{
name: "author",
changedPr: { ...pr, user: { id: 2, login: "other-author", type: "User" } },
status: 1,
},
])("stops for a changed $name during file-list recovery", ({ changedPr, status }) => {
const result = evaluate({
[`GET ${pullPath}`]: { responses: [pr, pr, changedPr] },
[`GET ${pullPath}/files`]: { responses: [files.slice(0, 1), files] },
});
expect(result.status).toBe(status);
expect(result.stdout + result.stderr).toContain(
status === 0 ? "skipping" : "pull request changed",
);
expect(result.requests.some((entry) => entry.body?.state === "success")).toBe(false);
});
it.each(["detect", "autoscrub", "enforce"])(
"stops superseded diff recovery at its first checkpoint in %s mode",
(mode) => {
const result = evaluate(
{
[`GET ${pullPath}`]: {
settlesAt: "2026-01-02T00:00:30Z",
before: pr,
after: { ...pr, head: { ...pr.head, sha: "d".repeat(40) } },
},
[`GET ${pullPath}/files`]: files.slice(0, 1),
},
mode,
);
expect(result.status, result.stderr).toBe(0);
expect(result.stdout).toContain("Superseded");
expect(result.waits).toEqual([30_000]);
expect(result.requests.filter((entry) => entry.path === pullPath + "/files")).toHaveLength(1);
const afterWait = result.requests.slice(
result.requests.findIndex((entry) => entry.method === "WAIT") + 1,
);
expect(afterWait).toEqual([{ method: "GET", path: pullPath }]);
expect(result.combined).not.toContain("success");
},
);
it.each([false, true])(
"checks the head before more slow file pages (superseded=%s)",
(superseded) => {
const firstPage = Array.from({ length: 100 }, (_, index) => ({
filename: "docs/page-" + index + ".md",
status: "modified",
}));
const original = { ...pr, changed_files: 202 };
const result = evaluate({
[`GET ${pullPath}`]: {
settlesAt: "2026-01-02T00:00:30Z",
before: original,
after: superseded ? { ...original, head: { ...pr.head, sha: "d".repeat(40) } } : original,
},
[`GET ${pullPath}/files`]: {
responses: [
{ advanceMs: 15_000, response: firstPage },
{ advanceMs: 15_000, response: firstPage },
files,
],
},
});
expect(result.status, result.stderr).toBe(0);
expect(result.requests.filter((entry) => entry.path === pullPath + "/files")).toHaveLength(
superseded ? 2 : 3,
);
if (superseded) {
expect(result.stdout).toContain("Superseded");
expect(result.requests.at(-1)).toEqual({ method: "GET", path: pullPath });
expect(result.requests.some((entry) => entry.path.endsWith("/comments"))).toBe(false);
expect(result.combined).not.toContain("success");
} else {
expect(result.combined.at(-1)).toBe("success");
}
},
);
it("keeps checkpoint reads inside the original backoff interval", () => {
const result = evaluate({
[`GET ${pullPath}`]: { responses: [pr, pr, { advanceMs: 20_000, response: pr }, pr] },
[`GET ${pullPath}/files`]: { responses: [files.slice(0, 1), files] },
});
expect(result.status, result.stderr).toBe(0);
expect(result.waits).toEqual([30_000, 10_000]);
expect(result.combined.at(-1)).toBe("success");
});
it("honors a rate limit from a recovery checkpoint before observing supersession", () => {
const result = evaluate({
[`GET ${pullPath}`]: {
responses: [
pr,
pr,
{ httpError: 429, headers: { "retry-after": "180" } },
{ ...pr, head: { ...pr.head, sha: "d".repeat(40) } },
],
},
[`GET ${pullPath}/files`]: files.slice(0, 1),
});
expect(result.status, result.stderr).toBe(0);
expect(result.stdout).toContain("Superseded");
expect(result.waits).toHaveLength(2);
expect(result.waits[0]).toBe(30_000);
expect(result.waits[1]).toBeGreaterThanOrEqual(180_000);
const checkpoint = result.requests.findIndex((entry) => entry.method === "WAIT");
expect(result.requests.slice(checkpoint).map((entry) => entry.method)).toEqual([
"WAIT",
"GET",
"WAIT",
"GET",
]);
expect(result.combined).not.toContain("success");
});
it("fails closed when a recovery checkpoint cannot read the PR", () => {
const result = evaluate({
[`GET ${pullPath}`]: { responses: [pr, pr, { httpError: 403 }] },
[`GET ${pullPath}/files`]: files.slice(0, 1),
});
expect(result.status).toBe(1);
expect(result.waits).toEqual([30_000]);
expect(result.stderr).toContain("Fixture API failure");
expect(result.combined.at(-1)).toBe("failure");
});
it("bounds file-list recovery across both guards and reports the conflicting counts", () => {
const result = evaluate({ [`GET ${pullPath}/files`]: files.slice(0, 1) });
expect(result.status).toBe(1);
expect(result.waits).toEqual(Array<number>(14).fill(30_000));
expect(result.requests.filter((entry) => entry.path === `${pullPath}/files`)).toHaveLength(4);
expect(result.stderr).toContain("expected 2, received 1, current count 2");
expect(result.stderr).toContain("recovery budget exhausted");
expect(result.stderr).not.toContain("Split the PR");
expect(result.requests.some((entry) => entry.body?.state === "success")).toBe(false);
expect(result.combined.at(-1)).toBe("failure");
});
it.each([
{
httpError: 403,
headers: { "x-ratelimit-remaining": "0", "x-ratelimit-reset": "1767315600" },
minimum: 3_600_000,
},
{ httpError: 429, headers: { "retry-after": "90" }, minimum: 90_000 },
{
httpError: 403,
headers: {
"x-ratelimit-remaining": "0",
"x-ratelimit-reset": "1767315600",
"retry-after": "120",
},
minimum: 3_600_000,
},
{ httpError: 403, message: "You have exceeded a secondary rate limit.", minimum: 60_000 },
])(
"recovers a rate-limited PR lookup using server timing: $httpError $minimum",
({ minimum, ...limited }) => {
const result = evaluate({ [`GET ${pullPath}`]: { responses: [limited, pr] } });
expect(result.status, result.stderr).toBe(0);
expect(result.waits).toHaveLength(1);
expect(result.waits[0]).toBeGreaterThanOrEqual(minimum);
expect(result.waits[0]).toBeLessThan(minimum + 17_000);
expect(result.combined.at(-1)).toBe("success");
expect(result.environment).toBe(
`OPENCLAW_SECURITY_REVIEW_DEADLINE_MS=${Date.parse("2026-01-02T01:05:00Z")}\n`,
);
},
);
it.each([
{ httpError: 500 },
{ httpError: 502 },
{ httpError: 503 },
{ httpError: 504 },
{ transportError: "ECONNRESET" },
])("restarts evaluation after a transient status publication failure: %j", (failure) => {
const result = evaluate({ [statusPath]: { responses: [failure, {}] } });
expect(result.status, result.stderr).toBe(0);
expect(result.waits).toEqual([1_000]);
const afterWait = result.requests.slice(
result.requests.findIndex((entry) => entry.method === "WAIT") + 1,
);
expect(afterWait[0]).toMatchObject({ method: "GET", path: pullPath });
expect(result.combined.at(-1)).toBe("success");
});
it.each([
{ httpError: 429 },
{ httpError: 500, recordStatusBeforeError: true },
{ transportError: "ECONNRESET" },
])("rereads authority after a failed success write instead of replaying it: %j", (failure) => {
const result = evaluate({
// First POST records pending; the dependency guard then records failure and success.
[statusPath]: {
responses: [{}, {}, failure, {}],
},
[rolePath]: {
responses: [{ role_name: "maintain" }, { role_name: "maintain" }, { role_name: "read" }],
},
});
expect(result.status, result.stderr).toBe(0);
expect(result.waits).toHaveLength(1);
const afterWait = result.requests.slice(
result.requests.findIndex((entry) => entry.method === "WAIT") + 1,
);
expect(afterWait[0]?.path).toBe(pullPath);
expect(afterWait.some((entry) => entry.body?.state === "success")).toBe(false);
expect(result.combined.at(-1)).toBe("failure");
});
it("stops after the head changes while recovering a failed success write", () => {
const result = evaluate({
[statusPath]: { responses: [{}, {}, { httpError: 500 }, {}] },
[`GET ${pullPath}`]: {
responses: [pr, pr, pr, { ...pr, head: { ...pr.head, sha: "d".repeat(40) } }],
},
});
expect(result.status, result.stderr).toBe(0);
expect(result.waits).toEqual([1_000]);
expect(result.stdout).toContain("Superseded");
const afterWait = result.requests.slice(
result.requests.findIndex((entry) => entry.method === "WAIT") + 1,
);
expect(afterWait.every((entry) => entry.method === "GET")).toBe(true);
expect(result.combined).not.toContain("success");
});
it.each([
{ failure: { httpError: 429, headers: { "retry-after": "120" } }, minimumDelay: 120_000 },
{ failure: { httpError: 500 }, minimumDelay: 1_000 },
])(
"preserves publication recovery when reporting an inconsistent diff: $failure.httpError",
({ failure, minimumDelay }) => {
const result = evaluate({
[statusPath]: { responses: [{}, {}, failure, {}] },
[`GET ${pullPath}/files`]: { responses: [files.slice(0, 1), files] },
});
expect(result.status, result.stderr).toBe(0);
expect(result.waits).toHaveLength(1);
expect(result.waits[0]).toBeGreaterThanOrEqual(minimumDelay);
expect(result.waits[0]).toBeLessThan(minimumDelay + 17_000);
expect(result.combined.at(-1)).toBe("success");
},
);
it("rereads CI after a failed combined-success publication", () => {
const result = evaluate({
[statusPath]: { responses: [{}, {}, {}, {}, {}, { httpError: 500 }, {}] },
[jobsPath]: {
responses: [jobs, { ...jobs, jobs: [{ ...jobs.jobs[0], conclusion: "failure" }] }],
},
});
expect(result.status, result.stderr).toBe(0);
expect(result.waits).toEqual([1_000]);
expect(result.combined.at(-1)).toBe("failure");
expect(result.requests.filter((entry) => `GET ${entry.path}` === jobsPath)).toHaveLength(2);
});
it("bounds persistent status publication failures without granting approval", () => {
const result = evaluate({ [statusPath]: { httpError: 500 } });
expect(result.status).toBe(1);
expect(result.waits).toEqual([1_000, 2_000, 4_000]);
expect(result.requests.filter((entry) => entry.method === "POST")).toHaveLength(4);
expect(result.stderr).toContain("recovery budget exhausted");
expect(result.requests.some((entry) => entry.body?.state === "success")).toBe(false);
});
it("shares three evaluation restarts between status failures and rate limits", () => {
const result = evaluate({
[statusPath]: {
responses: [{ httpError: 500 }, { httpError: 429 }, { httpError: 500 }],
},
});
expect(result.status).toBe(1);
expect(result.waits).toHaveLength(3);
expect(result.waits[0]).toBe(1_000);
expect(result.waits[1]).toBeGreaterThanOrEqual(120_000);
expect(result.waits[1]).toBeLessThan(137_000);
expect(result.waits[2]).toBe(4_000);
expect(result.requests.filter((entry) => entry.method === "POST")).toHaveLength(4);
expect(result.stderr).toContain("recovery budget exhausted");
expect(result.combined).not.toContain("success");
});
it.each([
{ name: "comment only", statusReplies: [{}] },
{ name: "failure report also fails", statusReplies: [{}, {}, {}, {}, {}, { httpError: 500 }] },
{ name: "sibling status also fails", statusReplies: [{}, {}, {}, { httpError: 500 }, {}] },
])("does not restart evaluation for a failed comment publication: $name", ({ statusReplies }) => {
const commentPath = "/repos/openclaw/openclaw/issues/7/comments";
const result = evaluate({
[statusPath]: { responses: statusReplies },
[`GET ${pullPath}`]: { ...pr, changed_files: 1 },
[`GET ${pullPath}/files`]: [files[1]],
[`POST ${commentPath}`]: { httpError: 500 },
});
expect(result.status).toBe(1);
expect(result.waits).toEqual([]);
expect(
result.requests.filter((entry) => entry.method === "POST" && entry.path === commentPath),
).toHaveLength(1);
expect(result.combined.at(-1)).toBe("failure");
expect(result.stderr).toContain(`GitHub API POST ${commentPath} failed: 500`);
});
it("recovers rate-limited notice writes instead of treating them as missing permissions", () => {
const result = evaluate({
"POST /repos/openclaw/openclaw/issues/7/labels": {
responses: [{ httpError: 403, headers: { "retry-after": "60" } }, {}],
},
});
expect(result.status, result.stderr).toBe(0);
expect(result.waits).toHaveLength(1);
expect(result.combined.at(-1)).toBe("success");
expect(result.stderr).not.toContain("Skipping");
});
it("stops rate-limit recovery after three restarts without publishing success", () => {
const result = evaluate({ [rolePath]: { httpError: 429 } });
expect(result.status).toBe(1);
expect(result.stderr).toContain("recovery budget exhausted");
expect(result.waits).toHaveLength(3);
for (const [index, delay] of result.waits.entries()) {
expect(delay).toBeGreaterThanOrEqual(60_000 * 2 ** index);
}
expect(result.requests.some((entry) => entry.body?.state === "success")).toBe(false);
});
it.each([
{
route: `GET ${pullPath}`,
failure: { httpError: 429, headers: { "retry-after": "120" } },
deadline: "2026-01-02T00:01:00Z",
},
{ route: statusPath, failure: { httpError: 500 }, deadline: "2026-01-02T00:00:30Z" },
{
route: `GET ${pullPath}`,
failure: { requestTimeout: "fetch" },
deadline: "2026-01-02T00:01:00Z",
},
{
route: `GET ${pullPath}/files`,
failure: files.slice(0, 1),
deadline: "2026-01-02T00:01:00Z",
},
])("keeps $route recovery within the shared deadline", ({ route, failure, deadline }) => {
const result = evaluate({ [route]: failure }, "enforce", Date.parse(deadline));
expect(result.status).toBe(1);
expect(result.stderr).toContain("recovery budget exhausted");
expect(result.waits).toEqual([]);
expect(result.combined).not.toContain("success");
});
it.each([rolePath, statusPath])(
"does not retry an ordinary permission rejection: %s",
(route) => {
const result = evaluate({ [route]: { httpError: 403 } });
expect(result.status).toBe(1);
expect(result.waits).toEqual([]);
expect(result.combined).toEqual(route === rolePath ? ["pending", "failure"] : ["pending"]);
},
);
it("requires successful CI and both guard decisions on the actual PR head", () => {
const result = evaluate();
expect(result.status, result.stderr).toBe(0);
expect(result.combined).toEqual(["pending", "success"]);
expect(result.reviews.filter((entry) => entry.body?.state === "success")).toHaveLength(2);
expect(
result.requests
.filter((entry) => entry.path.includes("/statuses/"))
.every((entry) => entry.path.endsWith(head)),
).toBe(true);
});
it("does not transfer an author's exemption to another PR with the same head", () => {
const result = evaluate({
[historyPath]: [otherReview],
"GET /repos/openclaw/openclaw/pulls/8": {
...pr,
number: 8,
user: { id: 2, login: "contributor", type: "User" },
},
});
expect(result.status).toBe(1);
expect(result.stderr).toContain("same head");
expect(result.combined).not.toContain("success");
expect(result.reviews.some((entry) => entry.body?.state === "success")).toBe(false);
});
it("rechecks recorded PR identities before the combined success", () => {
const result = evaluate({
[historyPath]: { responses: [[], [], [otherReview]] },
"GET /repos/openclaw/openclaw/pulls/8": { ...pr, number: 8 },
});
expect(result.status).toBe(1);
expect(result.combined).not.toContain("success");
});
it("fails closed when recorded PR identity cannot be read", () => {
const result = evaluate({ [historyPath]: { httpError: 403 } });
expect(result.status).toBe(1);
expect(result.combined).not.toContain("success");
});
it.each([
{ ...otherReview, creator: { login: "contributor", type: "User" } },
{ ...otherReview, context: "unrelated-check" },
{ ...otherReview, description: "unstructured legacy result" },
])("ignores untrusted or unrelated status records (%j)", (record) => {
const result = evaluate({ [historyPath]: [record] });
expect(result.status, result.stderr).toBe(0);
expect(result.requests.some((entry) => entry.path.endsWith("/pulls/8"))).toBe(false);
});
it.each([
{ ...pr, number: 8, state: "closed" },
{ ...pr, number: 8, head: { ...pr.head, sha: "d".repeat(40) } },
])("recovers after another recorded PR stops sharing the head (%j)", (other) => {
const result = evaluate({
[historyPath]: [otherReview],
"GET /repos/openclaw/openclaw/pulls/8": other,
});
expect(result.status, result.stderr).toBe(0);
expect(result.combined).toEqual(["pending", "success"]);
});
it.each(["openclaw/ci-gate", "openclaw/dependency-review", "openclaw/security-sensitive-review"])(
"leaves %s failed before GitHub can exhaust its status-write capacity",
(context) => {
const result = evaluate({
[historyPath]: Array.from({ length: 900 }, () => ({
...otherReview,
context,
description: "PR #7: Previous review",
state: "success",
})),
});
expect(result.status).toBe(1);
expect(result.stderr).toContain("capacity is nearly exhausted");
expect(result.combined).not.toContain("success");
expect(
result.requests.findLast((entry) => entry.body?.context === context)?.body?.state,
).toBe("failure");
},
);
it.each(["detect", "autoscrub", "enforce"])(
"skips a superseded scheduled head in %s mode without mutations",
(mode) => {
const result = evaluate(
{
[`GET ${pullPath}`]: { ...pr, head: { ...pr.head, sha: "d".repeat(40) } },
},
mode,
);
expect(result.status, result.stderr).toBe(0);
expect(result.stdout).toContain("Superseded");
expect(result.requests.some((entry) => entry.method !== "GET")).toBe(false);
},
);
it.each(lifecycleChanges)("skips an ineligible $name at each workflow step", ({ changedPr }) => {
for (const mode of ["detect", "autoscrub", "enforce"]) {
const result = evaluate({ [`GET ${pullPath}`]: changedPr }, mode);
expect(result.status, result.stderr).toBe(0);
expect(result.requests.every((entry) => entry.method === "GET")).toBe(true);
}
});
it.each(["before enforcement", "during review", "during recovery", "before publication"])(
"finishes merged PR evidence %s without waiving missing approval",
(phase) => {
const result = evaluate({
[`GET ${pullPath}`]: {
responses:
phase === "before enforcement"
? [mergedPr]
: phase === "during recovery"
? [pr, pr, mergedPr]
: phase === "before publication"
? [pr, pr, pr, mergedPr]
: [pr, mergedPr],
},
[`GET ${pullPath}/files`]: {
responses: phase === "during recovery" ? [files.slice(0, 1), files] : [files],
},
[rolePath]: { role_name: "read" },
});
expect(result.status, result.stderr).toBe(0);
expect(result.combined.at(-1)).toBe("failure");
for (const context of ["openclaw/dependency-review", "openclaw/security-sensitive-review"]) {
expect(
result.reviews.findLast((entry) => entry.body?.context === context)?.body?.state,
).toBe("failure");
}
const notices = result.requests.map((entry) => entry.body?.body ?? "").join("\n");
expect(notices).toContain("/allow-dependencies-change");
expect(notices).toContain("/allow-security-sensitive-change");
expect(result.stdout).not.toContain("skipping");
},
);
it("publishes successful evidence when the reviewed PR merges before the final check", () => {
const result = evaluate({
[`GET ${pullPath}`]: { responses: [pr, pr, pr, pr, pr, mergedPr] },
});
expect(result.status, result.stderr).toBe(0);
expect(result.combined).toEqual(["pending", "success"]);
expect(result.reviews.filter((entry) => entry.body?.state === "success")).toHaveLength(2);
});
it("keeps reading file pages after a merge so late sensitive files are reviewed", () => {
const page = Array.from({ length: 100 }, (_, index) => ({
filename: `docs/page-${index}.md`,
status: "modified",
}));
const result = evaluate({
[`GET ${pullPath}`]: {
settlesAt: "2026-01-02T00:00:30Z",
before: { ...pr, changed_files: 202 },
after: { ...mergedPr, changed_files: 202 },
},
[`GET ${pullPath}/files`]: {
responses: [
{ advanceMs: 15_000, response: page },
{ advanceMs: 15_000, response: page },
files,
],
},
[rolePath]: { role_name: "read" },
});
expect(result.status, result.stderr).toBe(0);
expect(result.requests.filter((entry) => entry.path === `${pullPath}/files`)).toHaveLength(3);
expect(result.combined.at(-1)).toBe("failure");
expect(result.stdout).toContain("src/gateway/auth.ts");
});
it("retains the original dependency base when a merge arrives during diff recovery", () => {
const result = evaluate({
[`GET ${pullPath}`]: {
responses: [pr, { ...mergedPr, base: { ...pr.base, sha: "e".repeat(40) } }],
},
[`GET ${pullPath}/files`]: {
responses: [
files.slice(0, 1),
[files[0], { filename: "package.json", status: "modified" }],
],
},
[`GET /repos/openclaw/openclaw/compare/${pr.base.sha}...${head}`]: {
base_commit: { sha: pr.base.sha },
merge_base_commit: { sha: pr.base.sha },
},
[`GET /repos/openclaw/openclaw/dependency-graph/compare/${pr.base.sha}...${head}`]: [],
[`GET /repos/openclaw/openclaw/contents/package.json?ref=${pr.base.sha}`]: {
type: "file",
content: Buffer.from('{"dependencies":{"fixture":"1"}}').toString("base64"),
},
[`GET /repos/openclaw/openclaw/contents/package.json?ref=${head}`]: {
type: "file",
content: Buffer.from('{"dependencies":{"fixture":"2"}}').toString("base64"),
},
[rolePath]: { role_name: "read" },
});
expect(result.status, result.stderr).toBe(0);
expect(result.waits).toEqual([30_000, 30_000]);
expect(result.combined.at(-1)).toBe("failure");
expect(result.stdout).toContain("package.json");
expect(result.stdout).toContain("/allow-dependencies-change");
});
it.each(["before cleanup", "before commit"])(
"preserves merged lockfiles %s and still reports their missing approval",
(phase) => {
const cleanupPr = {
...pr,
changed_files: 1,
head: { ...pr.head, repo: { id: 1, full_name: "openclaw/openclaw" } },
};
const merged = { ...cleanupPr, state: "closed", merged: true };
const routes = {
[`GET ${pullPath}`]: {
responses:
phase === "before cleanup" ? [merged] : [cleanupPr, cleanupPr, cleanupPr, merged],
},
[`GET ${pullPath}/files`]: [{ filename: "pnpm-lock.yaml", status: "modified" }],
[rolePath]: { role_name: "read" },
[`GET /repos/openclaw/openclaw/dependency-graph/compare/${pr.base.sha}...${head}`]: [],
[`GET /repos/openclaw/openclaw/compare/${pr.base.sha}...${head}`]: {
base_commit: { sha: pr.base.sha },
merge_base_commit: { sha: pr.base.sha },
},
[`GET /repos/openclaw/openclaw/contents/pnpm-lock.yaml`]: {
type: "file",
encoding: "base64",
content: Buffer.from("lockfileVersion: '9.0'\n").toString("base64"),
},
};
const cleanup = evaluate(routes, "autoscrub");
expect(cleanup.status, cleanup.stderr).toBe(0);
expect(cleanup.requests.some((entry) => entry.path === "/graphql")).toBe(false);
const enforcement = evaluate({ ...routes, [`GET ${pullPath}`]: merged });
expect(enforcement.status, enforcement.stderr).toBe(0);
expect(enforcement.combined.at(-1)).toBe("failure");
expect(enforcement.stdout).toContain("/allow-dependencies-change");
},
);
it.each(lifecycleChanges)("stops a $name before reading more file pages", ({ changedPr }) => {
const page = Array.from({ length: 100 }, (_, index) => ({
filename: `docs/page-${index}.md`,
status: "modified",
}));
const result = evaluate({
[`GET ${pullPath}`]: {
settlesAt: "2026-01-02T00:00:30Z",
before: { ...pr, changed_files: 202 },
after: { ...changedPr, changed_files: 202 },
},
[`GET ${pullPath}/files`]: {
responses: [
{ advanceMs: 15_000, response: page },
{ advanceMs: 15_000, response: page },
files,
],
},
});
expect(result.status, result.stderr).toBe(0);
expect(result.requests.filter((entry) => entry.path === `${pullPath}/files`)).toHaveLength(2);
expect(result.requests.at(-1)).toMatchObject({ method: "GET", path: pullPath });
expect(result.requests.some((entry) => entry.body?.state === "success")).toBe(false);
});
it.each([...lifecycleChanges, { name: "merged PR", changedPr: mergedPr }])(
"preserves an earlier guard error after a $name change",
({ changedPr }) => {
const result = evaluate({
[rolePath]: { httpError: 403 },
[`GET ${pullPath}`]: { responses: [pr, pr, changedPr] },
});
expect(result.status).toBe(1);
expect(result.stderr).toContain("Fixture API failure");
expect(result.combined.at(-1)).toBe("failure");
},
);
it.each([
{ ...pr, state: "unknown" },
{ ...pr, draft: "true" },
{ ...pr, base: { ...pr.base, ref: "" } },
{ ...pr, state: "closed", maintainer_can_modify: false },
{ ...mergedPr, maintainer_can_modify: false },
{ ...pr, draft: true, user: { ...pr.user, id: 2 } },
{ ...pr, base: { ref: "stable", repo: { id: 3 } } },
])("keeps invalid or authority-changing transitions as failures: %j", (changedPr) => {
const result = evaluate({ [`GET ${pullPath}`]: { responses: [pr, changedPr] } });
expect(result.status).toBe(1);
expect(result.stderr).toContain("pull request changed");
expect(result.combined).not.toContain("success");
});
it.each([undefined, "", "main"])(
"does not treat an invalid live head %s as superseded",
(sha) => {
const result = evaluate({ [`GET ${pullPath}`]: { ...pr, head: { ...pr.head, sha } } });
expect(result.status).toBe(1);
expect(result.stdout).not.toContain("Superseded");
expect(result.requests.some((entry) => entry.method !== "GET")).toBe(false);
},
);
it("accepts the existing exact-head CI fallback without a manual guard run", () => {
const fallback = {
...run,
event: "workflow_dispatch",
display_title: `CI release gate ${head}`,
};
const result = evaluate({
[runsPath]: { total_count: 1, workflow_runs: [fallback] },
[`GET ${actions}/runs/10`]: fallback,
});
expect(result.status, result.stderr).toBe(0);
expect(result.combined).toEqual(["pending", "success"]);
});
it("does not accept a manual historical-target run as current PR proof", () => {
const result = evaluate({
[runsPath]: {
total_count: 1,
workflow_runs: [{ ...run, event: "workflow_dispatch", display_title: "CI" }],
},
});
expect(result.status, result.stderr).toBe(0);
expect(result.combined).not.toContain("success");
});
it("finds the CI gate beyond the first page of a large CI run", () => {
const result = evaluate({
[jobsPath]: {
responses: [
{
total_count: 101,
jobs: Array.from({ length: 100 }, () => ({
name: "test shard",
status: "completed",
conclusion: "success",
})),
},
{ ...jobs, total_count: 101 },
],
},
});
expect(result.status, result.stderr).toBe(0);
expect(result.combined).toEqual(["pending", "success"]);
expect(result.requests.filter((entry) => `GET ${entry.path}` === jobsPath)).toHaveLength(2);
});
const skippedRun = { ...run, id: 12, conclusion: "skipped" };
const skippedJobs = {
...jobs,
jobs: [{ ...jobs.jobs[0], conclusion: "skipped" }],
};
const skippedRunRoutes = {
[`GET ${actions}/runs/12`]: skippedRun,
[`GET ${actions}/runs/12/attempts/1/jobs`]: skippedJobs,
};
it("ignores a delayed skipped PR workflow after successful same-head CI", () => {
const result = evaluate({
...skippedRunRoutes,
[runsPath]: { total_count: 2, workflow_runs: [skippedRun, run] },
});
expect(result.status, result.stderr).toBe(0);
expect(result.combined).toEqual(["pending", "success"]);
});
it.each([
{ status: "in_progress", conclusion: null, expected: "pending" },
{ status: "completed", conclusion: "failure", expected: "failure" },
])(
"rechecks a skipped run before ignoring its $status rerun",
({ status, conclusion, expected }) => {
const currentRun = { ...skippedRun, run_attempt: 2, status, conclusion };
const result = evaluate({
...skippedRunRoutes,
[runsPath]: { total_count: 2, workflow_runs: [skippedRun, run] },
[`GET ${actions}/runs/12`]: currentRun,
[`GET ${actions}/runs/12/attempts/2/jobs`]: {
...jobs,
jobs: [{ ...jobs.jobs[0], status, conclusion }],
},
});
expect(result.status, result.stderr).toBe(0);
expect(result.combined).toEqual(["pending", expected]);
},
);
it.each([
{ field: "id", value: 13 },
{ field: "head_sha", value: "d".repeat(40) },
{ field: "run_attempt", value: 0 },
{ field: "status", value: "unknown" },
])("rejects invalid live skipped-run metadata: $field", ({ field, value }) => {
const result = evaluate({
...skippedRunRoutes,
[runsPath]: { total_count: 2, workflow_runs: [skippedRun, run] },
[`GET ${actions}/runs/12`]: { ...skippedRun, [field]: value },
});
expect(result.status).toBe(1);
expect(result.combined).toEqual(["pending", "failure"]);
});
it.each([
{ status: "completed", conclusion: "failure", expected: "failure" },
{ status: "completed", conclusion: "cancelled", expected: "failure" },
{ status: "in_progress", conclusion: null, expected: "pending" },
])(
"keeps newer substantive CI authoritative before a skipped PR workflow: $status/$conclusion",
({ status, conclusion, expected }) => {
const currentRun = { ...run, id: 11, status, conclusion };
const result = evaluate({
...skippedRunRoutes,
[runsPath]: { total_count: 3, workflow_runs: [run, skippedRun, currentRun] },
[`GET ${actions}/runs/11`]: currentRun,
[`GET ${actions}/runs/11/attempts/1/jobs`]: {
...jobs,
jobs: [{ ...jobs.jobs[0], status, conclusion }],
},
});
expect(result.status, result.stderr).toBe(0);
expect(result.combined).toEqual(["pending", expected]);
},
);
it("does not approve when every matching CI workflow was skipped", () => {
const result = evaluate({
...skippedRunRoutes,
[runsPath]: { total_count: 1, workflow_runs: [skippedRun] },
});
expect(result.status, result.stderr).toBe(0);
expect(result.combined).toEqual(["pending", "pending"]);
});
it("does not ignore a skipped explicit CI release gate", () => {
const fallback = {
...skippedRun,
event: "workflow_dispatch",
display_title: `CI release gate ${head}`,
};
const result = evaluate({
...skippedRunRoutes,
[runsPath]: { total_count: 2, workflow_runs: [run, fallback] },
[`GET ${actions}/runs/12`]: fallback,
});
expect(result.status, result.stderr).toBe(0);
expect(result.combined).toEqual(["pending", "failure"]);
});
it.each([
{ name: "missing CI", response: { total_count: 0, workflow_runs: [] } },
{
name: "unfinished CI",
response: { total_count: 1, workflow_runs: [{ ...run, status: "in_progress" }] },
},
{
name: "another workflow",
response: {
total_count: 1,
workflow_runs: [{ ...run, path: ".github/workflows/forged.yml" }],
},
},
{
name: "stale head",
response: { total_count: 1, workflow_runs: [{ ...run, head_sha: "d".repeat(40) }] },
},
{
name: "newer unfinished run",
response: { total_count: 2, workflow_runs: [run, { ...run, id: 11, status: "queued" }] },
},
])("does not turn $name into a passing combined gate", ({ response }) => {
const result = evaluate({ [runsPath]: response });
expect(result.status, result.stderr).toBe(0);
expect(result.combined).toEqual(["pending", "pending"]);
});
it.each([
{ field: "status", value: undefined },
{ field: "status", value: "unknown" },
{ field: "run_attempt", value: undefined },
{ field: "run_attempt", value: 0 },
{ field: "id", value: undefined },
{ field: "id", value: 0 },
])("fails malformed CI metadata instead of waiting: $field=$value", ({ field, value }) => {
const malformed = { ...run, [field]: value };
for (const routes of [
{ [runsPath]: { total_count: 1, workflow_runs: [malformed] } },
{ [`GET ${actions}/runs/10`]: malformed },
]) {
const result = evaluate(routes);
expect(result.status).toBe(1);
expect(result.combined).toEqual(["pending", "failure"]);
}
});
it.each([0, -1, undefined, "invalid"])(
"rejects malformed eligible run ID %s before selecting older successful CI",
(id) => {
const result = evaluate({
[runsPath]: {
total_count: 2,
workflow_runs: [run, { ...run, id, status: "queued" }],
},
});
expect(result.status).toBe(1);
expect(result.stderr).toContain("invalid run identity");
expect(result.combined).toEqual(["pending", "failure"]);
},
);
it.each(["failure", "cancelled", "skipped", "neutral"])(
"does not hide a %s CI gate",
(conclusion) => {
const result = evaluate({ [jobsPath]: { ...jobs, jobs: [{ ...jobs.jobs[0], conclusion }] } });
expect(result.status, result.stderr).toBe(0);
expect(result.combined).not.toContain("success");
},
);
it("does not accept a previously successful CI attempt while a new attempt runs", () => {
const result = evaluate({
[`GET ${actions}/runs/10`]: { ...run, run_attempt: 2, status: "in_progress" },
});
expect(result.status, result.stderr).toBe(0);
expect(result.combined).toEqual(["pending", "pending"]);
});
it("fails evaluation when a replacement CI attempt has already completed", () => {
const result = evaluate({
[`GET ${actions}/runs/10`]: { ...run, run_attempt: 2 },
});
expect(result.status).toBe(1);
expect(result.stderr).toContain("completed CI attempt changed");
expect(result.combined).toEqual(["pending", "failure"]);
});
it("settles after CI completes without leaving either evaluation failed", () => {
const waiting = evaluate({
[runsPath]: { total_count: 1, workflow_runs: [{ ...run, status: "in_progress" }] },
});
const completed = evaluate();
expect(waiting.status, waiting.stderr).toBe(0);
expect(waiting.combined.at(-1)).toBe("pending");
expect(completed.status, completed.stderr).toBe(0);
expect(completed.combined.at(-1)).toBe("success");
});
it.each([
{ name: "CI API failure", routes: { [runsPath]: { httpError: 403 } } },
{ name: "invalid CI metadata", routes: { [runsPath]: { workflow_runs: null } } },
{ name: "invalid job list", routes: { [jobsPath]: { jobs: null } } },
{
name: "status publication failure",
routes: { [`POST /repos/openclaw/openclaw/statuses/${head}`]: { httpError: 403 } },
},
])("fails the job and keeps the gate closed for $name", ({ routes }) => {
const result = evaluate(routes);
expect(result.status).toBe(1);
expect(result.combined).not.toContain("success");
expect(result.stderr).not.toBe("");
});
it.each(["src/gateway/auth.ts", "pnpm-workspace.yaml"])(
"keeps the combined gate closed when only %s requires approval",
(filename) => {
const result = evaluate({
[rolePath]: { role_name: "write" },
[`GET ${pullPath}`]: { ...pr, changed_files: 1 },
[`GET ${pullPath}/files`]: [{ filename, status: "modified" }],
});
expect(result.status, result.stderr).toBe(0);
expect(result.combined.at(-1)).toBe("failure");
expect(result.reviews.filter((entry) => entry.body?.state === "success")).toHaveLength(1);
},
);
it("does not hide a guard error when the other guard awaits approval", () => {
const result = evaluate({
[rolePath]: { responses: [{ httpError: 403 }, { role_name: "write" }] },
});
expect(result.status).toBe(1);
expect(result.stderr).toContain("Fixture API failure");
expect(result.combined).not.toContain("success");
expect(
result.requests.some((entry) =>
entry.body?.body?.includes("/allow-security-sensitive-change"),
),
).toBe(true);
});
it("preserves a guard error when the sibling observes a superseded head", () => {
const result = evaluate({
[rolePath]: { httpError: 403 },
[`GET ${pullPath}`]: {
responses: [pr, pr, { ...pr, head: { ...pr.head, sha: "d".repeat(40) } }],
},
});
expect(result.status).toBe(1);
expect(result.stderr).toContain("Fixture API failure");
expect(result.combined.at(-1)).toBe("failure");
expect(result.reviews.some((entry) => entry.body?.state === "success")).toBe(false);
});
it("publishes both review notices and settles automatically after command approval", () => {
const result = evaluate({ [rolePath]: { role_name: "write" } });
expect(result.status, result.stderr).toBe(0);
expect(result.combined).toEqual(["pending", "failure"]);
const notices = result.requests.filter(
(entry) => entry.method === "POST" && entry.path.endsWith("/comments"),
);
expect(notices).toHaveLength(2);
expect(notices.map((entry) => entry.body?.body).join("\n")).toContain(
"/allow-dependencies-change",
);
expect(notices.map((entry) => entry.body?.body).join("\n")).toContain(
"/allow-security-sensitive-change",
);
expect(result.reviews.every((entry) => entry.body?.state === "failure")).toBe(true);
const approved = evaluate({
[rolePath]: { role_name: "write" },
"GET /repos/openclaw/openclaw/collaborators/reviewer/permission": { role_name: "maintain" },
"GET /repos/openclaw/openclaw/issues/7/comments": [
...notices.map((entry, index) => ({
id: index + 1,
body: entry.body?.body,
user: { login: "github-actions[bot]", type: "Bot" },
updated_at: "2026-01-01T00:00:00Z",
})),
{
id: 3,
user: { id: 2, login: "reviewer", type: "User" },
body: "/allow-dependencies-change\n/allow-security-sensitive-change",
created_at: "2026-01-01T00:01:00Z",
updated_at: "2026-01-01T00:01:00Z",
html_url: "https://github.com/openclaw/openclaw/pull/7#issuecomment-3",
},
],
});
expect(approved.status, approved.stderr).toBe(0);
expect(approved.combined.at(-1)).toBe("success");
expect(approved.reviews.filter((entry) => entry.body?.state === "success")).toHaveLength(2);
});
it("rechecks author authority after CI reads before publishing success", () => {
const result = evaluate({
[rolePath]: {
responses: [
{ role_name: "maintain" },
{ role_name: "maintain" },
{ role_name: "maintain" },
{ role_name: "read" },
],
},
});
expect(result.status, result.stderr).toBe(0);
expect(result.stdout).toContain("approval changed");
expect(result.combined).not.toContain("success");
});
it("ignores main advancement and absent-to-null metadata during evaluation", () => {
const result = evaluate({
[`GET ${pullPath}`]: {
responses: [
pr,
{ ...pr, base: { ...pr.base, sha: "e".repeat(40) }, maintainer_can_modify: null },
],
},
});
expect(result.status, result.stderr).toBe(0);
expect(result.combined).toEqual(["pending", "success"]);
expect(result.reviews.filter((entry) => entry.body?.state === "success")).toHaveLength(2);
});
it.each([
{
name: "head",
changedPr: { ...pr, head: { ...pr.head, sha: "d".repeat(40) } },
changedFields: [],
},
...lifecycleChanges.map(({ name, changedPr }) => ({ name, changedPr, changedFields: [] })),
{
name: "missing head",
changedPr: { ...pr, head: { ...pr.head, sha: undefined } },
changedFields: ["head.sha"],
},
{
name: "author and edit permission",
changedPr: {
...pr,
user: { id: 2, login: "changed-author", type: "Bot" },
maintainer_can_modify: false,
},
changedFields: ["user.id", "user.login", "user.type", "maintainer_can_modify"],
},
])("does not publish combined success after a changed $name", ({ changedPr, changedFields }) => {
const result = evaluate({
[`GET ${pullPath}`]: {
responses: [pr, pr, pr, pr, pr, changedPr],
},
});
const obsolete = changedFields.length === 0;
expect(result.status).toBe(obsolete ? 0 : 1);
expect(result.combined).not.toContain("success");
if (obsolete) {
expect(result.stdout).toContain("skipping");
expect(result.stderr).toBe("");
expect(result.requests.at(-1)).toMatchObject({ method: "GET", path: pullPath });
expect(
result.requests
.filter((entry) => entry.method === "POST" && entry.path.includes("/statuses/"))
.every((entry) => entry.path.endsWith(head)),
).toBe(true);
} else {
// Only fixed field names belong in the diagnostic, never the PR's values.
expect(result.stderr.trim()).toBe(
`The pull request changed during security review (changed fields: ${changedFields.join(", ")}); the next automatic event will evaluate it.`,
);
}
});
const exemptRoutes = {
"GET /repos/openclaw/openclaw/pulls/152415": { ...rollout, merged_at: "2026-02-01T00:00:00Z" },
[`GET /repos/openclaw/openclaw/compare/${landed}...${head}`]: {
base_commit: { sha: landed },
merge_base_commit: { sha: "b".repeat(40) },
status: "diverged",
},
};
it("grandfathers an old branch without issuing reusable standalone successes or notices", () => {
const result = evaluate(exemptRoutes);
expect(result.status, result.stderr).toBe(0);
expect(result.combined).toEqual(["pending", "success"]);
expect(result.reviews).toEqual([]);
expect(result.requests.some((entry) => entry.path.includes("/issues/"))).toBe(false);
expect(result.requests.some((entry) => entry.path.endsWith("/files"))).toBe(false);
});
it("still requires real CI for a grandfathered PR", () => {
const result = evaluate({ ...exemptRoutes, [runsPath]: { total_count: 0, workflow_runs: [] } });
expect(result.status, result.stderr).toBe(0);
expect(result.combined).toEqual(["pending", "pending"]);
expect(result.reviews).toEqual([]);
});
it("does not autoscrub a grandfathered PR", () => {
const result = evaluate(exemptRoutes, "autoscrub");
expect(result.status, result.stderr).toBe(0);
expect(
result.requests
.filter((entry) => entry.method !== "GET")
.every((entry) => entry.body?.context === "openclaw/ci-gate"),
).toBe(true);
});
it("activates both guards once an old head contains the rollout commit", () => {
const result = evaluate({
...exemptRoutes,
[`GET /repos/openclaw/openclaw/compare/${landed}...${head}`]: {
base_commit: { sha: landed },
merge_base_commit: { sha: landed },
status: "ahead",
},
[rolePath]: { role_name: "read" },
});
expect(result.status, result.stderr).toBe(0);
expect(result.reviews.filter((entry) => entry.body?.state === "failure")).toHaveLength(4);
expect(result.combined).not.toContain("success");
});
it("leaves the combined gate failed when rollout metadata cannot be read", () => {
const result = evaluate({ "GET /repos/openclaw/openclaw/pulls/152415": { httpError: 403 } });
expect(result.status).toBe(1);
expect(result.combined).toEqual(["pending", "failure"]);
expect(result.reviews).toEqual([]);
});
});