Skip to main content

juan — self-hosted GitHub pull request reviewer powered by an LLM.

Project description

juan-contreras

A self-hosted pull request reviewer that connects your local machine or Raspberry Pi to GitHub and uses an LLM to review code. It is designed to be a lightweight, private alternative to cloud-hosted code review bots.

There are two ways to use it:

  • Part 1 - Server mode: run the reviewer as an HTTP server on an always-on host (Raspberry Pi, VPS, pythonanywhere). Friends can submit review requests to your server without ever seeing your LLM API key.
  • Part 2 - CLI mode: run the reviewer directly on your own computer when you have your API key available.

Both modes use the same review engine and prompt.


Requirements

  • Python 3.13 or newer
  • A GitHub personal access token with repo read access (classic) or Contents + Pull requests fine-grained permissions
  • An API key for one of the supported LLM providers (OpenAI, Anthropic, DeepSeek, GLM) — or the codex CLI for personal use

Installation

Pick the path that matches what you want to do.

Option 1 — pipx (CLI users, recommended)

pipx install juan-contreras       # or: pipx install 'juan-contreras[server]'
juan --version
juan config init                  # writes ./.env scaffold
juan doctor                       # validate env without spending tokens

pipx puts juan on your PATH in an isolated venv. The plain install gives you everything needed to run juan review / juan remote-review / juan history / juan doctor / juan config. The [server] extra is only needed if you also want to run juan serve from the same install.

Option 2 — uv tool install

uv tool install juan-contreras    # or: uv tool install 'juan-contreras[server]'
juan --version

Same shape as pipx but uses uv's tool sandbox. Run uv tool update juan-contreras to upgrade.

Option 3 — pip into a venv

python -m venv .venv && source .venv/bin/activate
pip install 'juan-contreras[server]'
juan --version

Use this when you want to run juan alongside other Python tooling in the same venv.

Option 4 — clone for development

If you want to modify juan (or contribute), clone the repo:

git clone https://github.com/karimpichara/juan-contreras.git
cd juan-contreras
make setup-dev    # uv sync + pre-commit hooks + scaffold .env
uv run juan --help

uv sync reads pyproject.toml, creates .venv/, and installs dependencies. You don't need to activate the venv manually — prefix commands with uv run or use Makefile targets (make help). See CONTRIBUTING.md for the full dev loop.

Configure your environment

After installing, scaffold a .env file and fill in your credentials:

juan config init       # writes ./.env from the bundled template (refuses to overwrite)
$EDITOR .env
juan doctor            # validates GITHUB_TOKEN, LLM_PROVIDER, API key, etc.

Minimum .env:

GITHUB_TOKEN=ghp_your_token_here
LLM_PROVIDER=openai
OPENAI_API_KEY=sk-your_key_here

juan searches for .env in ./ first (walking up from cwd), then $XDG_CONFIG_HOME/juan/.env (or ~/.config/juan/.env). Run juan config path to see exactly what's loaded.

For the full CLI reference — every flag, env var, exit code, JSON output schema, provider notes — see docs/CLI.md.


Part 1 - Server mode (Raspberry Pi or always-on host)

Setting up a fresh Raspberry Pi from scratch? Follow docs/RASPBERRY_PI_SETUP.md end-to-end. It covers the headless OS install, SSH hardening, uv, the systemd or Docker runtime, and a verification curl. The sections below are the reference docs — the Pi guide stitches them together.

Configuration

In addition to the GitHub and LLM settings, set a strong server API key. This is the only secret your friends need:

SERVER_API_KEY=change_me_to_a_long_random_secret
SERVER_HOST=0.0.0.0
SERVER_PORT=8000

# Optional: slowapi rate limit (per X-API-Key, fallback per-IP).
# Format: "<count>/<period>" where period is second|minute|hour|day.
RATE_LIMIT=30/minute

Starting the server

uv run juan serve

The serve command honours SERVER_HOST and SERVER_PORT from .env (and falls back to 0.0.0.0:8000). You can also pass --host / --port explicitly, or run uvicorn directly: uv run uvicorn server.app:app --host 0.0.0.0 --port 8000.

For production use, run it as a background service. Example systemd unit at /etc/systemd/system/juan.service:

[Unit]
Description=PR Reviewer
After=network.target

[Service]
User=pi
WorkingDirectory=/home/pi/juan-contreras
EnvironmentFile=/home/pi/juan-contreras/.env
# `uv run` resolves the project venv automatically. If `uv` is not on the
# system PATH for the service user, use the absolute path (e.g. /home/pi/.local/bin/uv).
ExecStart=/usr/bin/env uv run juan serve
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now juan
sudo journalctl -u juan -f      # tail logs

Exposing the server to the internet

Your Raspberry Pi is probably behind a home router. Choose one of the options below to make it reachable from anywhere.

Option A - ngrok (easiest, free tier available)

# Install: https://ngrok.com/download
ngrok http 8000
# ngrok prints a public URL like https://abc123.ngrok-free.app

Share that URL and your SERVER_API_KEY with friends.

Option B - Cloudflare Tunnel (free, stable URL)

# Install cloudflared: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/get-started/
cloudflared tunnel --url http://localhost:8000
# Cloudflare prints a *.trycloudflare.com URL

For a permanent subdomain, configure a named tunnel via the Cloudflare dashboard.

Option C - pythonanywhere

pythonanywhere supports always-on tasks and Flask/FastAPI apps. You can deploy this project directly:

  1. Upload the repository to pythonanywhere via git clone in a Bash console.
  2. Install uv and run uv sync (or make setup).
  3. Configure a web app pointing to server/app.py using the ASGI worker.
  4. Set environment variables in the pythonanywhere web app configuration.

See: https://help.pythonanywhere.com/pages/DeployingFastAPI

Option D - router port forwarding

Forward port 8000 on your router to the Raspberry Pi's local IP. Your public IP is then the address. Use a dynamic DNS service (e.g. DuckDNS, No-IP) if your ISP assigns a dynamic IP.

Option E - Docker / docker-compose (recommended for Pi)

The repo ships a multi-stage Dockerfile and docker-compose.yml (multi-arch, builds on a Pi 4/5 directly):

cp .env.example .env       # fill in the secrets
docker compose up -d --build
docker compose logs -f

Compose binds the container's port to 127.0.0.1:8000 only — put a reverse proxy (Caddy / Traefik / nginx) in front for HTTPS and public exposure. The SQLite database lives in ./data/ on the host and persists across restarts.

/metrics is exposed inside the container with no auth; in this default config it's only reachable from the host (loopback bind).

API reference

Health checks and history

GET /live    # liveness: 200 if the process is up
GET /ready   # readiness: 200 if GITHUB_TOKEN and the LLM key both work
GET /health  # alias of /live for backwards compatibility
GET /metrics # Prometheus scrape endpoint (bind to localhost in production)
GET /reviews # paginated review history (auth-protected)

CLI niceties

  • uv run juan review --repo owner/repo --pr 42 --post runs a review locally and pushes it back to the PR via GitHub's Review API. Comments mapped to a diff position are posted inline; the rest are folded into the review summary.
  • uv run juan history --repo owner/repo --pr 42 lists the persisted reviews from the local SQLite store. Pass --server/--server-key to query a remote juan instead.
  • Server-side, set POST_REVIEW_TO_PR=true in .env to make every /review call also push back to the PR (off by default).

/ready performs a lightweight authenticated probe against GitHub and the configured LLM provider. Results are cached for 30 seconds. A 503 response includes per-probe diagnostics:

{
  "ok": false,
  "probes": [
    {"name": "github", "ok": false, "detail": "GITHUB_TOKEN rejected (401)."},
    {"name": "openai", "ok": true,  "detail": "OK"}
  ]
}

All endpoints accept and echo an optional X-Request-ID header so you can correlate client logs with server logs. Server logs are emitted as one JSON object per line on stderr (visible via journalctl -u juan -f).

Request a review

POST /review
Headers: X-API-Key: <your SERVER_API_KEY>
Content-Type: application/json

{
  "repo": "owner/repo",
  "pr": 42,
  "fresh": false
}

Set "fresh": true to ignore stored history and always run a full new review.

Response:

{
  "pr_number": 42,
  "pr_title": "Add user authentication",
  "pr_author": "alice",
  "repo": "owner/repo",
  "overall_assessment": "needs_changes",
  "summary": "...",
  "categories_found": ["security", "missing_test"],
  "comments": [
    {
      "file": "auth/views.py",
      "line": 31,
      "category": "security",
      "severity": "high",
      "comment": "Password compared with == instead of a constant-time function."
    }
  ]
}

Example with curl

curl -s -X POST https://abc123.ngrok-free.app/review \
  -H "X-API-Key: change_me_to_a_long_random_secret" \
  -H "Content-Type: application/json" \
  -d '{"repo": "alice/myapp", "pr": 42}' | python -m json.tool

Part 2 - CLI mode (your own machine, your own API key)

Convention: the examples below show uv run juan ... because they assume a repo checkout (git clone). If you installed juan from PyPI via pipx / uv tool install / pip, drop the uv run prefix — the juan console script is on your PATH directly.

Environment setup

Quickest path:

juan config init       # scaffold ./.env
$EDITOR .env           # fill in GITHUB_TOKEN, LLM_PROVIDER, API key
juan doctor            # validate before your first review

Minimum .env:

GITHUB_TOKEN=ghp_your_token_here
LLM_PROVIDER=openai
OPENAI_API_KEY=sk-your_key_here

Commands

Review a PR locally

uv run juan review --repo owner/repo --pr 42

Override the model or provider:

uv run juan review --repo owner/repo --pr 42 --provider anthropic --model claude-3-haiku-20240307

Get JSON output (useful for scripting):

uv run juan review --repo owner/repo --pr 42 --output json

The JSON payload includes llm_provider, llm_model, and llm_usage (token counts from the provider). Text mode prints a one-line token summary after the review. Codex reports llm_usage: null (subprocess path has no usage API).

Set LLM_MODEL in .env to override the provider default without passing --model (ignored for codex).

Inspect prompts without calling the LLM (GitHub fetch only):

uv run juan review --repo owner/repo --pr 42 --dry-run
uv run juan review --repo owner/repo --pr 42 --dry-run -v   # prompt previews on stderr

Verbose mode (juan -v review ... or juan review -v ...): more logging, full traceback on errors, and truncated prompt previews on stderr after a review or dry run.

uv run juan --version

Pass credentials directly without a .env file:

uv run juan review \
  --repo owner/repo \
  --pr 42 \
  --github-token ghp_xxx \
  --api-key sk-xxx

Review a PR via the remote server

uv run juan remote-review \
  --server https://abc123.ngrok-free.app \
  --server-key change_me_to_a_long_random_secret \
  --repo owner/repo \
  --pr 42

Store server details in environment variables to avoid repeating them:

export REVIEWER_SERVER=https://abc123.ngrok-free.app
export REVIEWER_SERVER_KEY=change_me_to_a_long_random_secret

uv run juan remote-review --repo owner/repo --pr 42

Help

uv run juan --help
uv run juan review --help
uv run juan remote-review --help
uv run juan serve --help

CLI reference: docs/CLI.md.


Review categories

Category Description
bug Logic errors, null handling, off-by-one, incorrect conditions
security Injection, XSS, CSRF, hardcoded secrets, insecure auth
performance N+1 queries, unnecessary loops, memory leaks
race_condition Shared mutable state, missing locks, TOCTOU
missing_test New logic with no test coverage
nitpick Naming, formatting, minor style issues
design SOLID violations, poor abstractions, tight coupling

Severity levels: high, medium, low.


Supported LLM providers

Set LLM_PROVIDER in .env (see .env.example for notes on cost and Pi use).

Provider Default model API key env var
openai gpt-4o OPENAI_API_KEY
anthropic claude-3-5-sonnet-20241022 ANTHROPIC_API_KEY
deepseek deepseek-v4-flash DEEPSEEK_API_KEY
glm glm-4.5-air GLM_API_KEY
codex (ChatGPT subscription) none — local codex login

codex shells out to the Codex CLI and is for personal use only (not multi-user server exposure). See docs/RASPBERRY_PI_SETUP.md.

Set LLM_MODEL to override the default for your chosen provider.


Development

A Makefile wraps the usual uv commands — run make help for the list.

First time (dev machine):

make setup-dev    # uv sync + pre-commit hooks; creates .env if missing

Before pushing (same gates as CI):

make check        # ruff lint, format check, mypy, pytest
make fmt          # auto-format when check fails on format

Install hooks so you cannot forget (blocks git push on failure):

make install-hooks   # once per clone; also part of make setup-dev

Emergency bypass only: git push --no-verify.

Equivalent raw commands:

uv sync --all-groups
uv run pre-commit install
uv run pytest
uv run ruff check server reviewer tests
uv run ruff format --check server reviewer tests
uv run mypy server reviewer

CI runs the same gates on Python 3.13 (.github/workflows/ci.yml).

On a Raspberry Pi (runtime only): make setup, edit .env, then make serve or use Docker via make docker-up. See docs/RASPBERRY_PI_SETUP.md.


Deployment scenarios

Three layered guides, in increasing order of trust:

  1. docs/RASPBERRY_PI_SETUP.md — fresh Pi from a flashed SD card to a working POST /review. Headless OS install, SSH hardening, uv, systemd vs Docker.
  2. docs/DEPLOYMENT_SELFHOST.md — "anyone can self-host this safely". HTTPS via Caddy/Traefik, log retention, backups, locked-down GitHub token, blocked /metrics, secret rotation playbook.
  3. docs/DEPLOYMENT_TEAM.md — "small team / company". Per-user key strategy, audit-log shipping, IP allowlist via proxy, Prometheus cost alerts, on-call hand-off.

Read them in order; each one assumes the previous is in place.


Re-review (follow-up passes)

When SQLite history exists for a PR (local CLI or server JUAN_DB), a second review includes the last stored review in the prompt. The model returns:

  • prior_findings — each earlier comment with status: fixed, partially_fixed, not_addressed, or obsolete
  • comments — new issues only (or still-open high/medium items)

If the PR head commit is unchanged since the last review, the server skips the LLM call and returns {"skipped": true, ...} (daily budget is refunded). Force a full new review with:

  • CLI: uv run juan review --repo owner/repo --pr 42 --fresh
  • Server: "fresh": true in the POST /review JSON body

Env knobs (server): REVIEW_FOLLOW_UP, REVIEW_SKIP_UNCHANGED (both default to true). The bot still does not read comments from GitHub itself — only from local history. Posting to GitHub still creates a new review each time.


Limitations

  • Prior context comes from SQLite history, not from GitHub review threads.
  • No webhook automation yet — reviews are on-demand (CLI or HTTP). See TODO.md for planned work (webhooks, GitHub-thread sync, and more).

Security notes

  • The server refuses to start if SERVER_API_KEY, GITHUB_TOKEN, or the matching LLM key is missing or set to a known placeholder. All problems are reported in one go.
  • The SERVER_API_KEY is the only credential exposed to the outside world. Use a long, random string (e.g. openssl rand -hex 32).
  • API key comparison is constant-time (secrets.compare_digest) — bad keys can't be brute-forced via timing.
  • Your GitHub token and LLM API key never leave the server host.
  • The /review endpoint is rate-limited per X-API-Key (or per IP if no key is sent). Default 30/minute; override with RATE_LIMIT. Bad-key requests count too, so credential-stuffing is bounded.
  • A bounded semaphore caps simultaneous reviews (MAX_CONCURRENT_REVIEWS, default 2). Excess returns 503 with Retry-After: 5.
  • POST bodies on /review are capped at MAX_REQUEST_BODY_BYTES (default 4 KiB).
  • The GitHub /files pagination is capped at MAX_PR_FILE_PAGES (default 50, ~5000 files) to bound a worst-case PR's memory and time.
  • LLM calls have a 120s client-side timeout so a stuck upstream cannot hold a worker indefinitely.
  • Server errors are sanitized: upstream messages from GitHub / OpenAI / Anthropic are logged on the host but not returned to the client. Tail with journalctl -u juan -f to debug.
  • Consider restricting access further via IP allowlisting on your router or using a VPN (see TODO.md).
  • Keep your .env file out of version control (it is listed in .gitignore).

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

juan_contreras-0.3.0.tar.gz (195.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

juan_contreras-0.3.0-py3-none-any.whl (70.2 kB view details)

Uploaded Python 3

File details

Details for the file juan_contreras-0.3.0.tar.gz.

File metadata

  • Download URL: juan_contreras-0.3.0.tar.gz
  • Upload date:
  • Size: 195.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for juan_contreras-0.3.0.tar.gz
Algorithm Hash digest
SHA256 a72303a573327b04a451ba4b7fade368f48829bf213d13217cca0b432ebbc739
MD5 0d9e83e8b9662b8cf58255f06405204d
BLAKE2b-256 2dd9ea662d5b37af61bda3978e1d0d9346be737f73399879239b10b9a3c2fb77

See more details on using hashes here.

Provenance

The following attestation bundles were made for juan_contreras-0.3.0.tar.gz:

Publisher: release.yml on karimpichara/juan-contreras

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file juan_contreras-0.3.0-py3-none-any.whl.

File metadata

  • Download URL: juan_contreras-0.3.0-py3-none-any.whl
  • Upload date:
  • Size: 70.2 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for juan_contreras-0.3.0-py3-none-any.whl
Algorithm Hash digest
SHA256 6df4d7a4438252f57234c1c7c0d5fccb7cb91fe60d26d84ad2303f637c745ffe
MD5 9d4bd141e52c2b4988607a78a3acf0f8
BLAKE2b-256 54189c247a440dd14fdd0d044ba38e7e6d01bd297b3e560c6585e1e06b244560

See more details on using hashes here.

Provenance

The following attestation bundles were made for juan_contreras-0.3.0-py3-none-any.whl:

Publisher: release.yml on karimpichara/juan-contreras

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page