Skip to content

A pull-request reviewer with an approval before merging#

You will build pr-reviewer: given a pull request, it reads the change and the code around it, posts a review, and — when it judges the change ready — asks to merge it. The merge waits on the machine until an admin approves it. Nothing else on GitHub is writable, and a guardrail keeps merges out of every other agent's reach.

flowchart LR
  I[Input: acme/api#7] --> A[pr-reviewer]
  A -->|pull_request_read,<br/>get_file_contents| G[(GitHub MCP)]
  A -->|pull_request_review_write| G
  A -->|merge_pull_request| H{{Held: approval}}
  H -->|admin approves| G

Before you begin#

  • A machine that can run agents, and the editor role (the admin role for the guardrail and to approve).
  • A model: an Anthropic key in a credential anthropic-key (key api_key), or a strong coding model on Eos.
  • A GitHub fine-grained token for the repositories to review, with Contents, Pull requests and Issues set to Read and write, kept in a credential github-token under the key token. Or use Connect with GitHub (Add tools and connections).

1. Create the agent#

The policy permits reads and reviews, and permits merging only with an admin's approval, held up to an hour:

pr-reviewer.cedar
@id("github-reads")
permit (principal, action == Action::"tools/call", resource in Server::"github")
when {
  ["get_file_contents", "get_repository_tree", "list_commits", "get_commit", "search_code",
   "pull_request_read", "list_pull_requests", "issue_read"].contains(resource.name)
};

@id("github-review")
permit (principal, action == Action::"tools/call", resource in Server::"github")
when { ["pull_request_review_write", "add_comment_to_pending_review"].contains(resource.name) };

@id("merge-needs-an-admin")
@approval("role:admin")
@approval_wait("1h")
permit (principal, action == Action::"tools/call", resource in Server::"github")
when { resource.name == "merge_pull_request" };
  1. Anemoi → Agents → New agent, name pr-reviewer.
  2. Start from: Pull request reviewer. It brings the Assistant, the instructions and the GitHub connection with Writes need approval.
  3. Model: Anthropic, claude-sonnet-4-5, credential anthropic-key.
  4. Connections → GitHub: credential github-token.
  5. Switch to Advanced and replace the Tool policy with the text above. Add to the Instructions: When the change is correct, tested and approved by your review, merge it with merge_pull_request (squash). If merging is refused or not approved, say so and stop.
  6. Limits: 30 minutes, Stop at, per run $3.
  7. Create the agent.
$ jq -n --rawfile p pr-reviewer.cedar '{
    metadata: {name: "pr-reviewer"},
    spec: {
      kind: "astralyx",
      model: {provider: "anthropic", model: "claude-sonnet-4-5", credential: "anthropic-key"},
      instructions: "You review pull requests on GitHub. Read the description, the changed files and, where needed, the surrounding code. Post one review: what the change does, what is wrong or risky, concrete suggestions. When the change is correct and tested, merge it with merge_pull_request (squash). If merging is refused or not approved, say so and stop.",
      tools: [{name: "github", kind: "mcp", url: "https://api.githubcopilot.com/mcp/", credential: "github-token"}],
      policy: {tools: $p},
      budget: {max_seconds: 1800, max_cost_usd: 3}
    }}' \
  | curl -sS -X POST "$WS/agents" -H "Authorization: Bearer $ASTRA_TOKEN" -H "Content-Type: application/json" -d @-

2. Check the policy before it runs#

Under the policy, Test a call on github: pull_request_read → allowed by github-reads; merge_pull_request → needs a person's approval (merge-needs-an-admin); delete_file → denied: no policy permits it.

$ jq -n --rawfile p pr-reviewer.cedar '{tools: $p, tests: [
    {server: "github", tool: "pull_request_read"},
    {server: "github", tool: "merge_pull_request"},
    {server: "github", tool: "delete_file"}]}' \
  | curl -sS -X POST "$WS/agent-policy-checks" -H "Authorization: Bearer $ASTRA_TOKEN" \
      -H "Content-Type: application/json" -d @- | jq -c '.tests[] | {decision, reason}'
{"decision":"allow","reason":"permitted by github-reads"}
{"decision":"requires_approval","reason":"needs a person's approval (merge-needs-an-admin)"}
{"decision":"deny","reason":"no policy permits it"}

The tool names are those of GitHub's MCP server. The agent sees only the tools its policy allows when it lists them.

3. Keep merges for this agent alone#

Add a workspace guardrail so no other agent merges, whatever its policy:

only-the-reviewer-merges.cedar
@id("only-the-reviewer-merges")
@reason("only pr-reviewer merges, with an admin's approval")
forbid (principal, action == Action::"tools/call", resource)
when { resource.name like "*merge*" && principal.agent != "pr-reviewer" };

@id("no-rest-merges")
@reason("merging goes through pr-reviewer")
forbid (principal, action == Action::"http", resource)
when { resource.host == "api.github.com" && resource.method == "PUT" && resource.path like "/repos/*/pulls/*/merge" };

Anemoi → Guardrails → New guardrail, name only-the-reviewer-merges, the text above, Create.

$ jq -n --rawfile t only-the-reviewer-merges.cedar '{metadata: {name: "only-the-reviewer-merges"}, spec: {text: $t}}' \
  | curl -sS -X POST "$WS/agent-guardrails" -H "Authorization: Bearer $ASTRA_TOKEN" -H "Content-Type: application/json" -d @-

4. Review a pull request#

Run agent with the input acme/api#7.

$ curl -sS -X POST "$WS/agents/pr-reviewer/runs" -H "Authorization: Bearer $ASTRA_TOKEN" \
    -H "Content-Type: application/json" -d '{"input": "acme/api#7"}' | jq -r .run.metadata.name
pr-reviewer-5e01c2

The run reads the pull request, posts its review, then calls merge_pull_request. That call stops on the machine; the run shows waiting for approval, and the workspace's admins get an email.

5. Approve the merge#

Anemoi → Approvals: github/merge_pull_request from pr-reviewer. Open it: The call shows the arguments (owner, repo, pull number, merge method), read from the machine. Read the review on GitHub, then Approve — or Deny with a reason, which the agent is told.

$ A=$(curl -sS "$WS/approvals?state=Pending&run=pr-reviewer-5e01c2" -H "Authorization: Bearer $ASTRA_TOKEN" | jq -r '.items[0].metadata.name')
$ curl -sS "$WS/approvals/$A/details" -H "Authorization: Bearer $ASTRA_TOKEN"
$ curl -sS -X POST "$WS/approvals/$A/decision" -H "Authorization: Bearer $ASTRA_TOKEN" \
    -H "Content-Type: application/json" -d '{"decision": "approve"}'

The held call goes ahead once; the run finishes and says the pull request was merged. If nobody decides within the hour, the agent is told the merge is still waiting and ends; the approval stays open for 24 hours, and the same call from a new run goes ahead once approved.

6. Check the record#

The run's Trace shows each call with its rule; its Receipt lists the approval used — its name, Used, who approved and when — and its policy digest covers the guardrail. Check it offline:

$ astra anemoi receipts verify pr-reviewer-5e01c2.receipt.json --jwks jwks.json

Run it on every pull request#

Anemoi has no GitHub trigger of its own. Start a run from your CI when a pull request is opened or marked ready, with a personal API token kept as a secret:

.github/workflows/review.yml
on:
  pull_request:
    types: [opened, ready_for_review]
jobs:
  review:
    runs-on: ubuntu-latest
    steps:
      - run: |
          curl -fsS -X POST "$WS/agents/pr-reviewer/runs" -H "Authorization: Bearer $ASTRA_TOKEN" \
            -H "Content-Type: application/json" \
            -d "{\"input\": \"${{ github.repository }}#${{ github.event.pull_request.number }}\"}"
        env:
          WS: https://console.astralyx.cloud/api/v1/orgs/acme/workspaces/research/clusters/main/api
          ASTRA_TOKEN: ${{ secrets.ASTRA_TOKEN }}