Skip to content
112 changes: 112 additions & 0 deletions .failproofai/policies/review-policies.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/**
* review-policies.mjs — Require bot review comments to be resolved before stopping.
*
* Runs on the Stop event, after built-in workflow policies (require-ci-green-before-stop).
* Uses the GitHub GraphQL API to check for unresolved review threads authored by bots.
*/
import { customPolicies, allow, deny } from "failproofai";
import { execSync } from "node:child_process";

customPolicies.add({
name: "require-bot-reviews-resolved",
description: "Require all bot review comments (e.g. CodeRabbit) to be resolved before stopping",
match: { events: ["Stop"] },
fn: async (ctx) => {
const cwd = ctx.session?.cwd;
if (!cwd) return allow("No working directory, skipping bot review check.");

try {
execSync("gh --version", { cwd, encoding: "utf8", timeout: 3000 });
} catch {
return allow("GitHub CLI (gh) not installed, skipping bot review check.");
}

let branch;
try {
branch = execSync("git rev-parse --abbrev-ref HEAD", {
cwd,
encoding: "utf8",
timeout: 5000,
}).trim();
} catch {
return allow("Could not determine branch, skipping bot review check.");
}

if (!branch || branch === "HEAD" || branch === "main" || branch === "master") {
return allow("Not on a feature branch, skipping bot review check.");
}

let prNumber;
try {
const raw = execSync(`gh pr view "${branch}" --json number`, {
cwd,
encoding: "utf8",
timeout: 10000,
});
prNumber = JSON.parse(raw).number;
} catch {
return allow("No PR found for this branch, skipping bot review check.");
}

let repoOwner, repoName;
try {
const raw = execSync("gh repo view --json owner,name", {
cwd,
encoding: "utf8",
timeout: 5000,
});
const parsed = JSON.parse(raw);
repoOwner = parsed.owner.login;
repoName = parsed.name;
} catch {
return allow("Could not determine repository, skipping bot review check.");
}

const query = `query {
repository(owner: "${repoOwner}", name: "${repoName}") {
pullRequest(number: ${prNumber}) {
reviewThreads(first: 100) {
nodes {
isResolved
comments(first: 1) {
nodes {
author { login }
}
}
}
}
}
}
}`.replace(/\n/g, " ");

let threads;
try {
const raw = execSync(`gh api graphql -f query='${query}'`, {
cwd,
encoding: "utf8",
timeout: 15000,
});
threads = JSON.parse(raw).data.repository.pullRequest.reviewThreads.nodes;
} catch {
return allow("Could not fetch review threads, skipping bot review check.");
}

const unresolvedBotThreads = threads.filter((t) => {
if (t.isResolved) return false;
const author = t.comments?.nodes?.[0]?.author?.login ?? "";
return author.includes("[bot]");
});

if (unresolvedBotThreads.length > 0) {
const authors = [
...new Set(unresolvedBotThreads.map((t) => t.comments.nodes[0].author.login)),
];
return deny(
`${unresolvedBotThreads.length} unresolved bot review comment(s) on PR #${prNumber} from: ${authors.join(", ")}. ` +
`Address or resolve all bot review comments, then push your fixes before stopping.`,
);
}

return allow();
},
});
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

## Unreleased

## 0.0.6-beta.1 — 2026-04-20

### Features
- Add `prefer-package-manager` builtin policy to enforce allowed package managers (e.g., uv instead of pip) (#126)

### Docs
- Emphasize convention-based policies as org-wide quality standards in getting-started, custom-policies, examples, and README (#126)

## 0.0.6-beta.0 — 2026-04-20

### Fixes
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ Custom hooks support transitive local imports, async/await, and access to `proce

### Convention-based policies

Drop `*policies.{js,mjs,ts}` files into `.failproofai/policies/` and they're automatically loaded — no `--custom` flag or config changes needed. Works like git hooks: drop a file, it just works.
Drop `*policies.{js,mjs,ts}` files into `.failproofai/policies/` and they're automatically loaded — no flags or config changes needed. Commit the directory to git and every team member gets the same quality standards automatically.

```text
# Project level — committed to git, shared with the team
Expand All @@ -233,7 +233,7 @@ Drop `*policies.{js,mjs,ts}` files into `.failproofai/policies/` and they're aut
~/.failproofai/policies/my-policies.mjs
```

Both levels load (union). Files are loaded alphabetically within each directory. Prefix with `01-`, `02-`, etc. to control order. See [examples/convention-policies/](examples/convention-policies/) for ready-to-use examples.
Both levels load (union). Files are loaded alphabetically within each directory. Prefix with `01-`, `02-`, etc. to control order. As your team discovers new failure modes, add a policy and push — everyone gets the update on their next pull. See [examples/convention-policies/](examples/convention-policies/) for ready-to-use examples.

---

Expand Down
34 changes: 34 additions & 0 deletions __tests__/e2e/hooks/policy-params.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,40 @@ describe("block-read-outside-cwd allowPaths", () => {
});
});

// ── prefer-package-manager — allowed ────────────────────────────────────────

describe("prefer-package-manager allowed", () => {
it("denies pip when only uv is allowed", () => {
const env = createFixtureEnv();
env.writeConfig({
enabledPolicies: ["prefer-package-manager"],
policyParams: { "prefer-package-manager": { allowed: ["uv"] } },
});
const result = runHook("PreToolUse", Payloads.preToolUse.bash("pip install flask", env.cwd), { homeDir: env.home });
assertPreToolUseDeny(result);
});

it("allows when command uses an allowed manager", () => {
const env = createFixtureEnv();
env.writeConfig({
enabledPolicies: ["prefer-package-manager"],
policyParams: { "prefer-package-manager": { allowed: ["uv"] } },
});
const result = runHook("PreToolUse", Payloads.preToolUse.bash("uv add flask", env.cwd), { homeDir: env.home });
assertAllow(result);
});

it("allows when allowed list is empty (no-op)", () => {
const env = createFixtureEnv();
env.writeConfig({
enabledPolicies: ["prefer-package-manager"],
policyParams: { "prefer-package-manager": { allowed: [] } },
});
const result = runHook("PreToolUse", Payloads.preToolUse.bash("pip install flask", env.cwd), { homeDir: env.home });
assertAllow(result);
});
});

// ── hint — cross-cutting policyParams field ─────────────────────────────────

describe("policyParams hint", () => {
Expand Down
Loading