devops-thiago/ThrillhouseBot
devops-thiago/ThrillhouseBot has 37 GitHub stars and 8 forks. Star changes are not measured yet. Last checked 2026-11-09 09:14 UTC. Last push 2026-11-05.
Your nerdy PR reviewer — a Quarkus GraalVM-native GitHub App that AI-reviews diffs, posts inline suggestions, and streams live to a dashboard.
What you can do
- Reviews diffs for correctness, security, regressions, stale comments and code quality, and tags every finding critical, high, medium or low
- Splits large diffs into batches reviewed in parallel within a token budget; lists files that exceed the budget or have no reviewable patch
- Inline suggestions you can apply with one click
- A second pass checks findings, suggested fixes and contradicted description sentences against the diff; an optional summary check screens summary statements too
Measured star growth
- in 24h
- Update delayed
- in 7 days
- Update delayed
- in 15 days
- Update delayed
- in 1 month
- Update delayed
Net star changes over overlapping periods, not daily totals. Missing measurements are left blank. Stars measure attention, not software quality.
README.md
Original
ThrillhouseBot
"Everything's coming up Thrillhouse!"
ThrillhouseBot is a GitHub App that reviews pull requests. It is built with Quarkus and distributed as a GraalVM native binary. It sends diffs to an OpenAI-compatible chat API, including local models served by Ollama. The model you configure determines which languages it can review.
See how it compares to CodeRabbit, PR-Agent and Copilot code review.
📖 Documentation: setup guide, configuration reference, architecture, comparison, and the hosted GitHub App installer.
Features
- Reviews diffs for correctness, security, regressions, stale comments and code quality, and tags every finding
critical,high,mediumorlow - Splits large diffs into batches reviewed in parallel within a token budget; lists files that exceed the budget or have no reviewable patch
- Inline suggestions you can apply with one click
- A second pass checks findings, suggested fixes and contradicted description sentences against the diff; an optional summary check screens summary statements too
- Follow-up reviews track whether earlier findings were fixed, declined or are still open, and re-check a maintainer's decline against the code
- One summary comment per pull request, edited in place every round, with a risk breakdown, a changed-files walkthrough and every finding still open
- Comment commands (
/review,/summary,/describe,/changelog,/add-docs,/improve,/generate-tests,/resolve,/pause,/resume,/help, and with learnings on/learnings,/remember,/forget) and conversational replies to@thrillhousebot - Auto-review triggers: skip drafts, gate on labels or authors, filter by base branch, and optionally space out reviews of one PR (
AUTO_REVIEW_MIN_INTERVAL) - Maintainer 👍/👎 reactions and "not useful" replies are recorded as finding feedback
- Optional review context, each off by default: the issues a PR links and their acceptance criteria, failing CI checks and test names, patch coverage from your own coverage report, and review learnings (maintainer declines and
/rememberconventions from earlier reviews of the same repository) - Optional deterministic scan of added lines for leaked credentials and risky Terraform, Kubernetes, CloudFormation and Dockerfile settings, with no model call and every matched secret redacted
- Optional per-call review dimension routing, which sends each call only the review rules its files need
- Optional outgoing notification when a review completes or fails: JSON, Slack or Discord, HMAC-signed, metadata only unless you opt in
- Per-repository instructions in
.github/thrillhousebot.md(falling back to Copilot, Claude and Agents files) and per-repository ignore globs and path-scoped rules in.github/thrillhousebot.yml - Optional reasoning-effort setting and per-model generation and budget caps
- Live dashboard (Next.js) with a WebSocket activity feed, cost charts and token tracking
- OpenTelemetry traces, token histograms, cost counters, latency and review-outcome metrics, exported over OTLP and scrapeable in Prometheus format at
/metrics
Provider support
ThrillhouseBot works with any endpoint that implements the OpenAI
chat-completions API. Set AI_BASE_URL and AI_MODEL for your provider:
| Provider | AI_BASE_URL |
Example AI_MODEL |
|---|---|---|
| DeepSeek | https://api.deepseek.com/v1 |
deepseek-chat |
| OpenRouter | https://openrouter.ai/api/v1 |
openai/gpt-4o-mini |
| Alibaba Cloud (Model Studio) | https://dashscope-intl.aliyuncs.com/compatible-mode/v1 |
qwen-plus |
| OpenAI | https://api.openai.com/v1 |
gpt-4o-mini |
| Hugging Face Inference Providers | https://router.huggingface.co/v1 |
openai/gpt-oss-120b |
| NVIDIA (build.nvidia.com) | https://integrate.api.nvidia.com/v1 |
meta/llama-3.3-70b-instruct |
| Ollama (local) | http://localhost:11434/v1 |
llama3.2 |
For Hugging Face, AI_API_KEY is an access token with the "Make calls to
Inference Providers" permission. A free Hugging Face account includes no
monthly credits, so requests need purchased credits or a PRO, Team or
Enterprise plan; see the
Inference Providers pricing.
NVIDIA has a free tier, useful for trying the bot. AI_API_KEY is a key from
build.nvidia.com, and the free tier is limited to about 40 requests per minute,
so set AI_MAX_CONCURRENT_CALLS low (for example 2). Check each provider's
current terms; both change them.
Ollama's cloud endpoint (https://ollama.com/v1) limits how many requests one
account has in flight and refuses the rest with timed out waiting for a concurrent request slot. Reviews of different pull requests run in parallel and
a large pull request sends its batches at once, so set AI_MAX_CONCURRENT_CALLS
to the number of concurrent requests your plan allows. Calls past it wait for a
slot instead of being refused, and a refusal that still gets through is retried
after 30 seconds.
DeepSeek is the default provider. Set AI_BASE_URL, AI_MODEL and AI_API_KEY
to use another provider.
Commands
Comment one of these on a pull request. Each also has a mention form, such as
@Thrillhousebot review. The bot reacts with 👀 to acknowledge a command and
does the work in the background. A plain @thrillhousebot mention with no
command word gets a written answer instead.
| Command | What it does | Access |
|---|---|---|
/help |
List the commands | anyone |
/review |
Run (or re-run) a full review | write |
/summary |
Post the PR summary when the PR has none, for example after it was deleted. If one is already there, the bot replies that it did nothing. | write |
/describe |
Suggest a PR title and description, as a comment to copy in. The PR is never edited. | write |
/changelog |
Draft a CHANGELOG entry (Added, Changed, Fixed, Security…), as a comment to copy in | write |
/add-docs |
Suggest doc comments for the symbols the PR changed, as committable suggestions | write |
/improve |
Suggest improvements across the whole PR, as committable suggestions | write |
/generate-tests |
Propose unit tests for the changed code, one code block per test file | write |
/resolve |
Resolve the bot's open finding threads | write |
/pause |
Silence the bot on the PR | write |
/resume |
Re-enable the bot on a paused PR | write |
/learnings |
List what the bot remembers about this repository, with ids and source links | write |
/remember <text> |
Remember a convention for later reviews of this repository | write |
/forget <id> |
Retract one remembered learning | write |
@thrillhousebot resolved <path>:<line> — <title> |
Close an earlier finding that has no review thread (see Clearing a finding with no thread under Configuration), or, replied on a finding's own thread, close that finding and resolve the thread | write |
@thrillhousebot declined <path>:<line> — <title> |
Decline an earlier finding that has no review thread, with the reason on the following lines (see Declining a finding with no thread under Configuration) | write |
Access. Every slash command except /help needs write access to the
repository, or a login listed in
THRILLHOUSEBOT_REVIEW_MANUAL_TRIGGER_ALLOWED_LOGINS, because the commands spend
the operator's AI budget. The resolved and declined directives always need
write access, judged by the comment's GitHub author_association (OWNER,
MEMBER or COLLABORATOR); the allowlist does not extend to them.
Directives. @thrillhousebot resolved and @thrillhousebot declined are
not slash commands. They have no / form and are applied by the next review,
not straight away. The bot replies at once to say what that review will
evaluate, and when the comment names no path:line it says that nothing will
be cleared. A trailing question mark (@thrillhousebot resolved?) makes it a
question, which changes nothing. Do not confuse resolved with /resolve,
which resolves review threads and does nothing to a finding that never opened
one.
Learnings. /learnings, /remember and /forget work only when the
deployment sets REVIEW_LEARNINGS_ENABLED=true; otherwise the bot replies that
learnings are off. /learnings lists up to 30 entries.
Pause. While a PR is paused, pushes are not reviewed and mentions get no
answer. /review, /summary, /describe, /changelog, /add-docs,
/improve and /generate-tests reply with a short paused notice and do
nothing else. /help, /resolve, /resume and the learnings commands keep
working.
Batching. /describe, /changelog, /add-docs, /improve and
/generate-tests read the whole change set, not the first
REVIEW_MAX_DIFF_LINES of it. The changed files are packed into batches that
each fit REVIEW_MAX_INPUT_TOKENS, with one model call per batch, and a run
never makes more than REVIEW_MAX_AI_CALLS calls. /describe and /changelog
merge the per-batch results with one extra call, which they spend only when the
PR needed more than one batch. /add-docs, /improve and /generate-tests
merge their results locally. Any file the budget could not cover is named in a
partial-coverage note. Nothing is ever committed.
/add-docs proposes doc comments for the public symbols the PR changed, in
each file's language and following the repository instructions. Each one is a
suggestion block on the symbol's declaration (spanning the whole signature
when it wraps), so applying it only inserts docs. When a multi-line declaration
cannot be pinned to one diff hunk, the bot posts the drafted docs as a note
instead. Turn it off with REVIEW_ADD_DOCS_ENABLED=false.
/improve proposes better code even when nothing is broken: clearer
naming, dead or duplicated code, simpler control flow, missing error handling,
avoidable work in loops, and gaps in the tests for the change. /review looks
for defects; /improve does not. Each improvement whose quoted code matches
the diff is posted inline as a suggestion block; the rest are listed as
copy-paste blocks in the run's summary comment. Turn it off with
REVIEW_IMPROVE_ENABLED=false.
/generate-tests proposes unit tests in the framework the project already
uses. A new test file has no diff line to anchor a suggestion to, so each test
file is posted as a code block headed by its path. Proposals from different
batches are merged by path. Turn it off with
REVIEW_GENERATE_TESTS_ENABLED=false.
Quick start
Prerequisites
- Docker and Docker Compose
- An API key for any OpenAI-compatible provider
1. Create the GitHub App
Follow GitHub App setup below. You get an App ID, a private key, a webhook secret and an OAuth client ID and secret.
2. Clone and configure
git clone https://github.com/devops-thiago/ThrillhouseBot.git && cd ThrillhouseBot
cp .env.example .env
Fill in .env:
| Variable | Value |
|---|---|
GITHUB_APP_ID |
GitHub App settings → About |
GITHUB_PRIVATE_KEY |
The .pem from generating a private key, on one line with newlines as \n, unquoted |
GITHUB_WEBHOOK_SECRET |
The webhook secret you set |
GITHUB_CLIENT_ID |
App settings → Identifying and authorizing users |
GITHUB_CLIENT_SECRET |
App settings → Identifying and authorizing users |
AI_API_KEY |
Your AI provider's API key |
3. Start the bot
docker compose up -d
This starts the bot and a PostgreSQL database. The bot listens on
http://localhost:8080; put your reverse proxy in front of it. Set DB_USER
and DB_PASSWORD in .env before you expose the database, or DATABASE_URL
to use a managed PostgreSQL.
GitHub App setup
Create the GitHub App before starting the bot; its credentials go in .env.
Option A: manifest install (recommended)
Open the hosted installer at devops-thiago.github.io/ThrillhouseBot/install.html, enter the public hostname the bot will run on, and click Create ThrillhouseBot GitHub App. For local development with Smee.io, enter your Smee channel URL: the webhook is then registered at the channel root, and the smee client forwards it to the bot's local
/api/webhook.Offline alternative: serve the installer locally
Replace every
<your-host>inmanifest.jsonwith your public hostname (no trailing slash) and serve the repository root:java -m jdk.httpserver -p 8081Then open http://localhost:8081/install.html and click Create ThrillhouseBot GitHub App.
On the confirmation page, note the App ID, generate a private key and create a webhook secret. Copy the Client ID and Client secret from Identifying and authorizing users (used for dashboard login).
Install the app on your account or organization and copy the values into
.env. You can also generate.envfrom the manifest conversion response:gh api --method POST /app-manifests/<code>/conversions \ | java scripts/GenEnv.java --host <your-host>
Once the bot is running,
install.htmlon its own URL (https://<your-host>/install.html, orhttp://localhost:8080/install.html) detects the URL and builds the manifest for you.
Option B: manual registration
| Setting | Value |
|---|---|
| Webhook URL | https://<your-host>/api/webhook |
| Webhook secret | Random string |
| Repository permissions | Pull requests: R/W, Checks: R/W, Contents: Read, Issues: R/W, Actions: Read, Commit statuses: Read |
| Organization permissions | Members: Read (shows installation-wide dashboard counters to organization members) |
| Subscribe to events | Pull request, Issue comment, Pull request review comment, Check suite, Status, Repository |
| Identifying and authorizing users | Enabled (for dashboard login) |
| Callback URL | https://<your-host>/api/auth/callback |
Configuration
Configuration comes from environment variables (see .env.example). The short
names (AI_*, REVIEW_*, WEBHOOK_*, …) are explicit aliases. Any other
thrillhousebot.* key can be set through the standard Quarkus mapping:
uppercase, with . and - replaced by _ (thrillhousebot.review.ignored-files
→ THRILLHOUSEBOT_REVIEW_IGNORED_FILES).
Every optional feature added in 0.7.0 is off by default. Each one validates its bounds at boot while it is on.
Most-used settings
| Variable | Purpose | Default |
|---|---|---|
AI_API_KEY |
API key for the AI provider. Required. | (none) |
AI_BASE_URL |
OpenAI-compatible base URL of the AI provider. | https://api.deepseek.com/v1 |
AI_MODEL |
Chat model name. It also selects the model's entry in the per-model settings and the pricing map. | deepseek-chat |
GITHUB_APP_ID |
GitHub App ID, from the App's settings page. Required. | (none) |
GITHUB_PRIVATE_KEY |
GitHub App private key (PEM), on one line with each newline written as \n, unquoted. Required. |
(none) |
GITHUB_WEBHOOK_SECRET |
The GitHub App's webhook secret, used to check each delivery's HMAC signature. Required. | (none) |
GITHUB_CLIENT_ID |
OAuth client ID for dashboard login, from the GitHub App's settings. Leave it and GITHUB_CLIENT_SECRET unset to disable login. |
(none) |
GITHUB_CLIENT_SECRET |
OAuth client secret for dashboard login, from the GitHub App's settings. | (none) |
DASHBOARD_URL |
Public dashboard URL, used for the OAuth callback and the session links posted to GitHub. | http://localhost:8080 |
DATABASE_URL |
JDBC URL of the production database (jdbc:postgresql://db:5432/thrillhouse in Docker Compose). |
%prod: jdbc:postgresql://localhost:5432/thrillhouse |
REVIEW_CI_GATING |
How CI affects approval: strict holds approval while required CI is pending, failing or unreadable; warn approves and notes the CI state; off ignores CI. See CI gating. |
strict |
REVIEW_BLOCKING_STRICTNESS |
Which findings request changes: balanced, strict or lenient. See Blocking strictness. |
balanced |
REVIEW_MAX_INPUT_TOKENS |
Input-token budget per call for reviews and the batched commands; larger PRs are split into batches. Capped by the model's max-input-tokens (see the per-model settings). 0 disables budgeting. |
48000 |
REVIEW_MAX_AI_CALLS |
Most AI calls per review (batch calls plus the summary call) and per command run (batch calls plus, for /describe and /changelog, the merge call). At 1, a review that makes its review call has none left for the summary and posts a counts-only summary that says so. Verifier calls are not counted. |
6 |
WEBHOOK_SKIP_DRAFTS |
Do not auto-review drafts; the PR is reviewed once marked ready (ready_for_review). |
false |
WEBHOOK_REQUIRED_LABELS |
Comma-separated labels; auto-review only PRs carrying at least one (case-insensitive exact match). Unset means no label is required. | (none) |
Docker Compose also reads DB_USER and DB_PASSWORD (both thrillhouse by
default) for the bot and the PostgreSQL container.
Every setting, with its type and default, is listed in the configuration reference, which is generated from the code. The sections below explain the features those settings control.
AI call budget
A review makes a review call, which returns findings and the status of earlier findings, and then a summary call, which writes the PR summary (purpose, description gaps, file walkthrough, labels, diagram) from the verified findings and the list of changed files. The summary call does not carry the diff, so it is small. When the review call reports findings, a verification call re-sends the diff with the candidate findings, so a review with findings sends the diff roughly twice, before retries. Output tokens and the other context add to that cost. A large PR makes one review call and one verification call per batch, then one summary call.
At REVIEW_MAX_AI_CALLS=1 the summary call is skipped after a review call. A
review whose every file exceeded the budget makes no review call and still gets
its summary.
REVIEW_VERIFIER_ENABLED=false skips the verifier: cheaper, with more false
positives. A deterministic hedging guard still runs. The verifier fails open: if
it errors, returns an empty or cut response, or is skipped at
REVIEW_MAX_TOKENS_PER_REVIEW, the findings it did not rule on are still
posted, each with its confidence capped at medium and a line saying it was not
verified. Under balanced strictness such a finding cannot request changes on
its own. The summary states how many findings went unverified.
REVIEW_SUMMARY_VERIFICATION_ENABLED=true adds one call after the summary call.
The summary call does not see the diff, so it can describe something the change
does not do. This call gets the summary's sentences, walkthrough rows and
diagram lines with the diff and the findings, and names the statements they
contradict. A named sentence is cut, a named row shows "Not summarized", and a
named diagram line drops the diagram. A "What this PR does" sentence is cut only
on evidence from the diff, rather than a finding alone. If the check would
remove every purpose sentence, all of them stay. It checks only text written
in that round; it does not check text carried from the previous round on the
same head or restore a removed statement from it. Each removal is logged at
INFO with the statement and the evidence. Findings are never changed. It fails
open: an error, a cut answer or the spend ceiling leaves the summary as written.
With REVIEW_RESPONSE_REPAIR_ENABLED (on by default), an answer that is not
valid review JSON gets one repair call before a full retry. It gets only the
failed answer, the parser's error and the response shape, and returns the same
content in that shape. The repair runs on the model that wrote the answer and
sees no diff. A repaired review answer must preserve every title from the
failed answer, including repeated titles. It rejects new titles and a
previous-finding status count greater than the failed answer's. If the repair
errors, is cut, does not parse or fails those checks, the full retry runs as
before. A summary answer that
wrote file_summaries as a string holding JSON is read as that JSON; any other
string gets one small call that asks for that field alone, and only rows whose
path is in the string are kept. A finding verification call that returns no
response body is made once more before the findings are kept unverified. These
calls are not counted by REVIEW_MAX_AI_CALLS; they record their usage against
REVIEW_MAX_TOKENS_PER_REVIEW and are skipped at that ceiling.
Dimension routing
Every review call carries a system prompt made of a core plus ten review
dimension blocks, about 11,000 tokens. With
REVIEW_DIMENSION_ROUTING_ENABLED=true, each call carries only the blocks its
files can use. The router reads each file's kind (documentation, configuration
or infrastructure, script, source, test) and probes its patch for API or list
code and for mocks. Correctness, security and regressions are always included,
an unrecognized file type brings in every block, and a batch of mixed files
gets the union. The verifier's dimension rules follow the files its candidates
are in. Each call's choice is logged at INFO with the file that brought each
block in.
In measurements, a documentation-only call drops about 27% of the prompt, a source-only call 19–22% and a configuration-only call 17%; a call mixing source, tests, configuration and documentation carries every block. The budget planner sizes batches from the PR's routed prompt, so a PR with no configuration, test or API code fits more diff per call. Every routed prompt opens with the same ~7,000-token core, so a provider's prompt cache still matches across batches and reviews. With routing off, the prompts are unchanged from earlier releases.
Model routing and fallback
Every review call goes to AI_MODEL unless one of two optional models is set.
AI_ROUTING_LOW_RISK_MODELreviews low-risk pull requests with a cheaper model. A pull request touching anAI_ROUTING_HIGH_RISK_PATHSfile (workflows, SQL, migrations,auth/,security/and Dockerfiles by default) is never low risk, even when that file also matches a low-risk glob. Otherwise it is low risk when every reviewable file matchesAI_ROUTING_LOW_RISK_PATHS(docs, text and lockfiles by default), or when it changes at mostAI_ROUTING_LOW_RISK_MAX_CHANGED_LINESlines (default100;0routes by paths alone). The decision is made once per review and logged at INFO; only the review calls are routed, while the summary call, the verifier and the commands keep their models.AI_FALLBACK_MODELtakes over a review or summary call after an attempt fails with a provider error (HTTP 5xx, an unknown model) or a rate limit. The call's remaining retries go to the fallback model, with no 30-second throttle wait before the first one.
Both models are sent on the same provider (AI_BASE_URL, AI_API_KEY) by
overriding the request's model name, and cost is recorded under the model that
answered, so add a pricing entry for each. They run under AI_MODEL's token
budget and generation settings: the bot refuses to start when either names
AI_MODEL itself, or declares a per-model max-input-tokens below the
review's effective per-call budget. A routed model with neither a per-model nor
a pricing entry is logged as a startup warning, so a typo in its name shows at
boot rather than as failed reviews.
File triage
A pull request too large for one call is split into batches, and
REVIEW_MAX_AI_CALLS caps how many there are. By default, build manifests are
packed first and then the largest changes, so the cap leaves out the smallest
files, whatever they are. With REVIEW_TRIAGE_ENABLED=true, files are packed by
review priority instead: build manifests, then files matching
AI_ROUTING_HIGH_RISK_PATHS (under the new or the old name), then the rest,
then files matching AI_ROUTING_LOW_RISK_PATHS, the larger change first within
each. A high-risk glob wins over a low-risk one, as for model routing. When the
cap leaves files out, the low-risk ones go first, and they are named in the
summary as before. The globs are the same ones model routing reads, and
triage works whether or not a low-risk model is set. No model call is made.
Per-model AI settings
Model-specific settings live under thrillhousebot.ai.models.<model>.*, keyed by
the AI_MODEL value like the pricing map. Only the active model's entry is
read, so you can keep entries for several models and switch AI_MODEL freely:
# Input hard cap. The effective review budget is min(REVIEW_MAX_INPUT_TOKENS,
# cap); models without an entry get a 128000 cap.
thrillhousebot.ai.models.deepseek-chat.max-input-tokens=64000
# The model's total context window. On a shared window the prompt and the
# completion are both charged to it, so boot fails when max-input-tokens +
# max-output-tokens do not fit inside it. Omit it and the ceiling isn't checked.
thrillhousebot.ai.models.deepseek-chat.context-tokens=128000
# Per-model overrides of REVIEW_OUTPUT_BUFFER_TOKENS / REVIEW_TOKEN_SAFETY_MARGIN
thrillhousebot.ai.models.deepseek-chat.output-buffer-tokens=8192
thrillhousebot.ai.models.deepseek-chat.token-safety-margin=0.9
# Generation parameters, sent on every chat call when set
thrillhousebot.ai.models.deepseek-chat.temperature=0.2
thrillhousebot.ai.models.deepseek-chat.top-p=0.95
thrillhousebot.ai.models.deepseek-chat.max-output-tokens=8192
thrillhousebot.ai.models.deepseek-chat.frequency-penalty=0.1
thrillhousebot.ai.models.deepseek-chat.presence-penalty=0.1
thrillhousebot.ai.models.deepseek-chat.seed=42
# Set true only when the provider bills the response outside the context
# window (1M in with 384K out on top, rather than 384K carved out of the 1M).
# It switches off both the reservation and the context-tokens ceiling.
thrillhousebot.ai.models.some-separate-budget-model.separate-output-budget=true
max-input-tokensis a cap, not the budget.REVIEW_MAX_INPUT_TOKENSstays the spend setting; the per-model value keeps it inside the model's real window. To go beyond 128k on a large-context model, raise both. Startup logs a warning when the cap lowers your budget.context-tokensis the window. On a shared window the provider charges the prompt and the completion to one context, so boot fails whenmax-input-tokens + max-output-tokensexceeds it, or when the effective budget plus the largest response cap in play (max-output-tokensorREVIEW_CONCISE_MAX_OUTPUT_TOKENS) does. Without it, an impossible pair shows up only when the provider rejects every call for length. It is optional.max-output-tokensvsoutput-buffer-tokens.max-output-tokensis the response cap sent to the provider;output-buffer-tokensonly reserves room in the input budget. On a shared window the buffer must be at least the output cap, or boot fails, so set both when you cap output.separate-output-budget(defaultfalse) says whether the response allowance sits on top of the input window. Left off, the budgeter reservesoutput-buffer-tokensfrom the input budget and the completion counts againstcontext-tokens. Set ittrueonly for a model whose provider documents a separate response allowance: the reservation and the ceiling are then dropped. Getting it wrong is costly either way. A 384000-token cap on a 1M window wrongly marked shared costs about 40% of every call's diff budget, and a shared model wrongly marked separate loses every guard and has its calls rejected, which is howdeepseek-v4-flashshipped its wrong pair. Check the provider's documented window first.- The
concisemodel. The summary call every review ends with, the verifier and replies run on a second binding, theconcisenamed model (quarkus.langchain4j.openai.concise.*). It uses the same provider, credentials and model through the sameAI_*variables and the active model's temperature settings, but has its own response cap,REVIEW_CONCISE_MAX_OUTPUT_TOKENS(default8192), and its own reasoning effort,AI_REASONING_EFFORT_CONCISE(defaultlow). The review call and the command generators, whose output grows with the diff, stay on the default model and itsmax-output-tokens. - Reasoning effort is per lane. Reasoning tokens count against the response
cap, so a high effort on the
concisemodel can use the whole allowance and leave no answer; the verifier then keeps its findings unverified. RaisingREVIEW_CONCISE_MAX_OUTPUT_TOKENSonly shifts the odds. KeepAI_REASONING_EFFORT_CONCISElow and spend effort onAI_REASONING_EFFORT. - Reading a cut response. When a review, summary, verifier, summary-check
or repair call spends its whole cap reasoning and returns no answer, it is
repeated once at a lower effort, and the summary's scope note says so. A
command such as
/describeor a reply to a maintainer gets the same one repeat, skipped once that command or reply has spentREVIEW_MAX_TOKENS_PER_REVIEW. WithAI_REASONING_ENABLED=truethe repeat sendslow, and a lane already atlowornoneis not repeated. With it off, nothing was sent and the provider's default effort applied, so the repeat sends the lane's effort (lowunless set).AI_REASONING_REPEAT_EFFORTsets the repeat's effort in both cases; set it tononefor a provider that honorsnone. A model that reasons by default, such asglm-5.3-flash, can hit this on most concise calls and bill the cap each time, so setAI_REASONING_ENABLED=truefor such a model. Measured onglm-5.3-flash, 9 of 12 summary and summary-check calls were cut at the 8192-token cap with it off (94,533 output tokens in all) and none with it on (6,982). A line logged for a cut call with reasoning off says the same. If anonerepeat writes its deliberation into the response instead, it is stopped once 8,000 characters arrive with no JSON object opened, so the cap is not billed twice. On anyfinish_reason=lengthstop, the log line, the failed check run and the PR notice give themax_tokensthe request carried, the setting that supplied it (max-output-tokens, orREVIEW_CONCISE_MAX_OUTPUT_TOKENSon the concise lane), and the completion and prompt tokens billed. They advise raising the setting only when the billed completion reached it; a stop short of the cap is a provider-side limit, and lowering the reasoning effort is the other lever. When the provider reports no usage, the notice says so. - Quote model names that contain
.or/(thrillhousebot.ai.models."gpt-5.5".…), as in the pricing map. From the environment, hyphen-only names use underscores (THRILLHOUSEBOT_AI_MODELS_DEEPSEEK_V4_PRO_MAX_INPUT_TOKENS=1000000) and dotted names use the quoted form (THRILLHOUSEBOT_AI_MODELS__GPT_5_5__MAX_INPUT_TOKENS=256000).application.propertiesships empty stubs for known models so the Quarkus env mapping can resolve hyphenated keys. For another model, add an emptythrillhousebot.ai.models."<model>".max-input-tokens=line (externalapplication.propertiesor-D) next to the environment variable. seedasks for repeatable sampling on providers that support it; others ignore it. For steadier reviews, lowertemperaturefirst.top_kis not part of the OpenAI-compatible API, so it cannot be set.- Validation. At boot,
temperaturemust be in[0, 2],top-pin(0, 1], penalties in[-2, 2]and token counts positive. A bad value in any entry, even an inactive model's, fails startup with the key named.
Blocking strictness
REVIEW_BLOCKING_STRICTNESS decides which findings turn the review into
REQUEST_CHANGES and fail the check run. Other findings are posted as comments
with a neutral check.
| Mode | Blocks merge when |
|---|---|
balanced (default) |
CRITICAL or HIGH risk at HIGH confidence |
strict |
CRITICAL or HIGH risk at any confidence |
lenient |
CRITICAL risk at HIGH confidence |
For security-sensitive repositories, use strict, so a CRITICAL or HIGH finding
cannot pass as a comment because its confidence was lowered. Under strict, the
verifier's confidence demotions and the medium cap on unverified findings no
longer prevent a block; only a lower risk or a dropped finding does. Stay on
balanced if you want those demotions to keep speculative findings
non-blocking.
Strictness affects only the verdict. Where a finding is posted is separate: a LOW-confidence finding at MEDIUM or LOW risk goes to the summary's "Things to double-check" list instead of an inline thread, while CRITICAL and HIGH findings stay inline at any confidence. Placement reads the finding's final grade, after the verifier and the deterministic confidence floors, the same way on every review lane.
CI gating
With REVIEW_CI_GATING=strict, a review that finds nothing while a required
check is pending, failing or unreadable ends as COMMENT with a neutral check
run instead of APPROVE. CI is read while the model call runs and, if that
reading would hold approval, again after it, so a check that finished in the
meantime counts. A verdict with findings is not held, since CI would not change
it.
The hold is not final. The bot subscribes to check_suite and status events,
and when CI reports on the held head it re-reads the CI gate and nothing else.
If the required checks are green, it posts the APPROVE the review earned and
concludes its check run success, with no model call. If a check failed, the
hold stays and the check run says so; a re-run that turns green lifts it. A push
replaces the hold, because the new head gets its own review. Held verdicts are
kept in memory, so after a restart a held PR needs a manual /review.
Apps registered before these events were in the manifest must subscribe by hand:
Settings → Developer settings → GitHub Apps → your app → Permissions & events
→ Subscribe to events (under the organization's settings for an
organization-owned app), tick Check suite and Status, and save. The
existing Checks: Read & write and Commit statuses: Read permissions cover
both.
Summary comment
Each PR has one summary comment. The first round posts it; every later round
finds it by its <!-- thrillhousebot:summary --> marker (or, for a summary
posted before 0.7.0, by its heading) and replaces its body, so it always
describes the current head. A round that renders the same body makes no write.
If the summary was deleted, the round posts a new one; if an edit fails, it
posts a new one and logs a WARN. GitHub keeps the edit history. Inline
findings and review bodies stay per round. With REVIEW_FOLLOW_UP_SUMMARY_ENABLED,
the review body also carries new, resolved and still-open counts, naming each closed
finding once instead of posting a second delta comment. The review keeps its verdict;
a definite review refusal preserves the same outcome as a regular comment.
The risk counts, Key Findings and "Things to double-check" cover the round's
new findings and every earlier finding still open, each listed once and marked
"open since an earlier review". Key Findings ranks both together by severity. A
finding leaves the summary once it is resolved, declined, cleared with
@thrillhousebot resolved, or its code leaves the diff. A later finding replaces
an open double-check item only when it is the same defect. A finding about one
config key does not replace an item about the key on the next row, but the same
key restated on its paired doc or code file does. The same test decides when a
double-check item is marked "Same issue as" an inline finding; location or a
few shared words are not enough. The verdict
is computed over the same open set, so an earlier CRITICAL still open keeps a
follow-up round at REQUEST_CHANGES under the configured strictness. When a
follow-up opens nothing new while earlier findings are open, its review body
leads with "No new issues in this revision, but N previous finding(s) remain
unresolved".
The Description vs. Implementation section appears only when the PR description and the change disagree. A summary with model prose ("What this PR does", per-file summaries) and no such section means the check ran and found nothing; a counts-only summary means it did not run, and says why. Each gap names what the description or a linked issue says. A gap that quotes the description is dropped when the quoted words are not in it. A finding that reports a mismatch with the description is named in the section by title and location.
A gap stays listed on later rounds until one of these happens: the summary call says what resolved it, the same gap is reported again, the quoted claim leaves the description, or the PR stops linking the issue the gap names. On the head the previous round reviewed, the summary call cannot resolve a gap, because nothing it reads has changed. The exception is an edited title or description: then the call may release a description gap (never a linked-issue gap), and a gap saying the description omits something leaves once the edited text holds it.
A follow-up on the head the previous round reviewed also keeps that round's overview where its own summary has none. When the summary call returns no overview, or is skipped, cut or fails, the whole overview is carried, and a banner says so and that only the counts are new. Otherwise the new summary keeps each earlier file summary and the diagram that its answer leaves out.
Re-checking declines
When a maintainer replies to a finding to decline it, the next review records the finding justified. A decline is a claim, though, and a correct finding can be closed by a wrong rebuttal that names the very mechanism making the bug real ("it only runs after the webhook is acked, so there's no race", on an executor that starts a thread per event).
With REVIEW_DECLINE_RECHECK_ENABLED=true (the default), the bot traces the
stated reason against the code the review saw. When the reviewed diff plainly
contradicts it, the finding stays open for one more round with a note quoting
the claim and the contradicting line. The check is conservative:
- The maintainer is trusted by default. A reason about style, intent, accepted risk or priority, or one whose supporting code is not in the diff, is respected.
- It pushes back once. The re-check runs only while the thread has a single maintainer reply; a second reply always ends it.
- A re-opened finding is not posted again. It stays in Previous Findings Status and counts toward the verdict at its own severity.
- When the review keeps a finding open after a maintainer replied on its thread, the review body says why, while the decline re-check is enabled. A decline the re-check overturns reads "Decline re-checked against the code and not accepted: …" with the code (a line, or a comment that says the path runs concurrently) that refutes the reply. Otherwise the model's status note is quoted under "Reply on this finding's thread weighed, finding kept open: …", since the reply may decline the finding or acknowledge it; while the thread has a single reply, the note ends with "If the reply declines the finding, reply again to keep the decline." A finding nobody replied to, or whose status note is empty, gets no such note.
A follow-up round's model reports on the findings of the newest round that raised any. A decline on a finding from an older round is weighed by the approval backstop under the same one-push-back rule: a lone reply the reviewed diff contradicts keeps the finding open, and so does a lone "this cannot run concurrently" reply to a concurrency finding, because what makes a path concurrent (a server that handles each request on its own thread, an executor in another file) is rarely in the diff. Any other reply, or a second one, stands: the finding is counted justified, its thread is resolved, and with review learnings on the reply is offered to the store under the same rules as any other decline.
Every older round's finding gets a status, so the summary's counts add up to the findings carried in. One whose code left the diff is counted superseded, and one cleared or declined from the PR conversation is counted resolved or justified. One whose flagged line is still there is closed as resolved when every line its suggestion adds is now an added line of the file beside the flagged code, with at least one real line of code among them; otherwise it stays open until a reply or a directive closes it.
A finding whose code left the diff, whose suggested fix landed, or that a maintainer cleared is closed once. Its review thread is resolved, the review body names it under the round's closed findings, and the round records the close so later rounds neither name it nor count it again. A clear wins over the code leaving the diff, so the thread still gets the reply that records the clear. None of these re-post the summary.
Set it to false to make a maintainer's reply final.
Clearing a finding with no thread
A finding listed under "Things to double-check" has no review thread to reply
on, yet follow-up reviews keep it unresolved and it holds approval (APPROVE
becomes COMMENT). To close one, comment on the PR conversation:
@thrillhousebot resolved src/main/java/com/example/Widget.java:42 — Missing null check
Closing a finding by mistake is worse than leaving it open, so the match is strict, and an ambiguous comment leaves the finding held:
- Write
@thrillhousebot resolvedas plain text.resolveis a different command, and a mention alone does nothing. A question mark straight after the directive (resolved?) makes it a question; one later in the comment is fine. Directive words in backticks, in a fenced block or on a>quoted line are read as documentation and clear nothing. - Name the finding by its
path:lineand its title (or its description when it has no title). Each "Things to double-check" row prints both, title first and then the locator, so copying the row is the reliable way. Naming only one clears nothing. The path is written from the repository root and matched whole, soWidget.java:42never closesWidget.java:4. The locator and title may be in backticks. - Hold write access. The comment's
author_associationmust beOWNER,MEMBERorCOLLABORATOR. Comments from fork authors, drive-by commenters and the bot itself are ignored. - Quoted text never counts. Blockquotes and fenced code are dropped before matching, so a Quote reply of the summary names nothing.
One comment may name several findings; each is matched on its own. A finding with neither title nor description cannot be named, so fix it or reply on its thread if it has one. The next review records a named finding resolved.
On a finding's own review thread, reply @thrillhousebot resolved (the
path:line and title are optional there, since the thread names the finding).
The bot resolves the thread straight away and replies with a fixed
acknowledgement, not a generated answer; if GitHub does not confirm the
resolution, the reply says the next review closes the finding instead. The next
review records the finding resolved either way. The same write-access rule
applies, and a reply whose path:line is not that finding's own (another file,
or another line of the same file) closes nothing on that thread.
The bot answers the directive when it sees it, but it has no findings loaded
then, so it says what the next review will evaluate. The exception is a
directive with no path:line at all, which clears nothing; the reply says so and
shows the format. A locator that is present but wrong gets the general reply,
and the review reports the finding still unresolved.
Conversation read limit. A review reads at most 1000 PR conversation comments (10 pages of 100). GitHub returns them oldest first, so on a longer PR the newest comments are the ones left unread, and a directive among them does nothing that round. The bot logs a warning when it hits the limit; push a commit to re-review, or reply on the finding's thread if it has one.
Declining a finding with no thread
Declining says "this is fine, and here is why". On a review thread it is the
maintainer's reply. For a finding with no thread, write it on the PR
conversation: the directive line names the finding and the lines below it give
the reason. The directive may wrap once, before or after declined; the line
carrying the path:line and title still belongs to the directive.
@thrillhousebot declined src/main/java/com/example/Widget.java:42 — Missing null check
The value is validated two frames up, in `RequestGuard`, and this helper is
private to that path.
The word is declined, not decline. All the rules for resolved apply
unchanged: plain-text directive, both locator and title, write access, quoted
blocks dropped, declined? declines nothing, an ambiguous naming holds the
finding, and the conversation read limit applies.
The next review records the named finding justified, unless the reviewed code plainly contradicts the reason; then the finding stays open for one more round with the same note a thread re-check writes. Fenced code, blockquotes and inline code inside the reason are quoted material, not the maintainer's claim, and the directive line is never read as the reason. With several directives in one comment, each reason runs to the next directive. A directive with no reason is still a decline and is recorded as is.
A second declined comment naming the same finding always wins and is recorded
justified with no further check, as a second thread reply is.
REVIEW_DECLINE_RECHECK_ENABLED=false makes the first comment final. The bot
acknowledges the directive the same way it acknowledges resolved.
Security scan
REVIEW_SECRET_SCAN_ENABLED and REVIEW_IAC_SCAN_ENABLED turn on a scan of the
lines a pull request adds, for leaked credentials and risky
infrastructure-as-code settings. It is a fixed list of regular expressions: no
model call, no extra dependency, and the same input always gives the same
findings. Both halves are off by default, because each adds findings that can
request changes. General linter integration is tracked separately in
#34.
Secret rules (high confidence):
| Rule | Matches | Risk |
|---|---|---|
| AWS access key ID | AKIA/ASIA/ABIA/ACCA + 16 characters |
critical |
| GitHub token | ghp_, gho_, ghu_, ghs_, ghr_ tokens and github_pat_ fine-grained tokens |
critical |
| Slack token | xoxb-, xoxp-, xoxa-, xoxo-, xoxs-, xoxr- tokens |
critical |
| Google API key | AIza + 35 characters |
critical |
| Stripe live key | sk_live_ / rk_live_ keys (test keys are ignored) |
critical |
| Private key | a PEM BEGIN … PRIVATE KEY header followed by a key body (a header alone is code that parses keys) |
critical |
| JSON Web Token | three base64url segments whose first two start eyJ |
high |
| Credential assignment | a quoted literal assigned to a key named like password, secret, token, api_key, access_key, private_key or client_secret, with =, :, := or =>, including typed and array declarations (C char API_TOKEN[] =, Rust const API_TOKEN: &str =, Zig const api_token: []const u8 =, TypeScript, Kotlin, Scala, Swift and Python apiToken: String =, Go var apiToken string =). The literal must have at least 8 characters, a non-letter, two character classes and entropy at or above REVIEW_SECRET_SCAN_ENTROPY_THRESHOLD, and must not be a URL, a path or an identifier such as DB_PASSWORD |
high |
IaC rules (high confidence):
| Rule | Files | Matches | Risk |
|---|---|---|---|
| Admin port open to the internet | .tf, .yaml/.yml, .json |
an ingress rule admitting 0.0.0.0/0 or ::/0 to port 22, 23, 3389, 5985 or 5986, to a range containing one, or to every protocol |
high |
| S3 bucket made public | .tf, .yaml/.yml, .json |
a public-read/public-read-write ACL (AccessControl: PublicRead…), or a Block Public Access setting set to false |
high |
| IAM wildcard | .tf, .yaml/.yml, .json |
Action: "*" with Resource: "*" in the same statement and no Effect: Deny; a "*" anywhere in a valid one-line list counts, with no element-count or element-width limit; scalar wildcards allow trailing Terraform #/// and whitespace-separated YAML # comments |
high |
| Privileged container | .yaml/.yml, .json |
privileged: true (in YAML also yes, on, y, any case) |
high |
| Host namespace | .yaml/.yml, .json |
hostNetwork, hostPID or hostIPC set to true (in YAML also yes, on, y) |
high |
| Final stage runs as root | Dockerfiles | USER root (or 0) with no later FROM or USER, in a new file or in a last hunk that reaches the end of the file |
medium |
| Storage encryption off | .tf |
encrypted = false or storage_encrypted = false |
medium |
A rule that needs context (a firewall rule's direction and port, an IAM
statement's resource, what follows a USER) reads it from the same hunk. When
the context is not in the patch the rule does not fire, and the model review
still covers the line.
What is skipped. Files the review ignores (the deployment list and the
repository's review.ignored-files), files matching
REVIEW_SECURITY_SCAN_SKIPPED_FILES, files GitHub sends no patch for, comment
lines for the IaC rules, and placeholder values: anything containing example,
sample, dummy, placeholder, changeme, redacted, your_/your-,
fake, xxxx, ****, </>, ${, {{, $(, %s or an ellipsis, a run of
six identical characters, and for assignments any value starting with $ or
written as a Windows %VAR% reference, a
regular expression (a value holding (?, a character-class range such as
[A-Z0-9] or an escape such as \d, with at least a quarter of its characters
regex syntax), and sample text quoted inside another complete string, including
the explicit prose leaders before, text before or prefix before the
credential name in a raw list or a List.of, Set.of, Arrays.asList or
Stream.of builder, and a string
assigned to a fixture/sample/example-named variable
(String fixture = "prefix client_secret: 'abc…'";). New sample forms require
a bare key or one of those prose leaders and whitespace only after the
assignment inside the string, and a standalone literal/factory or simple
fixture/sample/example-named assignment or collection declaration. Unknown outer
calls, other assignment targets, expressions and additional statements on the
same line, including preceding comma-separated sibling entries, remain scanned;
closing delimiters and further quoted inputs are
allowed. Unknown prefixes and trailing command text
stay scanned even with a sample-named variable. Other calls, including
Map.of and fixtures.add, retain scanning for prefixed strings because
their values can be executable command text. An assignment in a
direct or assigned command string, or a prefixed execution call such as
Runtime.exec("export API_KEY='…'"), is still reported. Bare command strings
written as list elements can read exactly like samples, as before. Known
token formats are reported wherever they appear. To
accept a line, put thrillhousebot:allow-secret (or thrillhousebot:allow-iac)
in a comment on that line or the line above; above is the only place a
Dockerfile instruction can take one. JSON cannot hold a comment, so put such a
JSON file in the skipped globs.
IAM list elements use their file format: YAML ARN/URL slashes, non-whitespace
# fragments, and quotes or parentheses inside plain scalars do not start
comments or expressions. Nested or tagged stars do not prove a literal wildcard.
Block-comment trivia belongs to Terraform, and an unclosed comment proves no
wildcard.
The secret is never repeated. A secret finding shows only the value's first
characters and its length: the four-character provider prefix for a known
format (ghp_…, 40 chars), at most one character in four for a generic value.
It carries no quoted code, so the inline comment, check run, notification,
stored session and dashboard never hold the value, and logs carry only counts.
Every verbatim copy of a matched value in the same review's model findings,
status notes and summary is replaced with the redacted form. Those texts also
have any secret-looking quoted literal redacted when its line assigns it to a
credential-named key through the first = or : after the name, whatever sits
between (static API_TOKEN: Lazy<&str> = Lazy::new(|| "…")), as long as the
literal passes the same placeholder, identifier and entropy checks. A
declaration with no = or : before the literal (Kotlin
val apiToken by lazy { "…" }), a fragment or an altered copy is not covered.
Treat a reported credential as leaked even after it is removed from the branch:
it is still in the git history.
How scan findings join the review. They are added after the verifier and severity calibration, so no model re-grades them, and they are never marked unverified. A model finding in the same file is dropped as a duplicate in these cases:
- It is within three lines and reports the same defect: the same title by the deduplicator's measure, or a title using two of the rule's words.
- For a secret, it sits on the matched line and its title uses one of the rule's words.
- For a generic secret assigned to a distinctive name (
apiToken,SCHEDULER_API_TOKEN), its title makes a secret claim and its text names that identifier, at any distance. A finding in a documentation or configuration file of the same pull request that names the identifier with a credential word, or names the environment key it stands in for with a credential claim in its title, is dropped the same way. - For an IaC rule, it is within three lines and its title uses a word that
names that rule's defect (
root,privileged,hostNetwork/hostPID/hostIPC,SSH/RDP/WinRM/Telnet,public,wildcard/administrator,encryption/encrypted/unencrypted) and no word naming a neighbouring defect (filesystem,read-only,writable,root-owned,subnet).
A title that only names a key (PAYMENT_API_KEY is never read) is a different
defect and is kept. Nearby detections are indexed by file and line; distant
secret duplicates are looked up by the identifiers their text names.
An
environment key aliases a secret only when its fallback expression reads that
secret's simple identifier, or a comment directly above the declaration names
the key. SQL/Lua -- and HTML/XML/SVG <!-- comments are recognized in
those file types, along with //, #, /* and * comment lines. INI and
systemd unit files accept ;, including unit drop-in .conf files under
name.service.d/ or service.d/ (and the other unit types); an arbitrary
.conf extension does not establish this syntax. Properties files accept !.
A complete standalone Python triple-quoted string (plain, raw or Unicode)
may document the next declaration, with only whitespace or a trailing #
comment after its closing delimiter. Assigned, concatenated or unclosed
strings and Java operators do not supply aliases. A different call argument
does not tie the key to the secret.
Grades. Every scan finding is high confidence, so none carries the "verify
before acting" note: a match settles whether the pattern is on the line. The
risk is the rule's, from the tables above. A final stage running as root is
medium, the grade the review gives the same defect when the model raises it: a
container that starts as root breaks nothing by itself, but a compromise of it
starts with root. Medium never requests changes under any strictness, so it
informs the verdict without deciding it. Under balanced strictness a critical
or high scan finding requests changes on its own.
Model secret findings on a stand-in. Whether or not the scan is on, a model
finding whose title reports a secret is dropped when its own line assigns every
credential-named key an interpolation (${VAR}, $VAR, %VAR%, $(…),
{{ … }}) or a placeholder (the list under "What is skipped"), and no scan rule
matches the line. ENV API_TOKEN=${API_TOKEN} names where a credential comes
from, and API_TOKEN=changeme in a sample stands in for one.
Later rounds. A scan finding an earlier round raised is not posted again. It
stays unresolved while the pattern is still on an added line, and becomes
resolved once the line is gone or carries the allow marker. A declined scan
finding stays declined: once a round has recorded the decline justified, the
finding is not raised again while the rule matches the same content (same file,
title and anchor, and for a secret the same line). A secret finding ends with a
hidden thrillhousebot:scan-content marker holding the first eight hex digits
of an HMAC-SHA-256 of the flagged line (for a private key, its block), keyed
from GITHUB_WEBHOOK_SECRET, which is how a later round tells a new value with
the same prefix and length from the declined one. The key means a reader of the
pull request cannot test a guessed value against the marker. The same secret
gives the same marker across rounds and restarts; changing the webhook secret
makes each declined secret finding come back once, and the next decline stores
the new marker. A decline recorded under the unkeyed marker of 0.7.0 keeps
matching its line. A new value, or any other change to the line apart from
leading and trailing whitespace, is raised again. A secret finding declined
before 0.7.0 has no fingerprint, so its decline holds only until the head moves;
it is then raised once more, with the marker.
CI-failure context
REVIEW_CI_CONTEXT_ENABLED=true gives the review the checks that had already
failed on the head commit when it started, so a finding can name the test or
build step the change breaks. The checks come from the CI gate's own read (with
REVIEW_CI_GATING=off that read is still made for this, and never holds
approval). For each failing check, required or not, the prompt gets its name,
conclusion, output title and summary, and up to eight annotations from one page,
failures first; the runner's own notices on the workflow (a deprecated Node.js
version, a cache miss) are left out. Five checks are described in full and any
others by name. A failed GitHub Actions job's annotations rarely name the test
(they say "Process completed with exit code 1"), so the log of up to two failing
jobs is read and the lines that name a failed test (Surefire, Gradle, TAP and
node --test, Jest, Vitest, pytest, unittest, go test, cargo test,
dotnet test, ScalaTest, munit, CTest, Zig, RSpec) are listed, up to ten per job.
REVIEW_CI_CONTEXT_INCLUDE_LOGS=true also adds the last 30 lines of those logs.
The extra cost is one annotations request per detailed check that has
annotations, plus one log download per failing Actions job (at most two). Job
logs use the Actions: Read permission the app already has.
Check output is written by the PR's own code, so it is handled like the PR description: ANSI codes and control and bidi characters are stripped, each field is clipped, and the list sits inside the untrusted-data fence. The model is told to use a failure to find the changed line that causes it, to name the failing test in the finding that explains it, to quote the failure it relied on, and not to report a failure the diff does not explain. The summary's overview then says, for each failing check, which open finding explains it or that none does. That sentence is computed rather than written by the model: a finding explains a check when it names the check or one of the failing tests listed for it, and findings still open from earlier reviews count.
Checks still running add only a count next to a failure. An automatic review
usually starts before CI finishes, so the review call often sees no failure.
When a check was still running at the start, or no check had appeared yet,
the head's check runs are read once more after the model call. A check that
has failed by then is added before the summary is rendered: the overview's sentence names it
and the open findings that cite it, and a finding that cites it is raised from
low to medium confidence. A CI failure after the review is posted does not start
a new one; comment /review to get a review whose findings see it.
Linked-issue context
REVIEW_TICKET_CONTEXT_ENABLED=true gives the review the issue the PR says it
implements. Issues are read from GitHub Issues through a provider interface
(REVIEW_TICKET_CONTEXT_PROVIDER), so another tracker can be added later. Links
are taken in this order:
- Closing keywords in the PR body (
Closes #57,fixes owner/repo#57,resolves https://github.com/owner/repo/issues/57), in order. Code spans, fenced blocks and HTML comments are skipped, so a template's commented-outCloses #is not a link. GitHub records these links only when the base is the default branch, so for a PR into a release branch the body is the only source. - GitHub's closing references for the PR (one GraphQL read), which add issues linked from the Development panel.
- The head branch name (
issue-57,fix/57-short-name), only withREVIEW_TICKET_CONTEXT_FROM_BRANCH=trueand only when the first two link nothing.
Only issues in the PR's own repository are read. Pull requests, the PR's own
number and missing or unreadable issues are skipped. At most
REVIEW_TICKET_CONTEXT_MAX_ISSUES issues are used, and a review makes at most
ten issue reads plus the one GraphQL read. The issue is never modified. Reading
uses the Issues permission the app already has.
For each issue the prompt gets its title, acceptance criteria and body. The
criteria are the list items under a heading such as "Acceptance criteria" or
"Definition of done", or else the issue's task-list items, leaving out an issue
form's code-of-conduct checkbox. Issue text is stripped of control, ANSI and
bidi characters, lines are clipped, each issue gets an equal share of
REVIEW_TICKET_CONTEXT_MAX_CHARS with criteria ahead of the body, and the
section sits inside the untrusted-data fence.
The review call reads the issue as intent: a changed line that contradicts a
criterion is a finding and must quote it, but work the diff does not contain is
not. The summary call checks each criterion against the PR description, the
changed-file list and the findings, and lists the ones the change shows it does
not address under Description vs. Implementation, each starting with
Linked issue #N:. A missing criterion is a question of scope, so it never
becomes a finding or changes the verdict. The summary call does not see the
diff, so it reports only what it can show (a file the criterion names that the
change never touches, or a description that defers it). An entry naming an
issue the PR does not link is rewritten to the linked one. When this section is
present, the bug-fix efficacy check uses it instead of reading the issue again.
A criterion that an earlier pull request already met looks unmet to the summary
call, since no changed file touches it. When the PR description states such a
criterion in a paragraph or list item, says it is already met ("already",
"shipped", "existing code", "is met") with no negation earlier in that clause,
and cites the code as path:line or as a
Type.member code span (a method or a test), the bot checks each citation
against the repository: the file must exist, must not be added or renamed by
the pull request, and the cited line or member must not be one the pull request
adds. When every citation passes, the criterion moves from Description vs.
Implementation to a "Met by existing code" section that lists what was cited.
Text inside a fenced code block, a blockquote or an HTML comment is not read as
a claim. A criterion with no citation, a citation that cannot be found, or a
repository that cannot be read keeps the entry listed.
Review learnings
REVIEW_LEARNINGS_ENABLED=true lets the bot remember what maintainers taught it
on one pull request when it reviews the next. Without it, a finding declined for
a stable reason (PR #159's "GitHub review threads are flat, every reply's
in_reply_to_id is the thread root") can come back on every later PR.
.github/thrillhousebot.md is still the place for facts you want to write down
yourself. The feature needs REVIEW_DECLINE_RECHECK_ENABLED=true.
What becomes a learning. Two things only:
- A decline of a finding, with its reason, once it has survived the
decline re-check. The review must record the finding
justified, the re-check must have run against the reviewed code, and the reason must not rest on a premise the code could refute. A "this cannot run concurrently" decline (only one caller, runs serially, single-threaded) is never stored, whatever the finding's title, because the code that refutes it (PR #160's unbounded executor) can be outside the diff. Nor is a decline that stood only because the maintainer answered the re-check a second time. The author must have write access, confirmed through the collaborator-permission API. - A convention stated with
/remember <text>.
A 👍/👎 or a bare "not useful" reply says a finding was unwelcome but not why, so it is not a learning. It is still recorded as finding feedback.
How it is used. Before each review, the repository's active learnings are
ranked by how close their file is to the changed files: the same file, then the
same directory (and repository-wide conventions), then files of the same type. A
file's type is its extension, except that every container build file spelling
(Dockerfile, Dockerfile.prod, prod.Dockerfile, Containerfile) is one
type, Makefile, GNUmakefile and *.mk are one type, and any other name
without a dot (Jenkinsfile, CODEOWNERS) is its own type. A dotfile such as
.env has no type and matches by file and directory only. Learnings about
unrelated file types are left out. The best ones, within
REVIEW_LEARNINGS_PROMPT_MAX_ITEMS and REVIEW_LEARNINGS_PROMPT_MAX_CHARS, go
into the review call inside the untrusted-data fence, after the CI-failure and
linked-issue sections and before the repository instructions. The model is told
not to raise a declined finding again while its reason still holds, and to
describe a learning in plain words when the diff breaks it; a learning id that
reaches review text anyway is rewritten to "a maintainer's earlier decision".
The verifier and the summary call do not see learnings.
A deterministic check also recognizes one explicit policy: a selected active
decline saying "We don't pin images by digest in Dockerfiles" (or "We do not")
as its complete text, or followed by "the release pipeline resolves every image
to a digest at deploy time", optionally with "and the deployed manifest carries
that digest". Case, whitespace and a final period may vary. The contraction accepts
a straight or curly apostrophe, or the explicit dont spelling. The exact rationale
may follow whitespace or one colon, period, comma or semicolon. Other punctuation,
qualifiers, incomplete text and other rationales are left to the model. A low-risk
mutable-reference finding on that file type, anchored on a FROM or external
COPY --from= image it names, stays
visible under Things to double-check with a note naming the learning id.
The check uses the exact bounded selection sent to the review call. A disabled
feature, a decision outside those caps, another defect class or file type,
an unknown premise, an applicable current digest requirement, or independent
failure or security evidence leaves the finding alone. Linked-issue evidence
can still raise its confidence afterward. Deterministic scan findings skip this
check. Other policies continue to rely on the model's assessment.
The deterministic backstop recognizes complete policy-only titles and descriptions: the unpinned-base-image title forms with the mutable-tag/rebuild claim or the identical-image-content reproduction forms. Unknown wording, extra consequences and linked acceptance-criterion claims leave the finding unchanged. Only case, whitespace and a final period may vary; arbitrary prefixes, suffixes, punctuation or negation are not removed. The verifier's exact appended unverified paragraph stays visible as metadata. Other wording continues to rely on the model's assessment of the selected learning.
A rebuild losing identical image contents is the accepted policy's own consequence; a functional failure or a failed CI check still leaves the finding unchanged. Current instructions mentioning an image, container, Dockerfile or base subject, or a Dockerfile directive together with digest/SHA wording leave the finding unchanged, including declarative rules without a requirement keyword. Wrapped lines, headings and implicit references across sentences stay together. A Dockerfile-scoped rule already supplies the subject, so digest/SHA wording in an applicable scoped rule also leaves the finding unchanged. SHA-family tokens include bare SHA, numeric suffixes such as SHA1 and SHA512, and hyphenated forms; lookalike words do not establish digest context. Global artifact-only digest prose and the ordinary words "from" and "copy" do not make an image-pinning requirement; ambiguous image requirements leave the finding unchanged.
Governance. /learnings lists active learnings with their ids, what each is
about, who taught it and a link to the source comment. /forget <id> retracts
one at once; the row is kept with who retracted it and when. The dashboard
serves the newest 200 learnings, retracted ones included, at
GET /api/dashboard/learnings?repository=owner/repo to signed-in users with
access to the repository.
Safety. Learnings belong to one repository under one GitHub App installation
and are never read by another. Text with anything credential-shaped (GitHub,
AWS, Slack, OpenAI and Google keys, private-key headers, JWTs, bearer values, a
value assigned to a key such as secret_key or authToken, and every format
the secret scan knows) is refused, and is filtered again before a learning is
shown to a review. Quoted lines and control and bidi characters are dropped, and
each learning is clipped to 1000 characters. The review_learning table is
created on first start, whether the feature is on or not.
Outgoing notifications
The bot can tell another system when a review finishes, so a team can follow
outcomes in chat or a pipeline. It is off until NOTIFICATIONS_WEBHOOK_URL is
set.
When it fires. Once per review run, after the outcome is final:
review.completed once the verdict is on the PR, review.failed once a failed
review has been reported (check run and failure comment). A run abandoned
because the head moved sends nothing; the run for the new head sends its own. A
verdict held on pending CI is sent once, when posted, with
review.held_on_ci: true; the approval posted when CI turns green is not sent
again. Commands such as /describe or /improve send nothing.
What is sent. With the default json format:
{
"schema_version": 1,
"event": "review.completed",
"timestamp": "2026-09-29T12:00:00Z",
"bot": { "name": "thrillhousebot", "version": "0.9.0" },
"repository": "octo/repo",
"pull_request": { "number": 42, "head_sha": "0123abc…", "url": "https://github.com/octo/repo/pull/42" },
"session_url": "https://bot.example/session/…",
"review": {
"verdict": "REQUEST_CHANGES",
"check_conclusion": "failure",
"highest_risk": "HIGH",
"held_on_ci": false,
"partial_coverage": false,
"first_review": true,
"findings": { "total": 3, "critical": 0, "high": 1, "medium": 2, "low": 0 },
"previous_findings": { "resolved": 0, "unresolved": 0 }
}
}
A review.failed payload carries failure.category instead of review: one of
ai_response_truncated, ai_context_window_exceeded, ai_timeout,
token_spend_ceiling, ai_error, github_auth, publish_failed, github_api
or internal. The error message is never sent, since it can quote provider or
GitHub responses. No code, diff, finding description, suggested fix, summary or
PR title is sent. NOTIFICATIONS_WEBHOOK_INCLUDE_CONTENT=true adds
pull_request.title and review.finding_list (up to 25 findings, most severe
first, each with severity, file, line and title); descriptions and code
are never sent. session_url is built from DASHBOARD_URL.
Chat formats. slack and discord send one message (verdict, counts, head
commit, links to the PR and the dashboard session) to an incoming-webhook URL.
With content on, the message also lists the PR title and up to five findings. PR
and model text is escaped so it cannot mention anyone or inject links, and
Discord messages disable mentions outright. One endpoint is supported; use a
relay to reach several.
Verifying requests. Every request carries X-Thrillhousebot-Event
(review.completed or review.failed) and X-Thrillhousebot-Delivery (the same
id on every retry of one notification, for de-duplication). With
NOTIFICATIONS_WEBHOOK_SECRET set, it also carries
X-Thrillhousebot-Signature-256: sha256=<hex>, the HMAC-SHA256 of the exact
body keyed by the secret, the same scheme GitHub uses for X-Hub-Signature-256.
Recompute it over the raw bytes, compare in constant time, and check timestamp
to refuse replays.
Delivery. Sending never delays or fails a review: it runs on a background
virtual thread. A timeout, connection failure, 429 or 5xx is retried up to
NOTIFICATIONS_WEBHOOK_MAX_ATTEMPTS, then dropped with a WARN; any other status
is final. Nothing is queued across a restart.
Security. Notifications go only to the configured URL. Redirects are not
followed, so a receiver cannot bounce the payload and signature elsewhere.
https:// is required unless NOTIFICATIONS_WEBHOOK_ALLOW_HTTP=true, and a URL
with credentials before the host is refused at boot. Slack and Discord webhook
URLs are credentials themselves, so logs and boot errors show only scheme, host
and port. The bot does not restrict which hosts it may reach, so point the URL
only at a receiver you trust with the metadata above.
Startup checks
The app checks its configuration at startup and fails fast, naming every
problem in one message, when GITHUB_APP_ID, GITHUB_PRIVATE_KEY,
GITHUB_WEBHOOK_SECRET or AI_API_KEY is missing, or the private key is not a
valid PEM RSA key. Dashboard OAuth is optional: with GITHUB_CLIENT_ID and
GITHUB_CLIENT_SECRET unset, dashboard login is disabled.
Cost tracking
Cost uses per-model pricing keyed by model name:
thrillhousebot.ai.pricing.deepseek-chat.input-per-1k=0.00014
thrillhousebot.ai.pricing.deepseek-chat.output-per-1k=0.00028
When you change AI_MODEL, add a matching thrillhousebot.ai.pricing.<model>.*
pair. Without one, tokens are still recorded, a warning is logged once per model,
and its sessions are flagged "no pricing" in the dashboard instead of showing
$0. Adding the entry later fills in those sessions on the next restart.
Dashboard
After you sign in with GitHub, the dashboard shows an overview with summary cards and a live activity feed, plus cost charts by model, input and output token breakdowns, and a paginated session history with PR links.
The dashboard asks GitHub, with your own sign-in, which installations of the
app you can access and which of their repositories you can read: as a
collaborator of any role, through a team, or through your organization's base
permission. You see exactly those repositories, across every installation, and
anyone who can read none of them sees an access-denied screen. An outside
contributor, or an organization member whose base permission is none, does not
see a private repository GitHub would not show them. With several installations,
the overview and the session history offer an installation filter. Set
thrillhousebot.dashboard.github.account-owner to serve only one account's
installation.
Everything the dashboard shows is scoped this way: the session list and its
totals, session details, costs, tokens, the overview, feedback, learnings and
the live feed. Repository permissions are cached for five minutes, and live
connections refresh them in the background; uninstalling or suspending the App,
or removing a repository from it, applies at once. A ?repository= you cannot
access returns 403, and a session id from such a repository returns 404. The overview's skip counts span a whole installation, so
they are shown for a personal account to that account, and for an organization
to its members. Checking membership needs the app's Organization → Members: read
permission; without it the counts stay hidden. Viewing never grants acting:
/review and anything else the bot does on request needs write, maintain or
admin on the repository.
A review cut off by a restart or crash would stay marked as running, so on startup every session still in progress is marked failed with the reason "Review interrupted before it finished (bot restart or crash)". The tokens and cost it had already spent stay on the session.
![]() |
![]() |
![]() |
![]() |
The Overview's live panel streams the model's output as a review runs. On a
batched review, per-token streaming is off and the panel shows review.batch
progress (batch X/Y) instead:
The Live page follows one review at a time, from the moment it starts until it ends. It shows the step the review is on (reading the pull request, reviewing, checking the findings, summarizing, publishing), the tokens and cost spent so far across every model call, the model's output, and, when enabled, its reasoning. A review in batches shows its batch count instead of the output. The page keeps the last ten reviews that ended, and a link from the Overview's live panel opens a review on it.
The reasoning panel is off by default. Set DASHBOARD_REASONING_STREAM_ENABLED=true
to stream the review call's reasoning there. Only a provider that sends its
reasoning as reasoning_content on the OpenAI-compatible stream has any to
show; the flag reads what the provider already sends and changes nothing in the
request. The reasoning can quote the reviewed code, and every dashboard user who
can read the repository sees it.
Repository instructions
Add a .github/thrillhousebot.md file to a repository to guide its reviews:
## Review Priorities
1. Payment calculations must be exact; flag any floating-point usage
2. All DB queries must use the repository pattern, never raw SQL
## Known Gotchas
- The `price` field in Product is in cents, not dollars
Fallback chain: .github/thrillhousebot.md → .github/copilot-instructions.md → CLAUDE.md → AGENTS.md → AGENT.md
Repository configuration
The instructions file (.github/thrillhousebot.md) is prose for the model.
Structured settings go in an optional .github/thrillhousebot.yml (or .yaml).
They are kept apart because the instructions fallback may land on a file owned by
another tool, and that whole file reaches the model as untrusted prose.
review:
# Extra paths this repository never wants reviewed, on top of the deployment default.
ignored-files:
- "docs/generated/**"
- "**/*.snap"
- "testdata/**"
# Review rules for one path only, on top of the prose in .github/thrillhousebot.md
# (which keeps applying everywhere).
path-instructions:
- path: "payments/**"
instructions: |
Money is handled in integer cents; flag any floating-point arithmetic.
Every state change must be idempotent under retry.
- path: "**/generated/**"
instructions: "Generated code: style and naming findings do not apply."
# Name of the workflow artifact holding this repository's JaCoCo XML coverage report.
# Only read when the deployment sets REVIEW_PATCH_COVERAGE_ENABLED=true.
coverage-artifact: "coverage-report"
Ignore list: deployment list plus repository list. A file is skipped if it
matches the deployment's thrillhousebot.review.ignored-files or a glob the
repository declares. A repository can take more files out of review but never
put back one the deployment excludes. A repository with no config file gets the
deployment list unchanged.
Globs are gitignore-style, with the same meaning as in a root .gitignore,
here and in the deployment setting:
| Pattern | Matches |
|---|---|
build/ |
everything under any directory named build, at any depth |
vendor |
a file or directory called vendor at any depth, and its tree |
*.lock |
every lockfile at any depth |
generated/** |
the top-level generated tree only (a pattern with a / is anchored to the root) |
**/generated/** |
a generated tree at any depth |
/Makefile |
only the root Makefile |
**/*.{js,ts} |
brace alternation, in the YAML form |
* never crosses a /; use ** to span directories. Negation (!path) is not
supported and is dropped with a warning, since nothing may put back an excluded
file. A glob that matched no file in the PR is named in the review summary, so a
declaration that does nothing is visible.
Review rules: global instructions plus every matching path scope. The prose
in .github/thrillhousebot.md (or whichever file the fallback lands on) applies
to every file. Each path-instructions scope whose glob matches a changed file
adds its rules for that file only; the model sees each scope next to the files it
governs, so payments/ rules never reach generated code. Scopes may overlap: a
file matching two gets both, in declaration order, and a scope wins over the
global instructions for its own files. A scope matching nothing in the PR is not
sent, and ignored files are never scoped. Path globs use the same syntax and
matcher as ignored-files.
Patch coverage needs a named report. The bot never builds the PR, so it
cannot measure coverage itself. When the deployment sets
REVIEW_PATCH_COVERAGE_ENABLED=true and the repository names an artifact here,
each review looks for that artifact on a completed workflow run for the exact
head commit, downloads it, and intersects the report's never-executed lines with
the lines the diff adds. The reviewer gets a short list of uncovered changed
lines and is told that changed logic nothing runs is reportable, and that a
correctness claim about such a line must not be softened by "a test in this diff
covers it". The workflow must upload the report:
- run: ./mvnw clean test jacoco:report
- uses: actions/upload-artifact@v7
with:
name: coverage-report # must match review.coverage-artifact
path: target/site/jacoco/jacoco.xml
Only JaCoCo XML is understood. A repository that names no artifact gets the same
review as before, and so does one whose run uploaded nothing by that name, whose
artifact expired or failed to download, or whose report is in another format:
the section is left out. Coverage is never inferred from the diff, a line's
absence from the list is not evidence that a test covers it, and ignored files
are never reported. Uploading the whole target/site/jacoco/ tree is fine: only
.xml entries count toward the 512-entry limit. An artifact the bot found but
refused (more .xml entries than that, or more than 128 MB unpacked) is named
in the summary's scope note.
Loading and limits. The file is read from the default branch and cached for
five minutes. YAML anchors, aliases and merge keys are resolved; an oversized or
deeply nested document, or one built on a runaway alias expansion, is refused
whole. Everything else fails soft: a missing file, invalid YAML, an unexpected
shape, an uncompilable glob, a repeated key (the last value wins) or a malformed
path-instructions entry is logged and skipped, and the deployment's ignore list
and instructions stay in force. A read that fails for another reason than "no
such file" (a 5xx, a rate limit) is retried on the next review. A repository may
declare at most 25 scopes of at most 4000 characters each; the rest is dropped
with a warning. Set THRILLHOUSEBOT_REVIEW_REPO_CONFIG_ENABLED=false to turn the
whole mechanism off.
PR labels
With REVIEW_LABELS_ENABLED=true, the summary call suggests a few labels (area,
change type, risk) from the repository's existing labels. A suggestion that
matches no existing label is dropped, unless REVIEW_LABELS_ALLOW_CREATE=true
lets the bot create it. REVIEW_LABELS_APPLY decides what happens next:
false(default): the labels are posted as a one-line comment on the first review for a maintainer to apply.true: the labels are added to the PR.
REVIEW_LABELS_MAX caps the number of labels (default 3).
REVIEW_LABELS_BLOCKED lists labels the bot never suggests, applies or creates
(comma-separated, case-insensitive), such as a label your team sets by hand to
ask for a review. Labelling never blocks or fails a review.
Observability
Traces and metrics are exported over OTLP (gRPC) to OTEL_EXPORTER_ENDPOINT
(default http://localhost:4317). A Quarkus per-signal variable such as
QUARKUS_OTEL_EXPORTER_OTLP_TRACES_ENDPOINT sends that one signal elsewhere.
| Signal | Metric |
|---|---|
| Traces | One trace per review: a review span, linked to the webhook deliveries it serves (the first 32; thrillhouse.review.requests counts all of them), with a span per LLM call (request and response events) inside it |
gen_ai.client.token.usage |
Histogram: input and output tokens |
gen_ai.client.operation.duration |
Histogram: latency in seconds |
thrillhouse.ai.cost.total |
Counter: USD cost by model |
thrillhouse.review.skips |
Counter: automatic reviews skipped, tagged with reason and repository |
thrillhouse.reviews.total |
Counter: finished reviews by outcome (completed/failed) |
Spans and metrics carry gen_ai.provider.name, derived from AI_BASE_URL
(deepseek, openai, groq, openrouter, …). Loopback and unrecognized
endpoints report unknown; set AI_PROVIDER to name them (a local ollama or
vllm server, a proxy, a gateway).
Quarkus also records JVM metrics (jvm.*) and HTTP server metrics
(http.server.request.duration). They are exported over OTLP and, with the
Prometheus endpoint on, served on /metrics too. Turn them off with
OTEL_JVM_METRICS_ENABLED=false and
OTEL_HTTP_SERVER_METRICS_ENABLED=false.
Log records stay on the console. Set OTEL_LOGS_ENABLED=true to export them
over OTLP to the same endpoint as well.
OTLP export is at-least-once: when an export fails after the collector has
received it, Quarkus sends the batch again (up to three attempts, not logged),
so a span or metric point can reach the backend twice. Quarkus also uses the
export timeout as the connection's idle timeout, and below the collector's
60-second HTTP/2 keep-alive that closes a connection right after an export
written after a quiet spell, which duplicates it
(quarkusio/quarkus#57107).
OTEL_EXPORTER_TIMEOUT therefore defaults to 65s for traces, metrics and
logs; keep it above 60 seconds.
Prometheus
With PROMETHEUS_METRICS_ENABLED=true, the same metrics are also served in
Prometheus text format at GET /metrics on the main HTTP port — no OTLP
collector needed:
scrape_configs:
- job_name: thrillhousebot
static_configs:
- targets: ["your-bot-host:8080"]
Metric names follow the Prometheus conversion of the OTel names above (e.g.
thrillhouse_ai_cost_total, gen_ai_client_token_usage,
thrillhouse_reviews_total).
The endpoint is off by default because it is unauthenticated and reveals model
names, token counts, and spend. On an internet-facing deployment, restrict
/metrics at your reverse proxy or firewall before enabling it. While it is
disabled, scrapes return 404; OTLP export is unaffected.
Turning telemetry off
Set OTEL_SDK_DISABLED=true to turn telemetry off. Nothing is exported, the
bot makes no connection to OTEL_EXPORTER_ENDPOINT, and /metrics returns
404 even with PROMETHEUS_METRICS_ENABLED=true. The dashboard's cost and
token figures come from the database, so they still work.
Restricted-network deployment
To keep code inside your network, run the model yourself and allow only the connections the bot needs. The bot is a GitHub App for GitHub.com, so it can never be fully offline: it must reach GitHub. GitHub Enterprise Server is not supported.
Outbound connections
| Destination | When | Required |
|---|---|---|
api.github.com |
Every review: reading the pull request and posting the review | Yes |
AI_BASE_URL |
Every review and command. Diffs and context are sent here | Yes. Point it inside your network to keep code there |
github.com/login/oauth (GITHUB_OAUTH_URL) |
Dashboard login | Only with the dashboard (GITHUB_CLIENT_ID set) |
| The Actions artifact and log storage GitHub redirects to | Patch coverage and CI-failure logs | Only with REVIEW_PATCH_COVERAGE_ENABLED or REVIEW_CI_CONTEXT_ENABLED |
NOTIFICATIONS_WEBHOOK_URL |
After a review | Only when set |
OTEL_EXPORTER_ENDPOINT |
Telemetry export | No. Point it at your own collector, or set OTEL_SDK_DISABLED=true |
The database is the only other connection. The bot does not phone home or check
for updates. GitHub must also be able to send webhooks to the bot, so its
/api/webhook must be reachable from GitHub (directly, or through a relay such
as Smee).
Local model
Run Ollama (or any OpenAI-compatible server such as vLLM) on your network and point the bot at it:
AI_BASE_URL=http://ollama.internal:11434/v1
AI_MODEL=llama3.2
AI_API_KEY=unused # required at boot; Ollama ignores it
AI_PROVIDER=ollama # label for telemetry; loopback and unknown hosts report "unknown"
OTEL_SDK_DISABLED=true
From Docker Compose, a server on the host is http://host.docker.internal:11434/v1
(on Linux, add extra_hosts: ["host.docker.internal:host-gateway"] to the bot's
service). Do not use Ollama's cloud endpoint (https://ollama.com/v1) here: it
sends the code off your network. Size REVIEW_MAX_INPUT_TOKENS for the local
model's context window, and raise AI_TIMEOUT if it is slow.
Dashboard
The dashboard is served by the bot itself and loads no fonts, scripts or styles
from a CDN. In the viewer's browser, two things reach GitHub: the signed-in
user's avatar (avatars.githubusercontent.com) and the links to pull requests.
Login goes through GitHub OAuth, so leave GITHUB_CLIENT_ID and
GITHUB_CLIENT_SECRET unset to run without the dashboard.
Installing
The manifest install can be served
locally (see Offline alternative there). The container images come from
ghcr.io; mirror them into your own registry and set the image in
docker-compose.yml if the host cannot pull from it.
Troubleshooting
PR opened but no review posted
When a pull_request webhook arrives but no review is posted, the bot logs
Automatic review skipped [reason=...], increments thrillhouse.review.skips,
and counts skips per reason in the dashboard summary
(GET /api/dashboard/summary, field skippedReviewsByReason). Check in order:
- Is the app installed on the repository? No delivery at all means the app is not installed or the webhook URL or secret is wrong. See the app's Advanced → Recent Deliveries page.
- Draft (
reason=DRAFT): withWEBHOOK_SKIP_DRAFTS=true, drafts wait until marked ready. - Paused (
reason=PAUSED): someone commented/pause; comment/resume. - Label and author gates (
reason=MISSING_REQUIRED_LABEL/EXCLUDED_LABEL/EXCLUDED_AUTHOR): checkWEBHOOK_REQUIRED_LABELS,WEBHOOK_EXCLUDED_LABELSandWEBHOOK_EXCLUDED_AUTHORS. - Base-branch filters (
reason=BASE_BRANCH_NOT_ALLOWED/IGNORED_BASE_BRANCH): checkWEBHOOK_BASE_BRANCHESandWEBHOOK_IGNORED_BASE_BRANCHES. - Rate window (
reason=RATE_LIMITED): an automatic review already ran withinAUTO_REVIEW_MIN_INTERVAL. - Redelivery (
reason=DUPLICATE_DELIVERY): GitHub redelivered a webhook the bot already handled. This is normal. - Executor saturated (
reason=DISPATCH_REJECTED): the review executor refused the task (overload or shutdown); redeliver the webhook from GitHub.
A /review from a user with write access bypasses the draft, label,
base-branch and rate-window gates, but not /pause.
Responsible use and security
AI review is advisory. The model raises false positives and misses real bugs, so confirm a finding before acting on it.
Pull request diffs go to the endpoint you configure. Use an HTTPS endpoint with an API key, and read the provider's data-retention policy before sending it private code. To keep code on your network, see Restricted-network deployment.
Set AI_API_KEY, GITHUB_PRIVATE_KEY and the webhook secret through your
environment or a secret manager, and never commit them. Treat
NOTIFICATIONS_WEBHOOK_URL and NOTIFICATIONS_WEBHOOK_SECRET the same way; see
Outgoing notifications for exactly what a
notification sends.
To report a vulnerability, see SECURITY.md.
Known limitations
- GitHub only. No GitLab or Bitbucket.
- Large diffs. A review splits a big PR into up to
REVIEW_MAX_AI_CALLS - 1batch calls, keeping one for the summary; files that still do not fit are named, not dropped. See AI call budget and Batching under Commands. - Pure renames. Files GitHub reports as
renamedwith no changes and no patch are left out of the review input; the summary lists a short rollup (N pure renames omitted…). A rename with edits is reviewed. - Single process. OAuth sessions, the WebSocket replay buffer, the per-PR auto-review window, the findings a superseded review hands to its replacement, verdicts held on pending CI and the claims the verifier rejected on a head are kept in memory: lost on restart and not shared between replicas. Review history and cost totals are stored in PostgreSQL. Multiple replicas are not supported.
- Dashboard access. GitHub OAuth only, for users who can read a repository
where the app is installed; no admin UI or guest mode. Access is checked
against GitHub's repository permissions and cached for five minutes; an
uninstall, a suspension or a removed repository applies at once. GitHub lets
only the owner of a private app sign in with it, so anyone else needs the app to be public; set
GITHUB_ALLOWED_ACCOUNTSthen, because anyone can install a public app. - Production database. Container and native builds use PostgreSQL. H2 is for
quarkus:devand the tests only, and its driver is not packaged in the image. - OpenAI-compatible APIs only. The endpoint must implement the chat-completions API that LangChain4j expects.
- Review output. At most
THRILLHOUSEBOT_REVIEW_MAX_REVIEW_COMMENTS(50) inline comments per review. Lockfiles (*.lock,package-lock.json,pnpm-lock.yaml,go.sum), minified bundles and sourcemaps, generated code (*.generated.*, protobuf output) and build or vendor directories (target/,node_modules/,dist/,build/,out/,.next/,vendor/,__pycache__/,.venv/,bin/,obj/) are skipped by default and named in the summary's scope note; build manifests (pom.xml,build.gradle,Cargo.toml,package.json,build.zig) are reviewed. SeeTHRILLHOUSEBOT_REVIEW_IGNORED_FILESand Repository configuration. - Security scan. The security scan matches a short list of
patterns on added lines. It does not replace GitHub secret scanning or push
protection: it reads neither git history nor unchanged lines, and a credential
in another format is left to the model review. A secret finding has no code
anchor (that would store the value), so when an older round's finding is no
longer in the round the bot compares against, it counts as present while its
file stays in the diff; a reply on its thread or
@thrillhousebot resolvedclears it. - Self-hosted. There is no managed offering.
Verifying a release
Release images and binary tarballs are signed with cosign (keyless, through Sigstore) and carry build-provenance attestations. To check a release:
# Signature
cosign verify \
--certificate-identity-regexp='https://github.com/devops-thiago/ThrillhouseBot.*' \
--certificate-oidc-issuer='https://token.actions.githubusercontent.com' \
ghcr.io/devops-thiago/thrillhousebot:v0.9.0
# Provenance (image)
gh attestation verify oci://ghcr.io/devops-thiago/thrillhousebot:v0.9.0 \
--repo devops-thiago/ThrillhouseBot
# Provenance (a downloaded binary)
gh attestation verify thrillhousebot-v0.9.0-linux-amd64.tar.gz \
--repo devops-thiago/ThrillhouseBot
Operations
docs/OPERATIONS.md covers what the bot stores and what it keeps only in memory, the single-replica limit, the health endpoints with Compose and Kubernetes probes, restarts, backups, and going back to an earlier release.
Upgrading
Only the latest release receives fixes, including security fixes. To upgrade, read the release's section in CHANGELOG.md, then point your deployment at the new image tag and restart it:
docker compose pull && docker compose up -d
Each release's notes start with what changes for an existing installation:
new or renamed settings, changed defaults and database changes. Database
changes are Flyway migrations that run on startup; back up the database first.
Flyway arrived in v0.9.0: a database created before it is recorded as version 1
(the v0.8.0 schema) on its first v0.9.0 start, so upgrade to v0.9.0 from v0.8.x
(see docs/OPERATIONS.md).
New settings default to the previous behaviour unless the notes say otherwise. If
you pin a version with IMAGE_TAG in .env, set it to the new tag before
pulling.
docs/VERSIONING.md describes what each kind of release can change, what counts as the public contract, how a setting is deprecated and which releases get fixes.
Development
Local development without Docker needs Java 25+, Node.js 22+ (dashboard) and a
Smee.io channel to forward webhooks. Use the included Maven
wrapper, ./mvnw.
Dev mode
Dev mode reads a .env file in the project root, and the bot will not boot until
GITHUB_APP_ID, GITHUB_PRIVATE_KEY, GITHUB_WEBHOOK_SECRET and AI_API_KEY
are set. Create it first:
cp .env.example .env
Fill it in from your GitHub App (see GitHub App setup).
GITHUB_PRIVATE_KEY must be a real PEM RSA key; placeholder text fails at boot.
Use the .pem GitHub gives you, or generate a throwaway key for local work:
openssl genrsa -traditional 2048 | awk '{printf "%s\\n", $0}'
-traditional writes the PKCS#1 format (BEGIN RSA PRIVATE KEY) that GitHub's
keys use and the bot parses; OpenSSL 3 otherwise writes PKCS#8. The awk step
puts the key on one line with literal \n escapes, as .env needs. Paste it
unquoted, since quotes are read as part of the key:
GITHUB_PRIVATE_KEY=-----BEGIN RSA PRIVATE KEY-----\nMIIEow...\n-----END RSA PRIVATE KEY-----
GITHUB_WEBHOOK_SECRET and AI_API_KEY only need to be non-empty to boot, but
webhooks and reviews need the real values. Dashboard OAuth is optional. Then:
# Terminal 1: Smee proxy
smee -u https://smee.io/YOUR_CHANNEL -t http://localhost:8080/api/webhook
# Terminal 2: Quarkus dev mode
./mvnw quarkus:dev
Build the dashboard
cd frontend
npm ci
npm run build
cp -r out/* ../src/main/resources/META-INF/resources/dashboard/
Build the native image
./mvnw package -Pnative -DskipTests -Dquarkus.native.container-build=true
Run tests and checks
./mvnw verify
./mvnw spotless:check
See CONTRIBUTING.md for the full workflow.
Community
Ask questions and get setup help in GitHub Discussions (see the pinned welcome post). Use Issues for bugs and feature requests.
GOVERNANCE.md explains how decisions are made, who holds which role, and how the project continues if the maintainer cannot.
Architecture
See docs/ARCHITECTURE.md.
Tech stack
| Layer | Choice |
|---|---|
| Framework | Quarkus 3.40 (REST) |
| LLM | Quarkus LangChain4j 1.14 (OpenAI-compatible API) |
| Frontend | Next.js 16 + React 19 (static export) |
| Database | H2 (dev) / PostgreSQL (prod) + Panache |
| Observability | OpenTelemetry |
| Native | GraalVM / Mandrel |
| Container | UBI9-micro (default) / distroless (-distroless) |
Container images
Both images are built from the same native binary and published to GHCR:
ghcr.io/devops-thiago/thrillhousebot:latest: UBI9-micro (default).ghcr.io/devops-thiago/thrillhousebot:latest-distroless: distroless base (:v0.9.0-distroless, and so on).- Snapshot tags:
:snapshot,:v<version>-<short-sha>-snapshotand:<full-sha>, each with a-distrolessvariant. A weekly job keeps the 10 newest snapshot builds of each variant and deletes the older ones; pin a release tag, not a snapshot, for anything that must keep pulling.test-*images are deleted after 30 days. The counts and ages are repository variables (GHCR_KEEP_SNAPSHOTS,GHCR_TEST_MAX_AGE_DAYS,GHCR_MIN_AGE_HOURS, plusGHCR_KEEP_TAGSfor tags to always keep), listed in.github/workflows/ghcr-prune.yml.
Both are multi-arch (linux/amd64, linux/arm64), signed with cosign, and carry build-provenance attestations (see Verifying a release).
License
Licensed under the Apache License 2.0 (SPDX: Apache-2.0).
Explore related projects
awesome-selfhosted/awesome-selfhosted
A list of Free Software network services and web applications which can be hosted on your own servers
335,792 starsimmich-app/immich
High performance self-hosted photo and video management solution.
118,645 starsStirling-Tools/Stirling-PDF
#1 PDF Application on GitHub that lets you edit PDFs on any device anywhere
96,149 starslouislam/uptime-kuma
A fancy self-hosted monitoring tool
93,425 stars


