GitHub's CODEOWNERS file is a static, path-based routing table. It says "if a PR touches src/billing/, request review from @org/payments-team." That worked when the PR author was a human you could trust to have read what they were submitting. It does not work when the PR author is an agent that batch-modified 23 files because the operator's prompt said "fix the linting issues across the repo."
The agent has no judgment about which of those files are sensitive. The operator may not have looked at most of the diff. Your CODEOWNERS rule still fires correctly — but the reviewer load and the accountability shape are wrong. You need an additional layer: conditional reviewer assignment based on PR author identity.
This article gives you the routing model, the GitHub Actions workflow that implements it, and the quorum rules that prevent agent-on-agent review (an actual failure mode we've seen at customers running multiple AI tools in parallel).
The Three Routing Cases
For any incoming PR, the routing logic needs to handle three cases:
- Human author, human reviewer — the historical case. CODEOWNERS does the work; no extra logic required.
- Agent author, human operator visible — the agent committed under a
[email protected]-style identity (see Attributing AI-Authored Commits in Git). The operator (Alice) needs to be a required reviewer regardless of which paths were touched. - Autonomous agent author, no individual operator — Dependabot, Renovate, scheduled refactor bots. Routes to the agent's "operator of record" group plus an elevated quorum.
A single workflow can handle all three. The key insight: don't try to encode the agent logic in CODEOWNERS itself. CODEOWNERS owns the path-to-team mapping; a separate workflow owns the author-to-extra-reviewer mapping. They compose.
The Routing Workflow
This GitHub Actions workflow runs on every PR open and synchronize event, inspects the head commit's author email, and adds the right reviewers using gh pr edit:
# .github/workflows/agent-pr-router.yml
name: Agent PR Router
on:
pull_request:
types: [opened, synchronize, reopened]
permissions:
pull-requests: write
contents: read
jobs:
route:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Inspect PR author and route reviewers
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
AUTHOR_EMAIL: ${{ github.event.pull_request.user.email }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
# Get the head commit author email (more reliable than PR user email,
# which is often the GitHub account, not the git author)
COMMIT_EMAIL=$(git show -s --format="%ae" "$HEAD_SHA")
echo "PR #$PR_NUMBER head commit author: $COMMIT_EMAIL"
# Parse the agent-operators registry
OPERATORS_FILE=".github/agent-operators.yaml"
# Match against known agent patterns
case "$COMMIT_EMAIL" in
cursor-agent+*@company.local)
# Plus-addressed: extract operator from local-part
OPERATOR=$(echo "$COMMIT_EMAIL" | sed -E 's/cursor-agent\+([^@]+)@.*/\1/')
gh pr edit "$PR_NUMBER" --add-reviewer "$OPERATOR"
gh pr edit "$PR_NUMBER" --add-reviewer "@org/staff-engineers"
gh pr edit "$PR_NUMBER" --add-label "agent:cursor"
;;
copilot-agent*@*|*"+Copilot[bot]@users.noreply.github.com"|*"+copilot[bot]@users.noreply.github.com")
# GitHub's Copilot SWE agent commits as the `Copilot` bot
# (numeric user-id 198982749, login `Copilot`). Its canonical
# commit email is `198982749+Copilot[bot]@users.noreply.github.com`.
# Quote the brackets so they are matched literally — bare
# `[bot]` is a bash character class meaning "one of b/o/t".
# Cover both casings because GitHub login casing has shifted
# historically and the noreply email is case-insensitive.
gh pr edit "$PR_NUMBER" --add-reviewer "@org/staff-engineers"
gh pr edit "$PR_NUMBER" --add-label "agent:copilot"
;;
*claude-code*@*)
# Pull operator from Generated-By trailer
OPERATOR=$(git show -s --format="%b" "$HEAD_SHA" \
| grep -oP "operator: \K[^)]+")
if [ -n "$OPERATOR" ]; then
gh pr edit "$PR_NUMBER" --add-reviewer "$OPERATOR"
fi
gh pr edit "$PR_NUMBER" --add-reviewer "@org/staff-engineers"
gh pr edit "$PR_NUMBER" --add-label "agent:claude-code"
;;
*"+dependabot[bot]@users.noreply.github.com"|*"+renovate[bot]@users.noreply.github.com")
# Autonomous bot — no individual operator. Match the canonical
# noreply email shape `<user-id>+dependabot[bot]@…`
# (Dependabot's user-id is 49699333; Renovate's varies per
# install). Brackets are quoted so the case pattern matches
# them literally — bare `[bot]` is a bash character class
# meaning "one of b/o/t" and would never match a real bot.
gh pr edit "$PR_NUMBER" --add-reviewer "@org/security-team"
gh pr edit "$PR_NUMBER" --add-label "agent:autonomous"
;;
*)
# Human author — let CODEOWNERS handle it
echo "Human author detected; no extra routing"
;;
esac
A few things this workflow does that pure CODEOWNERS cannot:
- Plus-address parsing — extracts the operator email from
[email protected]and adds Alice as a required reviewer - Trailer parsing — pulls the operator out of the
Generated-By:trailer for agents that use trailer-based attribution instead of plus-addressing - Label assignment — every agent PR gets a
agent:<vendor>label so review queues can filter and dashboards can count - Per-agent fallback — autonomous bots route to the security team because there is no individual operator
CODEOWNERS still fires on top of this. The result is a union: path-based maintainers + author-based operator + the staff-engineer escalation. Three reviewer slots, three different accountability functions.
The Quorum Rule: No Agent-on-Agent Review
A failure mode we've seen at multiple customers running both Copilot Workspace and a code-review bot like CodeRabbit: the PR is opened by an agent, the review is performed by an agent, the merge gate is satisfied, and zero humans saw the diff. From the dashboard it looks healthy — every PR has an approving review. From the audit log it's a hollow accountability chain.
The fix is a quorum rule: at least one human approver is required, regardless of how many bot approvals exist. Encode it in branch protection plus a small validation step:
# In the same workflow, add a job that runs on review submission
human-approval-quorum:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request_review'
steps:
- name: Verify human approval present
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
REVIEWS=$(gh pr view "$PR_NUMBER" --json reviews \
--jq '.reviews[] | select(.state == "APPROVED") | .author.login')
HUMAN_APPROVALS=0
for REVIEWER in $REVIEWS; do
# Authoritative bot check: ask the GitHub API whether the reviewer
# account is type=Bot. This is the only reliable way — login-name
# heuristics like `*[bot]` are bash character classes (matching one
# of {b,o,t}), NOT a literal `[bot]` suffix, so real bots like
# `dependabot[bot]` would silently fall through to the human branch
# and defeat the quorum gate.
REVIEWER_TYPE=$(gh api "users/$REVIEWER" --jq '.type' 2>/dev/null || echo "Unknown")
if [ "$REVIEWER_TYPE" = "Bot" ]; then
echo "Bot approval from $REVIEWER (does not count toward quorum)"
continue
fi
# Belt-and-braces: also exclude known bot logins that GitHub
# exposes as type=User (e.g. CodeRabbit, SonarCloud service
# accounts). Quote the bracket so it is matched literally, not
# as a character class.
case "$REVIEWER" in
*"[bot]"|coderabbitai|claude-bot|sonarcloud)
echo "Bot approval from $REVIEWER (does not count toward quorum)"
;;
*)
HUMAN_APPROVALS=$((HUMAN_APPROVALS + 1))
echo "Human approval from $REVIEWER"
;;
esac
done
if [ "$HUMAN_APPROVALS" -lt 1 ]; then
echo "::error::At least 1 human approver required; found $HUMAN_APPROVALS"
exit 1
fi
Pair this workflow with a required status check in branch protection. The bot approvals are still useful — they can catch patterns humans miss — but they cannot satisfy the merge gate alone.
For high-sensitivity paths (auth, crypto, billing), require two human approvals:
if [[ "$PR_TOUCHES_SENSITIVE" == "true" ]] && [ "$HUMAN_APPROVALS" -lt 2 ]; then
echo "::error::Sensitive paths require 2 human approvers; found $HUMAN_APPROVALS"
exit 1
fi
PR_TOUCHES_SENSITIVE is computed by checking the changed files against a list of glob patterns derived from CODEOWNERS.
The "Self-Approval" Trap
One subtle anti-pattern: an operator opens an agent PR (via plus-addressing, so the agent is the author and the operator is added as required reviewer), then approves it themselves. The branch protection sees one human approval and merges. But the operator never independently reviewed — they hit "approve" because the PR was theirs.
The fix is a "no operator self-approval" rule:
# In the human-approval-quorum job
COMMIT_EMAIL=$(git show -s --format="%ae" "$HEAD_SHA")
INFERRED_OPERATOR=$(echo "$COMMIT_EMAIL" | grep -oP "(?<=\+)[^@]+(?=@)")
for REVIEWER in $REVIEWS; do
if [ "$REVIEWER" = "$INFERRED_OPERATOR" ]; then
echo "::warning::Operator $REVIEWER cannot self-approve agent PR"
continue # Don't count toward quorum
fi
# ... rest of quorum logic
done
Now the operator's signoff (in the commit trailer) and the reviewer's approval (on the PR) are two distinct human acts by two distinct humans. That's the audit trail you want.
Tuning the SLA
Agent PRs accumulate faster than human PRs because there's no friction at the open step — Cursor can open ten PRs in an hour. Without a review SLA, agent PRs pile up and the queue becomes meaningless.
The pattern that works: assign each agent a review SLA in the agent-operators.yaml registry, then run a daily cron that comments on stale PRs and pings the operator of record:
# .github/workflows/agent-pr-sla.yml
on:
schedule:
- cron: "0 14 * * 1-5" # 14:00 UTC weekdays
jobs:
check-sla:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Find stale agent PRs
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Find open PRs with agent: labels older than 48 hours
gh pr list --state open --label "agent:cursor" \
--json number,createdAt,author \
--jq '.[] | select((now - (.createdAt | fromdate)) > 172800) | .number' \
| while read PR; do
gh pr comment "$PR" \
--body "This agent-authored PR has been open >48h. Operator review required."
done
Tune the threshold per agent: high-volume autonomous bots like Renovate may deserve a 7-day SLA; high-stakes Cursor refactors should be 24 hours.
Compliance Mapping
For audit, the routing workflow produces a recoverable record. Given any merged PR, you can show:
- Who authored it (commit author email)
- Who operated the agent (plus-address or trailer parse)
- Who reviewed it as a human (quorum log)
- Why those reviewers were assigned (CODEOWNERS path match + agent-operator registry)
That mapping satisfies NIST SSDF practice PO.2.1 ("Create new roles and alter responsibilities for existing roles" — the right SSDF cite for assigned-role obligations; PO.3.2 governs toolchain hygiene, not roles) together with PS.3.2 ("Collect, safeguard, maintain, and share provenance data for all components of each software release"), and most internal SOX-style change-management controls. (EU AI Act Article 12 is sometimes cited as a logging requirement for AI-assisted code, but Article 12 governs operational logs of high-risk AI systems themselves — not source-code commits produced with coding agents. The cite doesn't fit and we don't lean on it.) The tooling is GitHub Actions and gh; the discipline is treating agents as a distinct contributor class with their own routing rules.
What to Pilot First
- Drop the
agent-pr-router.ymlworkflow into one repo. Tune the email patterns to match your agents. - Add the human-approval-quorum job; turn it on as a required status check in branch protection.
- Dashboard the
agent:*labels in your PR review queue to make agent volume visible. - After 30 days, compare review latency on
agent:*PRs vs. human PRs. If agent PRs are slower, your SLA process needs work.
The goal is not to slow agents down — it's to make sure the human accountability chain is real, not theatre.