Architecture
How the bot is put together and what happens between a GitHub event and a posted review.
ThrillhouseBot is a Quarkus application that runs as a GitHub App. GitHub sends a webhook when a
pull request changes or someone comments a command. The bot reviews the change with an
OpenAI-compatible model, posts the result as a PR review, a summary comment and a check run, and
streams progress to a dashboard. Class names below are under
src/main/java/dev/thiagogonzaga/thrillhousebot/. The engineering rules behind this design
(invariants, where each stage lives, rejected alternatives) are in
AGENTS.md.
Components
Section titled “Components”flowchart LR
subgraph GH[GitHub]
EV[PR events, comments, CI results]
API[REST and GraphQL API]
end
subgraph BOT[ThrillhouseBot]
WH[webhook/<br/>WebhookEvents]
DSP[review/<br/>ReviewDispatcher]
RO[review/<br/>ReviewOrchestrator]
FP[review/<br/>FindingPipeline]
AI[review/ai/<br/>AiReviewService]
PUB[review/<br/>ReviewPublisher]
DASH[dashboard/<br/>broadcaster and REST]
NOTE[notification/<br/>ReviewNotifier]
end
LLM[(OpenAI-compatible<br/>model endpoint)]
DB[(PostgreSQL<br/>H2 in dev)]
UI[frontend/<br/>Next.js dashboard]
HOOK[(Outgoing webhook<br/>opt-in)]
EV -->|POST /api/webhook| WH --> DSP --> RO
RO --> FP --> AI --> LLM
RO --> PUB --> API
RO <-->|context reads| API
RO <-->|review sessions,<br/>earlier rounds| DB
RO --> DASH -->|WebSocket| UI
RO --> NOTE --> HOOK
The github/ clients handle app authentication, installation tokens and GitHub API calls for
pull request files, reviews and review comments, issue comments, check runs, labels, reactions,
thread resolution over GraphQL, the instructions file and the repository settings file.
Request flow
Section titled “Request flow”flowchart TD
GH[GitHub event] -->|HMAC check, delivery parse<br/>Quarkiverse GitHub App| WH[WebhookEvents]
WH -->|pull_request: pause, trigger filters,<br/>auto-review window| DSP
WH -->|/review: 👀, pause, write access| DSP
WH -->|other commands: 👀| CMD[CommentCommandService]
WH -->|mention or reply to a finding| REP[MaintainerReplyDispatcher]
WH -->|check_suite or status| CIR[CI-hold recheck]
WH -->|200 OK| GH
CIR --> DSP
DSP[ReviewDispatcher<br/>one worker per PR, coalesces] --> RO[ReviewOrchestrator.review]
RO --> CTX[ReviewContextLoader<br/>and CI, linked issues, learnings]
CTX --> PLAN[ReviewPromptAssembler<br/>DiffBudgetPlanner]
PLAN --> FP[FindingPipeline<br/>review, verify, scan, summary]
FP --> VB[VerdictBuilder<br/>reconcile earlier rounds, verdict]
VB --> HM{Head moved?}
HM -->|yes| SKIP[Check run skipped,<br/>findings handed to the next run]
HM -->|no| PUB[Summary comment, review,<br/>check run, thread resolution]
PUB --> POST[Feedback, learnings, labels,<br/>session saved, notification]
The Quarkiverse GitHub App extension verifies the signature and parses the delivery inside its
route; WebhookEvents then drops a redelivered delivery id, routes the event and returns before
any review work starts. Only cheap gates run on the request thread: the pause lookup, the trigger
filters, the 👀 reaction on a command (bounded by ACK_REACTION_TIMEOUT, default 3s) and the
write-access check for /review (bounded by MANUAL_TRIGGER_AUTH_TIMEOUT, default 5s). No
installation client is built on the ack path: the observers receive the raw event only.
| Event | What it starts |
|---|---|
pull_request opened, reopened, synchronize, ready_for_review | An automatic review, unless the PR is paused, filtered out by the WEBHOOK_* trigger settings (drafts, labels, base branches), or reviewed less than AUTO_REVIEW_MIN_INTERVAL ago (default 0, window off). ready_for_review clears that window. |
issue_comment with /review or @thrillhousebot review | A manual review. It bypasses the trigger filters and the rate-limit window, and needs write access. |
issue_comment with another command | The command: /help, /summary, /describe, /changelog, /add-docs, /improve, /generate-tests, /resolve, /pause, /resume, /learnings, /remember, /forget. /summary runs a review that posts a new summary comment. |
issue_comment mentioning the bot without a command | A conversational reply in the PR conversation. No 👀 reaction. |
pull_request_review_comment | A reply in the review thread when it mentions the bot, and finding-feedback capture when it answers a finding. |
check_suite completed, status | A recheck of any verdict held on pending CI for that commit. |
ReviewDispatcher keeps one worker per pull request on the review executor. A request that
arrives while a review of the same PR runs replaces any request already waiting, so only the
newest head is reviewed next. A manual /review first reads the head: if the running review
already covers that commit, the command acknowledges it without queuing another run. A moved
head or a failed head read queues a run. A CI-hold recheck runs once no review of that PR is queued.
A review, step by step
Section titled “A review, step by step”ReviewOrchestrator.review runs one round:
- Create the
ReviewSessionrow and broadcastreview.started. Register the head withCiHoldRegistryand create the "ThrillhouseBot Review" check run asin_progress, linked to the session on the dashboard. - Load the context (
ReviewContextLoader.load):- the PR files, filtered by the deployment's and the repository's ignore globs;
.github/thrillhousebot.yml(ignore globs and path-scoped instructions);- every earlier round of this PR, read back from the stored
ReviewSessionresponses; - on a follow-up, the review comments and the PR conversation;
- the instructions file (the first of
.github/thrillhousebot.md,.github/copilot-instructions.md,CLAUDE.md,AGENTS.md,AGENT.md), existing labels, project stack, linked bug-fix issue text, config-key definitions and, when enabled, patch coverage.
- Read the opt-in context sections: CI failures (
CiFailureContextResolver), linked issues (TicketContextResolver) and review learnings (ReviewLearnings.recall, returning the same bounded selection for the prompt and the deterministic decline-policy check). - Assemble the prompt inputs (
ReviewPromptAssembler.assemble) and plan the calls (DiffBudgetPlanner.plan). The CI reading for the verdict runs in parallel: from step 2 when CI context is on, from here otherwise. - Run the model calls and the finding chain (
FindingPipeline.run). - Refresh CI if the first reading would hold approval. With CI-failure context enabled, also
refresh when checks were pending or none had appeared at review start. Add newly failed checks
to the summary context and apply the confidence floor to findings citing them, then build the
verdict (
VerdictBuilder.build). - Check that the round may still post (
ReviewStandDown.beforeFirstWrite, which runs thePrePublishGuards in order and stops at the first that stands the round down). Read the PR head again (HeadMovedGuard): if it moved during the round, mark the check runskipped, hand the verified findings to the run queued for the new head (SupersededFindingsCarryover) and stop. Then, for an automatic run with a label gate configured, read the PR's labels again (LabelGateGuard): if a gate now rejects the PR, mark the check runskippedand stop. A failed read of either lets the round post. - Publish the summary comment and the PR review with its optional delta section, then conclude the check run.
- Run the post-result steps. Each may fail without undoing the review: resolve the threads of
addressed findings, capture finding feedback, capture review learnings, apply or suggest labels,
save the session and broadcast
review.completed, send the outcome notification.
A failure before anything was published marks the check run failure and posts a short notice on
the PR. For a cut response or a context-window rejection the notice names the cap and the setting
to change; otherwise it suggests /review. The session is saved as failed and the failure
notification is sent.
sequenceDiagram
participant GH as GitHub
participant TB as ThrillhouseBot
participant DB as Database
participant AI as Model endpoint
GH->>TB: webhook (PR push or /review)
TB-->>GH: 200 OK
TB->>DB: create review session
TB->>GH: create check run (in_progress)
TB->>GH: PR files, comments, instructions, settings
TB->>DB: earlier rounds of this PR
opt CI gating or CI context on
TB->>GH: check runs and statuses on the head
end
alt Diff fits one call
TB->>AI: review call (live tokens to the dashboard)
opt Findings and verifier on
TB->>AI: verifier call
end
else Large diff
par Each batch
TB->>AI: batch review call
opt Findings and verifier on
TB->>AI: verifier call for the batch
end
end
end
Note over TB: Security scan and open-thread guard
TB->>AI: summary call (concise model)
Note over TB: Reconcile earlier findings and build the verdict
TB->>GH: read the head again
alt First round or /summary
TB->>GH: post the summary comment
else Follow-up round
TB->>GH: edit the summary comment in place
end
TB->>GH: inline comments, then the PR review
TB->>GH: conclude the check run
TB->>GH: resolve threads of addressed findings
TB->>DB: save the result
Model calls
Section titled “Model calls”A review normally makes at least two model calls: one or more review calls, then one summary call. Each review call that returned findings adds a verifier call.
| Lane | When | Calls |
|---|---|---|
| Single call | The reviewable diff fits one call | Review call, verifier call if it found anything, summary call |
| Multi call | Token budgeting split the diff into batches | One review call per batch, in parallel; one verifier call per batch with findings; one summary call |
| Summary only | Every reviewable file was over the per-call budget or had no patch text | The summary call only. The files are disclosed by name. |
Token budgeting is on whenever REVIEW_MAX_INPUT_TOKENS is above 0 (default 48000).
DiffBudgetPlanner then packs files into at most REVIEW_MAX_AI_CALLS − 1 batches (default 6
calls) and reserves one call for the summary. At REVIEW_MAX_AI_CALLS=1 the single-call lane skips
the summary call and says so in the summary.
The review call (PrReviewer) returns findings and the status of each earlier finding, nothing
else. The summary call (PrSummarizer) writes every summary field (purpose, description gaps, file
walkthrough, labels, diagram) from the verified findings and the changed-file list. The summary,
the verifier and the conversational replies run on the concise model binding, with their own
output cap (REVIEW_CONCISE_MAX_OUTPUT_TOKENS, default 8192) and reasoning setting.
Review and summary calls stream from the provider. Only the single-call review streams tokens to
the dashboard; a batch emits review.batch progress events instead. Batches run on virtual
threads. A batch that failed for a reason other than a cut response, a context-window rejection or
the spend ceiling is retried once, one batch at a time, after the parallel pass.
A failed call attempt is retried with backoff, up to thrillhousebot.review.max-ai-retries
attempts (default 5). A response cut at its length cap is not retried, because the same call would
be cut the same way: the findings completed before the cut are kept and the affected files are
disclosed. A length stop with no content (reasoning used the whole cap) is repeated once at a
lower effort: low when reasoning is enabled (no repeat on a lane already at low or none),
the lane's effort (low unless set) when it is off in config and the provider default reasoned the
cap away, or AI_REASONING_REPEAT_EFFORT when set. The blocking review-path calls (the
finding verifier, the summary check, response repair) repeat the same way, and so do the
on-request commands and maintainer replies, whose calls are metered per command or reply.
Each call is bounded by AI_TIMEOUT (default 300 s).
AI_MAX_CONCURRENT_CALLS caps model calls in flight across the process (default 0, no cap); a
call past the cap waits for a slot. REVIEW_MAX_TOKENS_PER_REVIEW caps the tokens one review may
spend across all its calls, retries, verifier and summary included (default 0, no cap). Once it
is reached, the remaining batches are disclosed as not reviewed and the summary falls back to
counts.
The finding chain
Section titled “The finding chain”FindingPipeline runs the model's findings through a fixed chain before anything is published.
On the single-call lane (FindingPipeline.refine), in order (steps 1 to 6 are FindingScreen,
which both lanes share):
- Attach evidence for findings that cite code outside the diff (
ReviewEvidence). - Check that quoted code exists (
FindingQuoteValidator). - Drop known framework false positives (
FrameworkFalsePositiveFilter). - Merge duplicates (
FindingDeduplicator). - Drop what the verifier already rejected on this head (
VerifierRejectionMemory). - Verify (
FindingVerificationService), then remember its verdicts against the findings sent. - Calibrate severity (
SeverityCalibrator). - Drop restatements of findings a maintainer already answered, and of findings argued out on earlier rounds.
- Fill missing code anchors.
On the multi-call lane each batch runs steps 1, 2, 3, 5 and 6 in its own thread. Once every batch has finished, the pipeline merges the statuses the batches reported and runs steps 4, 7, 8 and 9 over the combined findings, so both lanes calibrate severity before a finding is placed inline or under "Things to double-check".
FindingScreen is a chain of stages. Each of steps 1 to 6 is a FindingStage
(EvidenceStage, QuoteValidationStage, FrameworkFilterStage, DedupeStage,
RejectionScreenStage, VerificationStage, RememberVerdictsStage) that owns only its own
collaborator and maps one immutable ScreeningContext (the call's response, its quote source and
prompt inputs, the plan, the evidence resolver, and the evidence and verifier candidates the
stages produce) to the next. The constructor builds one explicit ordered list per lane; the batch
list is the single-call list without DedupeStage. FindingScreenTest pins both orders.
Every lane then reaches FindingPipeline.finish: the narrow Dockerfile image-digest decline
check over the selected learnings, the linked-issue evidence floor, placeholder-secret screening,
the deterministic security scan merge, then the guard that drops a new finding restating one
still open on its own thread from any earlier round and path-mention screening. The decline
check keeps covered findings at low confidence and abstains on current digest requirements or
independent failure/security evidence; the issue floor runs afterward and the scan never passes
through the decline check. The summary call runs after that. Carried description gaps are merged
into its result, prompt vocabulary is scrubbed from the response (PromptLabelScrubber) and the
response is stored on the session.
Verification
Section titled “Verification”The verifier (FindingVerifier, on the concise model) gets the diff and each candidate finding,
and confirms, downgrades or drops it. It is on by default (REVIEW_VERIFIER_ENABLED). Before the
call, a deterministic pass drops findings that retract themselves and demotes hedged blocking
claims. That pass still runs with the verifier off.
The same call judges each kept finding's suggested fix and its side claims (FindingRevisions).
For a suggestion the verdict says keep, replace with corrected code, or drop: a replacement
takes the place of suggestion_new and leaves suggestion_old, so the suggestion is still placed
on the lines it quotes, and a drop removes suggestion_new and keeps the finding. Sentences of
the description that the material contradicts are listed under cut and removed when the
description holds them exactly as whole sentences, never the last one. A missing, unknown or
malformed part changes nothing.
Verification fails open. An error, an empty or cut response, or a call skipped at the spend
ceiling keeps the candidates. Those findings are capped at medium confidence and say in their text
that they were not verified, so under the default blocking strictness they cannot request changes
on their own. The summary states how much of the round was verified (VerificationCoverage).
Summary verification
Section titled “Summary verification”With REVIEW_SUMMARY_VERIFICATION_ENABLED (off by default), SummaryVerificationService makes one
call on the concise model (SummaryVerifier) after the summary call. It sends the summary's
purpose sentences, walkthrough rows and diagram lines, each with an id, together with the diff the
review calls read, the findings the round posts and the PR text. The answer names the ids the
material contradicts, each with its evidence, and SummaryStatements removes them: a sentence is
cut, a row loses its file summary, and any named diagram line drops the diagram. An entry with no
evidence is ignored. Each entry names its source, the diff or a finding. A purpose sentence is
cut only on evidence from the diff, and when every purpose sentence would go, all of them stay; a
missing or unknown source counts as a finding. The check runs on that round's new summary text,
before earlier overview text fills missing fields. Removed statements are not filled back in.
Each removal is logged with its statement and evidence. Errors, empty, cut or malformed answers,
and input or spend limits keep the summary unchanged. The check never changes findings.
Response repair
Section titled “Response repair”When the parser refuses a review or summary answer, AiReviewService hands it to
ResponseRepairService before the full retry (REVIEW_RESPONSE_REPAIR_ENABLED, on by default).
One call (ResponseRepairer on the active model for a review answer, SummaryResponseRepairer on
the concise model for a summary answer) gets the failed answer, fenced, the parser's error and the
response contract, and the result is read by the same parser. A repaired review answer is rejected
if it loses a title, including any repeated occurrence, introduces a new title, or contains
more previous-finding statuses than the failed answer. Timeouts, provider errors, throttles and length
stops never reach the repair. A failed or rejected repair leaves the retry loop as it was. A summary
answer whose file_summaries is a string holding JSON is read as that JSON; any other string gets
one re-ask for that field, and only rows whose path is in the string are kept. The finding
verifier makes its call once more when the first returns no body. Each call records its usage on the
token ledger, is skipped at the spend ceiling and holds a model-call gate slot.
Deterministic security scan
Section titled “Deterministic security scan”With REVIEW_SECRET_SCAN_ENABLED or REVIEW_IAC_SCAN_ENABLED (both off by default),
SecurityScan.merge reads the added lines of every reviewable file (PatchLines) with a fixed
rule list. SecretScanner matches credential formats, private keys, JWTs and credential-named
assignments whose value passes an entropy threshold (REVIEW_SECRET_SCAN_ENTROPY_THRESHOLD,
default 3.5). IacScanner matches admin ports open to the internet, public S3 buckets, wildcard
IAM, privileged pods, host namespaces, a final Dockerfile stage running as root and disabled
encryption. SecurityRule holds each rule's severity and title. A thrillhousebot:allow-secret or
thrillhousebot:allow-iac comment on the line or the line above suppresses a match, and
REVIEW_SECURITY_SCAN_SKIPPED_FILES excludes fixture paths.
The merge runs after verification, so no model grades a pattern match and the verifier's fail-open marking never applies to one. A model finding that reports the same defect nearby is dropped in favour of the scan's, and so is a model secret finding that names the identifier a generic secret rule matched, at any distance in the same file or in a documentation or configuration file of the PR. Every matched value is replaced in the remaining findings, status notes and summary before anything is stored or posted. A secret finding shows only the value's first characters and length and carries no code anchor, so the value reaches no comment, log line, stored session or dashboard view.
The scan also tracks its own findings across rounds:
- A detection that matches a scan finding still open from an earlier round is not raised again.
The scan reports that finding
unresolvedwhile it still matches andresolvedonce it does not. - A detection that matches a scan finding a maintainer declined (a round recorded it
justified) is not raised again while the content is the same: same file, title and anchor, and for a secret the same line, compared through a short HMAC, keyed fromGITHUB_WEBHOOK_SECRET, kept in a hiddenthrillhousebot:scan-contentmarker in the finding's text. A new value on that line is raised again.
Follow-up rounds
Section titled “Follow-up rounds”A round is a follow-up when an earlier round of the PR was stored. The stored responses are the source of truth, not the text of the bot's earlier reviews.
What the model is shown
Section titled “What the model is shown”Finding ids come from one round: the newest earlier round that raised any findings. Its findings
are listed by number with their thread replies, and the model reports a status for each:
resolved, unresolved or justified. Findings a newer round already settled are left out.
Findings the rounds before that one left open, each on its own thread, are listed in a separate unnumbered section that the model is told not to raise again or report on. Findings from older rounds that a maintainer answered are listed the same way. Without these sections, a round after one that raised something new would see the older open findings as never reported and post them again.
Reconciling statuses
Section titled “Reconciling statuses”VerdictBuilder.build turns the model's statuses into the round's final statuses, in this order:
- A finding whose targeted code left the diff becomes
superseded, unless the head is the one the finding was raised on. - A maintainer's decline is a claim, not ground truth (
REVIEW_DECLINE_RECHECK_ENABLED, on by default). When the only write-access reply on a thread rests on a premise the reviewed code contradicts, the finding goes back tounresolvedfor one more round. A second reply always stands. A finding without a thread is declined or cleared from the PR conversation with@thrillhousebot declined <path>:<line> — <title>or@thrillhousebot resolved <path>:<line> — <title>. - An
unresolvedid that is repeated or names no finding is dropped, so nothing is counted twice. - A "Things to double-check" item (a low-confidence medium or low finding, listed in the summary
without a thread) is replaced when this round or a later one raises the same defect at the same
or higher severity. Same defect means, in the same file, the same title, a similar title within
three lines, the same line with shared title words or a common identifier, or titles that restate
each other; in any file, both titles naming the same key or symbol plus another shared word or
defect class (a restatement on the paired doc or code file), or one finding citing the other's
file:lineand code. Two titles that name different identifiers, or different defect classes (a race and an off-by-one), are never the same defect. The summary's "Same issue as" note on a double-check item uses the same test. - The approval backstop replays every earlier round, oldest first. It keeps open each finding that no round closed and the model did not report on, as long as its code is still in the diff. A decline on a finding from an older round is weighed as in step 2: the finding stays open when the decline is contradicted or rests on an unconfirmed premise, and otherwise counts as justified without holding anything open; its thread, found by marker and content, is resolved and the decline is offered to the learnings capture. Every other older-round finding still gets a status: superseded when its code left the diff, resolved when its suggested fix landed beside the flagged line, and resolved or justified when a maintainer cleared or declined it on the PR conversation. Holds and standing declines are evaluated again on later rounds. An earlier finding closed this round is recorded once with its finding identity in the persisted response (#1066), so the next round does not report that closure again. Those recorded closes contribute to the resolved count and names in the review outcome, including its opt-in delta section.
The still-open set is the unresolved findings plus the backstop's holds, one entry per finding.
That one set feeds the summary's counts and lists, the "Still present" count and the verdict.
The summary comment
Section titled “The summary comment”The summary is one comment per pull request, marked with <!-- thrillhousebot:summary -->. The
first round posts it. Every later round finds the newest bot comment carrying the marker (or, for
summaries posted before 0.7.0, the summary heading) and replaces its body. An unchanged body is
not rewritten. A new comment is posted when the old one was deleted or the edit failed, and when
/summary asks for one.
Because the edit replaces the whole body, each round renders the PR as it stands. The risk counts,
Key Findings and "Things to double-check" cover the round's new findings plus every earlier finding
still open, tagged as open since an earlier review. Description gaps the previous summary listed
are passed to the summary call (DescriptionGapCarryover). On a changed head, the call can mark
a gap addressed. On an unchanged head, that answer cannot clear a gap. When the PR description
changed since the summary that listed the gaps, only the linked-issue gaps are carried and put to
the call. The description gaps are judged again against the new text, as on a first review, and
stay only if the call reports them again. A round with no summary answer keeps them all, except a
quoted omission the edited description now contains.
The current round's matching finding replaces its gap entry, and gaps tied to an issue that is no
longer linked are removed. When the summary call returns no overview on an unchanged head, the
previous round's overview is reused (PriorOverviewCarryover).
The comment keeps no history of its own. Each round's review and inline comments record that
round. The optional delta section (REVIEW_FOLLOW_UP_SUMMARY_ENABLED, off by default) lists
what changed since the last round inside that review, replacing its default closed-names section.
One review notification therefore carries the verdict, counts and closed identities. A definite
review refusal keeps the same body in the existing comment fallback; an ambiguous failure does
not risk posting it twice. An explicit /summary that posts its summary adds no delta section.
Threads
Section titled “Threads”After the review is posted, ReviewPublisher.resolveAddressedThreads resolves the review thread of
every finding of the reported round whose final status is resolved or justified. A finding
cleared from the PR conversation gets a short reply on its thread first.
Verdict and check run
Section titled “Verdict and check run”The review state is computed over this round's findings plus the still-open set, so an earlier critical finding that is still open requests changes even when every new finding is low.
| State | When | Check run |
|---|---|---|
REQUEST_CHANGES | A finding blocks under REVIEW_BLOCKING_STRICTNESS | failure |
COMMENT | Findings remain but none blocks, or APPROVE was held back | neutral |
APPROVE | Nothing open and nothing held it back | success |
REVIEW_BLOCKING_STRICTNESS decides what blocks: balanced (default) blocks on a critical or high
finding with high confidence, strict on any critical or high finding, lenient only on a
critical finding with high confidence. APPROVE drops to COMMENT when an earlier finding is still
unresolved, when files went unreviewed, or when CI holds it.
REVIEW_CI_GATING decides how CI counts. strict (default) holds approval while a required check
is pending, failing, missing or unreadable; warn approves and notes the CI state; off ignores
CI. When pending CI was the only thing between the round and APPROVE, the verdict is held in
CiHoldRegistry and the check run ends neutral. The check_suite or status event that reports
the head green then posts the approval (CiHoldRevisit) without another model call.
A follow-up round with no new findings but earlier findings still open says how many remain
unresolved instead of calling the PR clean. Findings are posted as individual inline comments, with
a committable suggestion where the range resolves. A comment GitHub refuses is retried without the
suggestion, then as a file-level comment. Low-confidence medium and low findings go to the
summary's "Things to double-check" list instead. At most thrillhousebot.review.max-review-comments (default 50)
findings are posted inline.
Context sections
Section titled “Context sections”These sections ride the review call's trailing guidance, fenced as untrusted data, so every batch
carries them and DiffBudgetPlanner counts them as shared overhead. The summary and verifier
calls do not get them unless stated. All are off by default.
| Section | Setting | Source |
|---|---|---|
| Patch coverage | REVIEW_PATCH_COVERAGE_ENABLED | PatchCoverageResolver reads the CI coverage report and lists the changed lines no test covers. |
| CI failures | REVIEW_CI_CONTEXT_ENABLED | CiStatusEvaluator's early reading of the head. CiFailureContextResolver renders the failing checks, one annotations page each (runner notices dropped), and the failing tests named in up to two failing Actions job logs, plus a job-log tail with REVIEW_CI_CONTEXT_INCLUDE_LOGS. The review call and the summary call both get it, each with its own request. Pending checks contribute a count only; when one was pending, the check runs are read again after the model call and a check failed since is added to the section the summary's CI sentence is computed from, and the CI-cited confidence floor is applied again over it (#1092). |
| Linked issues | REVIEW_TICKET_CONTEXT_ENABLED | TicketContextResolver over GitHubIssuesProvider: PR-body closing keywords, then GitHub's closing references, then optionally the branch name. Same-repository issues only, read and never written. The summary call gets it too and lists unaddressed acceptance criteria as description gaps, never as findings. |
| Review learnings | REVIEW_LEARNINGS_ENABLED | ReviewLearnings ranks the repository's active learnings by how close their file is to the changed files, and caps them in count and characters. The same typed selection feeds the prompt and the narrow Dockerfile image-digest decline check; excluded rows do not affect findings. |
Learnings are captured after a review is posted. FollowUpAnalyzer.survivingDeclines keeps only
the justified findings whose decline the re-check ran against and whose every reason rests on no
premise the code could refute. The author's write access is confirmed before
ReviewLearningService writes a review_learning row, scoped to the installation and repository,
and LearningText refuses credential-shaped text. /learnings, /remember and /forget list,
add and retract rows. Retracting only clears active, so the dashboard's audit view keeps the
history.
Review dimension routing
Section titled “Review dimension routing”The review call's system prompt (PrReviewPrompts) is a core plus ten dimension blocks.
Correctness, security and regressions are always on. With REVIEW_DIMENSION_ROUTING_ENABLED off,
the default, every call carries all ten. With it on, ReviewDimensionRouter picks each call's
blocks from its files' kinds (documentation, configuration or infrastructure, script, source,
test) and two probes over the patch (API or list code, mocks or stubs). An unrecognized file type
brings every block in. The core comes first, so it is the prefix a provider's prompt cache can
match across calls. The verifier routes its own carve-outs by the files its candidates are in.
Repository configuration
Section titled “Repository configuration”.github/thrillhousebot.yml holds a repository's own ignore globs and path-scoped review
instructions, read from the default branch and cached for five minutes. Its ignore globs add to the
deployment's list: a repository can narrow its review scope but cannot bring back a file the
deployment excludes. A missing or invalid file is logged and skipped. A glob that matched nothing
is disclosed in the summary.
Coverage disclosures
Section titled “Coverage disclosures”A file the review never read does not pass silently. A file with no patch text, a file that fit no batch, a file skipped at the spend ceiling and a file whose batch failed are named in the summary and withhold APPROVE. A cut response keeps the findings completed before the cut. A summary call that fails, is cut, or is skipped at a cap leaves a counts-only summary that says so.
Concurrency and in-memory state
Section titled “Concurrency and in-memory state”Reviews, CI rechecks, comment commands, replies and notification deliveries share one
virtual-thread executor with no size limit. Load is bounded by per-PR serialization in
ReviewDispatcher, the model call cap and GitHub write pacing.
| Limit | Setting | Default |
|---|---|---|
Model calls in flight, process-wide (ModelCallGate) | AI_MAX_CONCURRENT_CALLS | 0 (no cap) |
| Planned calls per review | REVIEW_MAX_AI_CALLS | 6 |
Tokens per review (ReviewTokenLedger) | REVIEW_MAX_TOKENS_PER_REVIEW | 0 (no cap) |
Spacing between content-creating GitHub writes, and the longest wait (GitHubWritePacer) | GITHUB_WRITE_MIN_INTERVAL, GITHUB_WRITE_MAX_WAIT | 1s, 90s |
The following state lives in memory, per process, and is lost on restart:
| State | Effect of losing it |
|---|---|
Per-PR queue and coalescing (ReviewDispatcher) | A queued review does not run; the next push or /review starts one. |
Verdicts held on CI (CiHoldRegistry, up to 256 PRs) | The approval is not posted when CI turns green; /review posts it. |
Findings handed over by a superseded run (SupersededFindingsCarryover) | The next round finds them again from the diff. |
Verifier rejections per head (VerifierRejectionMemory) | A rejected finding can go through the verifier again. |
| Webhook delivery ids (24 h) and the auto-review window | A redelivery or an early push can be reviewed. |
| Dashboard login sessions (8 h) | Users log in again. |
| Caches: installation tokens, instructions file, repository settings, project stack, dashboard access | Read again from GitHub. |
The summary comment is found by its marker each round, not by an id held in memory, so a restart
does not lose it. On startup InterruptedSessionReconciler marks every session still
in_progress as failed. The bot is designed to run as a single replica.
Persistence
Section titled “Persistence”| Entity | Table | Holds |
|---|---|---|
ReviewSession | ReviewSession | One row per review round: PR, head commit, model, tokens, cost, duration, finding counts, status, and the final response (aiResponseJson) that later rounds read their earlier findings from. |
PausedPr | PausedPr | PRs paused with /pause. |
FindingFeedback | finding_feedback | 👍/👎 reactions and reply signals on findings. See Finding feedback. |
ReviewLearning | review_learning | Review learnings, scoped to installation and repository. A retracted row stays, with active cleared. |
Production uses PostgreSQL (DATABASE_URL); dev mode and the tests use in-memory H2 in PostgreSQL
mode. Flyway runs the scripts in src/main/resources/db/migration at boot, and Hibernate only
validates the entities against the result. A database created before migrations were adopted is
baselined at V1, the v0.8.0 schema.
New rows carry the GitHub App installation id and GitHub's numeric repository id from the signed
webhook payload (#1220), and reads match both (RepoScope). A renamed repository keeps its rows;
a same-named repository under another installation, or one deleted and re-created, never matches
them. Rows stored
before v0.9.0 lack those ids. At startup, ScopeBackfill matches each owner/repo to the App's
installations and their repositories, following GitHub's redirect for a renamed one. It fills only
rows stored after the matched repository was created, since older ones belong to a deleted
repository of the same name. A row it cannot match keeps a null
scope and is never read, except a pause, which is still honored and lifted by its exact name so
that a pause a maintainer asked for is not silently dropped. The two startup maintenance passes,
InterruptedSessionReconciler and SessionCostBackfill, read every session by design.
Dashboard and notifications
Section titled “Dashboard and notifications”The dashboard backend (dashboard/) serves the Next.js static export under /dashboard/, a
GitHub OAuth login whose tokens stay on the server, REST endpoints under /api/dashboard
(sessions, costs, tokens, feedback, learnings, summary) and a WebSocket at /ws/dashboard. The
WebSocket carries review.started, review.phase, review.stream, review.reasoning,
review.batch, review.progress, review.retry, review.completed and review.failed events,
and replays an active session's events to a client that connects late; of the two text streams,
review.stream and review.reasoning, only the latest event is kept, since each carries the
text's tail. review.phase names the step a review is on: context and review from
ReviewOrchestrator, verify once the single review call answers and summarize when the
summary call starts, both from AiReviewService, then publish. A batched review checks each
batch's findings as the batch finishes, so it reports review.batch progress and no verify
step. review.progress is the review's spend so far: OtelObservabilityListener sends it from
the session row once a call's usage is added there, so it counts every call the review made, the
verifier's included. review.reasoning comes from the review call's thinking callback, only with
DASHBOARD_REASONING_STREAM_ENABLED on, which also sets returnThinking on the streaming model
so the provider's reasoning_content reaches that callback. Each is an ordinary SessionEvent,
so a relay between replicas (#24) carries it unchanged. A browser cannot send a header the server can read per
WebSocket connection, and the endpoint configuration is shared by every handshake, so the socket
authenticates with a single-use ticket in its query string: the page fetches it from
POST /api/auth/socket-ticket with its session cookie just before connecting, the ticket expires
after 30 seconds, and onOpen redeems it once. Check runs link to /session/<id>, which redirects
a login that may read the session to the session page, and anyone else to the sessions list.
Every endpoint and the WebSocket are scoped per repository (#1221). DashboardAccessChecker
asks GitHub with the login's own GitHub App user token: GET /user/installations for the App's
installations the user can access, and GET /user/installations/{id}/repositories for the
repositories they can read in each. The answer, a RepositoryScope of installation ids to
repository ids and names, is cached per token for five minutes. While GitHub cannot be asked,
the login keeps the last answer GitHub gave for up to 30 minutes, or is denied when there is
none, and GitHub is asked again at most once a minute; a token GitHub rejects ends the
dashboard session. With
account-owner set, only that account's installation is kept. Session lists, paging totals,
costs, tokens, the summary, feedback and learnings are filtered in the query by one
(installationId, repositoryId in …) term per installation, so a row with no ids is never
served and an empty scope matches nothing; ?installation= narrows to one installation,
?repository= naming a repository outside the scope answers 403, and a session id from one
answers 404, the same as a missing id. Skip counters are kept per installation and summed only
over installations whose installation-wide views the login may see: its own personal account, or
an organization it is a member of (GET /orgs/{org}/members/{user} with the installation token,
which needs Organization → Members: read and is granted only on 204). A WebSocket connection
reads its scope from memory on every event; the keepalive tick re-resolves it on a virtual
thread, never the event loop or a review thread, and closes the connection once nothing is
readable or the session has ended.
Acting is separate from viewing: github/RepositoryPermissions grants it to write, maintain and
admin (the legacy permission field or role_name), and /review, decline learnings and
finding feedback all use it. The dashboard has no endpoint that changes state.
With NOTIFICATIONS_WEBHOOK_URL set, ReviewNotifier posts each final outcome (completed or
failed) once, as JSON, Slack or Discord, off the review thread. Requests can be signed with
HMAC-SHA256 and are retried on timeouts, 429 and 5xx. A superseded run and the approval posted
when held CI turns green send nothing.
Traces, token counts and cost come from OpenTelemetry. Security reports follow SECURITY.md.
Conversational replies
Section titled “Conversational replies”sequenceDiagram
actor Dev
participant GH as GitHub
participant TB as ThrillhouseBot
participant AI as Model endpoint
Dev->>GH: mentions @thrillhousebot or replies to a finding
GH->>TB: webhook (issue_comment or pull_request_review_comment)
TB-->>GH: 200 OK
Note over TB: Async, after a write-access check
alt resolved or declined directive
TB->>GH: fixed acknowledgement, applied by the next review
else question
TB->>AI: reply call (concise model)
TB->>GH: reply in the thread or a PR comment
end
REVIEW_CONVERSATIONAL_REPLIES_ENABLED (default on) controls these replies. A thread reply sends
the model the finding, its diff hunk and the thread. A mention in the PR conversation sends the
diff, filtered by the ignore globs.
Packages
Section titled “Packages”| Package | Responsibility | Notable classes |
|---|---|---|
webhook/ | The webhook entry: the Quarkiverse GitHub App extension verifies the signature and parses deliveries, then applies trigger filters, pause state and the auto-review window, drops redeliveries, acks commands with 👀, routes commands and replies, starts CI-hold rechecks | WebhookEvents, WebhookDeduplicator, TriggerDetector, ReviewTriggerFilter, ManualReviewAuthorizer, AckReactionService, CommentCommandService, LearningCommands, PrPauseService |
review/ | Everything from "a review was requested" to "the result is on GitHub": dispatch, context, prompt assembly, budgeting, the finding chain, the security scan, follow-up reconciliation, verdict, publishing, CI gating, learnings, replies and the on-request commands | ReviewDispatcher, ReviewOrchestrator, ReviewContextLoader, ReviewPromptAssembler, DiffBudgetPlanner, FindingPipeline, FindingScreen, SecurityScan, FollowUpAnalyzer, VerdictBuilder, ReviewPublisher, PrSummaryGenerator, CiStatusEvaluator, CiHoldRevisit, TicketContextResolver, ReviewLearnings, MaintainerReplyService |
review/ai/ | The LangChain4j layer: AI service interfaces and prompts, streaming and retries, response parsing and salvage, the verifier, the token ledger and the call gate | AiReviewService, PrReviewer, PrSummarizer, FindingVerifier, FindingVerificationService, ReviewResponseParser, ReplyAssistant, ModelCallGate, ReviewTokenLedger |
github/ | GitHub REST and GraphQL clients, app auth, write pacing and retry, the instructions file and repository settings | GitHubAuthClient, GitHubReviewClient, GitHubCheckRunClient, GitHubWritePacer, GitHubWriteRetry, InstructionsResolver, RepoSettingsResolver, ReviewThreadService |
dashboard/ | OAuth sessions, the WebSocket broadcaster, review session persistence, dashboard REST | AuthResource, DashboardResource, SessionEventBroadcaster, ReviewSession, ReviewSessionPersistence, InterruptedSessionReconciler |
notification/ | The opt-in outgoing review-outcome webhook | ReviewNotifier, NotificationSettings, NotificationPayloads, WebhookDelivery |
config/ | Typed configuration, startup validation, the review executor, the HTTP client, model settings, bot identity | ThrillhouseConfig, StartupConfigValidator, ReviewExecutorProducer, ActiveModelSettings, BotIdentity |
frontend/ | The Next.js dashboard, built to a static export and served by Quarkus | — |
Adding an AI provider
Section titled “Adding an AI provider”There is no provider-specific code. The model is reached through LangChain4j's OpenAI-compatible
client, so a new provider is configuration: point AI_BASE_URL and AI_MODEL at it. Add a
thrillhousebot.ai.pricing.<model>.* pair for cost tracking (without it the bot warns once and
marks sessions "no pricing" instead of $0). Optionally set thrillhousebot.ai.models.<model>.*
for the model's input cap and generation parameters, and AI_REASONING_ENABLED /
AI_REASONING_EFFORT when the model supports reasoning. See the
provider table and the
configuration reference.