9.5 KiB
Steep
Let your code steep.
Steep is a Gitea bot that performs automated AI-powered code reviews on pull requests using OpenRouter.
Features
- Listens for Gitea webhook events and triggers code reviews on pull request comments
- Streams reviews back to Gitea as PR comments
- Concurrent review processing with configurable parallelism
- Graceful shutdown — in-progress reviews finish before the process exits
- Error tracking via Sentry
- Prometheus metrics endpoint for monitoring
- Lightweight, performant Rust service with Prometheus metrics and Sentry error tracking
Installation
Requirements: Rust toolchain (rustup.rs)
cargo build --release -p steep-server
./target/release/steep-server
Steep reads its configuration from environment variables (a .env file is supported):
| Variable | Description |
|---|---|
HTTP_PORT |
Port to listen on |
WEBHOOK_SIG_HEADER_SECRET |
Gitea webhook secret for signature verification |
OPEN_ROUTER_API_KEY |
OpenRouter API key |
OPEN_ROUTER_MODEL |
Model to use (e.g. deepseek/deepseek-v4-flash) |
OPEN_ROUTER_TIMEOUT |
OpenRouter request timeout in seconds |
BOT_MAX_CONCURRENT |
Maximum number of concurrent reviews |
GITEA_URL |
Base URL of your Gitea instance |
GITEA_TOKEN |
Gitea API token |
GITEA_TIMEOUT |
Gitea API request timeout in seconds |
DATABASE_URL |
PostgreSQL connection URL used for the durable review queue (e.g. postgresql://user:pass@localhost:5432/steep) |
METRICS_BIND_ADDR |
(optional) Bind address for the Prometheus metrics endpoint (e.g. 0.0.0.0:9100). If unset, the metrics exporter is disabled. |
SENTRY_DSN |
(optional) Sentry DSN for error tracking |
RUST_LOG |
(optional) Log level, defaults to info |
SANDBOX_MAX_ITERATIONS |
(optional) Maximum number of tool-calling iterations per sandboxed review. Defaults to 8; a value that is set but is not a number stops the startup |
DOCKER_HOST |
(optional) Container daemon socket Steep drives, e.g. unix:///run/user/1000/podman/podman.sock for a rootless podman. Defaults to unix:///var/run/docker.sock |
Sandboxed reviews
Steep reviews pull requests inside an ephemeral Dev Container. For each review it:
- clones the pull request head into a temporary directory,
- builds and starts the repository's devcontainer (
devcontainer-rs) — or, when the repository has none, a default one based ondebian:stable-slim, - reads the pull request diff and file list from the Gitea API with
GITEA_TOKEN(so private repositories work), tells the model which files and lines changed — additions and deletions, with the line numbers of the new and old versions of the file respectively — then lets it explore the repository with read-only tools (ls,file_size,read_file,grep,find) run inside the container: the code itself is not sent, so the model reads it at those lines, - posts the review, anchoring each comment on the added or removed line it refers to, and removes the container and the temporary clone.
Generated files are left out of the changes handed to the model: lockfiles
(Cargo.lock, package-lock.json, yarn.lock, go.sum…) are machine-written
dependency churn whose thousands of lines would drown the code under review, and
they are never a place where a comment belongs.
Each comment is tagged with a severity — bug, security, performance or
maintainability — shown at the start of the comment, and the summary also lists
what the pull request does well.
Steep drives the container daemon through its socket: DOCKER_HOST (default
unix:///var/run/docker.sock), which covers both docker and podman's
Docker-compatible socket. When the repository contains a
.devcontainer/devcontainer.json (or .devcontainer.json), Steep uses it;
otherwise it falls back to a default devcontainer that pulls debian:stable-slim
and runs the review in /workspace.
Steep can therefore run inside a container with only that socket mounted (no
shared workspace directory is required): the clone is streamed to the daemon over
the socket, like the build context, instead of being bind-mounted from a host
path the daemon would have to see. This is the setup the Containerfile
produces, e.g.:
podman run --env-file=.env -p 3001:3001 \
-v /run/user/$(id -u)/podman/podman.sock:/var/run/docker.sock \
steep:latest
The runArgs of that file are ignored: they come from an untrusted pull request,
and one of them (--network host) would attach the container to another network
and quietly defeat the network cut described below. A devcontainer that relies on
them (--gpus all, --cap-add, --shm-size…) will not get them.
Each sandbox is isolated: it gets its own image tag, container and network. The
container starts with network access so the postCreateCommand /
postStartCommand hooks can install dependencies (e.g. npm install); once the
hooks have run, the container is disconnected from the network for the rest of
the review. Every container command is bounded by a timeout, and the container,
network and image are removed when the review ends (including on failure).
Migrations
The database schema is versioned with Toasty migrations, stored in crates/steep-server/toasty/ (commit them).
After changing a model in crates/steep-server/src/schemas/, generate a migration:
./scripts/migrate-generate.sh describe_change
Apply the pending migrations before starting the service — the server does not migrate at boot. The tests need the same schema: run this before cargo test too (CI does it as well):
./scripts/migrate-apply.sh
Both scripts load the .env file at the project root (so DATABASE_URL is picked up). Generation runs through cargo run -p steep-server --; applying uses the compiled steep-server binary when present (as in the image), falling back to cargo run otherwise.
The Containerfile does this at startup: its entrypoint runs scripts/migrate-apply.sh before exec'ing the server. The CLI reads the migration files from MIGRATIONS_DIR (defaults to the crate's toasty/ directory; the image sets it to /app/toasty).
Other subcommands: migration snapshot, migration drop, migration reset. They read DATABASE_URL.
Development
The easiest way to get started is with the provided Dev Container (VS Code or Zed with the dev container extension).
Open the project and reopen it in the container — the Rust toolchain is pre-installed, along with rootless podman, so sandboxed reviews can be exercised locally: start its API socket with podman system service --time=0 & and set DOCKER_HOST=unix:///run/user/$(id -u)/podman/podman.sock.
Without Dev Container, you just need a Rust toolchain:
rustup toolchain install stable
cargo run -p steep-server
Copy .env.example to .env and fill in your values before running.
Job queue
Steep is meant to run as a single instance. Webhooks are stored in the jobs table
of PostgreSQL, and the worker is woken up as soon as a job is pushed (no polling).
A job still processing at startup was interrupted by the previous process: it is put
back in the queue and reviewed again. Do not run two instances on the same database,
they would both take over each other's running jobs.
Dependencies
TLS is handled by rustls (with ring), so neither
the build nor the image needs OpenSSL. The Containerfile runtime image only
installs ca-certificates and git.
cargo deny check audits the dependencies (security advisories, licenses, banned
crates, sources) with the policy of deny.toml. It runs in the CI, and it also fails
if openssl-sys or native-tls comes back into the dependency tree.
Metrics
Steep optionally exposes a Prometheus metrics endpoint, useful for scraping with an OpenTelemetry Collector or any Prometheus-compatible scraper.
Set METRICS_BIND_ADDR (e.g. 0.0.0.0:9100) to enable it. The metrics are then available at http://<host>:9100/metrics. The metric namespace changed from herald_* to steep_*; update any dashboards or alerts that use the old names.
Exposed metrics
| Metric | Type | Description |
|---|---|---|
steep_webhooks_received_total |
counter | Total webhooks received (label: event_type) |
steep_webhooks_duplicate_total |
counter | Webhooks rejected as duplicates (label: event_type) |
steep_webhooks_enqueue_failed_total |
counter | Webhooks dropped because enqueueing to PostgreSQL failed (label: event_type) |
steep_bot_tasks_active |
gauge | Bot tasks currently in progress |
steep_bot_tasks_skipped_total |
counter | Reviews skipped because they were already done (label: event_type) |
steep_bot_tasks_completed_total |
counter | Bot tasks completed successfully (label: event_type) |
steep_bot_tasks_failed_total |
counter | Bot tasks that failed (label: event_type) |
steep_openrouter_cost_cents_total |
counter | Total OpenRouter cost in cents (divide by 100 for USD) |
OTel collector example
receivers:
prometheus:
config:
scrape_configs:
- job_name: steep
scrape_interval: 15s
static_configs:
- targets: ["steep:9100"]
service:
pipelines:
metrics:
receivers: [prometheus]
exporters: [otlp]
