GitHub decoy reviewer
Why a GitHub App needs a decoy user account to be "assigned to review", and how the adapter matches review requests against it
A GitHub App cannot be added as a pull-request reviewer. The
requested_reviewer field on a pull_request.review_requested webhook only
ever holds a real user account; the App actor (slug[bot]) is not one, so
GitHub never emits a review_requested event targeting an App. The same is
true for assignees (assignee is also a user) and for @-mentions (an App's
[bot] handle is not a mentionable account in the normal sense).
This breaks the natural "assign the bot to review my PR" workflow for any
TypeClaw agent running the github adapter under App
auth (channels.github.auth.type: "app").
The decoy-user pattern
For an App, this is the recommended way to get on-demand PR reviews: create a real GitHub user account that impersonates the App — same display name, same avatar — and request that account as the reviewer. To a human looking at the PR, the reviewer reads as the app. Comments the agent posts still come from the App identity (the agent authenticates as the App), so the visible identity stays consistent.
By convention the decoy account is named after the App's slug: an App
whose bot actor is my-app[bot] is impersonated by a user account with login
my-app. Requesting my-app as a reviewer is what wakes the agent.
Set the decoy account up once, then drive reviews the natural way — request the decoy as a reviewer on any PR you want the agent to look at. A PAT-backed bot needs no decoy: it is already a real user you can request by its exact login.
How the adapter matches it
classifyReviewRequest (src/channels/adapters/github/inbound.ts) compares the
incoming requested_reviewer.login against the bot's identity. Under App auth
selfLogin is the bot actor slug[bot], which can never match a real reviewer
login — so the classifier also derives a decoy login and matches against
it:
function resolveDecoyReviewerLogin(selfLogin, authType) {
if (authType !== 'app') return null // PAT: the bot IS a real user
if (!selfLogin.endsWith('[bot]')) return null
return selfLogin.slice(0, -'[bot]'.length) || null // my-app[bot] → my-app
}A review_requested engages the agent when requested_reviewer.login equals
either selfLogin (the exact slug[bot], kept for completeness) or the
derived decoy login. The same decoy login extends the self-loop guard, so a
review the decoy account requested of itself is dropped rather than waking a
fresh session.
Under PAT auth there is no decoy: the bot is a real user that can be
requested directly by its exact login, so resolveDecoyReviewerLogin returns
null and matching stays strict (requested_reviewer.login === selfLogin).
The only review trigger
The decoy-reviewer path is the only way an App is asked to review: a review
fires when, and only when, someone requests the decoy (or the bot's team).
There is no implicit "review every opened PR" behavior — a pull_request.opened
event lands as plain awareness-only context, identical to PAT mode. This keeps
both auth modes on a single, predictable path: explicit review_requested
only, no opened-PR branching. To have the agent review a PR, request its decoy
reviewer.
Automatic cleanup after the review
GitHub auto-records the App as a reviewer the instant its formal review posts, but leaves the decoy user pinned in the requested-reviewers list as a perpetual "review requested" — as if the review never happened. The adapter clears this deterministically rather than relying on the agent to remember a cleanup step.
When the bot's own review lands, GitHub emits a pull_request_review.submitted
webhook authored by the bot. The handler already drops this as self-authored;
at that same point — App auth only, with a derivable decoy login — it schedules
a background DELETE /repos/{owner}/{repo}/pulls/{n}/requested_reviewers for the
decoy login (maybeScheduleDecoyReviewerDrop →
removeRequestedReviewer, src/channels/adapters/github/). The DELETE runs off
the webhook-ACK path so the 200 stays fast, and is best-effort: a 404/422
(decoy never on the list, already removed) is a benign no-op; only auth/network
failures warn.
Because the DELETE is authenticated as the App, the review_request_removed
webhook it triggers carries the bot actor (slug[bot]) as sender, which the
self-loop guard drops — the cleanup never wakes a fresh session. Under PAT auth,
or when the trigger was a plain @-mention or a team request (no decoy user was
ever placed), there is nothing to remove and the path is skipped.
Why slug-derivation, not config (for now)
The slug-strip is a heuristic default: it assumes the decoy account's login
equals the App slug, which holds when the slug was still available as a username
at account-creation time. When a decoy account's real login diverges from the
slug (the username was taken, so the operator used my-app-bot, myapp, …),
this derivation is the single seam to replace — resolveDecoyReviewerLogin is
the only place the decoy login is computed. A future
channels.github.auth.reviewerLogin (or equivalent) config field slots in there
without touching the matcher.