Org-wide GitHub App bot for the Xero-Team. Written in Rust — a single self-hosted binary (Docker / VPS).
Features:
- triagebot-style comment commands —
r?,?r cc, label management, assign/claim,r+approval on behalf, and more - Incremental AI code review — learns the project first and builds on the previous review round, instead of looking at the diff in isolation
- Rebase reminders — when a PR conflicts with its target branch, adds the
needs-rebaselabel and a reminder; clears it once resolved - CodeQL quality reports — reads the repo's existing code scanning alerts and maps them to files changed in the PR
- Bilingual replies — answers in English or Chinese, chosen from the PR's own commit messages; no configuration
Issued in comments (case-insensitive; one comment may contain several commands; content inside code blocks is ignored).
Most commands work in issues as well as pull requests — GitHub serves labels, assignees and
comments from the same API for both. The four that need a PR are review, codeql, r+ and
r-; used in an issue they say so rather than failing silently. In an issue r? @user is an
assignment, since an issue has no reviewers.
Once a user has run one command with a mention (@xero-review help) on a PR or issue,
their session on it is open: their later comments can use the mention-free forms below without
@. Only an explicit command is recognized — a comment has to open with the verb, and prose
is never parsed. The verbs that work without a mention are the unambiguous ones: review,
codeql, ready, author, blocked, ping, help, and bare r+ / r-. Argument-taking
verbs (claim, label, cc, assign) and the combined forms still need the mention; bare
r? @user and ?r never needed one. A mention-less command from someone who has no session
gets one line of explanation instead of silence.
| Command | Description |
|---|---|
@xero-review review |
AI code review (incremental: builds on the previous bot review and newer commits) |
@xero-review codeql |
CodeQL quality report |
@xero-review ping |
Health check |
@xero-review help |
Command help |
r? @user |
Request review from @user (auto-assigns; r? user without the @ also works; can appear anywhere in the comment) |
@xero-review cc @u1 @u2 |
CC / notify users |
?r or @xero-review ready |
Mark as waiting for review (adds waiting-on-review, removes the other two status labels) |
?r cc @user |
ready + cc combo (triagebot shorthand style) |
@xero-review author |
Mark as waiting on author (waiting-on-author) |
@xero-review blocked |
Mark as blocked (blocked) |
@xero-review label +bug -wip |
Add/remove labels |
@xero-review assign @user |
Assign to @user |
@xero-review claim / unclaim (aliases: take / untake) |
Claim/release (assign to self / remove self) |
@xero-review r+ |
Approve on behalf: the bot checks the commenter has write access and did not author the PR, then submits an APPROVE review in their name |
@xero-review r+ as @user |
Approve in @user's name (bors' r=, for relaying an approval given elsewhere). Refused unless R_PLUS_ALLOW_ON_BEHALF=true — see Approvals |
@xero-review r- |
Withdraw a previous bot APPROVE (dismiss) |
@xero-review queue |
Show the merge queue: batch under test + waiting PRs |
Automatic behavior (no command needed):
- After a PR push/reopen, checks for conflicts → adds
needs-rebase+ a reminder comment; once resolved → removes the label - Periodic sweep (built-in loop, default 6h) as a fallback check
- Adding the
CODEQL_LABELlabel to a PR (if configured) → auto-generates a CodeQL report
?r notifies the reviewer. A label alone reaches nobody, so on a PR ?r/ready also
pings the reviewer: whoever is currently listed under Reviewers (whether the bot's r? put
them there or they were added by hand in the GitHub UI), or — when nobody is listed — whoever
left the most recent CHANGES_REQUESTED/APPROVED review. With neither, the reply asks for
?r @user rather than guessing.
An APPROVE review submitted by the App is a real approval: a branch-protection rule that
requires one counts it, so r+ is a privileged write and not a comment. Three rules apply.
- The commenter needs write access or above, checked against the repo before anything is posted.
- The PR author can never approve their own PR, not directly and not via
r+ as @someone. GitHub enforces this for human reviews, but the review author here is the App, so the bot has to enforce it itself. - Relaying an approval to another login is off by default. With
R_PLUS_ALLOW_ON_BEHALF=true,r+ as @usercredits the approval to @user — who must also have write access. Left enabled, anyone with write access can manufacture an approval in a colleague's name and satisfy a required-review rule without that colleague ever seeing the PR, which is why it ships off. Plainr+is unaffected either way.
A refused r+ costs no API call beyond the checks above, and the help table says which side
of the switch the deployment is on.
With MERGE_QUEUE_ENABLED=true, an approval stops meaning "this looks good" and starts meaning
"merge it" — the semantics an automerge queue gives r+. The queue tests a batch of PRs
together, so main only ever advances to combinations that have actually passed CI:
- A successful
r+(or a web Approve by a write+ reviewer) adds themerge queue: queuedlabel.r-, a CHANGES_REQUESTED review, or closing the PR takes it out again. - The driver (a poll loop, default every 30s) builds a batch — up to
MERGE_QUEUE_MAX_BATCHPRs, ascending number order — by merging each PR's head into thestagingbranch as a merge commit (xero-bot: merge #n (head …)). The batch is then labeledmerge queue: testing. - CI runs on the staging pushes. Green → main is advanced via a
staging→mainPR (which inherits main's branch protection, so required checks are already satisfied by the tested tree). Red → the newest member is removed as the likely culprit, staging resets, and the remaining prefix re-tests automatically (tail-dropping is bisecting by construction). - On success every member gets a 🎉 comment and the staging branch is deleted (recreated next batch).
@xero-review queue shows the batch under test with its CI state, plus the waiting list.
Prerequisites (the queue fails open-ish: a batch that never gets a CI verdict times out
after MERGE_QUEUE_CI_TIMEOUT_SECS — default 2h — and returns its PRs to the queue with an
explanation):
- CI must run on staging pushes. A workflow with only
on: pull_requestnever fires on thestagingbranch — this is the single most common misconfiguration:on: push: branches: [main, staging]
- GitHub App settings: add permission Contents: read/write (the queue creates, resets and deletes the staging branch and creates the advance PR) and subscribe to the Pull request review event (a web Approve must reach the bot). Also subscribe to the Push event if you can: it is what notices "someone else's PR merged and dirtied an open PR" within seconds instead of at the next sweep. Everything else stays as before.
- Branch protection: leave
stagingunprotected — the bot force-updates it constantly. Keepmainprotected as today; the advance PR satisfies required checks on its own (its head is the tested tree). If main also requires human reviews, a write+ user approving the advance PR approves the whole batch — the bot says so and retries. - Only PRs targeting the repo's default branch are accepted (
MERGE_QUEUE_ADVANCE_METHOD=prdefault;refdoes a bare fast-forward and needs the App exempted from push restrictions — advanced setups only).
The queue keeps all state in GitHub — labels plus the staging merge-commit chain — so a restart mid-batch resumes exactly where it left off, with no database.
The bot answers in English or Chinese — including the prose of an AI review — and picks
which from the PR's own commit subjects: mostly English gets English, mostly Chinese gets
Chinese. Each commit casts one vote, so a single long message can't decide for the rest, and
only the subject line is read, so English trailers (Signed-off-by, Co-authored-by) don't
skew a Chinese PR. When the commits say nothing either way (bump deps, v2 -> v3) the
triggering comment is consulted, and failing that the reply is English. There is nothing to
configure, and no other languages are modelled — Japanese written in kanji is indistinguishable
from Chinese here and will be answered in Chinese.
Opt repositories into idle-time Actions dispatch and failure recovery with
.github/xero-bot.toml on their default branch. Configure the CI workflows to
wait for separately from workflows to dispatch/retry. Defaults: 30 minutes of
development inactivity, 15 minutes between attempts, and at most two retries.
Comments and reviews do not reset the timer; PR and merge queue CI must finish.
Set IDLE_WORKFLOWS_ENABLED=true, grant the App Actions: write, and retain
XERO_DATA_DIR for local SQLite state. See the
configuration and recovery guide and
TOML example.
Selected via REVIEW_ENGINE:
| Engine | Mechanism | Incremental capability |
|---|---|---|
agent (default) |
tool-calling loop, tools = GitHub API (list/read/search code); explores the project before reviewing | Injects the previous bot review on this PR + the list of newer commits |
builtin |
single HTTP call (OpenAI chat/responses/Anthropic formats) | Same (context injection) |
pi |
subprocess pi -p --session-dir, read-only toolset |
Session continuity: per-repo session files remember project understanding |
codex |
subprocess codex exec --sandbox read-only -o |
Same (codex exec resume) |
auto |
probes in order: pi → codex → agent → builtin | - |
agent automatically falls back to builtin on timeout/failure. All engines share the same publishing pipeline: risk-tiered summary table + inline comments on added lines + a publishing fallback chain (with inline → without inline → plain comment).
Every published finding carries a stable ID (XRV-…), a type, and a description that must
cite the code it is about. The ID is what makes a finding addressable across rounds: when a
PR is reviewed again, the prompt requires the model to audit the previous round's findings
one by one and state, in the summary, whether each is now fixed, still present, or
rejected as a false positive — an audit trail instead of a fresh list every time.
The PR's author sometimes disagrees with a finding — a thread reply ("this is the Python
3.14 syntax", as in AstrBot #5) or a 👎 on the inline comment. Both are read back before
the next review and injected into the prompt as a binding Author feedback section: a
rebutted finding is not repeated at the same place unless the current diff contains
verifiable new evidence that answers the rebuttal, and if the model believes the author is
wrong it must argue that in the summary rather than silently re-report. Attributions are
kept verbatim (@user: "quote"), so teammates' views are not laundered into the author's.
The bot has no execution environment, so "does it compile" is not its question — CI's
answer is read from the head commit's check runs and commit statuses and stated to the
model as fact: green CI means compilation and imports were executed and passed, and a
prompt section forbids invalid syntax / does not compile / cannot be imported
findings outright, naming the newer-grammar hypothesis (Python 3.14's paren-less
multi-exception except, which AstrBot #5 and #64 both misjudged as critical). Failed
checks are named instead of re-reported; a commit with no CI renders no section — silence
is never presented as success. Requires the App to have Checks: read; without it the
section is simply absent and the scope rule in the review brief still applies.
With REVIEW_VERIFY=true, each critical/high/medium finding additionally goes through a
blind second pass: a separate AI call that sees the diff and the claim only — never the
first verdict — and is asked to refute it. A finding the checker confirms is marked
[re-checked]; one it refutes is demoted a level and marked [not confirmed on re-check]
rather than deleted, because a disagreement between two passes is itself information. The
re-check costs one AI call per significant finding, so it ships off.
Self-hosted (Docker or a VPS):
- Prepare the config:
Open
cp .env.example .env
.envand fill in every field — each has a detailed comment saying where its value comes from (App ID, webhook secret, AI provider, …). The two things people trip over most:- Private key — recommended:
PRIVATE_KEY_B64. Convert the.pemdownloaded from the App settings page and paste the single-line output as the value:Works in Docker and on bare metal alike; nothing to mount. (Alternative: mount the file — addbase64 -w0 xero-review-bot.private-key.pem # Linux / Git Bash base64 -i xero-review-bot.private-key.pem # macOS
- ./xero-review-bot.pem:/keys/bot.pem:roto the composevolumesand setPRIVATE_KEY_PATH=/keys/bot.pem.) WEBHOOK_SECRETmust be byte-identical to the secret saved in the App's settings — a mismatch makes GitHub reject every delivery with 401.
- Private key — recommended:
- Subprocess engines need their own AI key. The container preinstalls both
piandcodex; they authenticate viaOPENAI_API_KEY(separate from the bot'sAI_API_KEY). Just add it to.env— compose'senv_fileinjects the whole file into the container. Skip it andREVIEW_ENGINE=autofalls back to theagentengine; the bot keeps working either way. - Start:
docker compose up -d --build docker compose logs -f # watch startup; config validation errors exit fast - Webhook URL:
https://<your-host>/webhook— must be reachable from the internet (GitHub delivers events to it; for a home server use a reverse proxy or tunnel).
GitHub → Settings → Developer settings → GitHub Apps → New GitHub App:
| Setting | Value |
|---|---|
| Webhook URL | https://<host>/webhook |
| Webhook secret | any random string — must match WEBHOOK_SECRET |
| Subscribed events | Issue comment + Pull request (+ Pull request review for the merge queue; Push recommended — seconds-level notice when the base moves) |
| Permissions | Contents: R (RW for the merge queue) · Pull requests: RW · Issues: RW · Checks: R · Code scanning alerts: R |
Then: generate a private key (downloads a .pem file), note the numeric App ID and the bot's @-name (for BOT_NAME), and install the App on the target org/repos.
What you get in the container:
-
A
/datanamed volume (xero-data) caches repo checkouts andpisessions — this is the bot's incremental memory; wiping it loses review context. Leave it alone or back it up. The layout:Path Contents Safe to delete? repos/{owner}__{repo}/pr-{n}One shallow checkout per PR (depth CHECKOUT_DEPTH, default 100)Yes — a merged PR's directory can go sessions/{owner}__{repo}pisessions, shared per repository = the incremental project understandingNo codex/{owner}__{repo}-pr{n}-{sha}.mdOne codexrun's output, deleted once readNothing to manage Per PR rather than per repository is required, not tidiness: the tree sits at a PR's head, so one shared directory meant two concurrent reviews could each be reading the other's code. Disk use is therefore roughly concurrently active PRs × shallow clone size. A duplicate
@bot reviewon the same PR is turned away with a note rather than paying for the model twice. -
Both
piandcodexCLIs are preinstalled, so all five engines work out of the box (REVIEW_ENGINE=autoprobes pi → codex → agent → builtin). If an npm install fails during the image build, that engine is skipped gracefully and selection falls through. -
A built-in rebase sweep loop (
REBASE_SWEEP_ENABLED=true, everyREBASE_SWEEP_INTERVAL_SECS= 6h by default) — no external cron required. Optionally, belt-and-braces via host crontab:curl -H "Authorization: Bearer $CRON_SECRET" http://localhost:8080/cron
Endpoints: POST /webhook (GitHub), GET /health, GET /cron (protected by CRON_SECRET).
Docker quick-check — from zero to a working bot
git clone https://github.com/Xero-Team/xero-bot.git && cd xero-bot
cp .env.example .env && edit .env # APP_ID, PRIVATE_KEY_B64, WEBHOOK_SECRET, BOT_NAME, AI_*, OPENAI_API_KEY
docker compose up -d --build
curl http://localhost:8080/health # {"status":"ok",...}
# then set the App's Webhook URL to https://<your-host>/webhook and install the App on your orgAll environment variables are documented in .env.example. Highlights:
PRIVATE_KEY_PATHorPRIVATE_KEY_B64— one of the two- Real environment variables always win over
.envvalues - Labels are configurable (
LABEL_*); defaults:needs-rebase/waiting-on-review/waiting-on-author/blocked - A non-empty
CODEQL_LABELmakes that label trigger a CodeQL report; empty (default) = command-only - CodeQL reports require code scanning to be enabled on the repo (CodeQL default setup or a
codeql.ymlworkflow); private repos need GitHub Advanced Security
cargo test # unit + integration tests (wiremock mocks the GitHub API)
cargo run # self-hosted mode on :8080
cargo run --example send_webhook -- issue-comment "@xero-review ping"
cargo run --example send_webhook -- issue-comment "r? @octocat"
cargo run --example send_webhook -- pr-synchronize
cargo run --example send_webhook -- pr-review-approvedsend_webhook signs the payload with WEBHOOK_SECRET (default dev-secret) and POSTs it to the local server, simulating the GitHub side.
src/
├── config.rs env config (.env loading; real env vars win)
├── webhook.rs HMAC-SHA256 signature verification + event classification
├── commands.rs command parser (multi-command / code-block skipping / r? anywhere / ? shorthands / mention-free sessions)
├── handlers.rs command execution (permission checks, reply rendering, ?r reviewer notification)
├── github.rs octocrab wrapper (the only GitHub API egress)
├── review.rs builtin engine + shared publishing pipeline (diff parsing / verdict parsing / rendering / fallback chain)
├── verify.rs adversarial re-check of findings (blind second pass, stable finding IDs)
├── agent.rs native review agent (tool-calling loop, tools = GitHub API)
├── engines_subproc.rs pi/codex subprocess engines + git checkout cache
├── codeql.rs code scanning alerts → PR changed-file mapping → report
├── rebase.rs mergeable detection + needs-rebase label + sweep
├── merge_queue.rs merge queue (staging batches, CI gate, main advance; state = labels + staging chain)
├── dispatch.rs event → background work routing (incl. the mention-free session check)
└── main.rs self-hosted axum server
State persistence: labels, PR review history and merge queue state live in GitHub.
The optional idle workflow scheduler also keeps activity timestamps, dispatch
intent and retry history in SQLite under XERO_DATA_DIR; no external database
service is required.