RubyRT is an opinionated but helpful and flexible AI code review tool for Ruby and Rails projects. It is heavily inspired by Gito and brings the same LLM-driven review workflow to Ruby codebases, with extra context pulled from the Ruby LSP.
- AI-powered code review via any OpenAI-compatible LLM (powered by ruby_llm).
- Ruby and Rails-aware review prompts out of the box.
- Reads project skills and rules from configurable directories (defaults to
.agents,.claude, and.cursor). - Supports auxiliary files (
aux_files) for injecting individual files as extra context into review prompts. - Configurable request timeouts, retries, and logging to fail fast on unreachable providers.
- Pulls extra context from language servers (LSP) during review β ruby-lsp first, any LSP via config. (Run static analyzers like RuboCop as a separate step.)
- Cuts false positives with a critic pass (
[verify]): every surviving finding is re-checked by a fresh, skeptical LLM call before it's reported. - Runs as a GitHub Action composite action, posting feedback as precise PR comments and (with a suitable token) resolving stale review threads automatically.
Add to your repository:
# .github/workflows/rubyrt-review.yml
name: "RubyRT Review"
on:
pull_request:
types: [opened, synchronize, reopened]
jobs:
review:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: Bonusly/rubyrt/.github/actions/rubyrt@v1
with:
api_key: ${{ secrets.LLM_API_KEY }}
provider: openrouter
model: moonshotai/kimi-k2.6ruby_versionβ Ruby the action installs to run RubyRT. Defaults to3.4.rubyrt_commandβ how the CLI is invoked. Defaults torubyrt. Set it tobundle exec rubyrt,direnv exec . rubyrt, etc. when your environment needs it.rubyrt_versionβ gem version to install (localbuilds from the checked-out repo). Set toskipto install nothing whenrubyrt_commandalready provides RubyRT (e.g. it's in your Gemfile).
- uses: Bonusly/rubyrt/.github/actions/rubyrt@v1
with:
api_key: ${{ secrets.LLM_API_KEY }}
ruby_version: "4.0"
rubyrt_command: bundle exec rubyrt
rubyrt_version: skip # rubyrt comes from the project's GemfileRubyRT reviews its own pull requests. Below is the actual
.github/workflows/rubyrt-review.yml from this repo β copy it as a known-good
starting point. It mints a GitHub App token when one is configured (so comments
post under the app's name) and otherwise falls back to the default token.
# .github/workflows/rubyrt-review.yml
name: "RubyRT Review"
on:
pull_request:
types: [opened, synchronize, reopened]
workflow_dispatch:
inputs:
pr_number:
description: "Pull Request number"
required: true
jobs:
review:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v7
with:
ref: ${{ github.event.pull_request.head.sha }}
# Mint an app installation token so review comments post under the app's
# name/avatar. Skipped (falls back to GITHUB_TOKEN) when the app isn't set up.
- uses: actions/create-github-app-token@v3
id: app_token
if: ${{ vars.RUBYRT_APP_ID != '' }}
with:
client-id: ${{ vars.RUBYRT_APP_ID }}
private-key: ${{ secrets.RUBYRT_APP_PRIVATE_KEY }}
- name: Run RubyRT review
uses: Bonusly/rubyrt/.github/actions/rubyrt@v1
with:
api_key: ${{ secrets.LLM_API_KEY }}
model: ${{ vars.LLM_MODEL }}
provider: ${{ vars.LLM_PROVIDER }}
# Post as the app when configured, otherwise as github-actions.
github_token: ${{ steps.app_token.outputs.token || github.token }}
# PAT (user token) with pull-requests write so stale review threads can
# be auto-resolved β GITHUB_TOKEN and App tokens cannot. Skipped if unset.
resolve_token: ${{ secrets.RUBYRT_RESOLVE_TOKEN }}Model and provider come from repo variables (vars.LLM_MODEL,
vars.LLM_PROVIDER) so you can change them without editing the workflow. This
repo runs the review on OpenRouter and re-checks findings with a stronger model
in the critic pass β see The critic pass.
The action referenced above is the composite action at
.github/actions/rubyrt/action.yml; it
installs Ruby, installs RubyRT, computes the base ref (unshallowing as needed so
merge-base is correct), runs the review, posts the comment, and uploads the JSON
and Markdown reports as artifacts.
secrets.LLM_API_KEY: Your LLM provider API key.secrets.GITHUB_TOKENis provided automatically by GitHub Actions. It must have at leastpull-requests: writepermission (set in the workflowpermissionsblock) so RubyRT can post review comments.- Want comments to post under a custom name and avatar instead of "github-actions"? See Posting as a GitHub App below.
- Resolving stale review threads needs an extra token β see Resolving stale review threads below. The default
GITHUB_TOKENcannot do it.
By default RubyRT posts as github-actions[bot] (the workflow GITHUB_TOKEN). To post under your own bot name and avatar, authenticate with a GitHub App installation token and pass it as the action's github_token input β review comments then appear as YourApp[bot].
- Create the app: Settings β Developer settings β GitHub Apps β New GitHub App (under your org or account). Give it the name and avatar you want to see on comments.
- Repository permissions β Pull requests: Read and write. (No webhook needed β uncheck Active.)
- Generate a private key and note the App ID.
- Install the app on the repositories that run RubyRT.
- Store the App ID as a variable (e.g.
vars.RUBYRT_APP_ID) and the private key as a secret (e.g.secrets.RUBYRT_APP_PRIVATE_KEY). - Mint an installation token with
actions/create-github-app-tokenand pass it asgithub_token:
jobs:
review:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/create-github-app-token@v2
id: app_token
with:
app-id: ${{ vars.RUBYRT_APP_ID }}
private-key: ${{ secrets.RUBYRT_APP_PRIVATE_KEY }}
- uses: Bonusly/rubyrt/.github/actions/rubyrt@v1
with:
api_key: ${{ secrets.LLM_API_KEY }}
github_token: ${{ steps.app_token.outputs.token }} # post as the app
resolve_token: ${{ secrets.RUBYRT_RESOLVE_TOKEN }} # resolve threads (PAT β see below)The app installation token posts comments fine, but cannot resolve review threads (
resolveReviewThreadrejects server-to-server tokens). If you also want stale threads auto-resolved, pair it with a user PAT inresolve_tokenas shown β see the next section.
When RubyRT re-reviews a PR, it can mark its earlier threads as resolved once the issue is fixed or the line is outdated. This uses the GraphQL resolveReviewThread mutation, which requires a user-to-server token (a personal access token).
The default
GITHUB_TOKENcannot do this, and neither can a GitHub App. Both are server-to-server tokens, andresolveReviewThreadreturnsResource not accessible by integrationfor them even withpull-requests: writeβ including the installation token fromactions/create-github-app-token. Only a user PAT works.
To enable auto-resolving, create a PAT and pass it via the resolve_token action input (or the RUBYRT_RESOLVE_TOKEN env var / --resolve-token flag for the github-comment CLI). If you don't set one, RubyRT skips resolving and still posts the new review.
- Create a token as a user with write access to the repo:
- Classic PAT with the
reposcope (recommended β reliably works forresolveReviewThread). Settings β Developer settings β Personal access tokens β Tokens (classic). If your org enforces SSO, click Configure SSO on the token and authorize it for the org. - Fine-grained PATs are not recommended here. Even with Pull requests: Read and write they often fail
resolveReviewThreadwithResource not accessible by personal access tokenβ and for an org repo the write permission stays read-only until an org owner approves it, so the fetch succeeds but resolving fails. If you must use one, get it org-approved and expect to troubleshoot.
- Classic PAT with the
- Store it as a repository (or org) secret, e.g.
secrets.RUBYRT_RESOLVE_TOKEN. - Pass it to the action:
jobs:
review:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: Bonusly/rubyrt/.github/actions/rubyrt@v1
with:
api_key: ${{ secrets.LLM_API_KEY }}
provider: openrouter
model: moonshotai/kimi-k2.6
resolve_token: ${{ secrets.RUBYRT_RESOLVE_TOKEN }}Threads are resolved as whichever user owns the PAT. (A machine/bot user account is a good fit if you don't want resolutions attributed to a person.)
RubyRT can submit an Approve review when a PR is clean enough. It's off by default β enable it under [approve] in .rubyrt/config.toml:
[approve]
enabled = true
max_changes = 500 # additions + deletions ceiling; skipped if GitHub doesn't report the size
max_severity = 3 # issues at/below this severity number (1=Critical) block approval
skip_label = "rubyrt-skip-approve"
protected_paths = ["app/billing/**", "config/secrets.yml"] # globs that block approval when changed
dry_run = false # evaluate and log the decision without approving/dismissingA PR is approved only when all of these hold:
- It isn't a draft and doesn't carry the
skip_label. - It doesn't modify
.rubyrt/config.tomlβ a PR can't weaken the approval rules and wave itself through. - No changed file matches a
protected_pathsglob β e.g. billing code or sensitive config you always want a human to review. - Total changes are within
max_changes(this check is skipped when the size is unknown). - This run produced no findings at or above
max_severity. - No RubyRT findings at or above
max_severityare still unresolved. - Every resolved RubyRT finding at or above
max_severitywas resolved by someone who is neither the PR author nor a contributor to the PR β an author can't clear their own findings to earn an approval.
Re-runs are idempotent: RubyRT won't stack a second approval on a head commit it already approved, and it dismisses its earlier approval if a later push stops meeting the rules. With dry_run = true it logs the decision and reasons but takes no action β useful for rollout.
The approval needs the workflow's pull-requests: write permission (already required for comments). RubyRT tries the approval with the main github_token first, then falls back to the resolve_token PAT if that attempt fails.
Heads up on which token approves. An approval submitted by
GITHUB_TOKEN(or a GitHub App installation token) does not count toward branch-protection "required approvals", and the fallback only triggers when the first attempt errors β a non-counting approval fromGITHUB_TOKENsucceeds and won't fall through to the PAT. If you need the approval to satisfy required reviewers, run RubyRT with the PAT as its maingithub_token(or as a bot user) so the counting identity is the one that approves.
Limitation. "Contributor" is determined from commit author/committer GitHub logins, so a
Co-authored-by:trailer doesn't count as having committed. If a thread's resolver can't be determined (e.g. a deleted account), RubyRT fails safe and does not approve.
RubyRT reads configuration from layers (later layers override earlier ones):
lib/rubyrt/config/default.tomlbundled with the gem..rubyrt/config.tomlin your project root.~/.rubyrt/.env(loaded into your environment).- Environment variables.
- CLI flags.
Key settings:
| Setting | Default | Config key | Environment variable | CLI flag |
|---|---|---|---|---|
| LLM provider | openai |
provider |
LLM_PROVIDER |
-p, --provider |
| LLM model | gpt-4o |
model |
LLM_MODEL |
-m, --model |
| API key | β | llm_api_key |
LLM_API_KEY |
β |
| API base URL | provider default | llm_api_base |
LLM_API_BASE |
β |
| Request timeout (seconds) | 120 |
request_timeout |
LLM_REQUEST_TIMEOUT |
β |
| Retries | 3 |
retries |
LLM_RETRIES |
β |
| Log file | stdout | log_file |
RUBYRT_LOG_FILE |
β |
| Log level | info |
log_level |
RUBYRT_LOG_LEVEL |
β |
| Max concurrent file reviews | 10 |
max_concurrent_tasks |
MAX_CONCURRENT_TASKS |
β |
| Confidence threshold (keep if β€) | 1 |
post_process.max_confidence |
β | β |
| Severity threshold (keep if β€) | 3 |
post_process.max_severity |
β | β |
| Critic pass enabled | true |
verify.enabled |
β | β |
| Critic pass model | review model | verify.model |
β | β |
| Auto-approve PRs | false |
approve.enabled |
β | β |
| Auto-approve change limit | 500 |
approve.max_changes |
β | β |
| Auto-approve severity gate | 3 |
approve.max_severity |
β | β |
| Auto-approve skip label | rubyrt-skip-approve |
approve.skip_label |
β | β |
| Auto-approve protected paths | [] |
approve.protected_paths |
β | β |
| Auto-approve dry run | false |
approve.dry_run |
β | β |
| Skill directories | .agents, .claude, .cursor |
skill_directories |
β | β |
| Auxiliary files | [] |
aux_files |
β | β |
| Language servers | none | lsp.<name> |
β | β |
Supported providers match whatever RubyLLM supports, including openai, anthropic, gemini, ollama, deepseek, openrouter, mistral, perplexity, xai, azure, bedrock, vertexai, and gpustack.
Example .rubyrt/config.toml:
provider = "openrouter"
model = "moonshotai/kimi-k2.6"
request_timeout = 60
log_file = "log/rubyrt.log"
log_level = "debug"
# Add extra files as context to every review prompt
aux_files = ["docs/conventions.md", ".rubyrt/style-guide.md"]
# Customize which directories are scanned for skill fragments
skill_directories = [".agents", ".github"]
# Only keep the most-confident findings (1) up to medium severity (3).
[post_process]
max_confidence = 1
max_severity = 3
# Re-check every surviving finding with a stronger model before reporting it.
[verify]
enabled = true
model = "anthropic/claude-opus-4.8"
# Give the LLM access to a language server for extra code context.
[lsp.ruby]
command = ["ruby-lsp"]
extensions = [".rb", ".rake"]RubyRT automatically discovers markdown files (.md) in skill directories and injects them as additional rules in every review prompt. By default it scans .agents/, .claude/, and .cursor/ in the project root. Override this with the skill_directories config key.
For individual files (rather than whole directories), use aux_files to list paths relative to the project root. Their contents are included as extra context in every review prompt:
aux_files = ["docs/coding-standards.md", "docs/security-rules.md"]The biggest source of noise in AI review is confident-but-wrong findings β claims that simply aren't true about the code. Tightening the confidence and severity thresholds only filters by the model's self-reported confidence, which it routinely over-states.
The [verify] critic pass attacks this directly. After the normal review and
threshold filtering, each surviving finding gets a second, fresh LLM call
framed adversarially: "try to refute this; uphold it only if you can point to
the lines that make it true; when uncertain, reject." The critic has the diff,
the full file, and (if configured) the LSP symbol tool, so it can confirm API
and method claims instead of trusting them. Findings it can't uphold are
dropped.
[verify]
enabled = true # on by default; set false to skip the pass
model = "anthropic/claude-opus-4.8" # optional: re-check on a stronger modelverify.enabledβ turn the critic pass on or off. Defaulttrue.verify.modelβ run the critic on a different (usually stronger) model than the review, using the same provider and API key. Leave it unset to reuse the review model. With OpenRouter the model slug is provider-prefixed (e.g.anthropic/claude-opus-4.8), so one key covers both the cheap review model and the strong critic.
Because the critic runs only on findings that survived filtering β not on every file β its cost scales with the number of findings, not the size of the diff. It also fails open: if a critic call errors or returns an unparseable verdict, the finding is kept and a processing warning is recorded, so a broken critic never silently swallows a real bug.
The model scores every finding on a 1β4 severity scale (1 = Critical) and a
1β4 confidence scale (1 = highest). post_process drops anything above your
thresholds before the critic pass runs:
[post_process]
max_confidence = 1 # keep only the model's highest-confidence findings
max_severity = 3 # keep Critical/High/Medium, drop LowLower numbers are stricter. An omitted threshold means "no limit". This is a cheap first filter; the critic pass is the precision filter on top of it.
When you configure a language server, RubyRT exposes a symbol-lookup tool to the LLM during both review and verification. The model uses it to confirm that a class, method, or constant actually exists (and how a third-party API is shaped) before reporting an "undefined" or "misused API" issue β a major source of hallucinated findings.
# Each [lsp.<name>] entry is launched in the reviewed project's root, so use
# that project's own LSP binary. Add more languages with more entries.
[lsp.ruby]
command = ["ruby-lsp"]
extensions = [".rb", ".rake"]A server is only started when the changeset contains a file whose extension
matches one of its extensions, so unrelated reviews don't pay the startup
cost. LSP is disabled entirely when no [lsp.*] entry is configured.
Set log_file to a file path to persist RubyLLM request logs for debugging. Set log_level to debug to see full request and response bodies. Valid levels are debug, info, warn, error, and fatal.
RubyRT uses structured output to ensure the LLM returns valid JSON. Not all models support this feature; see the RubyLLM available models page for a list of compatible models. For models that don't support structured output, RubyRT falls back to parsing the response as JSON from the prompt instructions.
bundle install
bundle exec rakeMIT. See LICENSE.txt.