Skip to content

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.

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.

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.

EventWhat it starts
pull_request opened, reopened, synchronize, ready_for_reviewAn 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 reviewA manual review. It bypasses the trigger filters and the rate-limit window, and needs write access.
issue_comment with another commandThe 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 commandA conversational reply in the PR conversation. No 👀 reaction.
pull_request_review_commentA reply in the review thread when it mentions the bot, and finding-feedback capture when it answers a finding.
check_suite completed, statusA 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.

ReviewOrchestrator.review runs one round:

  1. Create the ReviewSession row and broadcast review.started. Register the head with CiHoldRegistry and create the "ThrillhouseBot Review" check run as in_progress, linked to the session on the dashboard.
  2. 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 ReviewSession responses;
    • 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.
  3. 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).
  4. 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.
  5. Run the model calls and the finding chain (FindingPipeline.run).
  6. 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).
  7. Check that the round may still post (ReviewStandDown.beforeFirstWrite, which runs the PrePublishGuards 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 run skipped, 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 run skipped and stop. A failed read of either lets the round post.
  8. Publish the summary comment and the PR review with its optional delta section, then conclude the check run.
  9. 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

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.

LaneWhenCalls
Single callThe reviewable diff fits one callReview call, verifier call if it found anything, summary call
Multi callToken budgeting split the diff into batchesOne review call per batch, in parallel; one verifier call per batch with findings; one summary call
Summary onlyEvery reviewable file was over the per-call budget or had no patch textThe 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.

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):

  1. Attach evidence for findings that cite code outside the diff (ReviewEvidence).
  2. Check that quoted code exists (FindingQuoteValidator).
  3. Drop known framework false positives (FrameworkFalsePositiveFilter).
  4. Merge duplicates (FindingDeduplicator).
  5. Drop what the verifier already rejected on this head (VerifierRejectionMemory).
  6. Verify (FindingVerificationService), then remember its verdicts against the findings sent.
  7. Calibrate severity (SeverityCalibrator).
  8. Drop restatements of findings a maintainer already answered, and of findings argued out on earlier rounds.
  9. 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.

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).

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.

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.

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 unresolved while it still matches and resolved once 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 from GITHUB_WEBHOOK_SECRET, kept in a hidden thrillhousebot:scan-content marker in the finding's text. A new value on that line is raised again.

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.

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.

VerdictBuilder.build turns the model's statuses into the round's final statuses, in this order:

  1. A finding whose targeted code left the diff becomes superseded, unless the head is the one the finding was raised on.
  2. 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 to unresolved for 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>.
  3. An unresolved id that is repeated or names no finding is dropped, so nothing is counted twice.
  4. 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:line and 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.
  5. 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 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.

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.

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.

StateWhenCheck run
REQUEST_CHANGESA finding blocks under REVIEW_BLOCKING_STRICTNESSfailure
COMMENTFindings remain but none blocks, or APPROVE was held backneutral
APPROVENothing open and nothing held it backsuccess

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.

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.

SectionSettingSource
Patch coverageREVIEW_PATCH_COVERAGE_ENABLEDPatchCoverageResolver reads the CI coverage report and lists the changed lines no test covers.
CI failuresREVIEW_CI_CONTEXT_ENABLEDCiStatusEvaluator'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 issuesREVIEW_TICKET_CONTEXT_ENABLEDTicketContextResolver 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 learningsREVIEW_LEARNINGS_ENABLEDReviewLearnings 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.

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.

.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.

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.

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.

LimitSettingDefault
Model calls in flight, process-wide (ModelCallGate)AI_MAX_CONCURRENT_CALLS0 (no cap)
Planned calls per reviewREVIEW_MAX_AI_CALLS6
Tokens per review (ReviewTokenLedger)REVIEW_MAX_TOKENS_PER_REVIEW0 (no cap)
Spacing between content-creating GitHub writes, and the longest wait (GitHubWritePacer)GITHUB_WRITE_MIN_INTERVAL, GITHUB_WRITE_MAX_WAIT1s, 90s

The following state lives in memory, per process, and is lost on restart:

StateEffect 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 windowA 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 accessRead 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.

EntityTableHolds
ReviewSessionReviewSessionOne 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.
PausedPrPausedPrPRs paused with /pause.
FindingFeedbackfinding_feedback👍/👎 reactions and reply signals on findings. See Finding feedback.
ReviewLearningreview_learningReview 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.

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.

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.

PackageResponsibilityNotable 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 rechecksWebhookEvents, 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 commandsReviewDispatcher, 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 gateAiReviewService, 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 settingsGitHubAuthClient, GitHubReviewClient, GitHubCheckRunClient, GitHubWritePacer, GitHubWriteRetry, InstructionsResolver, RepoSettingsResolver, ReviewThreadService
dashboard/OAuth sessions, the WebSocket broadcaster, review session persistence, dashboard RESTAuthResource, DashboardResource, SessionEventBroadcaster, ReviewSession, ReviewSessionPersistence, InterruptedSessionReconciler
notification/The opt-in outgoing review-outcome webhookReviewNotifier, NotificationSettings, NotificationPayloads, WebhookDelivery
config/Typed configuration, startup validation, the review executor, the HTTP client, model settings, bot identityThrillhouseConfig, StartupConfigValidator, ReviewExecutorProducer, ActiveModelSettings, BotIdentity
frontend/The Next.js dashboard, built to a static export and served by Quarkus—

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.