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
reporead access (classic) orContents+Pull requestsfine-grained permissions - An API key for one of the supported LLM providers (OpenAI, Anthropic, DeepSeek, GLM) — or the
codexCLI 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.mdend-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:
- Upload the repository to pythonanywhere via
git clonein a Bash console. - Install uv and run
uv sync(ormake setup). - Configure a web app pointing to
server/app.pyusing the ASGI worker. - 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 --postruns 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 42lists the persisted reviews from the local SQLite store. Pass--server/--server-keyto query a remote juan instead.- Server-side, set
POST_REVIEW_TO_PR=truein.envto make every/reviewcall 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 installedjuanfrom PyPI viapipx/uv tool install/pip, drop theuv runprefix — thejuanconsole 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:
- 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. - 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. - 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 withstatus:fixed,partially_fixed,not_addressed, orobsoletecomments— 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": truein thePOST /reviewJSON 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_KEYis 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
/reviewendpoint is rate-limited perX-API-Key(or per IP if no key is sent). Default30/minute; override withRATE_LIMIT. Bad-key requests count too, so credential-stuffing is bounded. - A bounded semaphore caps simultaneous reviews (
MAX_CONCURRENT_REVIEWS, default 2). Excess returns503withRetry-After: 5. - POST bodies on
/revieware capped atMAX_REQUEST_BODY_BYTES(default 4 KiB). - The GitHub
/filespagination is capped atMAX_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 -fto debug. - Consider restricting access further via IP allowlisting on your router or using a VPN (see TODO.md).
- Keep your
.envfile out of version control (it is listed in.gitignore).
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a72303a573327b04a451ba4b7fade368f48829bf213d13217cca0b432ebbc739
|
|
| MD5 |
0d9e83e8b9662b8cf58255f06405204d
|
|
| BLAKE2b-256 |
2dd9ea662d5b37af61bda3978e1d0d9346be737f73399879239b10b9a3c2fb77
|
Provenance
The following attestation bundles were made for juan_contreras-0.3.0.tar.gz:
Publisher:
release.yml on karimpichara/juan-contreras
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
juan_contreras-0.3.0.tar.gz -
Subject digest:
a72303a573327b04a451ba4b7fade368f48829bf213d13217cca0b432ebbc739 - Sigstore transparency entry: 1740351756
- Sigstore integration time:
-
Permalink:
karimpichara/juan-contreras@d56d41499f71a2df710477ca77b6cbc1684f97ee -
Branch / Tag:
refs/tags/v0.3.0 - Owner: https://github.com/karimpichara
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@d56d41499f71a2df710477ca77b6cbc1684f97ee -
Trigger Event:
push
-
Statement type:
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6df4d7a4438252f57234c1c7c0d5fccb7cb91fe60d26d84ad2303f637c745ffe
|
|
| MD5 |
9d4bd141e52c2b4988607a78a3acf0f8
|
|
| BLAKE2b-256 |
54189c247a440dd14fdd0d044ba38e7e6d01bd297b3e560c6585e1e06b244560
|
Provenance
The following attestation bundles were made for juan_contreras-0.3.0-py3-none-any.whl:
Publisher:
release.yml on karimpichara/juan-contreras
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
juan_contreras-0.3.0-py3-none-any.whl -
Subject digest:
6df4d7a4438252f57234c1c7c0d5fccb7cb91fe60d26d84ad2303f637c745ffe - Sigstore transparency entry: 1740351765
- Sigstore integration time:
-
Permalink:
karimpichara/juan-contreras@d56d41499f71a2df710477ca77b6cbc1684f97ee -
Branch / Tag:
refs/tags/v0.3.0 - Owner: https://github.com/karimpichara
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@d56d41499f71a2df710477ca77b6cbc1684f97ee -
Trigger Event:
push
-
Statement type: