Build a PR reviewer with BDK
Build a PR reviewer with the Bot Development Kit (BDK). It classifies
changes as trivial, moderate, or large, then approves safe changes or
requests human review.
Prerequisites
-
Node 22.13 or newer.
-
The
@cursor/bdkpackage:
npm install -g @cursor/bdk- A Cursor credential for model turns. Sign in once:
bdk loginCreate and run the project
The preferred BDK workflow uses bdk init and bdk dev.
bdk init ./pr-reviewercd pr-reviewerbdk devKeep bdk dev running. In a second terminal, run:
bdk run --dir . --message "Introduce yourself in one sentence."Confirm the agent replies.
Add review instructions
Replace bot/instructions.md:
# PR reviewerYou review GitHub pull requests. Be specific and brief.For every pull request:1. Call `inspect_pr` first. Never judge a change you haven't fetched.2. Match your review to the complexity it reports: - `trivial`: read the patches. If the diff does what the title says and nothing looks risky, call `submit_review` with verdict `approve`. - `moderate`: read every patch. Approve only when you understand the whole change and see no risk. Otherwise ask for a human review and say which files worry you. - `large`: call `submit_review` with verdict `request_human_review` right away. Use the stats you already have to point the reviewer at the biggest files; don't dig further.3. Never approve a draft. Point out anything surprising, even when you approve.End with one sentence: the verdict and why.Add the inspect tool
Create bot/tools/inspect_pr.ts:
import { defineTool } from "@cursor/bdk/tools";import { z } from "zod";export type Complexity = "trivial" | "moderate" | "large";function rateComplexity(linesChanged: number, changedFiles: number): Complexity { if (linesChanged <= 25 && changedFiles <= 2) { return "trivial"; } if (linesChanged <= 400 && changedFiles <= 15) { return "moderate"; } return "large";}/** Keep one oversized file from flooding the model's context. */function trimPatch(patch: string | undefined): string | undefined { if (patch === undefined || patch.length <= 3000) { return patch; } return `${patch.slice(0, 3000)}\n[... patch trimmed ...]`;}export default defineTool({ description: "Fetch a pull request's title, stats, and per-file patches, plus a deterministic complexity rating (trivial, moderate, or large). Call this before any review decision.", inputSchema: z.object({ prUrl: z .string() .describe("Pull request URL: https://github.com/owner/repo/pull/123"), }), async execute({ prUrl }, ctx) { const url = new URL(prUrl); const [owner, repo, pulls, number] = url.pathname.split("/").filter(Boolean); const pull_number = Number.parseInt(number ?? "", 10); if (pulls !== "pull" || owner === undefined || Number.isNaN(pull_number)) { throw new Error(`Not a pull request URL: ${prUrl}`); } const octokit = await ctx.host.github.getOctokit(); const { data: pr } = await octokit.rest.pulls.get({ owner, repo, pull_number, }); const { data: files } = await octokit.rest.pulls.listFiles({ owner, repo, pull_number, per_page: 100, }); const complexity = rateComplexity( pr.additions + pr.deletions, pr.changed_files ); return { title: pr.title, author: pr.user?.login, state: pr.state, draft: pr.draft ?? false, additions: pr.additions, deletions: pr.deletions, changedFiles: pr.changed_files, complexity, files: files.map((file) => ({ path: file.filename, additions: file.additions, deletions: file.deletions, ...(complexity === "large" ? {} : { patch: trimPatch(file.patch) }), })), }; },});Verify complexity classification
Run inspect_pr against a small pull request:
bdk call inspect_pr --dir . \ --input '{"prUrl":"https://github.com/facebook/react/pull/35623"}'Confirm the result contains "complexity": "trivial".
Run it against a large pull request:
bdk call inspect_pr --dir . \ --input '{"prUrl":"https://github.com/facebook/react/pull/25229"}'Confirm the result contains "complexity": "large" and no patch
fields.
Add the review tool
Create bot/tools/submit_review.ts to post each verdict:
import { defineTool } from "@cursor/bdk/tools";import { z } from "zod";export default defineTool({ description: "Post the review decision to GitHub: approve the pull request, or comment asking for a human review.", inputSchema: z.object({ prUrl: z .string() .describe("Pull request URL: https://github.com/owner/repo/pull/123"), verdict: z.enum(["approve", "request_human_review"]), summary: z .string() .describe("One or two sentences explaining the verdict."), }), async execute({ prUrl, verdict, summary }, ctx) { const url = new URL(prUrl); const [owner, repo, pulls, number] = url.pathname.split("/").filter(Boolean); const pull_number = Number.parseInt(number ?? "", 10); if (pulls !== "pull" || owner === undefined || Number.isNaN(pull_number)) { throw new Error(`Not a pull request URL: ${prUrl}`); } const review = verdict === "approve" ? { event: "APPROVE" as const, body: `PR reviewer: ${summary}` } : { event: "COMMENT" as const, body: `PR reviewer: this change needs a human review. ${summary}`, }; const octokit = await ctx.host.github.getOctokit(); await octokit.rest.pulls.createReview({ owner, repo, pull_number, event: review.event, body: review.body, }); return { posted: true, ...review }; },});Use a GitHub credential with write access and a pull request you didn't author.
Review a pull request
Review an open pull request:
bdk run --dir . \ --message "Review https://github.com/acme/checkout/pull/42"Confirm inspect_pr runs before submit_review, then verify the review
on GitHub.
Trigger reviews from GitHub
Create bot/channels/github.ts:
import { defaultGitHubAuth, githubChannel,} from "@cursor/bdk/channels/github";const REVIEW_ACTIONS = new Set(["opened", "reopened", "ready_for_review"]);export default githubChannel({ botName: "bdk-pr-reviewer", cursorAccount: { repos: ["acme/checkout"] }, onPullRequest: (ctx, pr) => { if (!REVIEW_ACTIONS.has(pr.action) || pr.draft) { return null; } return { auth: defaultGitHubAuth(ctx), title: `Review ${ctx.repository.fullName}#${pr.number}`, context: [ `Review ${pr.url}. Inspect it first, then submit your verdict with submit_review.`, ], }; },});Channel-turn context is a readonly string[], so wrap each context block in
an array. The context label under progress.commitStatus is a separate
string option.
Keep bdk dev running. In a second terminal, replay an open pull
request. Pull access is enough; repo admin isn't required:
bdk github replay https://github.com/acme/checkout/pull/42 \ --dir . --action openedOpen the playground and confirm Review acme/checkout#42 contains the
trigger, both tool calls, and the verdict.
Run options
- CLI message
bdk run --dir . --message "Review https://github.com/acme/checkout/pull/42"- Local replay
bdk github replay https://github.com/acme/checkout/pull/42 \ --dir . --action openedWhere to go next
- Evals: freeze these two PRs as regression checks so prompt changes can't flip a verdict
- Tools: more on typed tools, dry-run effects, and direct calls
- GitHub: fixtures, forwarding, and pulling events from Cursor
- Building agents with agents: have a coding agent extend the project for you