TypeClawTypeClaw
Internals

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 (maybeScheduleDecoyReviewerDropremoveRequestedReviewer, 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.

On this page