Postgres persistance + job queue + api with sse #9

Merged
qpismont merged 8 commits from 1.3 into main 2026-10-26 21:19:32 +01:00
72 changed files with 8144 additions and 2010 deletions

No files matched your search

+2 -2
View File
@@ -1,4 +1,4 @@
FROM rust:1.98-trixie FROM rust:1.99-trixie
ARG USERNAME=dev ARG USERNAME=dev
ARG USER_UID=1000 ARG USER_UID=1000
@@ -11,10 +11,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
curl \ curl \
git \ git \
build-essential \ build-essential \
libssl-dev \
cmake \ cmake \
pkg-config \ pkg-config \
clang \ clang \
postgresql \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
RUN groupadd --gid ${USER_GID:-1000} $USERNAME \ RUN groupadd --gid ${USER_GID:-1000} $USERNAME \
+3
View File
@@ -19,6 +19,9 @@
}, },
"workspaceMount": "source=${localWorkspaceFolder},target=/workspaces/steep,type=bind", "workspaceMount": "source=${localWorkspaceFolder},target=/workspaces/steep,type=bind",
"workspaceFolder": "/workspaces/steep", "workspaceFolder": "/workspaces/steep",
"mounts": [
"source=/run/user/1000/podman/podman.sock,target=/var/run/docker.sock,type=bind"
Review

[maintainability] Le socket podman est monté avec le chemin en dur /run/user/1000/podman/podman.sock. Le devcontainer ne démarre pas pour un utilisateur dont l'UID n'est pas 1000, alors que le build utilise ${localEnv:UID}. De plus, si le socket n'existe pas, le montage fait échouer l'ouverture du conteneur. Il faut utiliser ${localEnv:XDG_RUNTIME_DIR}/podman/podman.sock.

**[maintainability]** Le socket podman est monté avec le chemin en dur `/run/user/1000/podman/podman.sock`. Le devcontainer ne démarre pas pour un utilisateur dont l'UID n'est pas 1000, alors que le build utilise `${localEnv:UID}`. De plus, si le socket n'existe pas, le montage fait échouer l'ouverture du conteneur. Il faut utiliser `${localEnv:XDG_RUNTIME_DIR}/podman/podman.sock`.
],
"runArgs": ["--userns=keep-id", "--security-opt", "label=disable"], "runArgs": ["--userns=keep-id", "--security-opt", "label=disable"],
"appPort": [3000] "appPort": [3000]
} }
+2
View File
@@ -22,3 +22,5 @@ METRICS_BIND_ADDR=
# Sandboxed tool execution # Sandboxed tool execution
# DOCKER_HOST= # DOCKER_HOST=
SANDBOX_MAX_ITERATIONS=8 SANDBOX_MAX_ITERATIONS=8
DATABASE_URL=
+2 -1
View File
@@ -10,8 +10,9 @@ steps:
- /data/woodpecker-builds:/data - /data/woodpecker-builds:/data
commands: commands:
- echo $DOCKER_PASSWORD | buildah login docker.io -u $DOCKER_USERNAME --password-stdin - echo $DOCKER_PASSWORD | buildah login docker.io -u $DOCKER_USERNAME --password-stdin
- chmod +x scripts/build.sh - chmod +x scripts/build.sh scripts/build-website.sh
- bash scripts/build.sh - bash scripts/build.sh
- bash scripts/build-website.sh
environment: environment:
DOCKER_USERNAME: DOCKER_USERNAME:
from_secret: docker_username from_secret: docker_username
+27 -5
View File
@@ -2,23 +2,45 @@ when:
event: event:
- push - push
services:
- name: postgres
image: postgres:18
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: steep
steps: steps:
- name: fmt - name: fmt
image: rust:1.98 image: rust:1.99
commands: commands:
- rustup component add rustfmt - rustup component add rustfmt
- cargo fmt --all -- --check - cargo fmt --all -- --check
- name: clippy - name: clippy
image: rust:1.98 image: rust:1.99
commands: commands:
- rustup component add clippy - rustup component add clippy
- cargo clippy --workspace --all-targets --all-features -- -D warnings - cargo clippy --workspace --all-targets --all-features -- -D warnings
- name: test - name: deny
image: rust:1.98 image: rust:1.99
commands: commands:
- cargo test --workspace --all-targets - cargo install cargo-deny --locked --version 0.18.9
Review

[performance] cargo install cargo-deny --locked recompile l'outil à chaque pipeline, ce qui ajoute plusieurs minutes de CI. Il vaut mieux utiliser une image préconstruite (ghcr.io/embarkstudios/cargo-deny) ou un binaire précompilé. Par ailleurs les étapes CI utilisent rust:1.99 alors que le Containerfile build avec rust:1.98-trixie : les versions de toolchain divergent.

**[performance]** `cargo install cargo-deny --locked` recompile l'outil à chaque pipeline, ce qui ajoute plusieurs minutes de CI. Il vaut mieux utiliser une image préconstruite (`ghcr.io/embarkstudios/cargo-deny`) ou un binaire précompilé. Par ailleurs les étapes CI utilisent `rust:1.99` alors que le `Containerfile` build avec `rust:1.98-trixie` : les versions de toolchain divergent.
- cargo deny check
- name: wait-for-postgres
image: postgres:18
commands:
- until pg_isready -h postgres -U postgres; do sleep 1; done
- name: test
image: rust:1.99
environment:
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/steep
commands:
- bash scripts/migrate-apply.sh
- cargo test --workspace --all-targets -- --include-ignored
- name: container-build - name: container-build
image: quay.io/buildah/stable image: quay.io/buildah/stable
Generated
+3128 -278
View File
File diff suppressed because it is too large. Load diff
+26 -3
View File
@@ -6,18 +6,35 @@ members = [
resolver = "3" resolver = "3"
[workspace.dependencies] [workspace.dependencies]
reqwest = { version = "0.13", default-features = false, features = ["json", "native-tls", "stream"] } # TLS is pure Rust (rustls + ring): no libssl is needed at build or run time.
# `rustls-no-provider` leaves the crypto provider to the binary, which installs
# `ring` at startup (see `main`), so that a single provider is ever compiled in.
reqwest = { version = "0.13", default-features = false, features = ["json", "rustls-no-provider", "stream"] }
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }
tokio = { version = "1.53", features = ["full"] } tokio = { version = "1.53", features = ["full"] }
tokio-stream = "0.1" tokio-stream = "0.1"
tokio-util = "0.7" tokio-util = "0.7"
futures-util = "0.3" futures-util = "0.3"
serde_json = "1.0" serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
sentry = { version = "0.49", features = ["tower-axum-matched-path"] } # Sentry's default `transport` feature pulls `native-tls`; the same set of features
# is spelled out here with rustls instead.
sentry = { version = "0.49", default-features = false, features = [
"backtrace",
"contexts",
"debug-images",
"logs",
"metrics",
"panic",
"release-health",
"reqwest",
"rustls-no-provider",
"tower-axum-matched-path",
] }
sentry-anyhow = { version = "0.49", features = ["backtrace"] } sentry-anyhow = { version = "0.49", features = ["backtrace"] }
dotenvy = "0.15" dotenvy = "0.15"
tower = "0.5" tower = "0.5"
tower-http = { version = "0.7", features = ["trace"] } tower-http = { version = "0.7", features = ["trace", "fs"] }
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
axum = "0.8" axum = "0.8"
@@ -28,6 +45,12 @@ hex = "0.4"
bytes = "1.1" bytes = "1.1"
metrics = "0.24" metrics = "0.24"
metrics-exporter-prometheus = { version = "0.18", default-features = false, features = ["http-listener"] } metrics-exporter-prometheus = { version = "0.18", default-features = false, features = ["http-listener"] }
toasty = { version = "0.11", features = ["postgresql", "serde", "jiff"] }
toasty-cli = "0.11"
jiff = { version = "0.2", features = ["serde"] }
tempfile = "3"
bollard = "0.21"
tar = "0.4"
[profile.release] [profile.release]
debug = 1 debug = 1
+11 -11
View File
@@ -1,4 +1,4 @@
FROM rust:1.98-trixie as builder FROM rust:1.99-trixie as builder
WORKDIR /app WORKDIR /app
@@ -10,22 +10,22 @@ RUN cargo build --release --package steep-server
FROM debian:trixie-slim FROM debian:trixie-slim
# git clones the pull request. ca-certificates is what every HTTPS call needs
# (Gitea, OpenRouter, git). The shared libraries are the non-base ones the binary
# links against, as reported by `ldd target/release/steep-server`; libc, libm
# and libgcc_s come from the base image.
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \ ca-certificates \
git \ git \
libssl3t64 \
libzstd1 \
zlib1g \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
COPY --from=builder /app/target/release/steep-server ./steep-server COPY --from=builder /app/target/release/steep-server ./steep-server
# Exec form, so the binary is PID 1 and receives the SIGTERM it handles to shut COPY front ./front
# down gracefully.
CMD ["./steep-server"] COPY crates/steep-server/toasty ./toasty
ENV MIGRATIONS_DIR=/app/toasty
COPY scripts/migrate-apply.sh ./scripts/migrate-apply.sh
RUN chmod +x ./scripts/migrate-apply.sh
CMD ["sh", "-c", "./scripts/migrate-apply.sh && exec ./steep-server"]
+45 -3
View File
@@ -1,4 +1,4 @@
![Steep — Let your code steep](banniere_readme.png) ![Steep — Let your code steep](public/banniere_readme.png)
# Steep # Steep
@@ -38,10 +38,11 @@ Steep reads its configuration from environment variables (a `.env` file is suppo
| `GITEA_URL` | Base URL of your Gitea instance | | `GITEA_URL` | Base URL of your Gitea instance |
| `GITEA_TOKEN` | Gitea API token | | `GITEA_TOKEN` | Gitea API token |
| `GITEA_TIMEOUT` | Gitea API request timeout in seconds | | `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. | | `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 | | `SENTRY_DSN` | *(optional)* Sentry DSN for error tracking |
| `RUST_LOG` | *(optional)* Log level, defaults to `info` | | `RUST_LOG` | *(optional)* Log level, defaults to `info` |
| `SANDBOX_MAX_ITERATIONS` | *(optional)* Maximum number of tool-calling iterations per sandboxed review. Defaults to `8` | | `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` | | `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 ## Sandboxed reviews
@@ -101,6 +102,28 @@ 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, 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). network and image are removed when the review ends (including on failure).
## Migrations
The database schema is versioned with [Toasty](https://github.com/tokio-rs/toasty) migrations, stored in `crates/steep-server/toasty/` (commit them).
After changing a model in `crates/steep-server/src/schemas/`, generate a migration:
```sh
./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):
```sh
./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 ## Development
The easiest way to get started is with the provided [Dev Container](https://containers.dev/) (VS Code or Zed with the dev container extension). The easiest way to get started is with the provided [Dev Container](https://containers.dev/) (VS Code or Zed with the dev container extension).
@@ -116,6 +139,24 @@ cargo run -p steep-server
Copy `.env.example` to `.env` and fill in your values before running. 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](https://github.com/rustls/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 ## Metrics
Steep optionally exposes a Prometheus metrics endpoint, useful for scraping with an [OpenTelemetry Collector](https://opentelemetry.io/docs/collector/) or any Prometheus-compatible scraper. Steep optionally exposes a Prometheus metrics endpoint, useful for scraping with an [OpenTelemetry Collector](https://opentelemetry.io/docs/collector/) or any Prometheus-compatible scraper.
@@ -128,8 +169,9 @@ Set `METRICS_BIND_ADDR` (e.g. `0.0.0.0:9100`) to enable it. The metrics are then
|---|---|---| |---|---|---|
| `steep_webhooks_received_total` | counter | Total webhooks received (label: `event_type`) | | `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_duplicate_total` | counter | Webhooks rejected as duplicates (label: `event_type`) |
| `steep_webhooks_channel_full_total` | counter | Webhooks dropped because the bot channel was full (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_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_completed_total` | counter | Bot tasks completed successfully (label: `event_type`) |
| `steep_bot_tasks_failed_total` | counter | Bot tasks that failed (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) | | `steep_openrouter_cost_cents_total` | counter | Total OpenRouter cost in cents (divide by 100 for USD) |
+18 -6
View File
@@ -11,15 +11,22 @@
- If a repository has no Dev Container configuration, run its review in a default container based on `debian:stable-slim`. - If a repository has no Dev Container configuration, run its review in a default container based on `debian:stable-slim`.
- Use `/workspace` as the workspace path for the default container and as the fallback when `workspaceFolder` is not specified. - Use `/workspace` as the workspace path for the default container and as the fallback when `workspaceFolder` is not specified.
## 1.3 — Typed action routing — planned ## 1.3 — Durable jobs & resumption — planned
- Persist review jobs and their progress in PostgreSQL so in-flight work survives a restart and can be resumed instead of being lost.
- Expose an API to retrieve the list of jobs by state: running, pending, and review done.
- Stream job events over Server-Sent Events (SSE) so clients see status changes in real time.
## 1.4 — YAML configuration — planned
- Add a YAML configuration file as the primary way to configure Steep, replacing part of the current environment variables.
- Move non-secret settings such as the model, timeouts, concurrency limit, and sandbox iterations into the file, and keep secrets (API keys, tokens, webhook secret, database URL) in environment variables.
## 2.0 — Typed action routing and implementation requests — planned
- Introduce a type-safe model output for the action Steep should take, such as `Review` or `Implementation`. - Introduce a type-safe model output for the action Steep should take, such as `Review` or `Implementation`.
- Route review requests (for example, “@steep, review this PR”) to the existing review flow. - Route review requests (for example, “@steep, review this PR”) to the existing review flow.
- Recognize implementation requests (for example, “@steep, rename method `XXX` to `YYY`”) and establish their dispatch path. Execution of implementation requests is planned for 2.0. - Recognize implementation requests (for example, “@steep, rename method `XXX` to `YYY`”) and route them to the implementation workflow.
## 2.0 — Implementation requests — planned
- Use the typed action from 1.3 to route explicit implementation requests to an implementation workflow.
- For a request on an issue, clone the repository from its default branch and implement the requested features on a new branch. - For a request on an issue, clone the repository from its default branch and implement the requested features on a new branch.
- For a request on an existing pull request, clone its branch and implement the requested modifications there. - For a request on an existing pull request, clone its branch and implement the requested modifications there.
- When the agent's iteration loop ends, the orchestration layer automatically pushes the resulting changes: it opens a pull request for issue-based work, or updates the existing pull request branch. The agent does not receive or invoke a push tool. - When the agent's iteration loop ends, the orchestration layer automatically pushes the resulting changes: it opens a pull request for issue-based work, or updates the existing pull request branch. The agent does not receive or invoke a push tool.
@@ -31,6 +38,11 @@
- Allow configuring a dedicated model for pull request reviews and another for implementation requests. - Allow configuring a dedicated model for pull request reviews and another for implementation requests.
- Route each action type to its configured model, with a clear fallback when only one model is configured. - Route each action type to its configured model, with a clear fallback when only one model is configured.
## 2.2 — Sub-agents — planned
- Let the agent delegate well-scoped parts of a task to sub-agents that run their own iteration loop.
- Run independent sub-agents in parallel and have the main agent consolidate their results.
## 3.0 — Autonomous Sentry fixes — planned ## 3.0 — Autonomous Sentry fixes — planned
- Automatically discover project errors reported by Sentry, without waiting for a user comment. - Automatically discover project errors reported by Sentry, without waiting for a user comment.
+4 -3
View File
@@ -2,12 +2,13 @@
name = "devcontainer-rs" name = "devcontainer-rs"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"
publish = false
[dependencies] [dependencies]
bollard = "0.21" bollard = { workspace = true }
bytes = { workspace = true } bytes = { workspace = true }
futures-util = { workspace = true } futures-util = { workspace = true }
tar = "0.4" tar = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
tokio-stream = { workspace = true } tokio-stream = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
@@ -17,4 +18,4 @@ anyhow = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
[dev-dependencies] [dev-dependencies]
tempfile = "3" tempfile = { workspace = true }
+8 -9
View File
@@ -1,25 +1,24 @@
//! Constantes de la crate : endpoint du daemon, timeouts et découpage du //! Crate constants: daemon endpoint, timeouts and build context chunking.
//! contexte de build.
use std::time::Duration; use std::time::Duration;
/// Endpoint affiché dans les logs quand `DOCKER_HOST` n'est pas défini. /// Endpoint shown in the logs when `DOCKER_HOST` is not set.
pub(crate) const DEFAULT_ENDPOINT: &str = "unix:///var/run/docker.sock"; pub(crate) const DEFAULT_ENDPOINT: &str = "unix:///var/run/docker.sock";
/// Timeout appliqué aux opérations de build/run/stop/remove. /// Timeout applied to build/run/stop/remove operations.
pub(crate) const DEFAULT_COMMAND_TIMEOUT: Duration = Duration::from_secs(600); pub(crate) const DEFAULT_COMMAND_TIMEOUT: Duration = Duration::from_secs(600);
/// Timeout appliqué aux commandes exécutées dans un container en cours d'exécution. /// Timeout applied to commands run inside a running container.
pub(crate) const DEFAULT_EXEC_TIMEOUT: Duration = Duration::from_secs(60); pub(crate) const DEFAULT_EXEC_TIMEOUT: Duration = Duration::from_secs(60);
/// Taille des morceaux du contexte de build envoyés au daemon. /// Size of the build context chunks sent to the daemon.
pub(crate) const CONTEXT_CHUNK_SIZE: usize = 64 * 1024; pub(crate) const CONTEXT_CHUNK_SIZE: usize = 64 * 1024;
/// Nombre de morceaux pouvant attendre dans le canal : c'est ce qui borne la /// Number of chunks that can wait in the channel: this is what bounds the
/// mémoire occupée par le contexte, quelle que soit sa taille. /// memory held by the context, regardless of its size.
pub(crate) const CONTEXT_CHUNKS: usize = 4; pub(crate) const CONTEXT_CHUNKS: usize = 4;
/// Lectures successives du code de sortie d'un exec, et attente entre elles. /// Successive reads of an exec's exit code, and the delay between them.
pub(crate) const EXIT_CODE_ATTEMPTS: usize = 10; pub(crate) const EXIT_CODE_ATTEMPTS: usize = 10;
pub(crate) const EXIT_CODE_DELAY: Duration = Duration::from_millis(20); pub(crate) const EXIT_CODE_DELAY: Duration = Duration::from_millis(20);
+11 -11
View File
@@ -1,4 +1,4 @@
//! Un devcontainer en cours d'exécution : inspection et exécution de commandes. //! A running devcontainer: inspection and command execution.
use std::time::Duration; use std::time::Duration;
@@ -7,7 +7,7 @@ use crate::{
runtime::ContainerRuntime, runtime::ContainerRuntime,
}; };
/// Un devcontainer en cours d'exécution. /// A running devcontainer.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Container { pub struct Container {
runtime: ContainerRuntime, runtime: ContainerRuntime,
@@ -19,8 +19,8 @@ pub struct Container {
} }
impl Container { impl Container {
/// Assemble un container démarré, avec son réseau et, le cas échéant, l'image /// Assembles a started container, with its network and, if any, the image
/// construite pour lui et à nettoyer. /// built for it that must be cleaned up.
pub(crate) fn new( pub(crate) fn new(
runtime: ContainerRuntime, runtime: ContainerRuntime,
name: String, name: String,
@@ -47,10 +47,10 @@ impl Container {
&self.workspace_folder &self.workspace_folder
} }
/// Exécute une commande dans le container et renvoie sa sortie. /// Runs a command in the container and returns its output.
/// ///
/// La commande est transmise sous forme de vecteur d'arguments (argv, sans shell), /// The command is passed as an argument vector (argv, no shell), so no
/// donc aucun échappement ni interpolation n'est effectué. /// escaping or interpolation is performed.
pub async fn exec(&self, cmd: &[&str]) -> Result<ExecOutput, ContainerError> { pub async fn exec(&self, cmd: &[&str]) -> Result<ExecOutput, ContainerError> {
self.exec_with_timeout(cmd, DEFAULT_EXEC_TIMEOUT).await self.exec_with_timeout(cmd, DEFAULT_EXEC_TIMEOUT).await
} }
@@ -65,19 +65,19 @@ impl Container {
.await .await
} }
/// Exécute un script shell dans le container via `sh -c`. /// Runs a shell script in the container via `sh -c`.
pub async fn exec_shell(&self, script: &str) -> Result<ExecOutput, ContainerError> { pub async fn exec_shell(&self, script: &str) -> Result<ExecOutput, ContainerError> {
self.exec(&["sh", "-c", script]).await self.exec(&["sh", "-c", script]).await
} }
/// Arrête le container. /// Stops the container.
pub async fn stop(&self) -> Result<(), ContainerError> { pub async fn stop(&self) -> Result<(), ContainerError> {
self.runtime.stop_container(&self.name).await self.runtime.stop_container(&self.name).await
} }
/// Supprime le container, son réseau et son image. /// Removes the container, its network and its image.
/// ///
/// La suppression du réseau et de l'image est best-effort : ils peuvent déjà être absents. /// Removing the network and the image is best-effort: they may already be gone.
pub async fn remove(&self) -> Result<(), ContainerError> { pub async fn remove(&self) -> Result<(), ContainerError> {
self.runtime.remove_container(&self.name).await?; self.runtime.remove_container(&self.name).await?;
+14 -14
View File
@@ -1,4 +1,4 @@
//! Empaquetage en flux du contexte de build envoyé au daemon. //! Streaming packaging of the build context sent to the daemon.
use std::{ use std::{
io::{BufWriter, Write}, io::{BufWriter, Write},
@@ -12,13 +12,13 @@ use tokio_stream::wrappers::ReceiverStream;
use crate::consts::{CONTEXT_CHUNK_SIZE, CONTEXT_CHUNKS}; use crate::consts::{CONTEXT_CHUNK_SIZE, CONTEXT_CHUNKS};
/// Empaquette le contexte de build dans un tar, **en flux**. /// Packs the build context into a tar, **as a stream**.
/// ///
/// Le CLI `docker build` empaquette le contexte puis l'envoie ; le faire d'un coup /// The `docker build` CLI packs the context and then sends it; doing that in one go
/// chargerait tout le dossier en mémoire, ce qui devient intenable dès que le /// would load the whole folder into memory, which becomes untenable as soon as the
/// `.devcontainer` embarque des binaires. Ici l'archive est écrite par une tâche /// `.devcontainer` ships binaries. Here the archive is written by a blocking task
/// bloquante et poussée morceau par morceau, avec la contre-pression du canal : /// and pushed chunk by chunk, with the channel's backpressure: if the daemon reads
/// si le daemon lit lentement, l'empaquetage ralentit. /// slowly, packing slows down.
pub(crate) fn tar_directory_stream( pub(crate) fn tar_directory_stream(
dir: &Path, dir: &Path,
) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send + 'static { ) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send + 'static {
@@ -33,13 +33,13 @@ pub(crate) fn tar_directory_stream(
let result = builder let result = builder
.append_dir_all(".", &dir) .append_dir_all(".", &dir)
.and_then(|()| builder.finish()) .and_then(|()| builder.finish())
// `finish` écrit les blocs de fin d'archive ; il reste à vider le tampon // `finish` writes the end-of-archive blocks; the buffer still has to be
// pour que le daemon reçoive tout, y compris une archive vide. // flushed so the daemon receives everything, even an empty archive.
.and_then(|()| builder.get_mut().flush()); .and_then(|()| builder.get_mut().flush());
if let Err(error) = result { if let Err(error) = result {
// L'échec est remonté au daemon par le flux : sinon il ne verrait qu'un // The failure is reported to the daemon through the stream: otherwise it
// tar tronqué et signalerait une erreur incompréhensible. // would only see a truncated tar and report a confusing error.
let _ = failures.blocking_send(Err(error)); let _ = failures.blocking_send(Err(error));
} }
}); });
@@ -47,9 +47,9 @@ pub(crate) fn tar_directory_stream(
ReceiverStream::new(receiver) ReceiverStream::new(receiver)
} }
/// Écrit les morceaux du tar dans un canal, en attendant qu'il se vide. /// Writes the tar chunks into a channel, waiting for it to drain.
/// ///
/// Utilisé depuis une tâche bloquante, seul contexte où `blocking_send` est autorisé. /// Used from a blocking task, the only context where `blocking_send` is allowed.
struct ChannelWriter { struct ChannelWriter {
sender: mpsc::Sender<Result<Bytes, std::io::Error>>, sender: mpsc::Sender<Result<Bytes, std::io::Error>>,
} }
@@ -80,7 +80,7 @@ mod tests {
use futures_util::StreamExt; use futures_util::StreamExt;
/// Rassemble le flux du contexte en un tar complet, pour l'inspecter. /// Collects the context stream into a complete tar, for inspection.
async fn context_tar(dir: &Path) -> Vec<u8> { async fn context_tar(dir: &Path) -> Vec<u8> {
let mut tar = Vec::new(); let mut tar = Vec::new();
let mut chunks = tar_directory_stream(dir); let mut chunks = tar_directory_stream(dir);
+9 -9
View File
@@ -1,5 +1,5 @@
//! Représentation analysée d'un `devcontainer.json` et son cycle de vie : //! Parsed representation of a `devcontainer.json` and its lifecycle: naming,
//! nommage, build, démarrage et hooks. //! build, startup and hooks.
use std::{ use std::{
collections::HashMap, collections::HashMap,
@@ -29,29 +29,29 @@ pub struct DevContainer {
} }
impl DevContainer { impl DevContainer {
/// Dossier de workspace dans le container, par défaut `/workspace`. /// Workspace folder inside the container, `/workspace` by default.
pub fn workspace_folder(&self) -> String { pub fn workspace_folder(&self) -> String {
self.workspace_folder self.workspace_folder
.clone() .clone()
.unwrap_or_else(|| "/workspace".to_string()) .unwrap_or_else(|| "/workspace".to_string())
} }
/// Tag de l'image construite localement à partir du Dockerfile. /// Tag of the image built locally from the Dockerfile.
fn local_image_tag(&self, id: &str) -> String { fn local_image_tag(&self, id: &str) -> String {
format!("devcontainer-rs/{}:{}", self.sanitized_name(), id) format!("devcontainer-rs/{}:{}", self.sanitized_name(), id)
} }
/// Nom du container de cette sandbox. /// Name of this sandbox's container.
fn container_name(&self, id: &str) -> String { fn container_name(&self, id: &str) -> String {
format!("devcontainer-rs-{}-{}", self.sanitized_name(), id) format!("devcontainer-rs-{}-{}", self.sanitized_name(), id)
} }
/// Nom du devcontainer nettoyé pour servir de nom d'image/container. /// Devcontainer name, sanitized to be usable as an image/container name.
fn sanitized_name(&self) -> String { fn sanitized_name(&self) -> String {
sanitize(self.name.as_deref().unwrap_or("devcontainer")) sanitize(self.name.as_deref().unwrap_or("devcontainer"))
} }
/// Construit l'image devcontainer sous `image_tag`. /// Builds the devcontainer image as `image_tag`.
async fn build( async fn build(
&self, &self,
runtime: &ContainerRuntime, runtime: &ContainerRuntime,
@@ -239,7 +239,7 @@ impl Default for DevContainer {
} }
} }
/// Nettoie une chaîne pour qu'elle puisse servir de nom d'image/container docker. /// Sanitizes a string so it can be used as a docker image/container name.
fn sanitize(input: &str) -> String { fn sanitize(input: &str) -> String {
let sanitized: String = input let sanitized: String = input
.chars() .chars()
@@ -260,7 +260,7 @@ fn sanitize(input: &str) -> String {
} }
} }
/// Identifiant unique à une exécution de sandbox, combinant l'identifiant de processus et un horodatage. /// Identifier unique to a sandbox run, combining the process id and a timestamp.
fn unique_id() -> String { fn unique_id() -> String {
let nanos = SystemTime::now() let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)
+5 -6
View File
@@ -1,5 +1,4 @@
//! Erreurs de la crate : échecs du daemon de containers et échecs d'analyse du //! Crate errors: container daemon failures and `devcontainer.json` parsing failures.
//! `devcontainer.json`.
use std::{path::PathBuf, time::Duration}; use std::{path::PathBuf, time::Duration};
@@ -7,25 +6,25 @@ use bollard::errors::Error as BollardError;
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum ContainerError { pub enum ContainerError {
/// Le daemon est injoignable, ou a refusé une requête. /// The daemon is unreachable, or rejected a request.
#[error("container daemon request failed: {source}")] #[error("container daemon request failed: {source}")]
Request { Request {
#[from] #[from]
source: BollardError, source: BollardError,
}, },
/// Une opération a dépassé son timeout. /// An operation exceeded its timeout.
#[error("`{operation}` timed out after {timeout:?}")] #[error("`{operation}` timed out after {timeout:?}")]
Timeout { Timeout {
operation: String, operation: String,
timeout: Duration, timeout: Duration,
}, },
/// La construction de l'image a échoué. /// The image build failed.
#[error("image build failed: {message}")] #[error("image build failed: {message}")]
Build { message: String }, Build { message: String },
/// Le daemon a répondu autre chose que ce qui était attendu. /// The daemon replied with something other than what was expected.
#[error("{0}")] #[error("{0}")]
Unexpected(String), Unexpected(String),
} }
+3 -3
View File
@@ -1,9 +1,9 @@
//! Résultat d'une commande exécutée dans un container. //! Result of a command run in a container.
/// Résultat d'une commande exécutée dans un container. /// Result of a command run in a container.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ExecOutput { pub struct ExecOutput {
/// Code de sortie, ou `-1` si le processus a été terminé par un signal. /// Exit code, or `-1` if the process was terminated by a signal.
pub status: i32, pub status: i32,
pub stdout: String, pub stdout: String,
pub stderr: String, pub stderr: String,
+18 -19
View File
@@ -1,28 +1,27 @@
//! Primitives de cycle de vie de container pour un [`DevContainer`] analysé. //! Container lifecycle primitives for a parsed [`DevContainer`].
//! //!
//! Cette crate pilote l'API du daemon de containers pour préparer l'image //! This crate drives the container daemon API to prepare the devcontainer image
//! devcontainer (construite depuis un Dockerfile, ou tirée d'un registre), //! (built from a Dockerfile, or pulled from a registry), start a container and
//! démarrer un container et y copier le workspace, exécuter les //! copy the workspace into it, run the `postCreateCommand` / `postStartCommand`
//! hooks `postCreateCommand` / `postStartCommand` et lancer des commandes à //! hooks, and run commands inside the running container.
//! l'intérieur du container en cours d'exécution.
//! //!
//! Le daemon est joint par son **socket** (celui de l'hôte, monté dans le //! The daemon is reached through its **socket** (the host's, mounted into the
//! container de Steep) : [`ContainerRuntime::connect`] suit `DOCKER_HOST` comme //! Steep container): [`ContainerRuntime::connect`] follows `DOCKER_HOST` like the
//! le fait le CLI `docker`, et retombe sur le socket local. Aucun binaire de //! `docker` CLI does, and falls back to the local socket. No runtime binary is
//! runtime n'est donc requis dans l'image, et `podman` fonctionne de la même //! therefore required in the image, and `podman` works the same way as long as it
//! façon dès lors qu'il expose son socket compatible Docker. //! exposes its Docker-compatible socket.
//! //!
//! # Isolation //! # Isolation
//! //!
//! Chaque sandbox dispose de son propre tag d'image, de son propre container et de //! Each sandbox has its own image tag, its own container and its own network. The
//! son propre réseau. Le container démarre attaché à ce réseau afin que les hooks //! container starts attached to that network so that the `postCreateCommand` /
//! `postCreateCommand` / `postStartCommand` puissent récupérer des dépendances //! `postStartCommand` hooks can fetch dependencies (e.g. `npm install`); once the
//! (par ex. `npm install`) ; une fois les hooks exécutés, le container est déconnecté //! hooks have run, the container is disconnected from the network for the rest of
//! du réseau pour le reste de sa durée de vie. Chaque commande est bornée par un timeout. //! its lifetime. Every command is bounded by a timeout.
//! //!
//! Les `runArgs` du `devcontainer.json` sont ignorés : ils viennent du dépôt, donc //! The `devcontainer.json` `runArgs` are ignored: they come from the repository,
//! d'une pull request non fiable, et pourraient rattacher le container à un autre //! hence from an untrusted pull request, and could attach the container to another
//! réseau ou lui donner des privilèges qui annuleraient cette isolation. //! network or grant it privileges that would defeat this isolation.
mod consts; mod consts;
mod container; mod container;
+3 -3
View File
@@ -1,9 +1,9 @@
//! Normalisation lexicale de chemins, sans accès au système de fichiers. //! Lexical path normalization, without accessing the filesystem.
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
/// Normalise lexicalement un chemin, en résolvant `.` et `..` sans toucher au /// Lexically normalizes a path, resolving `.` and `..` without touching the
/// système de fichiers. Renvoie `None` si le chemin sort de sa racine. /// filesystem. Returns `None` if the path escapes its root.
pub fn normalize(path: &Path) -> Option<PathBuf> { pub fn normalize(path: &Path) -> Option<PathBuf> {
use std::path::Component; use std::path::Component;
+25 -25
View File
@@ -1,4 +1,4 @@
//! Client de l'API du daemon de containers et opérations de cycle de vie. //! Container daemon API client and lifecycle operations.
use std::{collections::HashMap, path::Path, time::Duration}; use std::{collections::HashMap, path::Path, time::Duration};
@@ -24,7 +24,7 @@ use crate::{
exec::ExecOutput, exec::ExecOutput,
}; };
/// Un client de l'API du daemon de containers. /// A container daemon API client.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ContainerRuntime { pub struct ContainerRuntime {
docker: Docker, docker: Docker,
@@ -33,7 +33,7 @@ pub struct ContainerRuntime {
} }
impl ContainerRuntime { impl ContainerRuntime {
/// Se connecte au daemon désigné par `DOCKER_HOST`, ou au socket local par défaut. /// Connects to the daemon designated by `DOCKER_HOST`, or to the default local socket.
pub fn connect() -> Result<Self, ContainerError> { pub fn connect() -> Result<Self, ContainerError> {
let endpoint = let endpoint =
std::env::var("DOCKER_HOST").unwrap_or_else(|_| String::from(DEFAULT_ENDPOINT)); std::env::var("DOCKER_HOST").unwrap_or_else(|_| String::from(DEFAULT_ENDPOINT));
@@ -45,13 +45,13 @@ impl ContainerRuntime {
}) })
} }
/// Remplace le timeout appliqué aux opérations de build/run/stop/remove. /// Replaces the timeout applied to build/run/stop/remove operations.
pub fn with_timeout(mut self, timeout: Duration) -> Self { pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout; self.timeout = timeout;
self self
} }
/// Endpoint du daemon, pour les logs. /// Daemon endpoint, for logging.
pub fn endpoint(&self) -> &str { pub fn endpoint(&self) -> &str {
&self.endpoint &self.endpoint
} }
@@ -60,12 +60,12 @@ impl ContainerRuntime {
self.timeout self.timeout
} }
/// Vérifie que le daemon est joignable et répond. /// Checks that the daemon is reachable and responding.
pub async fn available(&self) -> bool { pub async fn available(&self) -> bool {
self.docker.ping().await.is_ok() self.docker.ping().await.is_ok()
} }
/// Exécute une requête du daemon en appliquant le timeout des commandes. /// Runs a daemon request, applying the command timeout.
async fn request<T>( async fn request<T>(
&self, &self,
operation: &str, operation: &str,
@@ -81,7 +81,7 @@ impl ContainerRuntime {
} }
} }
/// Construit l'image `tag` depuis le contexte `context_dir`. /// Builds the image `tag` from the `context_dir` context.
pub(crate) async fn build_image( pub(crate) async fn build_image(
&self, &self,
context_dir: &Path, context_dir: &Path,
@@ -102,8 +102,8 @@ impl ContainerRuntime {
.docker .docker
.build_image(options, None, Some(body_try_stream(context))); .build_image(options, None, Some(body_try_stream(context)));
// Le flux porte la progression du build : la dernière erreur signalée par le // The stream carries the build progress: the last error reported by the
// daemon fait échouer l'opération. // daemon makes the operation fail.
let consume = async { let consume = async {
let mut failure = None; let mut failure = None;
@@ -162,7 +162,7 @@ impl ContainerRuntime {
} }
} }
/// Crée un container, sans le démarrer. /// Creates a container without starting it.
pub(crate) async fn create_container( pub(crate) async fn create_container(
&self, &self,
name: &str, name: &str,
@@ -217,7 +217,7 @@ impl ContainerRuntime {
Ok(()) Ok(())
} }
/// Exécute une commande dans un container et renvoie sa sortie. /// Runs a command in a container and returns its output.
pub(crate) async fn exec( pub(crate) async fn exec(
&self, &self,
container: &str, container: &str,
@@ -254,8 +254,8 @@ impl ContainerRuntime {
let mut stdout = String::new(); let mut stdout = String::new();
let mut stderr = String::new(); let mut stderr = String::new();
// Le timeout couvre l'exécution de la commande elle-même, pas seulement sa // The timeout covers the execution of the command itself, not just its
// mise en place. // setup.
let collect = async { let collect = async {
while let Some(message) = output.next().await { while let Some(message) = output.next().await {
match message? { match message? {
@@ -276,10 +276,10 @@ impl ContainerRuntime {
Ok(Ok(())) => {} Ok(Ok(())) => {}
Ok(Err(source)) => return Err(ContainerError::Request { source }), Ok(Err(source)) => return Err(ContainerError::Request { source }),
Err(_) => { Err(_) => {
// Le processus continue de tourner dans le container : sans arrêt, il // The process keeps running in the container: left alone, it would
// consommerait CPU et mémoire et pourrait encore modifier le workspace // consume CPU and memory and could still modify the workspace for the
// pendant toute la durée de vie de la sandbox. L'API n'offre pas de // whole lifetime of the sandbox. The API offers no way to kill an exec,
// moyen de tuer un exec, on arrête donc le container qui le porte. // so we stop the container that hosts it.
let _ = self.stop_container(container).await; let _ = self.stop_container(container).await;
return Err(ContainerError::Timeout { return Err(ContainerError::Timeout {
@@ -289,10 +289,10 @@ impl ContainerRuntime {
} }
} }
// Le code de sortie n'est pas forcément publié au moment où le flux se ferme : // The exit code is not necessarily published by the time the stream closes:
// on laisse au daemon le temps de le renseigner, sans bloquer indéfiniment. // we give the daemon time to fill it in, without blocking indefinitely.
// Sans cela, une commande réussie peut être rapportée en échec (code -1) et // Without this, a successful command can be reported as failed (code -1) and
// l'outil renvoie une erreur au modèle à la place du contenu. // the tool returns an error to the model instead of the content.
let mut inspected = self let mut inspected = self
.request("inspect exec", self.docker.inspect_exec(&created.id)) .request("inspect exec", self.docker.inspect_exec(&created.id))
.await?; .await?;
@@ -331,7 +331,7 @@ impl ContainerRuntime {
Ok(()) Ok(())
} }
/// Supprime un container, ses volumes anonymes et le processus qui y tourne. /// Removes a container, its anonymous volumes and the process running in it.
pub(crate) async fn remove_container(&self, name: &str) -> Result<(), ContainerError> { pub(crate) async fn remove_container(&self, name: &str) -> Result<(), ContainerError> {
let options = RemoveContainerOptions { let options = RemoveContainerOptions {
force: true, force: true,
@@ -363,7 +363,7 @@ impl ContainerRuntime {
Ok(()) Ok(())
} }
/// Crée le réseau dédié d'une sandbox. /// Creates a sandbox's dedicated network.
pub(crate) async fn create_network(&self, name: &str) -> Result<(), ContainerError> { pub(crate) async fn create_network(&self, name: &str) -> Result<(), ContainerError> {
let request = NetworkCreateRequest { let request = NetworkCreateRequest {
name: String::from(name), name: String::from(name),
@@ -376,7 +376,7 @@ impl ContainerRuntime {
Ok(()) Ok(())
} }
/// Détache un container d'un réseau, ce qui coupe sa connectivité. /// Detaches a container from a network, which cuts off its connectivity.
pub(crate) async fn disconnect_network( pub(crate) async fn disconnect_network(
&self, &self,
network: &str, network: &str,
+16 -16
View File
@@ -1,5 +1,5 @@
//! Schéma du `devcontainer.json`, analyse du fichier et résolution des //! `devcontainer.json` schema, file parsing and resolution of `${localEnv:…}`
//! références `${localEnv:…}`. //! references.
use std::{ use std::{
collections::HashMap, collections::HashMap,
@@ -23,7 +23,7 @@ struct DevContainerSchema {
#[serde(default)] #[serde(default)]
name: Option<String>, name: Option<String>,
/// Image de base tirée du registre, alternative à `build`. /// Base image pulled from the registry, alternative to `build`.
#[serde(default)] #[serde(default)]
image: Option<String>, image: Option<String>,
@@ -106,16 +106,16 @@ impl TryFrom<(DevContainerSchema, PathBuf)> for DevContainer {
} }
} }
/// Résout les références `${localEnv:VAR}` et `${localEnv:VAR:default}` d'un /// Resolves the `${localEnv:VAR}` and `${localEnv:VAR:default}` references of a
/// `devcontainer.json`. /// `devcontainer.json`.
/// ///
/// La spécification veut que `${localEnv:VAR}` soit lu dans l'environnement du /// The specification says `${localEnv:VAR}` is read from the client's environment;
/// client ; ici le `devcontainer.json` vient d'une **pull request**, donc de code /// here the `devcontainer.json` comes from a **pull request**, hence from untrusted
/// non fiable : lire l'environnement du processus lui permettrait de récupérer /// code: reading the process environment would let it grab `GITEA_TOKEN`,
/// `GITEA_TOKEN`, `OPEN_ROUTER_API_KEY` ou n'importe quel autre secret de Steep et /// `OPEN_ROUTER_API_KEY` or any other Steep secret and exfiltrate it through a
/// de l'exfiltrer depuis un `build.args`, un `containerEnv`, son Dockerfile ou un /// `build.args`, a `containerEnv`, its Dockerfile or a hook. The environment is
/// hook. L'environnement n'est donc **jamais** consulté : seule la valeur par /// therefore **never** consulted: only the default value is used, and a reference
/// défaut est utilisée, et une référence sans défaut devient une chaîne vide. /// without a default becomes an empty string.
fn substitute_local_env(input: &str) -> String { fn substitute_local_env(input: &str) -> String {
const PREFIX: &str = "${localEnv:"; const PREFIX: &str = "${localEnv:";
@@ -129,7 +129,7 @@ fn substitute_local_env(input: &str) -> String {
match after.find('}') { match after.find('}') {
Some(end) => { Some(end) => {
let inner = &after[..end]; let inner = &after[..end];
// La clé est lue pour délimiter la référence, pas pour la résoudre. // The key is read to delimit the reference, not to resolve it.
let (_key, default) = match inner.split_once(':') { let (_key, default) = match inner.split_once(':') {
Some((key, default)) => (key, Some(default)), Some((key, default)) => (key, Some(default)),
None => (inner, None), None => (inner, None),
@@ -239,8 +239,8 @@ mod tests {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let devcontainer_path = dir.path().join("devcontainer.json"); let devcontainer_path = dir.path().join("devcontainer.json");
// `runArgs` vient d'une pull request non fiable : il doit être accepté par // `runArgs` comes from an untrusted pull request: it must be accepted by
// l'analyse mais jamais repris dans le devcontainer. // parsing but never carried over into the devcontainer.
fs::write( fs::write(
&devcontainer_path, &devcontainer_path,
r#"{ r#"{
@@ -271,8 +271,8 @@ mod tests {
fn local_env_references_are_never_read_from_the_environment() { fn local_env_references_are_never_read_from_the_environment() {
unsafe { std::env::set_var("DEVCONTAINER_TEST_SECRET", "s3cret") }; unsafe { std::env::set_var("DEVCONTAINER_TEST_SECRET", "s3cret") };
// Le `devcontainer.json` vient d'une pull request : résoudre la référence // The `devcontainer.json` comes from a pull request: resolving the reference
// depuis l'environnement de Steep y exposerait ses jetons. // from Steep's environment would expose its tokens to it.
assert_eq!( assert_eq!(
substitute_local_env("${localEnv:DEVCONTAINER_TEST_SECRET}"), substitute_local_env("${localEnv:DEVCONTAINER_TEST_SECRET}"),
"" ""
+8 -2
View File
@@ -1,12 +1,14 @@
[package] [package]
name = "steep-server" name = "steep-server"
version = "1.2.0" version = "1.3.0"
edition = "2024" edition = "2024"
publish = false
[dependencies] [dependencies]
reqwest = { workspace = true } reqwest = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
tokio-util = { workspace = true } tokio-util = { workspace = true }
tokio-stream = { workspace = true, features = ["sync"] }
futures-util = { workspace = true } futures-util = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
@@ -25,4 +27,8 @@ hex = { workspace = true }
metrics = { workspace = true } metrics = { workspace = true }
metrics-exporter-prometheus = { workspace = true } metrics-exporter-prometheus = { workspace = true }
devcontainer-rs = { path = "../devcontainer-rs" } devcontainer-rs = { path = "../devcontainer-rs" }
tempfile = "3" tempfile = { workspace = true }
rustls = { workspace = true }
toasty = { workspace = true }
toasty-cli = { workspace = true }
jiff = { workspace = true }
+121 -18
View File
@@ -1,31 +1,48 @@
use axum::body::{Bytes, to_bytes}; use axum::body::{Bytes, to_bytes};
use axum::extract::{FromRef, FromRequest, State}; use axum::extract::{FromRef, FromRequest, Path, State};
use axum::http::Request; use axum::http::Request;
use axum::response::IntoResponse; use axum::response::IntoResponse;
use axum::response::sse::{Event, KeepAlive, Sse};
use axum::routing::{get, post}; use axum::routing::{get, post};
use axum::{Json, Router}; use axum::{Json, Router};
use futures_util::stream::{self, Stream, StreamExt};
use reqwest::StatusCode; use reqwest::StatusCode;
use ring::hmac; use ring::hmac;
use sentry::integrations::tower::{NewSentryLayer, SentryHttpLayer}; use sentry::integrations::tower::{NewSentryLayer, SentryHttpLayer};
use serde_json::Value; use serde_json::{Value, json};
use tokio_stream::wrappers::{BroadcastStream, errors::BroadcastStreamRecvError};
use tower::ServiceBuilder; use tower::ServiceBuilder;
use tower_http::services::ServeDir;
use tower_http::trace::TraceLayer; use tower_http::trace::TraceLayer;
use tracing::{info, instrument}; use tracing::{info, instrument, warn};
use tokio_util::sync::CancellationToken;
use crate::consts::{GITEA_EVENT_TYPE_HEADER_NAME, GITEA_SIG_HEADER_NAME, MAX_WEBHOOK_BODY_SIZE}; use crate::consts::{GITEA_EVENT_TYPE_HEADER_NAME, GITEA_SIG_HEADER_NAME, MAX_WEBHOOK_BODY_SIZE};
use crate::errors::AppError; use crate::errors::AppError;
use crate::event_bus::JobEvent;
use crate::gitea::WebhookType; use crate::gitea::WebhookType;
use crate::metrics; use crate::metrics;
use crate::schemas::JobStatus;
use crate::state::AppState; use crate::state::AppState;
pub async fn start(app_state: AppState, shutdown: CancellationToken) -> anyhow::Result<()> { pub async fn start(app_state: AppState) -> anyhow::Result<()> {
let http_port = app_state.config.http_port; let http_port = app_state.config.http_port;
let shutdown = app_state.shutdown.clone();
// The dashboard is served from a static directory; the API routes below take
// precedence (the directory is only the fallback).
let front_dir = std::env::var("FRONT_DIR").unwrap_or_else(|_| String::from("front"));
if !std::path::Path::new(&front_dir).is_dir() {
warn!(dir = %front_dir, "front directory not found, the dashboard will 404");
}
let dashboard = ServeDir::new(&front_dir);
let app = Router::new() let app = Router::new()
.route("/", get(root))
.route("/webhook", post(webhook)) .route("/webhook", post(webhook))
.route("/reviews", get(reviews_list))
Review

[security] Les routes /reviews, /jobs, /events et /jobs/{id}/events sont publiques, sans authentification. Elles exposent les noms de dépôts privés, les numéros de PR, le payload complet des webhooks (corps des commentaires, clone_url), le markdown des reviews et le flux des appels d'outils de l'agent. Ce flux contient des extraits du code des dépôts, ce qui est sensible. Le service tourne sur 0.0.0.0 et le dashboard est servi par le même processus. Il faut protéger ces routes (token ou basic auth, ou reverse proxy authentifié), ou au moins les placer sur un port interne distinct du /webhook. Il n'y a aussi aucune limite sur le nombre de connexions SSE ouvertes.

**[security]** Les routes `/reviews`, `/jobs`, `/events` et `/jobs/{id}/events` sont publiques, sans authentification. Elles exposent les noms de dépôts privés, les numéros de PR, le payload complet des webhooks (corps des commentaires, clone_url), le markdown des reviews et le flux des appels d'outils de l'agent. Ce flux contient des extraits du code des dépôts, ce qui est sensible. Le service tourne sur `0.0.0.0` et le dashboard est servi par le même processus. Il faut protéger ces routes (token ou basic auth, ou reverse proxy authentifié), ou au moins les placer sur un port interne distinct du `/webhook`. Il n'y a aussi aucune limite sur le nombre de connexions SSE ouvertes.
.route("/jobs", get(jobs_list))
qpismont marked this conversation as resolved
Review

[security] Ces routes de consultation et les flux SSE sont accessibles sans authentification. Elles exposent notamment les noms de dépôts, les reviews et les sorties des outils exécutés sur le code, qui peuvent contenir des données de dépôts privés. Protégez ces endpoints ou limitez explicitement leur accès.

**[security]** Ces routes de consultation et les flux SSE sont accessibles sans authentification. Elles exposent notamment les noms de dépôts, les reviews et les sorties des outils exécutés sur le code, qui peuvent contenir des données de dépôts privés. Protégez ces endpoints ou limitez explicitement leur accès.
.route("/events", get(lifecycle_events))
.route("/jobs/{id}/events", get(job_events))
.fallback_service(dashboard)
.layer( .layer(
ServiceBuilder::new() ServiceBuilder::new()
.layer(NewSentryLayer::<Request<_>>::new_from_top()) .layer(NewSentryLayer::<Request<_>>::new_from_top())
@@ -46,8 +63,73 @@ pub async fn start(app_state: AppState, shutdown: CancellationToken) -> anyhow::
Ok(()) Ok(())
} }
async fn root() -> &'static str { async fn reviews_list(State(app_state): State<AppState>) -> Result<impl IntoResponse, AppError> {
"Hi, I'm Steep :)" let reviews = app_state.reviews.list().await?;
Ok(Json(json!({
"reviews": reviews
})))
}
async fn jobs_list(State(app_state): State<AppState>) -> Result<impl IntoResponse, AppError> {
let pending = app_state.queue.list(JobStatus::Pending).await?;
let processing = app_state.queue.list(JobStatus::Processing).await?;
Ok(Json(json!({
"pending": pending,
"processing": processing,
})))
}
/// SSE stream of the global lifecycle events (the job list).
///
/// The stream is closed as soon as the graceful shutdown starts: a live SSE
/// connection would otherwise keep the server from stopping.
async fn lifecycle_events(
State(app_state): State<AppState>,
) -> Sse<impl Stream<Item = Result<Event, axum::Error>>> {
let rx = app_state.event_bus.subscribe_lifecycle();
let shutdown = app_state.shutdown.cancelled_owned();
let stream = BroadcastStream::new(rx).map(|result| match result {
Ok(event) => sse_json("lifecycle", &event),
Err(BroadcastStreamRecvError::Lagged(skipped)) => sse_gap(skipped),
});
Sse::new(stream.take_until(shutdown)).keep_alive(KeepAlive::default())
}
async fn job_events(
State(app_state): State<AppState>,
Path(id): Path<u64>,
) -> Result<Sse<impl Stream<Item = Result<Event, axum::Error>>>, AppError> {
let subscription = app_state
.event_bus
.subscribe_job(id)
.ok_or(AppError::JobNotFoundErr(id))?;
let shutdown = app_state.shutdown.cancelled_owned();
let history = subscription
.history
.into_iter()
.map(|event| sse_json("progress", &event));
let live = BroadcastStream::new(subscription.live).map(|result| match result {
Ok(event) => sse_json("progress", &event),
Err(BroadcastStreamRecvError::Lagged(skipped)) => sse_gap(skipped),
});
let stream = stream::iter(history).chain(live).take_until(shutdown);
Ok(Sse::new(stream).keep_alive(KeepAlive::default()))
}
fn sse_json<T: serde::Serialize>(name: &'static str, payload: &T) -> Result<Event, axum::Error> {
Event::default().event(name).json_data(payload)
}
fn sse_gap(skipped: u64) -> Result<Event, axum::Error> {
Ok(Event::default().event("gap").data(skipped.to_string()))
} }
#[instrument(skip(app_state), fields(webhook_type), err)] #[instrument(skip(app_state), fields(webhook_type), err)]
@@ -61,20 +143,41 @@ async fn webhook(
metrics::webhook_received(event_type); metrics::webhook_received(event_type);
let event_id = wb.event_id(); let event_id = wb.event_id();
if app_state.bot.check_and_mark(event_id).await { match is_known(&app_state, event_id).await {
qpismont marked this conversation as resolved
Review

[bug] Le test is_known et l’insertion dans la file ne sont pas atomiques. Deux livraisons simultanées du même webhook peuvent toutes deux passer le test ; la contrainte unique fait ensuite échouer l’une et renvoie 503 au lieu de traiter ce doublon comme déjà accepté. Gérez explicitement ce conflit d’insertion.

**[bug]** Le test `is_known` et l’insertion dans la file ne sont pas atomiques. Deux livraisons simultanées du même webhook peuvent toutes deux passer le test ; la contrainte unique fait ensuite échouer l’une et renvoie 503 au lieu de traiter ce doublon comme déjà accepté. Gérez explicitement ce conflit d’insertion.
metrics::webhook_duplicate(event_type); Ok(true) => {
return Err(AppError::AlreadyProcessedErr); metrics::webhook_duplicate(event_type);
return Err(AppError::AlreadyProcessedErr);
}
Ok(false) => {}
Err(err) => {
metrics::webhook_enqueue_failed(event_type);
return Err(AppError::EnqueueErr(anyhow::Error::from(err)));
}
} }
if app_state.bot_tx.try_send(wb).is_err() { let job = match app_state.queue.push(wb).await {
Review

[bug] Le contrôle is_known puis queue.push n'est pas atomique. Deux livraisons concurrentes du même événement (retry Gitea) passent toutes les deux le contrôle. La seconde échoue sur la contrainte UNIQUE(event_id) et renvoie 503 Could not enqueue task, avec un faux événement Sentry et le compteur enqueue_failed. Gitea risque alors de re-livrer en boucle. Il faut traiter la violation d'unicité comme AlreadyProcessedErr (200), ou faire un insert avec ON CONFLICT DO NOTHING.

**[bug]** Le contrôle `is_known` puis `queue.push` n'est pas atomique. Deux livraisons concurrentes du même événement (retry Gitea) passent toutes les deux le contrôle. La seconde échoue sur la contrainte `UNIQUE(event_id)` et renvoie 503 `Could not enqueue task`, avec un faux événement Sentry et le compteur `enqueue_failed`. Gitea risque alors de re-livrer en boucle. Il faut traiter la violation d'unicité comme `AlreadyProcessedErr` (200), ou faire un insert avec `ON CONFLICT DO NOTHING`.
app_state.bot.unmark(event_id).await; Ok(job) => job,
metrics::webhook_channel_full(event_type); Err(err) => {
return Err(AppError::ChannelFullErr); metrics::webhook_enqueue_failed(event_type);
} return Err(AppError::EnqueueErr(anyhow::Error::from(err)));
}
};
app_state.event_bus.publish(JobEvent::Queued {
id: job.id,
repo: job.repo,
pr: job.pr,
});
Ok((StatusCode::CREATED, "Task started")) Ok((StatusCode::CREATED, "Task started"))
} }
/// Whether an event is already queued, or already reviewed.
async fn is_known(app_state: &AppState, event_id: u64) -> toasty::Result<bool> {
Ok(app_state.queue.is_queued(event_id).await?
|| app_state.reviews.is_reviewed(event_id).await?)
}
pub struct WebhookExtract(pub WebhookType); pub struct WebhookExtract(pub WebhookType);
impl<S> FromRequest<S> for WebhookExtract impl<S> FromRequest<S> for WebhookExtract
@@ -109,7 +212,7 @@ where
}); });
}); });
let webhook = parse_webhook(&type_header, &app_state.bot.name(), &body_bytes)?; let webhook = parse_webhook(&type_header, &app_state.bot_name, &body_bytes)?;
Ok(WebhookExtract(webhook)) Ok(WebhookExtract(webhook))
} }
} }
+147 -270
View File
@@ -1,191 +1,101 @@
use crate::{ use std::{sync::Arc, time::Duration};
gitea::{GiteaAPI, WebhookType},
metrics, use tokio::{sync::Semaphore, task::JoinSet};
open_router::OpenRouterClient,
sandbox::SandboxConfig,
};
use serde::{Deserialize, Deserializer};
use std::{collections::HashSet, sync::Arc};
use tokio::sync::Mutex;
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
use tracing::{error, info, instrument}; use tracing::{error, info, instrument};
#[derive(Deserialize, Debug)] use crate::{
pub struct ReviewResult { bot_actions::review::{self, ReviewContext, ReviewOutcome},
pub reviews: Vec<ReviewItem>, event_bus::{EventBus, Reporter},
pub comment: String, gitea::{GiteaAPI, WebhookType},
pub cost: Option<f64>, job_queue::JobQueue,
} metrics,
open_router::OpenRouterClient,
review_store::{ReviewRecord, ReviewStore},
sandbox::{SandboxConfig, tools},
schemas::Job,
};
#[derive(Deserialize, Debug)] /// Delay before claiming again after the queue could not be reached.
pub struct ReviewItem { const CLAIM_RETRY_DELAY: Duration = Duration::from_secs(1);
pub filename: String,
pub line: Option<u64>,
#[serde(default, deserialize_with = "deserialize_side")]
pub side: Option<ReviewSide>,
#[serde(default, deserialize_with = "deserialize_severity")]
pub severity: Option<ReviewSeverity>,
pub message: String,
}
/// Which version of the file a review comment is anchored on. /// Everything the bot works with: the external services and the stores.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Clone)]
pub enum ReviewSide { pub struct BotDeps {
/// The line was added by the pull request: `line` is a line number of the pub gitea: GiteaAPI,
/// new version of the file. pub open_router: OpenRouterClient,
Added, pub sandbox: SandboxConfig,
/// The line was removed by the pull request: `line` is a line number of the pub reviews: ReviewStore,
/// old version of the file. pub queue: JobQueue,
Removed, pub event_bus: EventBus,
}
impl ReviewSide {
/// Reads the side the model asked for, tolerating casing and synonyms.
fn parse(raw: &str) -> Option<Self> {
match raw.trim().to_ascii_lowercase().as_str() {
"added" | "add" | "new" | "right" => Some(Self::Added),
"removed" | "remove" | "deleted" | "delete" | "old" | "left" => Some(Self::Removed),
_ => None,
}
}
}
/// What kind of problem a review reports.
///
/// The categories are the ones the prompt asks for; a review whose severity is
/// missing or unreadable falls back to [`ReviewSeverity::Maintainability`], the
/// least alarming bucket, instead of failing the whole review.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReviewSeverity {
/// Wrong behaviour.
Bug,
/// A vulnerability.
Security,
/// A resource problem.
Performance,
/// Everything else: readability, structure, naming, tests.
Maintainability,
}
impl ReviewSeverity {
/// Reads the severity the model asked for, tolerating casing and synonyms.
fn parse(raw: &str) -> Option<Self> {
match raw.trim().to_ascii_lowercase().as_str() {
"bug" | "bugs" | "correctness" | "error" => Some(Self::Bug),
"security" | "vulnerability" => Some(Self::Security),
"performance" | "perf" => Some(Self::Performance),
"maintainability" | "quality" | "style" => Some(Self::Maintainability),
_ => None,
}
}
/// Lower-case label used in the review markdown.
pub fn label(self) -> &'static str {
match self {
Self::Bug => "bug",
Self::Security => "security",
Self::Performance => "performance",
Self::Maintainability => "maintainability",
}
}
}
/// Reads the side the model asked for. An unreadable value is ignored instead of
/// failing the whole review: the side is then resolved from the changed lines.
fn deserialize_side<'de, D>(deserializer: D) -> Result<Option<ReviewSide>, D::Error>
where
D: Deserializer<'de>,
{
let raw = Option::<serde_json::Value>::deserialize(deserializer)?;
Ok(raw
.as_ref()
.and_then(serde_json::Value::as_str)
.and_then(ReviewSide::parse))
}
/// Reads the severity the model asked for, ignoring an unreadable value: the
/// review is then reported as [`ReviewSeverity::Maintainability`].
fn deserialize_severity<'de, D>(deserializer: D) -> Result<Option<ReviewSeverity>, D::Error>
where
D: Deserializer<'de>,
{
let raw = Option::<serde_json::Value>::deserialize(deserializer)?;
Ok(raw
.as_ref()
.and_then(serde_json::Value::as_str)
.and_then(ReviewSeverity::parse))
} }
#[derive(Clone)] #[derive(Clone)]
pub struct Bot { pub struct Bot {
bot_name: String, deps: BotDeps,
gitea_api: GiteaAPI,
open_router_client: OpenRouterClient,
max_concurrent: usize, max_concurrent: usize,
open_router_model: String,
sandbox: SandboxConfig,
actions_handled: Arc<Mutex<HashSet<u64>>>,
} }
impl Bot { impl Bot {
pub fn new( pub fn new(deps: BotDeps, max_concurrent: usize) -> Self {
bot_name: String,
gitea_api: GiteaAPI,
open_router_client: OpenRouterClient,
max_concurrent: usize,
open_router_model: String,
sandbox: SandboxConfig,
) -> Self {
Self { Self {
bot_name, deps,
gitea_api,
open_router_client,
max_concurrent, max_concurrent,
open_router_model,
sandbox,
actions_handled: Arc::new(Mutex::new(HashSet::new())),
} }
} }
pub fn name(&self) -> String { /// Consumes the queue until `shutdown` is cancelled.
self.bot_name.clone() ///
} /// A semaphore bounds the concurrency, and a job is only claimed once a slot is
/// free, so the jobs waiting for one stay `pending`. Running jobs are left to
pub async fn start( /// finish; the unfinished ones stay `processing` and are requeued at the next
&self, /// startup.
mut rx: tokio::sync::mpsc::Receiver<WebhookType>, pub async fn start(&self, shutdown: CancellationToken) -> anyhow::Result<()> {
shutdown: CancellationToken,
) -> anyhow::Result<()> {
info!("Bot started"); info!("Bot started");
let sem = Arc::new(tokio::sync::Semaphore::new(self.max_concurrent)); let semaphore = Arc::new(Semaphore::new(self.max_concurrent));
let mut tasks = tokio::task::JoinSet::new(); let mut tasks = JoinSet::new();
loop { 'consume: loop {
let wb = tokio::select! { let permit = tokio::select! {
biased; biased;
_ = shutdown.cancelled() => break, () = shutdown.cancelled() => break,
msg = rx.recv() => match msg { permit = semaphore.clone().acquire_owned() => permit?,
Some(wb) => wb,
None => break,
},
}; };
while let Some(res) = tasks.try_join_next() { let job = loop {
if let Err(e) = res { while let Some(res) = tasks.try_join_next() {
error!("Task panicked: {e}"); if let Err(err) = res {
error!("Task panicked: {err}");
}
} }
}
info!(queued = rx.len(), active = tasks.len(), "Webhook received"); match self.deps.queue.claim_next().await {
let permit = sem.clone().acquire_owned().await?; Ok(Some(job)) => break job,
let self_clone = self.clone(); Ok(None) => {
tokio::select! {
biased;
() = shutdown.cancelled() => break 'consume,
() = self.deps.queue.wait_for_work() => {}
}
}
Err(err) => {
error!(%err, "failed to claim a job");
tokio::select! {
biased;
() = shutdown.cancelled() => break 'consume,
() = tokio::time::sleep(CLAIM_RETRY_DELAY) => {}
}
}
}
};
let bot = self.clone();
metrics::increment_task_active(); metrics::increment_task_active();
tasks.spawn(async move { tasks.spawn(async move {
self_clone.exec(wb).await; bot.process(job).await;
drop(permit); drop(permit);
metrics::decrement_task_active(); metrics::decrement_task_active();
}); });
@@ -197,124 +107,91 @@ impl Bot {
Ok(()) Ok(())
} }
#[instrument(skip(self, webhook), fields(repo, pr))] /// Runs a claimed job and records its outcome.
pub async fn exec(&self, webhook: WebhookType) { async fn process(&self, job: Job) {
let event_type_str = webhook.event_type_str(); self.run_job(&job).await;
match &webhook { if let Err(err) = self.deps.queue.remove(job.id).await {
WebhookType::Review(p) => { error!(%err, "failed to remove job");
tracing::Span::current().record("repo", &p.repository.full_name); }
tracing::Span::current().record("pr", p.pull_request.number); }
async fn run_job(&self, job: &Job) {
let deps = &self.deps;
let event_type = job.payload.event_type_str();
match deps.reviews.is_reviewed(job.event_id).await {
Ok(true) => {
metrics::task_skipped(event_type);
info!("Review already done, skipping");
deps.event_bus.skip(job.id, &job.repo, job.pr);
return;
}
Ok(false) => {}
Err(err) => error!(%err, "Review lookup failed, reviewing anyway"),
}
let reporter = deps.event_bus.start(job.id, job.repo.clone(), job.pr);
let started = std::time::Instant::now();
let result = self.exec(&job.payload, &reporter).await;
let duration_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX);
let (ok, outcome) = match result {
Ok(outcome) => {
metrics::task_completed(event_type);
info!("Task completed");
(true, outcome)
}
Err(err) => {
metrics::task_failed(event_type);
error!(%err, "Task error");
sentry_anyhow::capture_anyhow(&err);
(false, ReviewOutcome::empty())
} }
}; };
let tools = crate::sandbox::tools::for_webhook(&webhook); let record = ReviewRecord {
event_id: job.event_id,
repo: job.repo.clone(),
pr: job.pr,
ok,
model: deps.open_router.model().to_string(),
markdown: outcome.markdown,
cost: outcome.cost,
comments: outcome.comments,
duration_ms,
};
let exec_result = match webhook { if let Err(err) = deps.reviews.record(record).await {
WebhookType::Review(review_payload) => crate::bot_actions::review::exec_review( error!(%err, "failed to record review");
&self.gitea_api,
&self.open_router_client,
&self.open_router_model,
&self.sandbox,
tools,
review_payload,
),
} }
.await;
match exec_result { deps.event_bus
Ok(_) => { .finish(job.id, &job.repo, job.pr, ok, outcome.comments, duration_ms);
metrics::task_completed(event_type_str); }
info!("Task completed");
} /// Dispatches a webhook to the action it asks for.
Err(err) => { #[instrument(
metrics::task_failed(event_type_str); skip_all,
error!(%err, "Task error"); fields(repo = %webhook.repository_full_name(), pr = webhook.pull_request_number())
sentry_anyhow::capture_anyhow(&err); )]
} async fn exec(
&self,
webhook: &WebhookType,
reporter: &Reporter,
) -> anyhow::Result<ReviewOutcome> {
let deps = &self.deps;
let ctx = ReviewContext {
gitea: &deps.gitea,
open_router: &deps.open_router,
sandbox: &deps.sandbox,
qpismont marked this conversation as resolved
Review

[bug] L’attente d’un permis n’écoute pas shutdown. Si tous les slots sont occupés au moment de l’arrêt, le bot attend qu’un slot se libère puis lance quand même le job en attente, et peut continuer ainsi à vider la file. Cela contredit le comportement annoncé où seuls les jobs déjà en cours terminent ; rendez aussi cette attente interruptible.

**[bug]** L’attente d’un permis n’écoute pas `shutdown`. Si tous les slots sont occupés au moment de l’arrêt, le bot attend qu’un slot se libère puis lance quand même le job en attente, et peut continuer ainsi à vider la file. Cela contredit le comportement annoncé où seuls les jobs déjà en cours terminent ; rendez aussi cette attente interruptible.
reporter,
};
qpismont marked this conversation as resolved
Review

[bug] Si la mise à jour du statut échoue, le job est quand même traité. Il reste alors Pending et sera récupéré au prochain sondage, ce qui peut lancer plusieurs reviews concurrentes pour le même événement. Ne lancez pas le traitement si cette transition échoue, ou garantissez une prise en charge atomique.

**[bug]** Si la mise à jour du statut échoue, le job est quand même traité. Il reste alors `Pending` et sera récupéré au prochain sondage, ce qui peut lancer plusieurs reviews concurrentes pour le même événement. Ne lancez pas le traitement si cette transition échoue, ou garantissez une prise en charge atomique.
let tools = tools::for_webhook(webhook);
match webhook {
WebhookType::Review(payload) => review::exec_review(&ctx, tools, payload).await,
} }
qpismont marked this conversation as resolved
Review

[bug] En cas d’échec de mark_processing, le job est tout de même traité. Il reste alors Pending en base et pourra être repris après redémarrage — ou par une autre instance — pendant que cette exécution peut déjà publier la review. N’exécutez pas le job si sa transition d’état échoue.

**[bug]** En cas d’échec de `mark_processing`, le job est tout de même traité. Il reste alors Pending en base et pourra être repris après redémarrage — ou par une autre instance — pendant que cette exécution peut déjà publier la review. N’exécutez pas le job si sa transition d’état échoue.
} }
pub async fn check_and_mark(&self, event_id: u64) -> bool {
let mut action_handled_lock = self.actions_handled.lock().await;
!action_handled_lock.insert(event_id)
}
pub async fn unmark(&self, event_id: u64) {
let mut action_handled_lock = self.actions_handled.lock().await;
action_handled_lock.remove(&event_id);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_actions_handled() -> Arc<Mutex<HashSet<u64>>> {
Arc::new(Mutex::new(HashSet::new()))
}
async fn check_and_mark(actions_handled: &Arc<Mutex<HashSet<u64>>>, event_id: u64) -> bool {
let mut lock = actions_handled.lock().await;
!lock.insert(event_id)
}
async fn unmark(actions_handled: &Arc<Mutex<HashSet<u64>>>, event_id: u64) {
let mut lock = actions_handled.lock().await;
lock.remove(&event_id);
}
#[tokio::test]
async fn test_check_and_mark_first_call_returns_false() {
let actions_handled = make_actions_handled();
assert!(!check_and_mark(&actions_handled, 1).await);
}
#[tokio::test]
async fn test_check_and_mark_second_call_returns_true() {
let actions_handled = make_actions_handled();
check_and_mark(&actions_handled, 1).await;
assert!(check_and_mark(&actions_handled, 1).await);
}
#[tokio::test]
async fn test_check_and_mark_different_ids_are_independent() {
let actions_handled = make_actions_handled();
check_and_mark(&actions_handled, 1).await;
assert!(!check_and_mark(&actions_handled, 2).await);
}
#[tokio::test]
async fn test_unmark_allows_reprocessing() {
let actions_handled = make_actions_handled();
check_and_mark(&actions_handled, 1).await;
unmark(&actions_handled, 1).await;
assert!(!check_and_mark(&actions_handled, 1).await);
}
#[tokio::test]
async fn test_unmark_nonexistent_id_is_noop() {
let actions_handled = make_actions_handled();
unmark(&actions_handled, 99).await;
assert!(!check_and_mark(&actions_handled, 99).await);
}
#[tokio::test]
async fn test_check_and_mark_concurrent_same_id() {
let actions_handled = make_actions_handled();
let actions_handled2 = Arc::clone(&actions_handled);
let t1 = tokio::spawn(async move { check_and_mark(&actions_handled, 42).await });
let t2 = tokio::spawn(async move { check_and_mark(&actions_handled2, 42).await });
let (r1, r2) = tokio::join!(t1, t2);
let results = [r1.unwrap(), r2.unwrap()];
// exactement un seul des deux doit retourner false (non traité)
assert_eq!(results.iter().filter(|&&r| !r).count(), 1);
// l'autre doit retourner true (déjà traité)
assert_eq!(results.iter().filter(|&&r| r).count(), 1);
}
} }
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,177 @@
//! Anchoring of the model's reviews on the lines a pull request changes.
use tracing::warn;
use super::diff::ChangedLines;
use crate::domain::review::ReviewResult;
/// Resolves the side each review is anchored on and drops the reviews that do
/// not match a line the pull request changes.
///
/// The model is asked to pick a line and a side from the provided lists, but
/// nothing forces it to, and Gitea accepts any position: a wrong one places the
/// comment on an unrelated line of the file instead of failing. A review that
/// omits its side is resolved from the lists, and one that matches no changed
/// line is dropped.
pub(super) fn resolve_review_sides(review_result: &mut ReviewResult, changed_lines: &ChangedLines) {
let mut dropped = Vec::new();
review_result.reviews.retain_mut(|review| {
let side = review.line.and_then(|line| {
let file = changed_lines
.iter()
.find(|file| file.filename == review.filename)?;
let side = review.side.or_else(|| file.side_of(line))?;
file.lines(side).contains(&line).then_some(side)
});
match side {
Some(side) => {
review.side = Some(side);
true
}
None => {
dropped.push(match review.line {
Some(line) => format!("{}:{line}", review.filename),
None => format!("{}:no line", review.filename),
});
false
}
}
});
if !dropped.is_empty() {
warn!(
dropped = dropped.len(),
reviews = %dropped.join(", "),
"Dropped reviews that are not anchored on a changed line"
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
bot_actions::review::{diff::parse_changed_lines, fixtures::*},
domain::review::ReviewSide,
};
#[test]
fn reviews_keep_their_changed_line_and_resolve_their_side() {
let changed_lines = parse_changed_lines(DIFF);
let mut review_result = review_result(vec![
review("src/foo.rs", Some(4), Some(ReviewSide::Added)),
review("src/foo.rs", Some(2), Some(ReviewSide::Removed)),
review("src/bar.rs", Some(13), None),
]);
resolve_review_sides(&mut review_result, &changed_lines);
let sides = review_result
.reviews
.iter()
.map(|review| review.side)
.collect::<Vec<_>>();
assert_eq!(
sides,
vec![
Some(ReviewSide::Added),
Some(ReviewSide::Removed),
Some(ReviewSide::Added)
]
);
}
#[test]
fn a_line_changed_on_both_sides_defaults_to_added() {
let changed_lines = parse_changed_lines(DIFF);
let mut review_result = review_result(vec![review("src/foo.rs", Some(2), None)]);
resolve_review_sides(&mut review_result, &changed_lines);
assert_eq!(review_result.reviews[0].side, Some(ReviewSide::Added));
}
#[test]
fn reviews_outside_changed_lines_are_dropped() {
let changed_lines = parse_changed_lines(DIFF);
let mut review_result = review_result(vec![
// valid
review("src/foo.rs", Some(2), Some(ReviewSide::Added)),
// a line that exists but is not part of the change
review("src/foo.rs", Some(3), None),
// a line beyond the change
review("src/foo.rs", Some(999), None),
// a changed line, but on the wrong side
review("src/foo.rs", Some(4), Some(ReviewSide::Removed)),
// a line changed in another file
review("src/foo.rs", Some(11), None),
// an unknown file
review("src/baz.rs", Some(1), None),
// no line at all
review("src/bar.rs", None, None),
]);
resolve_review_sides(&mut review_result, &changed_lines);
assert_eq!(review_result.reviews.len(), 1);
assert_eq!(review_result.reviews[0].filename, "src/foo.rs");
assert_eq!(review_result.reviews[0].line, Some(2));
assert_eq!(review_result.reviews[0].side, Some(ReviewSide::Added));
}
#[test]
fn a_deleted_line_is_kept_as_a_removed_anchor() {
let changed_lines = parse_changed_lines(DELETION_ONLY);
let mut review_result = review_result(vec![
review("src/old.rs", Some(2), Some(ReviewSide::Removed)),
review("src/old.rs", Some(2), Some(ReviewSide::Added)),
]);
resolve_review_sides(&mut review_result, &changed_lines);
assert_eq!(review_result.reviews.len(), 1);
assert_eq!(review_result.reviews[0].side, Some(ReviewSide::Removed));
}
#[test]
fn odd_sides_from_the_model_are_tolerated() {
let changed_lines = parse_changed_lines(DIFF);
let mut review_result: ReviewResult = serde_json::from_str(
r#"{
"reviews": [
{ "filename": "src/foo.rs", "line": 4, "side": "Added", "message": "a" },
{ "filename": "src/foo.rs", "line": 2, "side": "new", "message": "b" },
{ "filename": "src/foo.rs", "line": 2, "side": "REMOVED", "message": "c" },
{ "filename": "src/foo.rs", "line": 2, "side": "banana", "message": "d" },
{ "filename": "src/foo.rs", "line": 5, "message": "e" }
],
"comment": ""
}"#,
)
.unwrap();
resolve_review_sides(&mut review_result, &changed_lines);
let sides = review_result
.reviews
.iter()
.map(|review| review.side)
.collect::<Vec<_>>();
assert_eq!(
sides,
vec![
Some(ReviewSide::Added),
Some(ReviewSide::Added),
Some(ReviewSide::Removed),
Some(ReviewSide::Added),
Some(ReviewSide::Added)
]
);
}
}
@@ -0,0 +1,389 @@
//! Parsing of a pull request unified diff into the lines it changes.
use tracing::warn;
use crate::{domain::review::ReviewSide, gitea::PullRequestFile};
/// The lines a pull request changed, per file.
///
/// Line numbers are the ones Gitea expects to place a review comment:
/// [`ReviewSide::Added`] numbers refer to the new version of the file (sent as
/// `new_position`), [`ReviewSide::Removed`] numbers to the old version (sent as
/// `old_position`).
pub(super) type ChangedLines = Vec<ChangedFile>;
pub(super) struct ChangedFile {
pub(super) filename: String,
pub(super) added: Vec<u64>,
pub(super) removed: Vec<u64>,
}
impl ChangedFile {
fn new(filename: &str) -> Self {
Self {
filename: String::from(filename),
added: Vec::new(),
removed: Vec::new(),
}
}
/// Lines that can be commented on for the given side.
pub(super) fn lines(&self, side: ReviewSide) -> &[u64] {
match side {
ReviewSide::Added => &self.added,
ReviewSide::Removed => &self.removed,
}
}
/// Side a line belongs to, used when the model did not state one.
pub(super) fn side_of(&self, line: u64) -> Option<ReviewSide> {
if self.added.contains(&line) {
Some(ReviewSide::Added)
} else if self.removed.contains(&line) {
Some(ReviewSide::Removed)
} else {
None
}
}
}
/// Lists the lines changed by the diff, per file, on both sides.
///
/// Only the line numbers are kept: the model reads the code itself through the
/// sandbox tools.
pub(super) fn parse_changed_lines(git_diff: &str) -> ChangedLines {
let mut files = Vec::new();
let mut current_file: Option<String> = None;
let mut in_hunk = false;
let mut old_line: u64 = 0;
let mut new_line: u64 = 0;
for line in git_diff.lines() {
if line.starts_with("diff --git ") {
current_file = None;
in_hunk = false;
continue;
}
// `--- a/x` and `+++ b/x` only appear before the first hunk of a file.
// Inside a hunk, a line may legitimately start with them: removing
// `--x` gives `---x`, adding `++i;` gives `+++i;`.
if !in_hunk && (line.starts_with("--- ") || line.starts_with("+++ ")) {
// A header is never a content line: `+++ /dev/null` on a deleted
// file keeps the path of the other side in place.
if let Some(path) = header_file_path(line) {
current_file = Some(path);
}
continue;
}
if line.starts_with("@@") {
if let Some((old_start, new_start)) = parse_hunk_starts(line) {
old_line = old_start;
new_line = new_start;
in_hunk = true;
}
continue;
}
let Some(filename) = current_file.as_deref() else {
continue;
};
match line.as_bytes().first() {
Some(b' ') => {
old_line += 1;
new_line += 1;
}
Some(b'-') => {
changed_file(&mut files, filename).removed.push(old_line);
old_line += 1;
}
Some(b'+') => {
changed_file(&mut files, filename).added.push(new_line);
new_line += 1;
}
// `\ No newline at end of file`, and anything unexpected: a line
// that advances neither side.
_ => {}
}
}
files
}
/// Path of the file on one side of the diff, from a `--- a/<path>` or
/// `+++ b/<path>` header line.
///
/// These lines are the only unambiguous source for the path: the `diff --git`
/// line is cut at the first space, and it names the old path of a renamed file.
/// `None` for `/dev/null`, which leaves the path of the other side in place.
fn header_file_path(line: &str) -> Option<String> {
let (prefix, raw) = match line.strip_prefix("--- ") {
Some(raw) => ("a/", raw),
None => ("b/", line.strip_prefix("+++ ")?),
};
let path = decode_git_path(raw);
Some(String::from(path.strip_prefix(prefix)?))
}
/// Decodes a path as git writes it in a diff header: git wraps it in quotes and
/// escapes the bytes that need it (`\303\251` for `é`) when the path contains
/// non-printable or non-ASCII characters.
fn decode_git_path(raw: &str) -> String {
let Some(quoted) = raw.strip_prefix('"').and_then(|raw| raw.strip_suffix('"')) else {
return String::from(raw);
};
let bytes = quoted.as_bytes();
let mut decoded = Vec::with_capacity(bytes.len());
let mut index = 0;
while let Some(byte) = bytes.get(index) {
index += 1;
if *byte != b'\\' {
decoded.push(*byte);
continue;
}
match bytes.get(index) {
// Octal escapes are the ones that matter for a path: git uses them
// for every non-ASCII byte.
Some(digit @ b'0'..=b'7') => {
let mut value = u32::from(digit - b'0');
let mut digits = 1;
while digits < 3 {
match bytes.get(index + digits) {
Some(next @ b'0'..=b'7') => {
value = value * 8 + u32::from(next - b'0');
digits += 1;
}
_ => break,
}
}
decoded.push(u8::try_from(value).unwrap_or(b'?'));
index += digits;
}
Some(escaped) => {
decoded.push(match escaped {
b't' => b'\t',
b'n' => b'\n',
b'r' => b'\r',
other => *other,
});
index += 1;
}
None => decoded.push(b'\\'),
}
}
String::from_utf8_lossy(&decoded).into_owned()
}
/// Replaces the paths parsed from the diff with the exact paths reported by the
/// API, which are the ones the model sees in the sandbox.
pub(super) fn resolve_filenames(changed_lines: &mut ChangedLines, files: &[PullRequestFile]) {
for changed in changed_lines.iter_mut() {
let parsed = changed.filename.as_str();
let Some(file) = files.iter().find(|file| file.filename == parsed) else {
if !files.is_empty() {
warn!(path = %parsed, "Changed file is not in the pull request file list");
}
continue;
};
changed.filename = file.filename.clone();
}
}
/// Basenames of generated files that are never reviewed.
///
/// Their diffs are machine-written dependency churn: they run to thousands of
/// lines, flood the prompt with line numbers and drown the code the model should
/// look at. A lockfile is also never a place where a review comment belongs.
const IGNORED_FILE_NAMES: [&str; 16] = [
"Cargo.lock",
"package-lock.json",
"npm-shrinkwrap.json",
"yarn.lock",
"pnpm-lock.yaml",
"bun.lock",
"bun.lockb",
"composer.lock",
"Gemfile.lock",
"poetry.lock",
"uv.lock",
"Pipfile.lock",
"go.sum",
"packages.lock.json",
"flake.lock",
"pubspec.lock",
];
/// Whether a changed path is a generated file that is never reviewed.
///
/// The comparison is on the basename, so a lockfile nested in a workspace (for
/// example `crates/foo/Cargo.lock`) is matched too.
pub(super) fn is_ignored(filename: &str) -> bool {
let basename = filename.rsplit('/').next().unwrap_or(filename);
IGNORED_FILE_NAMES.contains(&basename)
}
/// Drops the generated files from the changed lines.
///
/// Removing them here keeps the prompt free of their line numbers, and also makes
/// [`resolve_review_sides`] reject any review the model anchors on one of them.
pub(super) fn drop_generated_files(changed_lines: &mut ChangedLines) {
changed_lines.retain(|file| !is_ignored(&file.filename));
}
/// Borrows the entry of `files` for a file, creating it on its first change.
fn changed_file<'a>(files: &'a mut ChangedLines, filename: &str) -> &'a mut ChangedFile {
let index = match files.iter().position(|file| file.filename == filename) {
Some(index) => index,
None => {
files.push(ChangedFile::new(filename));
files.len() - 1
}
};
&mut files[index]
}
/// Extracts the old and new starting line numbers of a hunk header such as
/// `@@ -12,3 +12,5 @@`. The counts are optional and git may append a section
/// heading after the closing `@@`.
fn parse_hunk_starts(hunk_header: &str) -> Option<(u64, u64)> {
let body = hunk_header.strip_prefix("@@ ")?;
let body = body.split(" @@").next()?;
let (old, new) = body.split_once(" +")?;
let old = old.strip_prefix('-')?.split(',').next()?;
let new = new.split(',').next()?;
Some((old.parse().ok()?, new.parse().ok()?))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::bot_actions::review::{fixtures::*, prompt::format_changed_lines};
#[test]
fn a_deletion_only_pull_request_lists_removed_lines() {
let expected = "src/old.rs: removed 1, 2, 3";
assert_eq!(
format_changed_lines(&parse_changed_lines(DELETION_ONLY)),
expected
);
}
#[test]
fn hunk_content_starting_with_plus_or_minus_is_counted() {
let expected = "src/tricky.js: added 2 / removed 2";
assert_eq!(
format_changed_lines(&parse_changed_lines(TRICKY_CONTENT)),
expected
);
}
#[test]
fn a_renamed_file_uses_its_new_path() {
let expected = "src/new.rs: added 1 / removed 1";
assert_eq!(
format_changed_lines(&parse_changed_lines(RENAMED)),
expected
);
}
#[test]
fn a_path_with_a_space_is_read_from_the_headers() {
let expected = "src/my file.rs: added 2";
assert_eq!(
format_changed_lines(&parse_changed_lines(PATH_WITH_SPACE)),
expected
);
}
#[test]
fn a_quoted_path_is_decoded() {
let expected = "docs/café.md: added 2";
assert_eq!(
format_changed_lines(&parse_changed_lines(QUOTED_PATH)),
expected
);
}
#[test]
fn filenames_are_resolved_against_the_api_list() {
let mut changed_lines = parse_changed_lines(DIFF);
let files = vec![
pull_request_file("src/bar.rs", None, "modified"),
pull_request_file("src/foo.rs", Some("src/renamed.rs"), "renamed"),
];
resolve_filenames(&mut changed_lines, &files);
assert_eq!(changed_lines[0].filename, "src/foo.rs");
assert_eq!(changed_lines[1].filename, "src/bar.rs");
}
#[test]
fn a_file_absent_from_the_api_list_keeps_the_diff_path() {
let mut changed_lines = parse_changed_lines(DIFF);
let files = vec![pull_request_file("src/bar.rs", None, "modified")];
resolve_filenames(&mut changed_lines, &files);
assert_eq!(changed_lines[0].filename, "src/foo.rs");
assert_eq!(changed_lines[1].filename, "src/bar.rs");
}
#[test]
fn generated_lockfiles_are_ignored() {
assert!(is_ignored("Cargo.lock"));
assert!(is_ignored("crates/steep-server/Cargo.lock"));
assert!(is_ignored("package-lock.json"));
assert!(is_ignored("web/yarn.lock"));
assert!(is_ignored("go.sum"));
assert!(!is_ignored("src/main.rs"));
assert!(!is_ignored("Cargo.toml"));
assert!(!is_ignored("docs/lockfiles.md"));
}
#[test]
fn generated_files_are_dropped_from_the_changed_lines() {
const DIFF_WITH_LOCKFILE: &str = concat!(
"diff --git a/Cargo.lock b/Cargo.lock\n",
"--- a/Cargo.lock\n",
"+++ b/Cargo.lock\n",
"@@ -1,1 +1,2 @@\n",
" [[package]]\n",
"+name = \"x\"\n",
"diff --git a/src/foo.rs b/src/foo.rs\n",
"--- a/src/foo.rs\n",
"+++ b/src/foo.rs\n",
"@@ -1,1 +1,2 @@\n",
" fn a() {}\n",
"+fn b() {}\n",
);
let mut changed_lines = parse_changed_lines(DIFF_WITH_LOCKFILE);
drop_generated_files(&mut changed_lines);
assert_eq!(format_changed_lines(&changed_lines), "src/foo.rs: added 2");
}
}
@@ -0,0 +1,119 @@
//! Shared fixtures of the review tests.
use crate::{
domain::review::{ReviewItem, ReviewResult, ReviewSide},
gitea::PullRequestFile,
};
/// Additions, a removal and a line changed on both sides.
pub(super) const DIFF: &str = concat!(
"diff --git a/src/foo.rs b/src/foo.rs\n",
"--- a/src/foo.rs\n",
"+++ b/src/foo.rs\n",
"@@ -1,4 +1,6 @@\n",
" fn main() {\n",
"+ let x = 1;\n",
"- let removed = 0;\n",
" println!(\"hello\");\n",
"+ let y = 2;\n",
"+ let z = 3;\n",
" }\n",
"diff --git a/src/bar.rs b/src/bar.rs\n",
"--- a/src/bar.rs\n",
"+++ b/src/bar.rs\n",
"@@ -10,4 +10,6 @@\n",
" old context\n",
"+ let a = 10;\n",
" more context\n",
"+ let b = 20;\n",
);
/// A pull request that only deletes a file.
pub(super) const DELETION_ONLY: &str = concat!(
"diff --git a/src/old.rs b/src/old.rs\n",
"deleted file mode 100644\n",
"--- a/src/old.rs\n",
"+++ /dev/null\n",
"@@ -1,3 +0,0 @@\n",
"-fn a() {}\n",
"-fn b() {}\n",
"-fn c() {}\n",
);
/// A hunk whose content starts with `+++` / `---`, which must not be taken
/// for the file headers.
pub(super) const TRICKY_CONTENT: &str = concat!(
"diff --git a/src/tricky.js b/src/tricky.js\n",
"--- a/src/tricky.js\n",
"+++ b/src/tricky.js\n",
"@@ -1,4 +1,4 @@\n",
" let i = 0;\n",
"+++i;\n",
"---x;\n",
" console.log(i);\n",
);
/// A pull request that renames a file.
pub(super) const RENAMED: &str = concat!(
"diff --git a/src/old.rs b/src/new.rs\n",
"similarity index 50%\n",
"rename from src/old.rs\n",
"rename to src/new.rs\n",
"--- a/src/old.rs\n",
"+++ b/src/new.rs\n",
"@@ -1,1 +1,1 @@\n",
"-fn old() {}\n",
"+fn new() {}\n",
);
/// A file whose path contains a space, which the `diff --git` line cannot
/// express without ambiguity.
pub(super) const PATH_WITH_SPACE: &str = concat!(
"diff --git a/src/my file.rs b/src/my file.rs\n",
"--- a/src/my file.rs\n",
"+++ b/src/my file.rs\n",
"@@ -1,1 +1,2 @@\n",
" fn a() {}\n",
"+fn b() {}\n",
);
/// A file whose path git quotes and escapes (`caf\\303\\251.md` is
/// `caf\u{e9}.md`).
pub(super) const QUOTED_PATH: &str = concat!(
"diff --git \"a/docs/caf\\303\\251.md\" \"b/docs/caf\\303\\251.md\"\n",
"--- \"a/docs/caf\\303\\251.md\"\n",
"+++ \"b/docs/caf\\303\\251.md\"\n",
"@@ -1,1 +1,2 @@\n",
" intro\n",
"+ajout\n",
);
pub(super) fn review(filename: &str, line: Option<u64>, side: Option<ReviewSide>) -> ReviewItem {
ReviewItem {
filename: String::from(filename),
line,
side,
severity: None,
message: String::from("message"),
}
}
pub(super) fn review_result(reviews: Vec<ReviewItem>) -> ReviewResult {
ReviewResult {
reviews,
comment: String::new(),
cost: None,
}
}
pub(super) fn pull_request_file(
filename: &str,
previous_filename: Option<&str>,
status: &str,
) -> PullRequestFile {
PullRequestFile {
filename: String::from(filename),
previous_filename: previous_filename.map(String::from),
status: String::from(status),
}
}
@@ -0,0 +1,210 @@
//! The pull request review action.
//!
//! [`exec_review`] drives one review end to end. The work is split by concern:
//! - [`diff`] parses the pull request diff into the lines it changes,
//! - [`prompt`] renders those changes for the model,
//! - [`anchor`] checks that the model's reviews land on changed lines,
//! - [`render`] builds the markdown summary posted on the pull request.
mod anchor;
mod diff;
mod prompt;
mod render;
#[cfg(test)]
mod fixtures;
use tracing::{info, instrument, warn};
use crate::{
consts::{BOT_PROCESS_MSG, REVIEW_PROMPT, SANDBOX_SYSTEM_PROMPT},
domain::review::ReviewResult,
event_bus::Reporter,
gitea::{GiteaAPI, ReviewPayload},
metrics,
open_router::{OpenRouterClient, Tool},
sandbox::{Sandbox, SandboxConfig, agent},
};
use self::{
anchor::resolve_review_sides,
diff::{drop_generated_files, parse_changed_lines, resolve_filenames},
prompt::format_changes,
render::review_result_to_markdown,
};
/// The services a review needs, borrowed from the bot for the duration of one job.
pub struct ReviewContext<'a> {
pub gitea: &'a GiteaAPI,
pub open_router: &'a OpenRouterClient,
pub sandbox: &'a SandboxConfig,
pub reporter: &'a Reporter,
}
pub struct ReviewOutcome {
pub markdown: String,
pub cost: Option<f64>,
pub comments: u64,
}
impl ReviewOutcome {
pub fn empty() -> Self {
Self {
markdown: String::new(),
cost: None,
comments: 0,
}
}
}
#[instrument(
skip_all,
fields(repo = %payload.repository.full_name, pr = payload.pull_request.number)
)]
pub async fn exec_review(
ctx: &ReviewContext<'_>,
tools: Vec<Tool>,
payload: &ReviewPayload,
) -> anyhow::Result<ReviewOutcome> {
info!(action = %payload.action, "Starting review");
let full_name = &payload.repository.full_name;
let index = payload.pull_request.number;
let progress_comment = ctx
.gitea
.comment(
&BOT_PROCESS_MSG.replace("{model}", ctx.open_router.model()),
full_name,
index,
)
.await?;
let review = review_pull_request(ctx, tools, payload).await;
// The progress comment is housekeeping: failing to update it must neither turn a
// posted review into a failure nor hide the error that made the review fail.
match review {
Ok((review_result, markdown)) => {
if let Err(err) = ctx
.gitea
.delete_comment(full_name, progress_comment.id)
.await
{
warn!(%err, "Failed to delete the progress comment");
}
Ok(ReviewOutcome {
markdown,
cost: review_result.cost,
comments: review_result.reviews.len() as u64,
})
}
Err(err) => {
if let Err(edit_err) = ctx
.gitea
.edit_comment(
&format!("Error while reviewing: {err}"),
full_name,
progress_comment.id,
)
.await
{
warn!(err = %edit_err, "Failed to report the error on the progress comment");
}
Err(err)
}
}
}
/// Runs the review and posts it, returning the result and the markdown that was
/// posted.
async fn review_pull_request(
ctx: &ReviewContext<'_>,
tools: Vec<Tool>,
payload: &ReviewPayload,
) -> anyhow::Result<(ReviewResult, String)> {
let full_name = &payload.repository.full_name;
let index = payload.pull_request.number;
let git_diff = ctx.gitea.pull_request_diff(full_name, index).await?;
// The file list only refines the paths and describes the changes: a failure is
// not fatal, the diff is the source of truth for the lines.
let files = match ctx.gitea.pull_request_files(full_name, index).await {
Ok(files) => files,
Err(err) => {
warn!(%err, "Failed to list the pull request files");
Vec::new()
}
};
let mut changed_lines = parse_changed_lines(&git_diff);
drop_generated_files(&mut changed_lines);
resolve_filenames(&mut changed_lines, &files);
let changes = format_changes(&files, &changed_lines);
let bot_request = REVIEW_PROMPT
.replace("{subject}", &payload.pull_request.title)
.replace("{comment}", &payload.comment.body)
.replace("{changes}", &changes);
let (mut review_result, cost) = run_sandboxed_review(ctx, tools, payload, &bot_request).await?;
resolve_review_sides(&mut review_result, &changed_lines);
review_result.cost = cost;
if let Some(cost) = review_result.cost {
metrics::openrouter_cost_usd(cost);
}
let markdown = review_result_to_markdown(&review_result);
ctx.gitea
.post_pull_request_review(&review_result, &markdown, full_name, index)
.await?;
Ok((review_result, markdown))
}
async fn run_sandboxed_review(
ctx: &ReviewContext<'_>,
tools: Vec<Tool>,
payload: &ReviewPayload,
bot_request: &str,
) -> anyhow::Result<(ReviewResult, Option<f64>)> {
let sandbox = Sandbox::create(
&ctx.sandbox.runtime,
&payload.repository.clone_url,
ctx.gitea.token(),
payload.pull_request.number,
ctx.reporter,
)
.await?;
let result = agent::run(
ctx.open_router,
&sandbox,
tools,
SANDBOX_SYSTEM_PROMPT,
bot_request,
ctx.sandbox.max_iterations,
ctx.reporter,
)
.await;
if let Err(err) = sandbox.cleanup().await {
warn!(%err, "Failed to clean up sandbox container");
}
let result = result?;
info!(
iterations = result.iterations,
cost = ?result.cost,
"Sandboxed review finished"
);
Ok((result.answer, result.cost))
}
@@ -0,0 +1,125 @@
//! Rendering of a pull request's changes for the model.
use super::diff::{ChangedLines, is_ignored};
use crate::gitea::PullRequestFile;
/// Renders the changes for the model: the files the pull request touches, then
/// the lines to review per file.
pub(super) fn format_changes(files: &[PullRequestFile], changed_lines: &ChangedLines) -> String {
let mut sections = Vec::new();
let described = files
.iter()
.filter(|file| !is_ignored(&file.filename))
.map(describe_file)
.collect::<Vec<_>>()
.join(", ");
if !described.is_empty() {
sections.push(format!("Files changed by the pull request: {described}"));
}
sections.push(format!(
"Lines to review, per file:\n{}",
format_changed_lines(changed_lines)
));
sections.join("\n\n")
}
/// Describes a changed file for the model, including how it changed.
fn describe_file(file: &PullRequestFile) -> String {
match &file.previous_filename {
Some(previous) => format!("{} ({} from {})", file.filename, file.status, previous),
None => format!("{} ({})", file.filename, file.status),
}
}
/// Renders the changed lines as `filename: added 1, 2 / removed 3`, one file per
/// line, keeping only the sides the file actually has.
pub(super) fn format_changed_lines(changed_lines: &ChangedLines) -> String {
changed_lines
.iter()
.map(|file| {
let mut sides = Vec::new();
for (label, lines) in [("added", &file.added), ("removed", &file.removed)] {
if !lines.is_empty() {
sides.push(format!("{label} {}", format_line_numbers(lines)));
}
}
format!("{}: {}", file.filename, sides.join(" / "))
})
.collect::<Vec<_>>()
.join("\n")
}
fn format_line_numbers(lines: &[u64]) -> String {
lines
.iter()
.map(u64::to_string)
.collect::<Vec<_>>()
.join(", ")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::bot_actions::review::{diff::parse_changed_lines, fixtures::*};
#[test]
fn changed_lines_are_listed_per_file_and_side() {
let expected = concat!(
"src/foo.rs: added 2, 4, 5 / removed 2\n",
"src/bar.rs: added 11, 13"
);
assert_eq!(format_changed_lines(&parse_changed_lines(DIFF)), expected);
}
#[test]
fn changes_describe_the_files_then_the_lines() {
let changed_lines = parse_changed_lines(DELETION_ONLY);
let files = vec![pull_request_file("src/old.rs", None, "deleted")];
let expected = concat!(
"Files changed by the pull request: src/old.rs (deleted)\n",
"\n",
"Lines to review, per file:\n",
"src/old.rs: removed 1, 2, 3"
);
assert_eq!(format_changes(&files, &changed_lines), expected);
}
#[test]
fn changes_without_the_api_list_only_hold_the_lines() {
let changed_lines = parse_changed_lines(DELETION_ONLY);
let expected = "Lines to review, per file:\nsrc/old.rs: removed 1, 2, 3";
assert_eq!(format_changes(&[], &changed_lines), expected);
}
#[test]
fn a_generated_file_is_not_described_to_the_model() {
let files = vec![
pull_request_file("Cargo.lock", None, "modified"),
pull_request_file("src/foo.rs", None, "modified"),
];
let changed_lines = parse_changed_lines(DIFF);
let changes = format_changes(&files, &changed_lines);
assert!(!changes.contains("Cargo.lock"));
assert!(changes.contains("src/foo.rs (modified)"));
}
#[test]
fn a_renamed_file_is_described_with_its_previous_path() {
let file = pull_request_file("src/new.rs", Some("src/old.rs"), "renamed");
assert_eq!(describe_file(&file), "src/new.rs (renamed from src/old.rs)");
}
}
@@ -0,0 +1,110 @@
//! Markdown summary of a review, as posted on the pull request.
use crate::domain::review::{ReviewItem, ReviewResult, ReviewSeverity};
pub(super) fn review_result_to_markdown(review_result: &ReviewResult) -> String {
if review_result.reviews.is_empty() {
return String::from("No issues found. ✅");
}
let mut md = String::from("## Review Feedback\n\n");
md.push_str(&format!(
"### {} issues found.\n\n",
review_result.reviews.len()
));
let breakdown = severity_breakdown(&review_result.reviews);
if !breakdown.is_empty() {
md.push_str(&breakdown);
md.push('\n');
}
if !review_result.comment.is_empty() {
md.push_str("\n---\n\n");
md.push_str("### Summary\n\n");
md.push_str(&review_result.comment);
md.push('\n');
}
if let Some(cost) = review_result.cost {
md.push_str("\n---\n\n");
md.push_str(&format!("### Cost: ${}", cost));
md.push('\n');
}
md
}
/// Counts the reviews per severity, most severe first.
///
/// A review whose severity the model omitted or wrote unreadably is counted as
/// [`ReviewSeverity::Maintainability`], the same fallback used when posting it.
fn severity_breakdown(reviews: &[ReviewItem]) -> String {
[
ReviewSeverity::Bug,
ReviewSeverity::Security,
ReviewSeverity::Performance,
ReviewSeverity::Maintainability,
]
.into_iter()
.filter_map(|severity| {
let count = reviews
.iter()
.filter(|review| review.severity.unwrap_or(ReviewSeverity::Maintainability) == severity)
.count();
(count > 0).then(|| format!("- {} {}", count, severity.label()))
})
.collect::<Vec<_>>()
.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::bot_actions::review::fixtures::*;
#[test]
fn the_breakdown_counts_severities_most_severe_first() {
let reviews = vec![
ReviewItem {
severity: Some(ReviewSeverity::Maintainability),
..review("a", Some(1), None)
},
ReviewItem {
severity: Some(ReviewSeverity::Bug),
..review("b", Some(2), None)
},
ReviewItem {
severity: Some(ReviewSeverity::Bug),
..review("c", Some(3), None)
},
// A severity the model left out counts as maintainability.
review("d", Some(4), None),
];
assert_eq!(severity_breakdown(&reviews), "- 2 bug\n- 2 maintainability");
}
#[test]
fn the_markdown_breaks_the_issues_down_by_severity() {
let mut result = review_result(vec![
ReviewItem {
severity: Some(ReviewSeverity::Bug),
..review("a", Some(1), None)
},
ReviewItem {
severity: Some(ReviewSeverity::Performance),
..review("b", Some(2), None)
},
]);
result.comment = String::from("Le découpage en crates est propre.");
let markdown = review_result_to_markdown(&result);
assert!(markdown.contains("- 1 bug"));
assert!(markdown.contains("- 1 performance"));
assert!(markdown.contains("Le découpage en crates est propre."));
}
}
+20
View File
@@ -0,0 +1,20 @@
use toasty::Db;
pub async fn open(url: &str) -> toasty::Result<Db> {
Db::builder()
.models(toasty::models!(crate::*))
.connect(url)
.await
}
/// A connection for a test, on the database `DATABASE_URL` points to.
///
/// The schema must already be migrated: `scripts/migrate-apply.sh` does it, the same
/// way it does for the server. Each test opens its own connection, because the
/// connection tasks live on the runtime of the test that opened them.
#[cfg(test)]
qpismont marked this conversation as resolved
Review

[bug] Au démarrage, tous les jobs Processing sont remis à Pending sans notion de propriétaire ou de lease. Si deux instances tournent simultanément, le démarrage de l'une peut réenfiler les jobs activement traités par l'autre, en plus de la course déjà présente dans le poller. La récupération après crash doit être limitée aux jobs dont le lease a expiré, et le traitement doit être atomiquement réservé.

**[bug]** Au démarrage, tous les jobs `Processing` sont remis à `Pending` sans notion de propriétaire ou de lease. Si deux instances tournent simultanément, le démarrage de l'une peut réenfiler les jobs activement traités par l'autre, en plus de la course déjà présente dans le poller. La récupération après crash doit être limitée aux jobs dont le lease a expiré, et le traitement doit être atomiquement réservé.
Review

[bug] Au démarrage, chaque instance remet à Pending tous les jobs Processing, y compris ceux qu’une autre instance est encore en train de traiter. Un redémarrage ou déploiement pendant une review peut donc la faire exécuter en double. La récupération doit distinguer les jobs réellement abandonnés de ceux détenus par des workers actifs.

**[bug]** Au démarrage, chaque instance remet à `Pending` tous les jobs `Processing`, y compris ceux qu’une autre instance est encore en train de traiter. Un redémarrage ou déploiement pendant une review peut donc la faire exécuter en double. La récupération doit distinguer les jobs réellement abandonnés de ceux détenus par des workers actifs.
pub(crate) async fn test_connect() -> Db {
let url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
open(&url).await.expect("connect to postgres")
}
+1
View File
@@ -0,0 +1 @@
pub mod review;
+247
View File
@@ -0,0 +1,247 @@
//! Domain types of a review: what the model answers with, and how it is read.
use std::str::FromStr;
use serde::{Deserialize, Deserializer};
use tracing::warn;
use crate::text::excerpt;
#[derive(Deserialize, Debug)]
pub struct ReviewResult {
pub reviews: Vec<ReviewItem>,
pub comment: String,
pub cost: Option<f64>,
}
#[derive(Deserialize, Debug)]
pub struct ReviewItem {
pub filename: String,
pub line: Option<u64>,
#[serde(default, deserialize_with = "deserialize_side")]
pub side: Option<ReviewSide>,
#[serde(default, deserialize_with = "deserialize_severity")]
pub severity: Option<ReviewSeverity>,
pub message: String,
}
/// Which version of the file a review comment is anchored on.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReviewSide {
/// The line was added by the pull request: `line` is a line number of the
/// new version of the file.
Added,
/// The line was removed by the pull request: `line` is a line number of the
/// old version of the file.
Removed,
}
impl ReviewSide {
/// Reads the side the model asked for, tolerating casing and synonyms.
fn parse(raw: &str) -> Option<Self> {
match raw.trim().to_ascii_lowercase().as_str() {
"added" | "add" | "new" | "right" => Some(Self::Added),
"removed" | "remove" | "deleted" | "delete" | "old" | "left" => Some(Self::Removed),
_ => None,
}
}
}
/// What kind of problem a review reports.
///
/// The categories are the ones the prompt asks for; a review whose severity is
/// missing or unreadable falls back to [`ReviewSeverity::Maintainability`], the
/// least alarming bucket, instead of failing the whole review.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReviewSeverity {
/// Wrong behaviour.
Bug,
/// A vulnerability.
Security,
/// A resource problem.
Performance,
/// Everything else: readability, structure, naming, tests.
Maintainability,
}
impl ReviewSeverity {
/// Reads the severity the model asked for, tolerating casing and synonyms.
fn parse(raw: &str) -> Option<Self> {
match raw.trim().to_ascii_lowercase().as_str() {
"bug" | "bugs" | "correctness" | "error" => Some(Self::Bug),
"security" | "vulnerability" => Some(Self::Security),
"performance" | "perf" => Some(Self::Performance),
"maintainability" | "quality" | "style" => Some(Self::Maintainability),
_ => None,
}
}
/// Lower-case label used in the review markdown.
pub fn label(self) -> &'static str {
match self {
Self::Bug => "bug",
Self::Security => "security",
Self::Performance => "performance",
Self::Maintainability => "maintainability",
}
}
}
/// Reads the side the model asked for. An unreadable value is ignored instead of
/// failing the whole review: the side is then resolved from the changed lines.
fn deserialize_side<'de, D>(deserializer: D) -> Result<Option<ReviewSide>, D::Error>
where
D: Deserializer<'de>,
{
let raw = Option::<serde_json::Value>::deserialize(deserializer)?;
Ok(raw
.as_ref()
.and_then(serde_json::Value::as_str)
.and_then(ReviewSide::parse))
}
/// Reads the severity the model asked for, ignoring an unreadable value: the
/// review is then reported as [`ReviewSeverity::Maintainability`].
fn deserialize_severity<'de, D>(deserializer: D) -> Result<Option<ReviewSeverity>, D::Error>
where
D: Deserializer<'de>,
{
let raw = Option::<serde_json::Value>::deserialize(deserializer)?;
Ok(raw
.as_ref()
.and_then(serde_json::Value::as_str)
.and_then(ReviewSeverity::parse))
}
/// Number of characters of a model answer kept in the logs when it cannot be
/// parsed.
const MAX_LOGGED_ANSWER: usize = 500;
impl FromStr for ReviewResult {
type Err = anyhow::Error;
/// Parses the review the model answered with.
///
/// This is the contract the agent loop enforces: a rejected answer is sent
/// back to the model, with the reason, so that it can correct itself.
///
/// The contract is a raw JSON object, but models sometimes wrap it in a
/// markdown code fence or surround it with a sentence: the object is then
/// extracted from the answer before failing, and the answer is logged so a
/// breach of the contract can be diagnosed.
fn from_str(message: &str) -> Result<Self, Self::Err> {
let error = match serde_json::from_str::<Self>(message) {
Ok(review_result) => return Ok(review_result),
Err(error) => error,
};
// A markdown code fence or a sentence around the object is tolerated, with
// a warning: the contract asks for a raw JSON object.
if let Some(json) = json_object(message)
&& let Ok(review_result) = serde_json::from_str::<Self>(json)
{
warn!(
"Model answer is not a raw JSON object, it was extracted from the surrounding text"
);
return Ok(review_result);
}
// The reason of the rejection is logged along with the answer: without it,
// a broken answer is impossible to diagnose.
warn!(
answer = %excerpt(message, MAX_LOGGED_ANSWER),
reason = %error,
"Model answer is not the expected JSON"
);
anyhow::bail!("the answer is not valid JSON: {error}")
}
}
/// Returns the outermost `{...}` of an answer, which ignores a markdown code
/// fence or any text around it.
fn json_object(message: &str) -> Option<&str> {
let start = message.find('{')?;
let end = message.rfind('}')?;
(start < end).then(|| &message[start..=end])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_raw_answer_is_parsed() {
let answer = r#"{"reviews": [], "comment": "ok"}"#;
assert_eq!(answer.parse::<ReviewResult>().unwrap().comment, "ok");
}
#[test]
fn a_fenced_answer_is_extracted() {
let answer = concat!(
"Voici ma review :\n",
"```json\n",
"{\"reviews\": [], \"comment\": \"rien à signaler\"}\n",
"```\n"
);
assert_eq!(
answer.parse::<ReviewResult>().unwrap().comment,
"rien à signaler"
);
}
#[test]
fn a_sentence_around_the_object_is_ignored() {
let answer = r#"Rien à signaler. {"reviews": [], "comment": "ok"} Bonne journée !"#;
assert_eq!(answer.parse::<ReviewResult>().unwrap().comment, "ok");
}
#[test]
fn an_answer_without_json_is_rejected() {
assert!("Je n'ai rien relevé.".parse::<ReviewResult>().is_err());
}
#[test]
fn an_answer_that_is_an_object_but_not_a_review_is_rejected() {
assert!(r#"{"message": "LGTM"}"#.parse::<ReviewResult>().is_err());
}
#[test]
fn severities_are_read_from_the_model() {
let result: ReviewResult = serde_json::from_str(
r#"{
"reviews": [
{ "filename": "a", "line": 1, "severity": "Bug", "message": "x" },
{ "filename": "b", "line": 2, "severity": "SECURITY", "message": "x" },
{ "filename": "c", "line": 3, "severity": "perf", "message": "x" },
{ "filename": "d", "line": 4, "severity": "banana", "message": "x" }
],
"comment": ""
}"#,
)
.unwrap();
let severities = result
.reviews
.iter()
.map(|review| review.severity)
.collect::<Vec<_>>();
assert_eq!(
severities,
vec![
Some(ReviewSeverity::Bug),
Some(ReviewSeverity::Security),
Some(ReviewSeverity::Performance),
None,
]
);
}
}
+98 -30
View File
@@ -1,4 +1,9 @@
use anyhow::anyhow; use std::{str::FromStr, time::Duration};
use anyhow::Context;
/// Number of tool-calling iterations a sandboxed review may take, unless configured.
const DEFAULT_SANDBOX_MAX_ITERATIONS: usize = 8;
#[derive(Clone)] #[derive(Clone)]
pub struct EnvConfig { pub struct EnvConfig {
@@ -6,43 +11,31 @@ pub struct EnvConfig {
pub webhook_secret: String, pub webhook_secret: String,
pub open_router_api_key: String, pub open_router_api_key: String,
pub open_router_model: String, pub open_router_model: String,
pub open_router_timeout: u64, pub open_router_timeout: Duration,
pub bot_max_concurrent: usize, pub bot_max_concurrent: usize,
pub gitea_url: String, pub gitea_url: String,
pub gitea_token: String, pub gitea_token: String,
pub gitea_timeout: u64, pub gitea_timeout: Duration,
pub database_url: String,
pub metrics_bind_addr: Option<String>, pub metrics_bind_addr: Option<String>,
pub sandbox_max_iterations: usize, pub sandbox_max_iterations: usize,
} }
pub fn load_config() -> anyhow::Result<EnvConfig> { pub fn load_config() -> anyhow::Result<EnvConfig> {
let http_port = try_get_env("HTTP_PORT")?.parse()?;
let webhook_secret = try_get_env("WEBHOOK_SIG_HEADER_SECRET")?;
let open_router_api_key = try_get_env("OPEN_ROUTER_API_KEY")?;
let open_router_model = try_get_env("OPEN_ROUTER_MODEL")?;
let open_router_timeout = try_get_env("OPEN_ROUTER_TIMEOUT")?.parse()?;
let bot_max_concurrent = try_get_env("BOT_MAX_CONCURRENT")?.parse()?;
let gitea_url = try_get_env("GITEA_URL")?;
let gitea_token = try_get_env("GITEA_TOKEN")?;
let gitea_timeout = try_get_env("GITEA_TIMEOUT")?.parse()?;
let metrics_bind_addr = std::env::var("METRICS_BIND_ADDR").ok();
let sandbox_max_iterations = std::env::var("SANDBOX_MAX_ITERATIONS")
.ok()
.and_then(|value| value.parse().ok())
.unwrap_or(8);
Ok(EnvConfig { Ok(EnvConfig {
http_port, http_port: parse_env("HTTP_PORT")?,
webhook_secret, webhook_secret: try_get_env("WEBHOOK_SIG_HEADER_SECRET")?,
open_router_api_key, open_router_api_key: try_get_env("OPEN_ROUTER_API_KEY")?,
open_router_model, open_router_model: try_get_env("OPEN_ROUTER_MODEL")?,
open_router_timeout, open_router_timeout: parse_seconds_env("OPEN_ROUTER_TIMEOUT")?,
bot_max_concurrent, bot_max_concurrent: parse_env("BOT_MAX_CONCURRENT")?,
gitea_url, gitea_url: try_get_env("GITEA_URL")?,
gitea_token, gitea_token: try_get_env("GITEA_TOKEN")?,
gitea_timeout, gitea_timeout: parse_seconds_env("GITEA_TIMEOUT")?,
metrics_bind_addr, database_url: try_get_env("DATABASE_URL")?,
sandbox_max_iterations, metrics_bind_addr: std::env::var("METRICS_BIND_ADDR").ok(),
sandbox_max_iterations: parse_optional_env("SANDBOX_MAX_ITERATIONS")?
.unwrap_or(DEFAULT_SANDBOX_MAX_ITERATIONS),
}) })
} }
@@ -50,16 +43,57 @@ pub fn try_get_env(key: &str) -> anyhow::Result<String> {
let env_value = std::env::var(key).map_err(|e| anyhow::anyhow!("{}: {}", key, e))?; let env_value = std::env::var(key).map_err(|e| anyhow::anyhow!("{}: {}", key, e))?;
if env_value.trim().is_empty() { if env_value.trim().is_empty() {
return Err(anyhow!(format!("env var {} is empty", key))); anyhow::bail!("env var {key} is empty");
} }
Ok(env_value) Ok(env_value)
} }
/// Reads a required variable and parses it, naming the variable when it is invalid.
fn parse_env<T>(key: &str) -> anyhow::Result<T>
where
T: FromStr,
T::Err: std::error::Error + Send + Sync + 'static,
{
parse_value(key, &try_get_env(key)?)
}
/// Reads an optional variable and parses it. A missing or empty variable is `None`;
/// one that is set but invalid is an error, like it is for the required ones.
fn parse_optional_env<T>(key: &str) -> anyhow::Result<Option<T>>
where
T: FromStr,
T::Err: std::error::Error + Send + Sync + 'static,
{
match std::env::var(key) {
Ok(value) if !value.trim().is_empty() => parse_value(key, &value).map(Some),
_ => Ok(None),
}
}
/// Reads a required variable holding a number of seconds.
fn parse_seconds_env(key: &str) -> anyhow::Result<Duration> {
parse_env::<u64>(key).map(Duration::from_secs)
}
fn parse_value<T>(key: &str, value: &str) -> anyhow::Result<T>
where
T: FromStr,
T::Err: std::error::Error + Send + Sync + 'static,
{
value
.trim()
.parse()
.with_context(|| format!("env var {key} is invalid: `{value}`"))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
// Every test uses its own variable: the environment is shared by the tests,
// which run in parallel.
#[test] #[test]
fn test_try_get_env_returns_value() { fn test_try_get_env_returns_value() {
unsafe { std::env::set_var("TEST_ENV_PRESENT", "hello") }; unsafe { std::env::set_var("TEST_ENV_PRESENT", "hello") };
@@ -85,4 +119,38 @@ mod tests {
let err = try_get_env("TEST_ENV_WHITESPACE").unwrap_err(); let err = try_get_env("TEST_ENV_WHITESPACE").unwrap_err();
assert!(err.to_string().contains("TEST_ENV_WHITESPACE")); assert!(err.to_string().contains("TEST_ENV_WHITESPACE"));
} }
#[test]
fn a_timeout_is_read_as_seconds() {
unsafe { std::env::set_var("TEST_ENV_SECONDS", "30") };
assert_eq!(
parse_seconds_env("TEST_ENV_SECONDS").unwrap(),
Duration::from_secs(30)
);
}
#[test]
fn an_invalid_number_names_its_variable() {
unsafe { std::env::set_var("TEST_ENV_NOT_A_NUMBER", "soon") };
let err = parse_env::<u16>("TEST_ENV_NOT_A_NUMBER").unwrap_err();
assert!(format!("{err:#}").contains("TEST_ENV_NOT_A_NUMBER"));
assert!(format!("{err:#}").contains("soon"));
}
#[test]
fn an_absent_optional_variable_is_none() {
unsafe { std::env::remove_var("TEST_ENV_OPTIONAL_ABSENT") };
assert_eq!(
parse_optional_env::<usize>("TEST_ENV_OPTIONAL_ABSENT").unwrap(),
None
);
}
#[test]
fn an_invalid_optional_variable_is_an_error_instead_of_the_default() {
unsafe { std::env::set_var("TEST_ENV_OPTIONAL_INVALID", "many") };
assert!(parse_optional_env::<usize>("TEST_ENV_OPTIONAL_INVALID").is_err());
}
} }
+22 -6
View File
@@ -1,6 +1,6 @@
use anyhow::anyhow;
use axum::response::IntoResponse; use axum::response::IntoResponse;
use reqwest::StatusCode; use reqwest::StatusCode;
use sentry::capture_error;
#[derive(thiserror::Error, Debug)] #[derive(thiserror::Error, Debug)]
pub enum AppError { pub enum AppError {
@@ -22,15 +22,21 @@ pub enum AppError {
#[error("WebHook have bad action")] #[error("WebHook have bad action")]
InvalidActionErr, InvalidActionErr,
#[error("Channel full")] #[error("Could not enqueue task")]
ChannelFullErr, EnqueueErr(#[source] anyhow::Error),
#[error("Already processed")] #[error("Already processed")]
AlreadyProcessedErr, AlreadyProcessedErr,
#[error("Job {0} is not running")]
JobNotFoundErr(u64),
#[error(transparent)] #[error(transparent)]
BadJsonStructErr(#[from] serde_json::Error), BadJsonStructErr(#[from] serde_json::Error),
#[error("Other toasty error")]
OtherToastyErr(#[from] toasty::Error),
#[error(transparent)] #[error(transparent)]
Other(#[from] anyhow::Error), Other(#[from] anyhow::Error),
} }
@@ -62,11 +68,14 @@ impl IntoResponse for AppError {
"WebHook sig header is invalid".to_string(), "WebHook sig header is invalid".to_string(),
), ),
AppError::AlreadyProcessedErr => (StatusCode::OK, "Already processed".to_string()), AppError::AlreadyProcessedErr => (StatusCode::OK, "Already processed".to_string()),
AppError::ChannelFullErr => { AppError::JobNotFoundErr(id) => {
sentry_anyhow::capture_anyhow(&anyhow!("Max concurrent tasks reached")); (StatusCode::NOT_FOUND, format!("job {id} is not running"))
}
AppError::EnqueueErr(err) => {
sentry_anyhow::capture_anyhow(&err);
( (
StatusCode::SERVICE_UNAVAILABLE, StatusCode::SERVICE_UNAVAILABLE,
"Max concurrent tasks reached".to_string(), "Could not enqueue task".to_string(),
) )
} }
AppError::Other(err) => { AppError::Other(err) => {
@@ -76,6 +85,13 @@ impl IntoResponse for AppError {
"Internal server error".to_string(), "Internal server error".to_string(),
) )
} }
AppError::OtherToastyErr(err) => {
capture_error(&err);
(
StatusCode::INTERNAL_SERVER_ERROR,
"Internal server error".to_string(),
)
}
} }
.into_response() .into_response()
} }
+335
View File
@@ -0,0 +1,335 @@
use std::collections::{HashMap, VecDeque};
use std::sync::{Arc, Mutex};
use serde::Serialize;
use tokio::sync::broadcast;
use crate::text::excerpt;
const LIFECYCLE_CAPACITY: usize = 256;
const PROGRESS_CAPACITY: usize = 256;
const HISTORY_CAPACITY: usize = 256;
const MAX_EVENT_ARGUMENTS: usize = 200;
/// Lifecycle event of a job, broadcast globally.
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum JobEvent {
Queued {
id: u64,
repo: String,
pr: u64,
},
Started {
id: u64,
repo: String,
pr: u64,
},
ReviewDone {
id: u64,
repo: String,
pr: u64,
ok: bool,
comments: u64,
duration_ms: u64,
},
Failed {
id: u64,
repo: String,
pr: u64,
},
Skipped {
id: u64,
repo: String,
pr: u64,
},
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Progress {
ToolCall {
iteration: usize,
name: String,
arguments: String,
},
ToolResult {
iteration: usize,
name: String,
truncated: bool,
output: String,
},
Finished {
ok: bool,
},
}
#[derive(Debug)]
struct JobChannel {
tx: broadcast::Sender<Progress>,
history: Mutex<VecDeque<Progress>>,
}
impl JobChannel {
fn subscribe(&self) -> (Vec<Progress>, broadcast::Receiver<Progress>) {
let history = self.history.lock().expect("job history lock poisoned");
let rx = self.tx.subscribe();
(history.iter().cloned().collect(), rx)
}
fn emit(&self, event: Progress) {
let mut history = self.history.lock().expect("job history lock poisoned");
if history.len() >= HISTORY_CAPACITY {
history.pop_front();
}
let _ = self.tx.send(event.clone());
history.push_back(event);
}
}
pub struct JobSubscription {
pub history: Vec<Progress>,
pub live: broadcast::Receiver<Progress>,
}
#[derive(Clone)]
pub struct EventBus {
lifecycle: broadcast::Sender<JobEvent>,
jobs: Arc<Mutex<HashMap<u64, Arc<JobChannel>>>>,
}
impl EventBus {
pub fn new() -> Self {
let (lifecycle, _rx) = broadcast::channel(LIFECYCLE_CAPACITY);
Self {
lifecycle,
jobs: Arc::new(Mutex::new(HashMap::new())),
}
}
pub fn publish(&self, event: JobEvent) {
let _ = self.lifecycle.send(event);
}
pub fn subscribe_lifecycle(&self) -> broadcast::Receiver<JobEvent> {
self.lifecycle.subscribe()
}
pub fn start(&self, id: u64, repo: impl Into<String>, pr: u64) -> Reporter {
let (tx, _rx) = broadcast::channel(PROGRESS_CAPACITY);
let channel = Arc::new(JobChannel {
tx,
history: Mutex::new(VecDeque::new()),
});
self.jobs
.lock()
.expect("job registry lock poisoned")
.insert(id, channel.clone());
self.publish(JobEvent::Started {
id,
repo: repo.into(),
pr,
});
Reporter { channel }
}
pub fn finish(&self, id: u64, repo: &str, pr: u64, ok: bool, comments: u64, duration_ms: u64) {
let event = if ok {
JobEvent::ReviewDone {
id,
repo: repo.to_string(),
pr,
ok,
comments,
duration_ms,
}
} else {
JobEvent::Failed {
id,
repo: repo.to_string(),
pr,
}
};
self.publish(event);
if let Some(channel) = self
.jobs
.lock()
.expect("job registry lock poisoned")
.remove(&id)
{
let _ = channel.tx.send(Progress::Finished { ok });
}
}
pub fn skip(&self, id: u64, repo: &str, pr: u64) {
self.publish(JobEvent::Skipped {
id,
repo: repo.to_string(),
pr,
});
}
pub fn subscribe_job(&self, id: u64) -> Option<JobSubscription> {
let channel = self
.jobs
.lock()
.expect("job registry lock poisoned")
.get(&id)
.cloned()?;
let (history, live) = channel.subscribe();
Some(JobSubscription { history, live })
}
}
impl Default for EventBus {
fn default() -> Self {
Self::new()
}
}
#[derive(Clone, Debug)]
pub struct Reporter {
channel: Arc<JobChannel>,
}
impl Reporter {
pub fn tool_call(&self, iteration: usize, name: &str, arguments: &str) {
self.channel.emit(Progress::ToolCall {
iteration,
name: name.to_string(),
arguments: excerpt(arguments, MAX_EVENT_ARGUMENTS),
});
}
/// Reports the end of a tool call.
pub fn tool_result(&self, iteration: usize, name: &str, truncated: bool, output: &str) {
self.channel.emit(Progress::ToolResult {
iteration,
name: name.to_string(),
truncated,
output: output.to_string(),
Review

[performance] Contrairement aux arguments (tronqués à 200 caractères), output est conservé en entier, jusqu'à 32 Ko par appel d'outil. L'historique garde 256 événements par job, soit environ 8 Mo par job actif. Chaque nouvel abonné SSE reçoit ensuite tout cet historique. Il faut tronquer l'aperçu envoyé au dashboard (comme pour MAX_EVENT_ARGUMENTS), ce qui limite aussi ce qui fuit sur une route publique.

**[performance]** Contrairement aux arguments (tronqués à 200 caractères), `output` est conservé en entier, jusqu'à 32 Ko par appel d'outil. L'historique garde 256 événements par job, soit environ 8 Mo par job actif. Chaque nouvel abonné SSE reçoit ensuite tout cet historique. Il faut tronquer l'aperçu envoyé au dashboard (comme pour `MAX_EVENT_ARGUMENTS`), ce qui limite aussi ce qui fuit sur une route publique.
});
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_started_job_is_announced_and_subscribable() {
let bus = EventBus::new();
let mut lifecycle = bus.subscribe_lifecycle();
let _reporter = bus.start(1, "owner/repo", 7);
assert!(matches!(
lifecycle.try_recv().unwrap(),
JobEvent::Started { id: 1, pr: 7, .. }
));
assert!(bus.subscribe_job(1).is_some());
}
#[test]
fn progress_emitted_before_subscribing_is_replayed() {
let bus = EventBus::new();
let reporter = bus.start(1, "owner/repo", 7);
reporter.tool_call(1, "ls", "{}");
reporter.tool_result(1, "ls", false, "src\nmain.rs");
let subscription = bus.subscribe_job(1).unwrap();
assert_eq!(subscription.history.len(), 2);
assert!(matches!(
subscription.history[0],
Progress::ToolCall { iteration: 1, .. }
));
assert!(matches!(
&subscription.history[1],
Progress::ToolResult { name, .. } if name == "ls"
));
}
#[test]
fn progress_emitted_after_subscribing_arrives_live() {
let bus = EventBus::new();
let reporter = bus.start(1, "owner/repo", 7);
let mut subscription = bus.subscribe_job(1).unwrap();
reporter.tool_call(2, "grep", r#"{"pattern":"fn main"}"#);
assert!(matches!(
subscription.live.try_recv().unwrap(),
Progress::ToolCall { iteration: 2, name, .. } if name == "grep"
));
}
#[test]
fn finish_closes_the_channel_and_announces_the_review() {
let bus = EventBus::new();
let mut lifecycle = bus.subscribe_lifecycle();
let _reporter = bus.start(1, "owner/repo", 7);
let mut subscription = bus.subscribe_job(1).unwrap();
bus.finish(1, "owner/repo", 7, true, 3, 120);
assert!(bus.subscribe_job(1).is_none());
assert!(matches!(
subscription.live.try_recv().unwrap(),
Progress::Finished { ok: true }
));
let _started = lifecycle.try_recv().unwrap();
assert!(matches!(
lifecycle.try_recv().unwrap(),
JobEvent::ReviewDone {
id: 1,
ok: true,
comments: 3,
duration_ms: 120,
..
}
));
}
#[test]
fn a_failed_job_is_announced_as_failed() {
let bus = EventBus::new();
let mut lifecycle = bus.subscribe_lifecycle();
let _reporter = bus.start(1, "owner/repo", 7);
bus.finish(1, "owner/repo", 7, false, 0, 42);
let _started = lifecycle.try_recv().unwrap();
assert!(matches!(
lifecycle.try_recv().unwrap(),
JobEvent::Failed { id: 1, .. }
));
}
#[test]
fn a_skipped_job_is_announced_without_a_channel() {
let bus = EventBus::new();
let mut lifecycle = bus.subscribe_lifecycle();
bus.skip(1, "owner/repo", 7);
assert!(matches!(
lifecycle.try_recv().unwrap(),
JobEvent::Skipped { id: 1, pr: 7, .. }
));
assert!(bus.subscribe_job(1).is_none());
}
}
+43 -29
View File
@@ -1,15 +1,15 @@
use std::time::Duration; use std::time::Duration;
use futures_util::stream::TryStreamExt; use futures_util::stream::TryStreamExt;
use serde::Deserialize; use serde::{Deserialize, Serialize};
use serde_json::{Value, json}; use serde_json::{Value, json};
use tokio::io::AsyncReadExt; use tokio::io::AsyncReadExt;
use tokio_util::io::StreamReader; use tokio_util::io::StreamReader;
use tracing::{instrument, warn}; use tracing::{instrument, warn};
use crate::{ use crate::{
bot::{ReviewResult, ReviewSeverity, ReviewSide},
consts::MAX_DIFF_SIZE, consts::MAX_DIFF_SIZE,
domain::review::{ReviewResult, ReviewSeverity, ReviewSide},
errors::AppError, errors::AppError,
}; };
@@ -27,7 +27,7 @@ pub struct GiteaAPI {
} }
impl GiteaAPI { impl GiteaAPI {
pub fn new(base_url: &str, token: &str, timeout: u64) -> anyhow::Result<Self> { pub fn new(base_url: &str, token: &str, timeout: Duration) -> anyhow::Result<Self> {
let mut default_headers = reqwest::header::HeaderMap::new(); let mut default_headers = reqwest::header::HeaderMap::new();
default_headers.insert( default_headers.insert(
reqwest::header::HeaderName::from_static("authorization"), reqwest::header::HeaderName::from_static("authorization"),
@@ -38,7 +38,7 @@ impl GiteaAPI {
base_url: String::from(base_url), base_url: String::from(base_url),
token: String::from(token), token: String::from(token),
client: reqwest::Client::builder() client: reqwest::Client::builder()
.timeout(Duration::from_secs(timeout)) .timeout(timeout)
.default_headers(default_headers) .default_headers(default_headers)
.build()?, .build()?,
}) })
@@ -281,7 +281,7 @@ async fn read_capped(response: reqwest::Response) -> anyhow::Result<String> {
Ok(String::from_utf8_lossy(&buf).into_owned()) Ok(String::from_utf8_lossy(&buf).into_owned())
} }
#[derive(Debug)] #[derive(Debug, Serialize, Deserialize, Clone)]
pub enum WebhookType { pub enum WebhookType {
Review(ReviewPayload), Review(ReviewPayload),
} }
@@ -298,9 +298,21 @@ impl WebhookType {
WebhookType::Review(_) => "review", WebhookType::Review(_) => "review",
} }
} }
pub fn repository_full_name(&self) -> &str {
match self {
WebhookType::Review(payload) => &payload.repository.full_name,
}
}
pub fn pull_request_number(&self) -> u64 {
match self {
WebhookType::Review(payload) => payload.pull_request.number,
}
}
} }
#[derive(Deserialize, Debug)] #[derive(Deserialize, Serialize, Debug, Clone)]
pub struct ReviewPayload { pub struct ReviewPayload {
pub action: String, pub action: String,
pub pull_request: PullRequest, pub pull_request: PullRequest,
@@ -308,13 +320,28 @@ pub struct ReviewPayload {
pub comment: Comment, pub comment: Comment,
} }
#[derive(Deserialize, Debug)] impl ReviewPayload {
/// Checks that the comment is a new one that mentions the bot.
fn check_addressed_to(&self, bot_name: &str) -> Result<(), AppError> {
if self.action != "created" {
return Err(AppError::InvalidActionErr);
}
if !self.comment.body.starts_with(&format!("@{bot_name}")) {
return Err(AppError::UnauthorizedUserErr);
}
Ok(())
}
}
#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct PullRequest { pub struct PullRequest {
pub number: u64, pub number: u64,
pub title: String, pub title: String,
} }
#[derive(Deserialize, Debug)] #[derive(Deserialize, Serialize, Debug, Clone)]
pub struct Comment { pub struct Comment {
pub id: u64, pub id: u64,
pub body: String, pub body: String,
@@ -325,7 +352,7 @@ pub struct User {
pub login: String, pub login: String,
} }
#[derive(Deserialize, Debug)] #[derive(Deserialize, Serialize, Debug, Clone)]
pub struct Repository { pub struct Repository {
pub full_name: String, pub full_name: String,
pub clone_url: String, pub clone_url: String,
@@ -345,28 +372,15 @@ pub struct PullRequestFile {
impl WebhookType { impl WebhookType {
pub fn from_event(event: &str, bot_name: &str, json: Value) -> Result<Self, AppError> { pub fn from_event(event: &str, bot_name: &str, json: Value) -> Result<Self, AppError> {
let wb = match event { match event {
"pull_request_comment" => Ok(WebhookType::Review(serde_json::from_value(json)?)), "pull_request_comment" => {
let payload: ReviewPayload = serde_json::from_value(json)?;
payload.check_addressed_to(bot_name)?;
Ok(WebhookType::Review(payload))
}
_ => Err(AppError::UnknownEventErr), _ => Err(AppError::UnknownEventErr),
}?;
let action = match &wb {
WebhookType::Review(review_payload) => &review_payload.action,
};
if action != "created" {
return Err(AppError::InvalidActionErr);
} }
let pr_body = match &wb {
WebhookType::Review(review_payload) => &review_payload.comment.body,
};
if !pr_body.starts_with(&format!("@{}", bot_name)) {
return Err(AppError::UnauthorizedUserErr);
}
Ok(wb)
} }
} }
+273
View File
@@ -0,0 +1,273 @@
use std::sync::Arc;
use toasty::{Db, stmt::Value};
use tokio::sync::Notify;
use crate::{
gitea::WebhookType,
schemas::{Job, JobStatus},
};
/// The reviews to do, stored in PostgreSQL.
///
/// Steep runs as a single instance, so every job is pushed by this process: the
/// worker is woken up by [`JobQueue::push`] instead of polling the table. The table
/// only makes the queue survive a restart.
#[derive(Clone)]
pub struct JobQueue {
db: Db,
work: Arc<Notify>,
}
impl JobQueue {
pub fn new(db: Db) -> Self {
Self {
db,
work: Arc::new(Notify::new()),
}
}
pub async fn push(&self, webhook: WebhookType) -> toasty::Result<Job> {
let event_id = webhook.event_id();
let repo = webhook.repository_full_name().to_string();
let pr = webhook.pull_request_number();
let mut db = self.db.clone();
let job = toasty::create!(Job {
event_id,
repo,
pr,
payload: webhook,
status: JobStatus::Pending,
})
.exec(&mut db)
.await?;
// `notify_one` keeps a permit when nobody is waiting yet, so a job pushed
// while the worker is busy is not missed.
self.work.notify_one();
Ok(job)
}
/// Whether a job is already queued for this event.
pub async fn is_queued(&self, event_id: u64) -> toasty::Result<bool> {
let mut db = self.db.clone();
Ok(Job::filter_by_event_id(event_id)
.first()
.exec(&mut db)
qpismont marked this conversation as resolved
Review

[bug] La sélection des jobs Pending et leur passage à Processing sont deux opérations distinctes, sans réservation atomique. Avec plusieurs instances, chacune peut récupérer le même job et lancer la même review avant que l’autre ne le marque Processing. Il faut réclamer les jobs de façon atomique.

**[bug]** La sélection des jobs Pending et leur passage à Processing sont deux opérations distinctes, sans réservation atomique. Avec plusieurs instances, chacune peut récupérer le même job et lancer la même review avant que l’autre ne le marque Processing. Il faut réclamer les jobs de façon atomique.
.await?
.is_some())
}
/// Puts back in the queue the jobs a previous process left `processing`.
///
/// To call once at startup, before the worker starts: it is only correct because
/// no other instance can be working on those jobs.
qpismont marked this conversation as resolved
Review

[performance] Cette requête récupère tous les jobs en attente, puis les met en mémoire dans le buffer. Si la file grossit ou si le service redémarre avec un grand retard à rattraper, chaque worker peut charger un volume non borné de payloads JSON. Limitez la taille des lots et ne chargez que les jobs nécessaires à la capacité disponible.

**[performance]** Cette requête récupère tous les jobs en attente, puis les met en mémoire dans le buffer. Si la file grossit ou si le service redémarre avec un grand retard à rattraper, chaque worker peut charger un volume non borné de payloads JSON. Limitez la taille des lots et ne chargez que les jobs nécessaires à la capacité disponible.
Review

[bug] La lecture des jobs en attente n’en réserve aucun : deux instances peuvent récupérer le même job avant que l’une d’elles n’appelle mark_processing, puis lancer deux reviews et poster des résultats en double. Il faut réclamer les jobs de façon atomique, par exemple avec une mise à jour conditionnelle ou un verrouillage SKIP LOCKED.

**[bug]** La lecture des jobs en attente n’en réserve aucun : deux instances peuvent récupérer le même job avant que l’une d’elles n’appelle `mark_processing`, puis lancer deux reviews et poster des résultats en double. Il faut réclamer les jobs de façon atomique, par exemple avec une mise à jour conditionnelle ou un verrouillage `SKIP LOCKED`.
pub async fn requeue_interrupted(&self) -> toasty::Result<()> {
let mut db = self.db.clone();
Job::update_by_status(JobStatus::Processing)
.status(JobStatus::Pending)
.exec(&mut db)
.await?;
Ok(())
}
/// Takes the oldest pending job and marks it `processing`, or returns `None` when
/// the queue is empty.
///
/// The selection and the update are a single statement, so a job cannot be taken
/// twice.
pub async fn claim_next(&self) -> toasty::Result<Option<Job>> {
let mut db = self.db.clone();
let rows = toasty::sql::query(
"UPDATE jobs
SET status = 'processing'
WHERE id = (
SELECT id FROM jobs
WHERE status = 'pending'
ORDER BY id
LIMIT 1
)
RETURNING id",
)
.exec(&mut db)
.await?;
let Some(Value::Record(row)) = rows.into_iter().next() else {
return Ok(None);
};
let id = u64::try_from(row.as_slice()[0].clone())?;
Ok(Some(Job::get_by_id(&mut db, id).await?))
}
/// Waits until a job may have been pushed.
pub async fn wait_for_work(&self) {
self.work.notified().await;
}
/// Removes a job from the queue, once its review is recorded.
pub async fn remove(&self, id: u64) -> toasty::Result<()> {
let mut db = self.db.clone();
Job::delete_by_id(&mut db, id).await?;
Ok(())
}
pub async fn list(&self, status: JobStatus) -> toasty::Result<Vec<Job>> {
let mut db = self.db.clone();
let jobs = Job::filter_by_status(status).exec(&mut db).await?;
Ok(jobs)
}
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use tokio::sync::{Mutex, MutexGuard};
use super::*;
use crate::gitea::{Comment, PullRequest, Repository, ReviewPayload};
const FIRST_EVENT: u64 = 999_000_200;
fn webhook(event_id: u64) -> WebhookType {
WebhookType::Review(ReviewPayload {
action: "created".to_string(),
pull_request: PullRequest {
number: 1,
title: "Test".to_string(),
},
repository: Repository {
full_name: "owner/repo".to_string(),
clone_url: "https://example.com/owner/repo.git".to_string(),
},
comment: Comment {
id: event_id,
body: "@bot review".to_string(),
},
})
}
/// The tests claim whichever job is next, so they must not overlap: one would
/// claim the jobs of another.
async fn exclusive_queue() -> (MutexGuard<'static, ()>, JobQueue, Db) {
static LOCK: Mutex<()> = Mutex::const_new(());
let guard = LOCK.lock().await;
let db = crate::db::test_connect().await;
toasty::sql::statement(
"DELETE FROM jobs WHERE event_id >= 999000200 AND event_id < 999000300",
)
.exec(&mut db.clone())
.await
.unwrap();
(guard, JobQueue::new(db.clone()), db)
}
#[tokio::test]
#[ignore = "requires DATABASE_URL"]
async fn a_pushed_job_is_claimed_then_removed() {
let (_guard, queue, db) = exclusive_queue().await;
queue.push(webhook(FIRST_EVENT)).await.unwrap();
assert!(queue.is_queued(FIRST_EVENT).await.unwrap());
let job = queue.claim_next().await.unwrap().expect("a job to claim");
assert_eq!(job.event_id, FIRST_EVENT);
assert_eq!(job.status, JobStatus::Processing);
queue.remove(job.id).await.unwrap();
assert!(
Job::filter_by_event_id(FIRST_EVENT)
.first()
.exec(&mut db.clone())
.await
.unwrap()
.is_none()
);
}
#[tokio::test]
#[ignore = "requires DATABASE_URL"]
async fn a_duplicate_event_id_is_rejected() {
let (_guard, queue, _db) = exclusive_queue().await;
queue.push(webhook(FIRST_EVENT + 1)).await.unwrap();
assert!(queue.push(webhook(FIRST_EVENT + 1)).await.is_err());
}
#[tokio::test]
#[ignore = "requires DATABASE_URL"]
async fn a_claimed_job_is_not_claimed_again() {
let (_guard, queue, _db) = exclusive_queue().await;
queue.push(webhook(FIRST_EVENT + 2)).await.unwrap();
assert!(queue.claim_next().await.unwrap().is_some());
assert!(queue.claim_next().await.unwrap().is_none());
}
#[tokio::test]
#[ignore = "requires DATABASE_URL"]
async fn jobs_are_claimed_oldest_first() {
let (_guard, queue, _db) = exclusive_queue().await;
queue.push(webhook(FIRST_EVENT + 3)).await.unwrap();
queue.push(webhook(FIRST_EVENT + 4)).await.unwrap();
let first = queue.claim_next().await.unwrap().unwrap();
let second = queue.claim_next().await.unwrap().unwrap();
assert_eq!(first.event_id, FIRST_EVENT + 3);
assert_eq!(second.event_id, FIRST_EVENT + 4);
}
#[tokio::test]
#[ignore = "requires DATABASE_URL"]
async fn an_interrupted_job_is_requeued() {
let (_guard, queue, _db) = exclusive_queue().await;
queue.push(webhook(FIRST_EVENT + 5)).await.unwrap();
let job = queue.claim_next().await.unwrap().unwrap();
assert!(queue.claim_next().await.unwrap().is_none());
// The process died here: on restart, the job goes back to the queue.
queue.requeue_interrupted().await.unwrap();
let again = queue.claim_next().await.unwrap().expect("the job back");
assert_eq!(again.id, job.id);
}
#[tokio::test]
#[ignore = "requires DATABASE_URL"]
async fn waiting_for_work_returns_as_soon_as_a_job_is_pushed() {
let (_guard, queue, _db) = exclusive_queue().await;
let waiter = {
let queue = queue.clone();
tokio::spawn(async move { queue.wait_for_work().await })
};
tokio::time::sleep(Duration::from_millis(100)).await;
queue.push(webhook(FIRST_EVENT + 6)).await.unwrap();
tokio::time::timeout(Duration::from_secs(1), waiter)
.await
.expect("the worker was not woken up")
.unwrap();
}
}
+68 -16
View File
@@ -1,9 +1,12 @@
use std::time::Duration; use std::time::Duration;
use crate::{ use crate::{
bot::Bot, bot::{Bot, BotDeps},
gitea::{GiteaAPI, WebhookType}, event_bus::EventBus,
gitea::GiteaAPI,
job_queue::JobQueue,
open_router::OpenRouterClient, open_router::OpenRouterClient,
review_store::ReviewStore,
sandbox::SandboxConfig, sandbox::SandboxConfig,
state::AppState, state::AppState,
}; };
@@ -18,28 +21,46 @@ mod api;
mod bot; mod bot;
mod bot_actions; mod bot_actions;
mod consts; mod consts;
mod db;
mod domain;
mod env; mod env;
mod errors; mod errors;
mod event_bus;
mod gitea; mod gitea;
mod job_queue;
mod metrics; mod metrics;
mod open_router; mod open_router;
mod review_store;
mod sandbox; mod sandbox;
mod schemas;
mod state; mod state;
mod text; mod text;
/// Délai laissé aux reviews en cours et aux serveurs pour s'arrêter proprement. /// Time left to the running reviews and the servers to stop cleanly.
/// ///
/// Sans lui, une review prise dans une sandbox récalcitrante garderait le processus /// Without it, a review stuck in an unresponsive sandbox would keep the process
/// en vie jusqu'à ce que le superviseur le tue. /// alive until the supervisor kills it.
const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(60); const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(60);
fn main() -> anyhow::Result<()> { fn main() -> anyhow::Result<()> {
dotenv().ok(); dotenv().ok();
// TLS goes through rustls, which has no default crypto provider here: the
// workspace compiles `ring` only, and it is installed once, for every client
// (reqwest, sentry).
rustls::crypto::ring::default_provider()
.install_default()
.map_err(|_| anyhow::anyhow!("a rustls crypto provider is already installed"))?;
// Migration subcommand: `steep-server migration <generate|apply|...>`.
if std::env::args().nth(1).as_deref() == Some("migration") {
return tokio::runtime::Runtime::new()?.block_on(run_migrations());
}
tracing_subscriber::registry() tracing_subscriber::registry()
.with(fmt::layer()) .with(fmt::layer())
.with( .with(
EnvFilter::try_from_default_env() // lit RUST_LOG depuis l'env EnvFilter::try_from_default_env() // reads RUST_LOG from the environment
.unwrap_or_else(|_| EnvFilter::new("info")), .unwrap_or_else(|_| EnvFilter::new("info")),
) )
.init(); .init();
@@ -61,6 +82,24 @@ fn main() -> anyhow::Result<()> {
tokio::runtime::Runtime::new()?.block_on(run()) tokio::runtime::Runtime::new()?.block_on(run())
} }
async fn run_migrations() -> anyhow::Result<()> {
let database_url = env::try_get_env("DATABASE_URL")?;
let db = db::open(&database_url).await?;
let migrations = match std::env::var("MIGRATIONS_DIR") {
Ok(dir) => std::path::PathBuf::from(dir),
Err(_) => std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("toasty"),
};
let config =
toasty_cli::Config::new().migration(toasty_cli::MigrationConfig::new().path(migrations));
toasty_cli::ToastyCli::with_config(db, config)
.parse_and_run()
.await?;
Ok(())
}
async fn run() -> anyhow::Result<()> { async fn run() -> anyhow::Result<()> {
let config = env::load_config()?; let config = env::load_config()?;
@@ -99,20 +138,33 @@ async fn run() -> anyhow::Result<()> {
); );
} }
let db = db::open(&config.database_url).await?;
let reviews = ReviewStore::new(db.clone());
let queue = JobQueue::new(db);
queue.requeue_interrupted().await?;
let event_bus = EventBus::new();
let bot = Bot::new( let bot = Bot::new(
gitea_user.login, BotDeps {
gitea_api, gitea: gitea_api,
open_router_client, open_router: open_router_client,
sandbox,
reviews: reviews.clone(),
queue: queue.clone(),
event_bus: event_bus.clone(),
},
config.bot_max_concurrent, config.bot_max_concurrent,
config.open_router_model.clone(),
sandbox,
); );
let (tx, rx) = tokio::sync::mpsc::channel::<WebhookType>(config.bot_max_concurrent * 2);
let app_state = AppState { let app_state = AppState {
bot_tx: tx, queue,
bot: bot.clone(), reviews,
event_bus,
bot_name: gitea_user.login.clone(),
config, config,
shutdown: shutdown.clone(),
}; };
let signal = async { let signal = async {
@@ -136,8 +188,8 @@ async fn run() -> anyhow::Result<()> {
tokio::select! { tokio::select! {
result = async { result = async {
tokio::try_join!( tokio::try_join!(
bot.start(rx, shutdown.clone()), bot.start(shutdown.clone()),
api::start(app_state, shutdown.clone()), api::start(app_state),
signal signal
) )
} => { } => {
+13 -4
View File
@@ -10,8 +10,8 @@ pub fn webhook_duplicate(event_type: &str) {
counter!("steep_webhooks_duplicate_total", "event_type" => event_type.to_string()).increment(1); counter!("steep_webhooks_duplicate_total", "event_type" => event_type.to_string()).increment(1);
} }
pub fn webhook_channel_full(event_type: &str) { pub fn webhook_enqueue_failed(event_type: &str) {
counter!("steep_webhooks_channel_full_total", "event_type" => event_type.to_string()) counter!("steep_webhooks_enqueue_failed_total", "event_type" => event_type.to_string())
.increment(1); .increment(1);
} }
@@ -23,6 +23,10 @@ pub fn decrement_task_active() {
gauge!("steep_bot_tasks_active").decrement(1.0); gauge!("steep_bot_tasks_active").decrement(1.0);
} }
pub fn task_skipped(event_type: &str) {
counter!("steep_bot_tasks_skipped_total", "event_type" => event_type.to_string()).increment(1);
}
pub fn task_completed(event_type: &str) { pub fn task_completed(event_type: &str) {
counter!("steep_bot_tasks_completed_total", "event_type" => event_type.to_string()) counter!("steep_bot_tasks_completed_total", "event_type" => event_type.to_string())
.increment(1); .increment(1);
@@ -48,15 +52,20 @@ pub fn describe() {
"Webhooks rejected as duplicates" "Webhooks rejected as duplicates"
); );
describe_counter!( describe_counter!(
"steep_webhooks_channel_full_total", "steep_webhooks_enqueue_failed_total",
Unit::Count, Unit::Count,
"Webhooks dropped because the bot channel was full" "Webhooks dropped because enqueueing to PostgreSQL failed"
); );
describe_gauge!( describe_gauge!(
"steep_bot_tasks_active", "steep_bot_tasks_active",
Unit::Count, Unit::Count,
"Bot tasks currently in progress" "Bot tasks currently in progress"
); );
describe_counter!(
"steep_bot_tasks_skipped_total",
Unit::Count,
"Reviews skipped because they were already done"
);
describe_counter!( describe_counter!(
"steep_bot_tasks_completed_total", "steep_bot_tasks_completed_total",
Unit::Count, Unit::Count,
+7 -4
View File
@@ -254,16 +254,19 @@ pub struct OpenRouterClient {
} }
impl OpenRouterClient { impl OpenRouterClient {
pub fn new(token: &str, model: &str, timeout: u64) -> anyhow::Result<Self> { pub fn new(token: &str, model: &str, timeout: Duration) -> anyhow::Result<Self> {
Ok(Self { Ok(Self {
client: reqwest::Client::builder() client: reqwest::Client::builder().timeout(timeout).build()?,
.timeout(Duration::from_secs(timeout))
.build()?,
api_key: String::from(token), api_key: String::from(token),
model: String::from(model), model: String::from(model),
}) })
} }
/// The model every completion is requested from.
pub fn model(&self) -> &str {
&self.model
}
/// Sends a conversation with tool definitions and returns either a final /// Sends a conversation with tool definitions and returns either a final
/// message or the tool calls requested by the model. /// message or the tool calls requested by the model.
/// ///
+114
View File
@@ -0,0 +1,114 @@
use toasty::Db;
use crate::schemas::Review;
/// What is kept of a processed review.
pub struct ReviewRecord {
pub event_id: u64,
pub repo: String,
pub pr: u64,
pub ok: bool,
pub model: String,
pub markdown: String,
pub cost: Option<f64>,
pub comments: u64,
pub duration_ms: u64,
}
/// The history of the reviews Steep has processed.
///
/// It also is what keeps an event from being reviewed twice.
#[derive(Clone)]
pub struct ReviewStore {
db: Db,
}
impl ReviewStore {
pub fn new(db: Db) -> Self {
Self { db }
}
pub async fn is_reviewed(&self, event_id: u64) -> toasty::Result<bool> {
let mut db = self.db.clone();
Ok(Review::filter_by_event_id(event_id)
.first()
.exec(&mut db)
.await?
.is_some())
}
/// Records a processed review, replacing the one of the same event if any.
pub async fn record(&self, record: ReviewRecord) -> toasty::Result<()> {
let mut db = self.db.clone();
Review::upsert_by_event_id(record.event_id)
.repo(record.repo)
.pr(record.pr)
.ok(record.ok)
.model(record.model)
.markdown(record.markdown)
.cost(record.cost)
.comments(record.comments)
.duration_ms(record.duration_ms)
.exec(&mut db)
.await?;
Ok(())
}
pub async fn list(&self) -> toasty::Result<Vec<Review>> {
let mut db = self.db.clone();
let reviews = Review::all().exec(&mut db).await?;
Review

[performance] list() charge toutes les reviews, y compris la colonne markdown, sans pagination ni tri, et /reviews les renvoie toutes d'un coup. La table ne fait que grossir et chaque ouverture ou rafraîchissement du dashboard relit tout l'historique. Il faut une limite (par exemple les N dernières, triées par completed_at DESC), ou omettre markdown dans la liste.

**[performance]** `list()` charge toutes les reviews, y compris la colonne `markdown`, sans pagination ni tri, et `/reviews` les renvoie toutes d'un coup. La table ne fait que grossir et chaque ouverture ou rafraîchissement du dashboard relit tout l'historique. Il faut une limite (par exemple les N dernières, triées par `completed_at DESC`), ou omettre `markdown` dans la liste.
Ok(reviews)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
#[ignore = "requires DATABASE_URL"]
async fn a_recorded_review_is_reviewed() {
let db = crate::db::test_connect().await;
let store = ReviewStore::new(db.clone());
let event_id = 999_000_101;
Review::delete_by_event_id(&mut db.clone(), event_id)
.await
.ok();
assert!(!store.is_reviewed(event_id).await.unwrap());
store
.record(ReviewRecord {
event_id,
repo: "owner/repo".to_string(),
pr: 1,
ok: true,
model: "test/model".to_string(),
markdown: "looks good".to_string(),
cost: Some(0.01),
comments: 2,
duration_ms: 5,
})
.await
.unwrap();
assert!(store.is_reviewed(event_id).await.unwrap());
assert!(
store
.list()
.await
.unwrap()
.iter()
.any(|r| r.event_id == event_id)
);
Review::delete_by_event_id(&mut db.clone(), event_id)
.await
.unwrap();
}
}
+11 -16
View File
@@ -13,6 +13,7 @@ use serde_json::Value;
use tracing::{info, warn}; use tracing::{info, warn};
use crate::{ use crate::{
event_bus::Reporter,
open_router::{Message, OpenRouterClient, Role, Tool, ToolCall, ToolChoice}, open_router::{Message, OpenRouterClient, Role, Tool, ToolCall, ToolChoice},
sandbox::{Sandbox, tools}, sandbox::{Sandbox, tools},
text::{excerpt, truncate_bytes}, text::{excerpt, truncate_bytes},
@@ -61,17 +62,6 @@ pub struct AgentResult<A> {
pub iterations: usize, pub iterations: usize,
} }
/// Runs the tool-calling loop until the model answers or `max_iterations` is
/// reached.
///
/// `tool_definitions` is the set of tools the model may call; it is selected by
/// the caller based on the webhook action (see [`tools::for_webhook`]).
///
/// The answer of the model is parsed into `A`, which is how the caller states the
/// format it expects: while [`FromStr`] rejects the answer, the error is sent back
/// to the model so that it can correct itself, which costs an iteration. The
/// parsing happens here because that is where the conversation and the remaining
/// budget are at hand.
pub async fn run<A>( pub async fn run<A>(
open_router: &OpenRouterClient, open_router: &OpenRouterClient,
sandbox: &Sandbox, sandbox: &Sandbox,
@@ -79,6 +69,7 @@ pub async fn run<A>(
system_prompt: &str, system_prompt: &str,
user_prompt: &str, user_prompt: &str,
max_iterations: usize, max_iterations: usize,
reporter: &Reporter,
) -> anyhow::Result<AgentResult<A>> ) -> anyhow::Result<AgentResult<A>>
where where
A: FromStr, A: FromStr,
@@ -104,8 +95,8 @@ where
let mut last_rejection: Option<String> = None; let mut last_rejection: Option<String> = None;
for iteration in 1..=max_iterations { for iteration in 1..=max_iterations {
// Le dernier tour n'est plus un tour d'exploration : le modèle doit rendre // The last turn is no longer an exploration turn: the model must give its
// sa réponse, avec ce qu'il a déjà vu. // answer, with what it has already seen.
let last = iteration == max_iterations; let last = iteration == max_iterations;
if last { if last {
@@ -141,8 +132,8 @@ where
"Model answered" "Model answered"
); );
// Au dernier tour, les outils sont refusés : même si le fournisseur les // On the last turn, tools are refused: even if the provider returns them
// renvoie malgré tout, on ne les exécute pas et on tente la réponse. // anyway, we do not run them and try the answer instead.
if last || response.tool_calls.is_empty() { if last || response.tool_calls.is_empty() {
let answer = response.message.unwrap_or_default(); let answer = response.message.unwrap_or_default();
@@ -177,16 +168,20 @@ where
)); ));
for call in &response.tool_calls { for call in &response.tool_calls {
reporter.tool_call(iteration, call.name(), call.arguments_json());
let started = Instant::now(); let started = Instant::now();
let (content, truncated) = execute(sandbox, call).await; let (content, truncated) = execute(sandbox, call).await;
reporter.tool_result(iteration, call.name(), truncated, &content);
info!( info!(
iteration, iteration,
tool = call.name(), tool = call.name(),
arguments = %excerpt(call.arguments_json(), MAX_LOGGED_ARGUMENTS), arguments = %excerpt(call.arguments_json(), MAX_LOGGED_ARGUMENTS),
output_bytes = content.len(), output_bytes = content.len(),
truncated, truncated,
// Les sauts de ligne casseraient la lisibilité d'une ligne de log. // Line breaks would hurt the readability of a log line.
output = %excerpt(&content, MAX_LOGGED_OUTPUT).replace('\n', " "), output = %excerpt(&content, MAX_LOGGED_OUTPUT).replace('\n', " "),
elapsed_ms = started.elapsed().as_millis() as u64, elapsed_ms = started.elapsed().as_millis() as u64,
"Tool call finished" "Tool call finished"
+21 -18
View File
@@ -1,5 +1,5 @@
//! Le [`Sandbox`] : clone d'une pull request exécuté dans un devcontainer //! The [`Sandbox`]: a pull request clone running inside an ephemeral
//! éphémère, et sa configuration. //! devcontainer, and its configuration.
use std::{ use std::{
path::{Path, PathBuf}, path::{Path, PathBuf},
@@ -11,13 +11,15 @@ use devcontainer_rs::{Container, ContainerRuntime, ExecOutput};
use tempfile::TempDir; use tempfile::TempDir;
use tracing::{info, instrument}; use tracing::{info, instrument};
use crate::event_bus::Reporter;
/// Devcontainer locations recognized within a repository, in priority order. /// Devcontainer locations recognized within a repository, in priority order.
const DEVCONTAINER_PATHS: [&str; 2] = [".devcontainer/devcontainer.json", ".devcontainer.json"]; const DEVCONTAINER_PATHS: [&str; 2] = [".devcontainer/devcontainer.json", ".devcontainer.json"];
/// Sandbox-related runtime configuration. /// Sandbox-related runtime configuration.
#[derive(Clone)] #[derive(Clone)]
pub struct SandboxConfig { pub struct SandboxConfig {
/// Client du daemon de containers qui exécute la sandbox. /// Client of the container daemon that runs the sandbox.
pub runtime: ContainerRuntime, pub runtime: ContainerRuntime,
/// Maximum number of tool-calling iterations per agent run. /// Maximum number of tool-calling iterations per agent run.
pub max_iterations: usize, pub max_iterations: usize,
@@ -43,6 +45,7 @@ impl Sandbox {
clone_url: &str, clone_url: &str,
token: &str, token: &str,
pull_request_number: u64, pull_request_number: u64,
reporter: &Reporter,
) -> anyhow::Result<Self> { ) -> anyhow::Result<Self> {
let workspace = tempfile::tempdir().context("failed to create sandbox workspace")?; let workspace = tempfile::tempdir().context("failed to create sandbox workspace")?;
let repo_dir = workspace.path().join("repo"); let repo_dir = workspace.path().join("repo");
@@ -76,12 +79,12 @@ impl Sandbox {
Ok(sandbox) Ok(sandbox)
} }
/// Vérifie que le clone est bien visible dans le container. /// Checks that the clone is actually visible inside the container.
/// ///
/// Sans ce contrôle, un workspace vide — par exemple un daemon qui n'a pas pu /// Without this check, an empty workspace (for example a daemon that could not
/// recevoir le clone — fait échouer chaque outil ; le modèle enchaîne alors les /// receive the clone) makes every tool fail; the model then chains failed calls
/// appels ratés jusqu'au budget d'itérations, sans jamais pouvoir reviewer quoi /// until the iteration budget is spent, without ever being able to review
/// que ce soit. /// anything.
async fn check_workspace(&self) -> anyhow::Result<()> { async fn check_workspace(&self) -> anyhow::Result<()> {
let workspace_folder = self.workspace_folder(); let workspace_folder = self.workspace_folder();
let probe = self let probe = self
@@ -115,12 +118,12 @@ impl Sandbox {
Ok(()) Ok(())
} }
/// Rassemble de quoi expliquer un refus d'accès au workspace. /// Gathers enough information to explain a refused access to the workspace.
/// ///
/// Un `EACCES` a deux causes possibles, indistinguables dans le message de /// An `EACCES` has two possible causes, indistinguishable in the `ls` message:
/// `ls` : les permissions du clone, ou un confinement du noyau qui bloque /// the clone's permissions, or kernel confinement blocking the access. The
/// l'accès. L'identité de l'utilisateur d'exec et les permissions du point de /// identity of the exec user and the permissions of the mount point tell them
/// montage permettent de trancher. /// apart.
async fn diagnose_workspace(&self) -> String { async fn diagnose_workspace(&self) -> String {
let mut details = Vec::new(); let mut details = Vec::new();
@@ -212,12 +215,12 @@ async fn clone_pull_request(
Ok(()) Ok(())
} }
/// Rend le clone lisible par les utilisateurs du container sandbox. /// Makes the clone readable by the users of the sandbox container.
/// ///
/// Le container peut ne pas avoir les mêmes uid que Steep (podman rootless /// The container may not share Steep's uids (rootless podman maps uids through
/// mappe les uid à travers des plages subuid), et l'umask de l'opérateur peut être /// subuid ranges), and the operator's umask may be restrictive: without this, the
/// restrictif : sans cela, les outils de la sandbox échouent en `Permission /// sandbox tools fail with `Permission denied` on files that Steep itself just
/// denied` sur des fichiers que Steep vient de cloner lui-même. /// cloned.
async fn make_readable(repo_dir: &Path) -> anyhow::Result<()> { async fn make_readable(repo_dir: &Path) -> anyhow::Result<()> {
let output = tokio::process::Command::new("chmod") let output = tokio::process::Command::new("chmod")
.args(["-R", "a+rX"]) .args(["-R", "a+rX"])
+6 -6
View File
@@ -167,11 +167,11 @@ async fn read_file(sandbox: &Sandbox, args: &Value) -> anyhow::Result<String> {
Ok(number_lines(&into_stdout(output)?, first_line)) Ok(number_lines(&into_stdout(output)?, first_line))
} }
/// Préfixe chaque ligne par son numéro. /// Prefixes each line with its number.
/// ///
/// Le modèle doit citer une ligne précise pour ancrer son commentaire : sans /// The model must cite a precise line to anchor its comment: without numbers, it
/// numéros, il les compte lui-même et se décale de quelques lignes, ce qui place le /// counts them itself and drifts by a few lines, which puts the comment next to
/// commentaire à côté du code visé. /// the targeted code.
fn number_lines(content: &str, first_line: u64) -> String { fn number_lines(content: &str, first_line: u64) -> String {
content content
.lines() .lines()
@@ -291,8 +291,8 @@ mod tests {
#[test] #[test]
fn a_range_keeps_the_absolute_line_numbers() { fn a_range_keeps_the_absolute_line_numbers() {
// Un extrait lu à partir de la ligne 12 doit garder la numérotation du // An excerpt read from line 12 must keep the file's numbering: otherwise the
// fichier : sinon le modèle citerait des lignes décalées. // model would cite shifted lines.
assert_eq!(number_lines("x\ny", 12), "12:x\n13:y"); assert_eq!(number_lines("x\ny", 12), "12:x\n13:y");
} }
+33
View File
@@ -0,0 +1,33 @@
use jiff::Timestamp;
use serde::Serialize;
use crate::gitea::WebhookType;
#[derive(Debug, Clone, Copy, PartialEq, Eq, toasty::Embed, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum JobStatus {
Pending,
Processing,
}
#[derive(Debug, toasty::Model, Clone, Serialize)]
pub struct Job {
#[key]
#[auto]
pub id: u64,
#[unique]
pub event_id: u64,
pub repo: String,
pub pr: u64,
#[column(type = jsonb)]
pub payload: toasty::Json<WebhookType>,
#[index]
pub status: JobStatus,
#[auto]
pub created_at: Timestamp,
}
+5
View File
@@ -0,0 +1,5 @@
pub mod job;
pub mod review;
pub use job::{Job, JobStatus};
pub use review::Review;
+36
View File
@@ -0,0 +1,36 @@
use jiff::Timestamp;
use serde::Serialize;
#[derive(Debug, toasty::Model, Serialize, Clone)]
pub struct Review {
#[key]
#[auto]
pub id: u64,
#[unique]
pub event_id: u64,
pub repo: String,
pub pr: u64,
/// True if the review succeeded.
pub ok: bool,
/// Model used.
pub model: String,
/// Markdown posted on the pull request (empty on failure).
pub markdown: String,
/// OpenRouter cost in USD, if known.
pub cost: Option<f64>,
/// Number of comments produced.
pub comments: u64,
/// Processing duration in milliseconds.
pub duration_ms: u64,
#[default(jiff::Timestamp::now())]
pub completed_at: Timestamp,
}
+9 -3
View File
@@ -1,8 +1,14 @@
use crate::{bot::Bot, env::EnvConfig, gitea::WebhookType}; use tokio_util::sync::CancellationToken;
use crate::{env::EnvConfig, event_bus::EventBus, job_queue::JobQueue, review_store::ReviewStore};
#[derive(Clone)] #[derive(Clone)]
pub struct AppState { pub struct AppState {
pub bot_tx: tokio::sync::mpsc::Sender<WebhookType>, pub queue: JobQueue,
pub bot: Bot, pub reviews: ReviewStore,
pub event_bus: EventBus,
pub bot_name: String,
pub config: EnvConfig, pub config: EnvConfig,
/// Cancelled when the graceful shutdown starts: it closes the SSE streams.
pub shutdown: CancellationToken,
} }
+6
View File
@@ -0,0 +1,6 @@
version = 1
[[migrations]]
id = 3569579561409902287
name = "0000_init.sql"
snapshot_name = "0000_snapshot.toml"
@@ -0,0 +1,28 @@
CREATE TYPE "job_status" AS ENUM ('pending', 'processing');
CREATE TABLE "jobs" (
"id" BIGINT NOT NULL GENERATED BY DEFAULT AS IDENTITY,
"event_id" BIGINT NOT NULL,
"repo" TEXT NOT NULL,
"pr" BIGINT NOT NULL,
"payload" JSONB NOT NULL,
"status" "job_status" NOT NULL,
"created_at" TIMESTAMPTZ(6) NOT NULL,
PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "index_jobs_by_event_id" ON "jobs" ("event_id");
CREATE INDEX "index_jobs_by_status" ON "jobs" ("status");
CREATE TABLE "reviews" (
"id" BIGINT NOT NULL GENERATED BY DEFAULT AS IDENTITY,
"event_id" BIGINT NOT NULL,
"repo" TEXT NOT NULL,
"pr" BIGINT NOT NULL,
"ok" BOOLEAN NOT NULL,
"model" TEXT NOT NULL,
"markdown" TEXT NOT NULL,
"cost" DOUBLE PRECISION,
"comments" BIGINT NOT NULL,
"duration_ms" BIGINT NOT NULL,
"completed_at" TIMESTAMPTZ(6) NOT NULL,
PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "index_reviews_by_event_id" ON "reviews" ("event_id");
@@ -0,0 +1,160 @@
version = 1
[schema]
[[schema.tables]]
id = 0
name = "jobs"
primary_key = { columns = [{ table = 0, index = 0 }], index = { table = 0, index = 0 } }
[[schema.tables.columns]]
id = { table = 0, index = 0 }
name = "id"
ty = "U64"
storage_ty = { UnsignedInteger = 8 }
auto_increment = true
[[schema.tables.columns]]
id = { table = 0, index = 1 }
name = "event_id"
ty = "U64"
storage_ty = { UnsignedInteger = 8 }
[[schema.tables.columns]]
id = { table = 0, index = 2 }
name = "repo"
ty = "String"
storage_ty = "Text"
[[schema.tables.columns]]
id = { table = 0, index = 3 }
name = "pr"
ty = "U64"
storage_ty = { UnsignedInteger = 8 }
[[schema.tables.columns]]
id = { table = 0, index = 4 }
name = "payload"
ty = "String"
storage_ty = "Jsonb"
[[schema.tables.columns]]
id = { table = 0, index = 5 }
name = "status"
ty = "String"
storage_ty = { Enum = { name = "job_status", variants = [{ name = "pending" }, { name = "processing" }] } }
[[schema.tables.columns]]
id = { table = 0, index = 6 }
name = "created_at"
ty = "Timestamp"
storage_ty = { Timestamp = 6 }
[[schema.tables.indices]]
id = { table = 0, index = 0 }
name = "index_jobs_by_id"
on = 0
columns = [{ column = { table = 0, index = 0 }, op = "Eq", scope = "Partition" }]
unique = true
primary_key = true
[[schema.tables.indices]]
id = { table = 0, index = 1 }
name = "index_jobs_by_event_id"
on = 0
columns = [{ column = { table = 0, index = 1 }, op = "Eq", scope = "Partition" }]
unique = true
[[schema.tables.indices]]
id = { table = 0, index = 2 }
name = "index_jobs_by_status"
on = 0
columns = [{ column = { table = 0, index = 5 }, op = "Eq", scope = "Partition" }]
[[schema.tables]]
id = 1
name = "reviews"
primary_key = { columns = [{ table = 1, index = 0 }], index = { table = 1, index = 0 } }
[[schema.tables.columns]]
id = { table = 1, index = 0 }
name = "id"
ty = "U64"
storage_ty = { UnsignedInteger = 8 }
auto_increment = true
[[schema.tables.columns]]
id = { table = 1, index = 1 }
name = "event_id"
ty = "U64"
storage_ty = { UnsignedInteger = 8 }
[[schema.tables.columns]]
id = { table = 1, index = 2 }
name = "repo"
ty = "String"
storage_ty = "Text"
[[schema.tables.columns]]
id = { table = 1, index = 3 }
name = "pr"
ty = "U64"
storage_ty = { UnsignedInteger = 8 }
[[schema.tables.columns]]
id = { table = 1, index = 4 }
name = "ok"
ty = "Bool"
storage_ty = "Boolean"
[[schema.tables.columns]]
id = { table = 1, index = 5 }
name = "model"
ty = "String"
storage_ty = "Text"
[[schema.tables.columns]]
id = { table = 1, index = 6 }
name = "markdown"
ty = "String"
storage_ty = "Text"
[[schema.tables.columns]]
id = { table = 1, index = 7 }
name = "cost"
ty = "F64"
storage_ty = { Float = 8 }
nullable = true
[[schema.tables.columns]]
id = { table = 1, index = 8 }
name = "comments"
ty = "U64"
storage_ty = { UnsignedInteger = 8 }
[[schema.tables.columns]]
id = { table = 1, index = 9 }
name = "duration_ms"
ty = "U64"
storage_ty = { UnsignedInteger = 8 }
[[schema.tables.columns]]
id = { table = 1, index = 10 }
name = "completed_at"
ty = "Timestamp"
storage_ty = { Timestamp = 6 }
[[schema.tables.indices]]
id = { table = 1, index = 0 }
name = "index_reviews_by_id"
on = 1
columns = [{ column = { table = 1, index = 0 }, op = "Eq", scope = "Partition" }]
unique = true
primary_key = true
[[schema.tables.indices]]
id = { table = 1, index = 1 }
name = "index_reviews_by_event_id"
on = 1
columns = [{ column = { table = 1, index = 1 }, op = "Eq", scope = "Partition" }]
unique = true
+53
View File
@@ -0,0 +1,53 @@
# cargo-deny configuration: https://embarkstudios.github.io/cargo-deny/
# Run locally with `cargo deny check`.
[graph]
all-features = true
[advisories]
# A vulnerable, unmaintained or yanked dependency fails the build.
version = 2
yanked = "deny"
ignore = [
# Unmaintained, but only a thin wrapper around `rustls-pki-types`, and pulled by
# `toasty-driver-postgresql`: it can only go away with a toasty release.
{ id = "RUSTSEC-2025-0134", reason = "transitive dependency of toasty, no vulnerability" },
]
[licenses]
version = 2
# The workspace crates are not published, and have no license of their own.
private = { ignore = true }
allow = [
"Apache-2.0",
"Apache-2.0 WITH LLVM-exception",
"BSD-2-Clause",
"BSD-3-Clause",
"BSL-1.0",
"CC0-1.0",
"CDLA-Permissive-2.0",
"ISC",
"MIT",
"MIT-0",
"MPL-2.0",
"OpenSSL",
"Unicode-3.0",
"Zlib",
]
[bans]
multiple-versions = "warn"
wildcards = "deny"
# `path` dependencies between the (unpublished) workspace crates carry no version.
allow-wildcard-paths = true
# TLS goes through rustls: linking libssl would be a regression of the image
# (it would need `libssl` installed again).
deny = [
{ crate = "openssl-sys", reason = "TLS is rustls only, so that the image needs no libssl" },
{ crate = "native-tls", reason = "TLS is rustls only, so that the image needs no libssl" },
]
[sources]
unknown-registry = "deny"
unknown-git = "deny"
allow-registry = ["https://github.com/rust-lang/crates.io-index"]
+366
View File
@@ -0,0 +1,366 @@
/* Dashboard Steep — reprend le design system du site public
(mêmes jetons de couleur, mêmes composants d'en-tête/pied) et ajoute les
styles propres au tableau de bord. */
:root {
color-scheme: dark;
--paper: #0b1520;
--surface: #111f2d;
--ink: #edf4f7;
--ink-soft: #c2cfd9;
--muted: #94a8b8;
--line: #263b4d;
--green: #087b5b;
--green-dark: #5bd4a7;
--green-pale: #153a32;
--blue-pale: #1a3044;
--shadow: 0 18px 50px rgba(0, 0, 0, 0.24);
--button-hover: #066449;
--brand-mark-bg: #149b72;
--brand-mark-fg: #07111b;
--step-line: #23604c;
--setup-bg: #07111b;
--radius: 20px;
--content: 1160px;
--red: #d1667f;
--amber: #d7a35b;
}
:root[data-theme="light"] {
color-scheme: light;
--paper: #f7f8f5;
--surface: #ffffff;
--ink: #102f50;
--ink-soft: #38536c;
--muted: #718092;
--line: #e1e8e5;
--green: #149b72;
--green-dark: #087b5b;
--green-pale: #e8f5ef;
--blue-pale: #edf3f8;
--shadow: 0 18px 50px rgba(16, 47, 80, 0.08);
--button-hover: #087b5b;
--brand-mark-bg: #102f50;
--brand-mark-fg: #ffffff;
--step-line: #cce8da;
--setup-bg: #102f50;
--red: #b04a63;
--amber: #a9741f;
}
*, *::before, *::after { box-sizing: border-box; }
html { scroll-behavior: smooth; scroll-padding-top: 90px; }
body {
margin: 0;
background: var(--paper);
color: var(--ink);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 16px;
line-height: 1.65;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
a { color: inherit; }
img { display: block; max-width: 100%; }
.container { width: min(var(--content), calc(100% - 48px)); margin-inline: auto; }
.mono { font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; }
.muted { color: var(--muted); }
.site-header {
position: sticky;
z-index: 5;
top: 0;
border-bottom: 1px solid var(--line);
background: var(--paper);
backdrop-filter: blur(16px);
}
.nav {
min-height: 76px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 28px;
}
.wordmark {
display: inline-flex;
align-items: center;
gap: 11px;
color: var(--ink);
text-decoration: none;
font-size: 18px;
font-weight: 850;
letter-spacing: 0.13em;
}
.wordmark-mark {
width: 34px;
height: 34px;
display: grid;
place-items: center;
border-radius: 11px;
background: var(--brand-mark-bg);
color: var(--brand-mark-fg);
font-size: 17px;
letter-spacing: 0;
}
.nav-links { display: flex; align-items: center; gap: 22px; }
.nav-links a {
color: var(--ink-soft);
font-size: 14px;
font-weight: 650;
text-decoration: none;
transition: color 160ms ease;
}
.nav-links a:hover { color: var(--green-dark); }
.nav-links .nav-cta {
padding: 10px 16px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--surface);
color: var(--ink);
}
.nav-links .nav-cta:hover { border-color: var(--green); color: var(--green-dark); }
.theme-toggle {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 9px 13px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--surface);
color: var(--ink-soft);
font: inherit;
font-size: 13px;
font-weight: 700;
cursor: pointer;
transition: border-color 160ms ease, color 160ms ease, background 160ms ease;
}
.theme-toggle:hover { border-color: var(--green-dark); color: var(--green-dark); }
.theme-icon { color: var(--green-dark); font-size: 16px; line-height: 1; }
.section { padding: 46px 0 82px; }
.section-heading { max-width: 720px; margin-bottom: 26px; }
.section-heading h1 {
margin-bottom: 12px;
font-size: clamp(30px, 4vw, 44px);
line-height: 1.12;
letter-spacing: -0.045em;
}
.section-heading p { margin-bottom: 0; color: var(--muted); font-size: 17px; }
.eyebrow {
display: inline-flex;
align-items: center;
gap: 9px;
margin: 0 0 16px;
color: var(--green-dark);
font-size: 12px;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.eyebrow::before {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--green);
content: "";
}
.button {
display: inline-flex;
min-height: 42px;
align-items: center;
justify-content: center;
gap: 9px;
padding: 9px 16px;
border: 1px solid transparent;
border-radius: 999px;
background: var(--green);
color: white;
font: inherit;
font-size: 14px;
font-weight: 750;
text-decoration: none;
cursor: pointer;
transition: transform 160ms ease, background 160ms ease, box-shadow 160ms ease;
}
.button:hover { transform: translateY(-2px); background: var(--button-hover); box-shadow: 0 8px 20px rgba(20, 155, 114, 0.2); }
.button-secondary { border-color: var(--line); background: var(--surface); color: var(--ink); }
.button-secondary:hover { border-color: #b8d8ca; background: var(--green-pale); color: var(--green-dark); box-shadow: none; }
.arrow { font-size: 17px; line-height: 1; }
/* ---- Tableau de bord ---- */
.toolbar {
display: flex;
align-items: center;
justify-content: flex-end;
margin-bottom: 22px;
}
.board {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18px;
margin-bottom: 18px;
}
.panel {
padding: 22px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
box-shadow: 0 5px 20px rgba(16, 47, 80, 0.025);
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.panel-head h2 { margin: 0; font-size: 17px; letter-spacing: -0.02em; }
.count {
min-width: 26px;
padding: 3px 9px;
border-radius: 999px;
background: var(--blue-pale);
color: var(--ink);
font-size: 12px;
font-weight: 850;
text-align: center;
}
.detail-panel { margin-bottom: 18px; }
steep-job-list { display: grid; gap: 10px; }
.job {
border: 1px solid var(--line);
border-radius: 14px;
background: var(--paper);
transition: border-color 140ms ease, background 140ms ease;
}
.job:hover { border-color: var(--green); }
.job[data-selected="true"] { border-color: var(--green); background: var(--green-pale); }
.job-head {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: baseline;
gap: 10px;
padding: 13px 15px;
cursor: pointer;
list-style: none;
}
.job-head::-webkit-details-marker { display: none; }
.job-repo { font-weight: 700; font-size: 14px; overflow-wrap: anywhere; }
.job-pr { color: var(--green-dark); font-weight: 800; font-size: 13px; white-space: nowrap; }
.job-info { display: grid; gap: 12px; padding: 0 15px 15px; }
.job-info dl { display: grid; gap: 6px; margin: 0; }
.job-info dl > div { display: grid; grid-template-columns: 74px 1fr; gap: 10px; }
.job-info dt { color: var(--muted); font-size: 12px; }
.job-info dd { margin: 0; font-size: 12px; overflow-wrap: anywhere; }
.job-watch { justify-self: start; }
/* Chevron partagé par tous les <details> repliables. */
.chev::before { color: var(--muted); content: "▸"; font-size: 11px; }
details[open] .chev::before { content: "▾"; }
.empty { margin: 0; padding: 14px 2px; color: var(--muted); font-size: 14px; }
.pill {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 800;
text-transform: lowercase;
}
.pill.done { background: var(--green-pale); color: var(--green-dark); }
.pill.failed { background: rgba(209, 102, 127, 0.16); color: var(--red); }
.reviews { overflow-x: auto; }
.reviews-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.reviews-table th {
padding: 0 12px 10px 0;
border-bottom: 1px solid var(--line);
color: var(--muted);
font-size: 11px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
text-align: left;
}
.reviews-table td { padding: 11px 12px 11px 0; border-bottom: 1px solid var(--line); vertical-align: top; }
.reviews-table tr:last-child td { border-bottom: 0; }
/* Journal d'activité d'un job */
.log {
max-height: 360px;
overflow-y: auto;
padding: 4px 2px;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
font-size: 12.5px;
line-height: 1.6;
}
.log-entry { display: grid; grid-template-columns: auto 1fr; gap: 10px; padding: 6px 0; border-bottom: 1px solid var(--line); }
.log-entry:last-child { border-bottom: 0; }
/* Résultat d'outil replié par défaut : le résumé tient sur une ligne, le corps
complet s'affiche au dépliage. */
.log-result { padding: 6px 0; border-bottom: 1px solid var(--line); }
.log-result:last-child { border-bottom: 0; }
.log-result > summary {
display: grid;
grid-template-columns: auto auto 1fr;
gap: 10px;
align-items: baseline;
cursor: pointer;
list-style: none;
}
.log-result > summary::-webkit-details-marker { display: none; }
.log-result > .log-body { padding-top: 6px; }
.log-preview { color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.log-tag {
align-self: start;
padding: 2px 8px;
border-radius: 7px;
background: var(--blue-pale);
color: var(--ink);
font-size: 11px;
font-weight: 800;
white-space: nowrap;
}
.log-entry[data-kind="tool_call"] .log-tag { background: var(--green-pale); color: var(--green-dark); }
.log-entry[data-kind="finished"] .log-tag { background: var(--green-pale); color: var(--green-dark); }
.log-entry[data-kind="gap"] .log-tag { background: rgba(215, 163, 91, 0.18); color: var(--amber); }
.log-body { overflow-wrap: anywhere; white-space: pre-wrap; }
.log-args { color: var(--muted); }
.log-truncated { color: var(--amber); font-style: italic; }
footer { padding: 26px 0; background: #0b233b; color: #b6c5d3; font-size: 13px; }
.footer-inner { display: flex; align-items: center; justify-content: space-between; gap: 20px; }
footer a { color: white; text-decoration: none; }
footer a:hover { color: #8be0bc; }
.footer-brand { color: white; font-weight: 800; letter-spacing: 0.1em; }
:focus-visible { outline: 3px solid #35bb8e; outline-offset: 4px; }
@media (max-width: 820px) {
.board { grid-template-columns: 1fr; }
}
@media (max-width: 600px) {
.container { width: min(var(--content), calc(100% - 32px)); }
.nav { min-height: 66px; gap: 12px; }
.nav-links { gap: 12px; }
.nav-links .nav-cta { display: none; }
.section { padding: 30px 0 62px; }
.toolbar { flex-direction: column; align-items: stretch; }
.footer-inner { align-items: flex-start; flex-direction: column; gap: 6px; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; }
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

+46
View File
@@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#0b1520">
<link rel="icon" type="image/png" sizes="512x512" href="/favicon.png">
<link rel="apple-touch-icon" href="/favicon.png">
<meta name="description" content="Steep dashboard — pending jobs, work in progress, and completed reviews, live over SSE.">
<title>Steep — Dashboard</title>
<link rel="stylesheet" href="/css/dashboard.css">
<script>
// Applique le thème enregistré avant le premier rendu, pour éviter un flash.
try {
const savedTheme = localStorage.getItem("steep-theme");
if (savedTheme === "light" || savedTheme === "dark") {
document.documentElement.dataset.theme = savedTheme;
}
} catch (_) {}
</script>
</head>
<body>
<header class="site-header">
<nav class="nav container" aria-label="Main navigation">
<a class="wordmark" href="/" aria-label="Steep dashboard"><span class="wordmark-mark">S</span> STEEP</a>
<div class="nav-links">
<a class="nav-cta" href="https://gitea.qpismont.fr/qpismont/herald">View source <span class="arrow" aria-hidden="true">↗</span></a>
<steep-theme-toggle></steep-theme-toggle>
</div>
</nav>
</header>
<main id="top" class="container">
<steep-dashboard></steep-dashboard>
</main>
<footer>
<div class="container footer-inner">
<span><span class="footer-brand">STEEP</span> · Let your code steep.</span>
<span>Built with Rust · <a href="https://gitea.qpismont.fr/qpismont/herald">Source on Gitea</a></span>
</div>
</footer>
<script type="module" src="/js/dashboard.js"></script>
</body>
</html>
+78
View File
@@ -0,0 +1,78 @@
// Accès aux routes exposées par le serveur axum et petits utilitaires partagés
// par les web components du dashboard.
export const endpoints = {
jobs: "/jobs",
reviews: "/reviews",
lifecycle: "/events",
jobEvents: (id) => `/jobs/${id}/events`,
};
async function getJson(url) {
const response = await fetch(url, { headers: { accept: "application/json" } });
if (!response.ok) {
throw new Error(`${url} → ${response.status}`);
}
return response.json();
}
export const fetchJobs = () => getJson(endpoints.jobs);
export const fetchReviews = () => getJson(endpoints.reviews);
// Ouvre un flux SSE et route les évènements nommés vers les callbacks fournis.
// Les évènements du serveur sont nommés (`lifecycle`, `progress`, `gap`), donc
// `addEventListener("message")` ne suffit pas : il faut écouter par nom.
export function subscribe(url, handlers = {}) {
const source = new EventSource(url);
const onData = (name, handler) => {
if (handler) {
source.addEventListener(name, (event) => handler(JSON.parse(event.data)));
}
};
if (handlers.onOpen) source.addEventListener("open", () => handlers.onOpen());
if (handlers.onError) source.addEventListener("error", () => handlers.onError());
onData("lifecycle", handlers.onLifecycle);
onData("progress", handlers.onProgress);
if (handlers.onGap) {
source.addEventListener("gap", (event) => handlers.onGap(Number(event.data)));
}
return source;
}
export function escapeHtml(value) {
return String(value ?? "").replace(/[&<>"']/g, (char) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
})[char]);
}
// Horodatage relatif (« il y a 3 min »), tolérant sur les valeurs absentes.
export function relativeTime(value) {
if (!value) return "—";
const then = Date.parse(value);
if (Number.isNaN(then)) return "—";
const seconds = Math.round((then - Date.now()) / 1000);
const steps = [
["year", 60 * 60 * 24 * 365],
["month", 60 * 60 * 24 * 30],
["day", 60 * 60 * 24],
["hour", 60 * 60],
["minute", 60],
["second", 1],
];
const formatter = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
for (const [unit, size] of steps) {
if (Math.abs(seconds) >= size || unit === "second") {
return formatter.format(Math.round(seconds / size), unit);
}
}
return "—";
}
+212
View File
@@ -0,0 +1,212 @@
// <steep-dashboard> : assemble le tableau de bord.
//
// Au montage, il charge l'état initial via REST (`/jobs`, `/reviews`), puis
// s'abonne au flux global `/events` pour tenir les listes à jour en direct.
// La sélection d'un job ouvre son flux fin via <steep-job-detail>.
import {
endpoints,
escapeHtml,
fetchJobs,
fetchReviews,
relativeTime,
subscribe,
} from "./api.js";
import "./theme-toggle.js";
import "./job-list.js";
import "./job-detail.js";
class Dashboard extends HTMLElement {
connectedCallback() {
this._jobs = { pending: [], processing: [] };
this._reviews = [];
this._selectedId = null;
this.render();
this.refresh();
qpismont marked this conversation as resolved
Review

[bug] refresh() est lancé sans attendre avant l’abonnement SSE. Si un événement arrive pendant le chargement, la réponse REST peut ensuite écraser l’état plus récent fourni par SSE ; aucun nouvel événement n’est garanti pour corriger la liste. Abonnez-vous avant de charger l’état, ou réconciliez la réponse avec les événements reçus entre-temps.

**[bug]** `refresh()` est lancé sans attendre avant l’abonnement SSE. Si un événement arrive pendant le chargement, la réponse REST peut ensuite écraser l’état plus récent fourni par SSE ; aucun nouvel événement n’est garanti pour corriger la liste. Abonnez-vous avant de charger l’état, ou réconciliez la réponse avec les événements reçus entre-temps.
this.connect();
}
disconnectedCallback() {
this._source?.close();
if (this._refreshTimer) clearTimeout(this._refreshTimer);
}
// ---- Chargement et flux -------------------------------------------------
async refresh() {
try {
const [jobs, reviews] = await Promise.all([fetchJobs(), fetchReviews()]);
this._jobs = {
pending: jobs.pending ?? [],
processing: jobs.processing ?? [],
};
this._reviews = reviews.reviews ?? [];
this.update();
} catch (error) {
console.error("failed to refresh the dashboard", error);
}
}
connect() {
this._source = subscribe(endpoints.lifecycle, {
onLifecycle: (event) => this.applyLifecycle(event),
// En cas de gap (évènements ratés), on recharge l'état depuis le REST.
onGap: () => this.refresh(),
Review

[bug] Le flux /events reconnecte automatiquement (EventSource), mais les événements émis pendant la coupure (redémarrage serveur, réseau) sont perdus, sans événement gap. Le dashboard garde alors un état périmé (jobs fantômes dans Pending/In progress). Il faut brancher onOpen pour appeler refresh() à chaque (re)connexion, y compris la première.

**[bug]** Le flux `/events` reconnecte automatiquement (EventSource), mais les événements émis pendant la coupure (redémarrage serveur, réseau) sont perdus, sans événement `gap`. Le dashboard garde alors un état périmé (jobs fantômes dans Pending/In progress). Il faut brancher `onOpen` pour appeler `refresh()` à chaque (re)connexion, y compris la première.
});
}
// Recharge la liste des reviews peu après une fin de job, pour récupérer les
// champs que l'évènement ne porte pas (markdown, coût, modèle).
scheduleRefresh() {
if (this._refreshTimer) clearTimeout(this._refreshTimer);
this._refreshTimer = setTimeout(() => this.refresh(), 400);
}
applyLifecycle(event) {
switch (event.type) {
case "queued":
if (!this._jobs.pending.some((job) => job.id === event.id)) {
this._jobs.pending.push(this.jobFromEvent(event));
}
break;
case "started": {
// On garde le job déjà connu s'il existe, pour conserver les champs que
// l'évènement `started` ne porte pas (event_id, created_at).
const known = this._jobs.pending.find((job) => job.id === event.id);
this.removeJob(event.id);
this._jobs.processing.push(known ?? this.jobFromEvent(event));
break;
}
case "review_done":
case "failed":
this.removeJob(event.id);
this.scheduleRefresh();
break;
case "skipped":
this.removeJob(event.id);
break;
default:
return;
}
this.update();
}
removeJob(id) {
this._jobs.pending = this._jobs.pending.filter((job) => job.id !== id);
this._jobs.processing = this._jobs.processing.filter((job) => job.id !== id);
}
// L'évènement de cycle de vie porte assez de champs pour l'affichage ; le
// rafraîchissement REST fournira le reste (created_at, payload…).
jobFromEvent(event) {
return {
id: event.id,
event_id: event.event_id,
repo: event.repo,
pr: event.pr,
created_at: event.created_at,
};
}
// ---- Rendu --------------------------------------------------------------
render() {
this.innerHTML = `
<section class="section">
<div class="section-heading">
<p class="eyebrow">Live operations</p>
<h1>Review dashboard</h1>
<p>Pending jobs, work in progress, and completed reviews — kept in sync over SSE.</p>
</div>
<div class="toolbar">
<button class="button button-secondary" id="refresh" type="button">Refresh</button>
</div>
<div class="board">
<section class="panel">
<header class="panel-head"><h2>Pending</h2><span class="count" id="pending-count">0</span></header>
<steep-job-list id="pending" empty="No pending job."></steep-job-list>
</section>
<section class="panel">
<header class="panel-head"><h2>In progress</h2><span class="count" id="processing-count">0</span></header>
<steep-job-list id="processing" empty="No job is running."></steep-job-list>
</section>
</div>
<section class="panel detail-panel">
<header class="panel-head"><h2>Activity</h2></header>
<steep-job-detail id="detail"></steep-job-detail>
</section>
<section class="panel">
<header class="panel-head"><h2>Completed reviews</h2><span class="count" id="reviews-count">0</span></header>
<div class="reviews" id="reviews"></div>
</section>
</section>`;
this._pending = this.querySelector("#pending");
this._processing = this.querySelector("#processing");
this._detail = this.querySelector("#detail");
this._reviewsEl = this.querySelector("#reviews");
this.querySelector("#refresh").addEventListener("click", () => this.refresh());
this.addEventListener("job-select", (event) => this.select(event.detail.id));
this.update();
}
update() {
this._pending.jobs = this._jobs.pending;
this._pending.selected = this._selectedId;
this._processing.jobs = this._jobs.processing;
this._processing.selected = this._selectedId;
this.querySelector("#pending-count").textContent = String(this._jobs.pending.length);
this.querySelector("#processing-count").textContent = String(this._jobs.processing.length);
this.querySelector("#reviews-count").textContent = String(this._reviews.length);
this._reviewsEl.innerHTML = this.reviewsHtml();
}
select(id) {
this._selectedId = id;
this._detail.jobId = id;
this.update();
this._detail.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
reviewsHtml() {
if (this._reviews.length === 0) {
return `<p class="empty">No review completed yet.</p>`;
}
const rows = [...this._reviews]
.sort((a, b) => String(b.completed_at ?? "").localeCompare(String(a.completed_at ?? "")))
.map(
(review) => `
<tr>
<td><span class="pill ${review.ok ? "done" : "failed"}">${review.ok ? "done" : "failed"}</span></td>
<td class="mono">${escapeHtml(review.repo)}#${review.pr}</td>
<td>${review.comments}</td>
<td>${review.duration_ms} ms</td>
<td class="mono muted">${escapeHtml(review.model ?? "—")}</td>
<td class="muted">${relativeTime(review.completed_at)}</td>
</tr>`,
)
.join("");
return `
<table class="reviews-table">
<thead>
<tr>
<th>Status</th><th>Pull request</th><th>Comments</th><th>Duration</th><th>Model</th><th>Completed</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>`;
}
}
customElements.define("steep-dashboard", Dashboard);
+61
View File
@@ -0,0 +1,61 @@
// Carte d'un job. Le détail (id, event, date) est replié par défaut ; le
// bouton « Watch activity » remonte un évènement `job-select` que le dashboard
// écoute pour ouvrir le flux d'activité du job.
import { escapeHtml, relativeTime } from "./api.js";
class JobCard extends HTMLElement {
set job(value) {
this._job = value;
this.render();
}
get job() {
return this._job;
}
set selected(value) {
this._selected = Boolean(value);
this.render();
}
connectedCallback() {
this.render();
}
render() {
if (!this._job) return;
const job = this._job;
// Préserve l'état replié/déplié à travers les re-rendus (mises à jour SSE).
const open = this._details?.open ? " open" : "";
this.innerHTML = `
<details class="job" data-selected="${this._selected ? "true" : "false"}"${open}>
<summary class="job-head">
<span class="chev" aria-hidden="true"></span>
<span class="job-repo">${escapeHtml(job.repo)}</span>
<span class="job-pr">#${job.pr}</span>
</summary>
<div class="job-info">
<dl>
<div><dt>Job</dt><dd>${job.id}</dd></div>
${job.event_id != null ? `<div><dt>Event</dt><dd>${job.event_id}</dd></div>` : ""}
<div><dt>Created</dt><dd>${escapeHtml(relativeTime(job.created_at))}</dd></div>
</dl>
<button class="button button-secondary job-watch" type="button">Watch activity</button>
Review

[bug] Le bouton « Watch activity » est affiché aussi pour les jobs pending. Or /jobs/{id}/events renvoie 404 tant que le job n'a pas démarré (le canal n'est créé que par EventBus::start). Le panneau affiche alors « stream closed » sans explication. Il faut masquer ou désactiver le bouton pour les jobs en attente, ou ré-ouvrir le flux au passage en started.

**[bug]** Le bouton « Watch activity » est affiché aussi pour les jobs `pending`. Or `/jobs/{id}/events` renvoie 404 tant que le job n'a pas démarré (le canal n'est créé que par `EventBus::start`). Le panneau affiche alors « stream closed » sans explication. Il faut masquer ou désactiver le bouton pour les jobs en attente, ou ré-ouvrir le flux au passage en `started`.
</div>
</details>`;
this._details = this.querySelector("details");
this.querySelector(".job-watch").addEventListener("click", () => this.select());
}
select() {
this.dispatchEvent(
new CustomEvent("job-select", { detail: { id: this._job.id }, bubbles: true }),
);
}
}
customElements.define("steep-job-card", JobCard);
+150
View File
@@ -0,0 +1,150 @@
// Panneau d'activité d'un job : ouvre un flux SSE vers `/jobs/{id}/events`,
// rejoue l'historique renvoyé par le serveur puis affiche le direct.
import { endpoints, escapeHtml, subscribe } from "./api.js";
class JobDetail extends HTMLElement {
set jobId(value) {
const id = value ?? null;
if (this._jobId === id) return;
this._jobId = id;
this.open();
}
connectedCallback() {
if (!this._jobId) this.renderPlaceholder();
}
disconnectedCallback() {
this.close();
}
close() {
if (this._source) {
this._source.close();
this._source = null;
}
}
open() {
this.close();
this._finished = false;
if (!this._jobId) {
this.renderPlaceholder();
return;
}
this.innerHTML = `
<div class="log" id="log" role="log" aria-live="polite"></div>`;
this._log = this.querySelector("#log");
this._source = subscribe(endpoints.jobEvents(this._jobId), {
onProgress: (event) => {
this.append(event);
if (event.type === "finished") {
this._finished = true;
this.close();
}
},
onGap: (skipped) =>
this.append({ type: "gap", note: `missed ${skipped} events, refresh to resync` }),
// Un `error` survient aussi quand le serveur ferme le flux (job terminé).
// On ferme alors la source pour éviter la boucle de reconnexion (le job
// n'est plus en cours, la route renverrait 404).
onError: () => {
qpismont marked this conversation as resolved
Review

[bug] Le gestionnaire ferme l’EventSource à la première erreur, y compris lors d’une coupure réseau temporaire pour laquelle EventSource aurait normalement réessayé. L’utilisateur perd alors les mises à jour du job. Fermez-le seulement quand la fin du job est confirmée ou quand le serveur indique que le job n’existe plus.

**[bug]** Le gestionnaire ferme l’EventSource à la première erreur, y compris lors d’une coupure réseau temporaire pour laquelle EventSource aurait normalement réessayé. L’utilisateur perd alors les mises à jour du job. Fermez-le seulement quand la fin du job est confirmée ou quand le serveur indique que le job n’existe plus.
if (this._finished) return;
this.append({ type: "gap", note: "stream closed" });
this.close();
},
});
}
renderPlaceholder() {
this.innerHTML = `<p class="empty">Select a job in progress to watch the agent work.</p>`;
}
append(event) {
if (!this._log) return;
this._log.append(this.entry(event));
this._log.scrollTop = this._log.scrollHeight;
}
entry(event) {
return event.type === "tool_result" ? this.resultEntry(event) : this.lineEntry(event);
}
// Ligne simple : appel d'outil, fin de review, gap.
lineEntry(event) {
const row = document.createElement("div");
row.className = "log-entry";
row.dataset.kind = event.type;
const { tag, body } = this.describe(event);
const tagEl = document.createElement("span");
tagEl.className = "log-tag";
tagEl.textContent = tag;
const bodyEl = document.createElement("span");
bodyEl.className = "log-body";
bodyEl.innerHTML = body;
row.append(tagEl, bodyEl);
return row;
}
// Résultat d'outil : replié par défaut, avec un aperçu sur une ligne.
resultEntry(event) {
const details = document.createElement("details");
details.className = "log-result";
const summary = document.createElement("summary");
const chev = document.createElement("span");
chev.className = "chev";
chev.setAttribute("aria-hidden", "true");
const tag = document.createElement("span");
tag.className = "log-tag";
tag.textContent = "result";
const preview = document.createElement("span");
preview.className = "log-preview";
preview.textContent = this.preview(event.output);
summary.append(chev, tag, preview);
const body = document.createElement("div");
body.className = "log-body";
const truncated = event.truncated
? ' <span class="log-truncated">(truncated)</span>'
: "";
body.innerHTML = `${escapeHtml(event.output)}${truncated}`;
details.append(summary, body);
return details;
}
preview(text) {
const line = String(text ?? "").replace(/\s+/g, " ").trim();
return line.length > 80 ? `${line.slice(0, 80)}…` : line;
}
describe(event) {
switch (event.type) {
case "tool_call":
return {
tag: "call",
body: `${escapeHtml(event.name)} <span class="log-args">${escapeHtml(event.arguments)}</span>`,
};
case "finished":
return {
tag: "done",
body: event.ok ? "review finished" : "review failed",
};
case "gap":
return { tag: "gap", body: escapeHtml(event.note ?? "gap") };
default:
return { tag: escapeHtml(event.type ?? "event"), body: "" };
}
}
}
customElements.define("steep-job-detail", JobDetail);
+58
View File
@@ -0,0 +1,58 @@
// Liste de jobs (une colonne du tableau). Reçoit un tableau de jobs via la
// propriété `jobs` et l'id sélectionné via `selected`.
import "./job-card.js";
class JobList extends HTMLElement {
set jobs(value) {
this._jobs = Array.isArray(value) ? value : [];
this.render();
}
get jobs() {
return this._jobs ?? [];
}
set selected(value) {
this._selected = value;
this.render();
}
connectedCallback() {
if (this._mounted) return;
this._mounted = true;
this.render();
}
render() {
const jobs = this.jobs;
if (jobs.length === 0) {
this.replaceChildren();
const empty = document.createElement("p");
empty.className = "empty";
empty.textContent = this.getAttribute("empty") || "Nothing here.";
this.append(empty);
return;
}
// Réconciliation par id : on réutilise les cartes existantes pour préserver
// l'état replié/déplié de chacune à travers les mises à jour.
const known = new Map();
for (const card of this.querySelectorAll("steep-job-card")) {
if (card.job) known.set(card.job.id, card);
}
for (const job of jobs) {
const card = known.get(job.id) ?? document.createElement("steep-job-card");
known.delete(job.id);
card.job = job;
card.selected = job.id === this._selected;
this.append(card);
}
for (const card of known.values()) card.remove();
Review

[bug] Dans la branche « liste vide », render() ajoute un <p class="empty">, mais la branche non vide ne le retire jamais. Après la première apparition d'un job (liste vide puis job), le message « No pending job. » reste affiché au-dessus ou au-dessous des cartes. Il faut supprimer l'élément .empty dans la branche non vide, ou faire un replaceChildren avant de ré-attacher les cartes.

**[bug]** Dans la branche « liste vide », `render()` ajoute un `<p class="empty">`, mais la branche non vide ne le retire jamais. Après la première apparition d'un job (liste vide puis job), le message « No pending job. » reste affiché au-dessus ou au-dessous des cartes. Il faut supprimer l'élément `.empty` dans la branche non vide, ou faire un `replaceChildren` avant de ré-attacher les cartes.
}
}
customElements.define("steep-job-list", JobList);
+59
View File
@@ -0,0 +1,59 @@
// Bascule de thème, identique à celle du site public (même clé localStorage,
// même rendu), empaquetée en web component.
const STORAGE_KEY = "steep-theme";
export function currentTheme() {
return document.documentElement.dataset.theme === "light" ? "light" : "dark";
}
export function applyTheme(theme, persist = true) {
document.documentElement.dataset.theme = theme;
const meta = document.querySelector('meta[name="theme-color"]');
if (meta) meta.content = theme === "dark" ? "#0b1520" : "#f7f8f5";
if (persist) {
try {
localStorage.setItem(STORAGE_KEY, theme);
} catch (_) {
// stockage indisponible : on garde juste le thème en mémoire.
}
}
window.dispatchEvent(new CustomEvent("steep:theme", { detail: theme }));
}
class ThemeToggle extends HTMLElement {
connectedCallback() {
this.innerHTML = `
<button class="theme-toggle" type="button" role="switch" aria-label="Toggle color theme">
<span class="theme-icon" aria-hidden="true"></span><span class="theme-label"></span>
</button>`;
this._button = this.querySelector("button");
this._icon = this.querySelector(".theme-icon");
this._label = this.querySelector(".theme-label");
this._onTheme = () => this.render();
this._button.addEventListener("click", () => {
applyTheme(currentTheme() === "dark" ? "light" : "dark");
});
window.addEventListener("steep:theme", this._onTheme);
this.render();
}
disconnectedCallback() {
window.removeEventListener("steep:theme", this._onTheme);
}
render() {
const theme = currentTheme();
this._icon.textContent = theme === "dark" ? "☾" : "☀";
this._label.textContent = theme === "dark" ? "Dark theme" : "Light theme";
this._button.setAttribute("aria-checked", String(theme === "dark"));
}
}
customElements.define("steep-theme-toggle", ThemeToggle);
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

+5
View File
@@ -0,0 +1,5 @@
:80 {
root * /usr/share/caddy
encode zstd gzip
file_server
}
+4
View File
@@ -0,0 +1,4 @@
FROM caddy:2-alpine
COPY Caddyfile /etc/caddy/Caddyfile
COPY index.html favicon.png banniere_readme.png /usr/share/caddy/
File renamed without changes.
Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

+646
View File
@@ -0,0 +1,646 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#0b1520">
<link rel="icon" type="image/png" sizes="512x512" href="favicon.png">
<link rel="apple-touch-icon" href="favicon.png">
<meta name="description" content="Steep is an open-source Gitea bot that reviews pull requests in isolated Dev Containers.">
<title>Steep — Let your code steep</title>
<style>
:root {
color-scheme: dark;
--paper: #0b1520;
--surface: #111f2d;
--ink: #edf4f7;
--ink-soft: #c2cfd9;
--muted: #94a8b8;
--line: #263b4d;
--green: #087b5b;
--green-dark: #5bd4a7;
--green-pale: #153a32;
--blue-pale: #1a3044;
--shadow: 0 18px 50px rgba(0, 0, 0, 0.24);
--button-hover: #066449;
--brand-mark-bg: #149b72;
--brand-mark-fg: #07111b;
--step-line: #23604c;
--setup-bg: #07111b;
--radius: 20px;
--content: 1160px;
}
:root[data-theme="light"] {
color-scheme: light;
--paper: #f7f8f5;
--surface: #ffffff;
--ink: #102f50;
--ink-soft: #38536c;
--muted: #718092;
--line: #e1e8e5;
--green: #149b72;
--green-dark: #087b5b;
--green-pale: #e8f5ef;
--blue-pale: #edf3f8;
--shadow: 0 18px 50px rgba(16, 47, 80, 0.08);
--button-hover: #087b5b;
--brand-mark-bg: #102f50;
--brand-mark-fg: #ffffff;
--step-line: #cce8da;
--setup-bg: #102f50;
}
*, *::before, *::after { box-sizing: border-box; }
html { scroll-behavior: smooth; scroll-padding-top: 90px; }
body {
margin: 0;
background: var(--paper);
color: var(--ink);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 16px;
line-height: 1.65;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
a { color: inherit; }
img { display: block; max-width: 100%; }
.container { width: min(var(--content), calc(100% - 48px)); margin-inline: auto; }
.site-header {
position: sticky;
z-index: 5;
top: 0;
border-bottom: 1px solid var(--line);
background: var(--paper);
backdrop-filter: blur(16px);
}
.nav {
min-height: 76px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 28px;
}
.wordmark {
display: inline-flex;
align-items: center;
gap: 11px;
color: var(--ink);
text-decoration: none;
font-size: 18px;
font-weight: 850;
letter-spacing: 0.13em;
}
.wordmark-mark {
width: 34px;
height: 34px;
display: grid;
place-items: center;
border-radius: 11px;
background: var(--brand-mark-bg);
color: var(--brand-mark-fg);
font-size: 17px;
letter-spacing: 0;
}
.nav-links { display: flex; align-items: center; gap: 30px; }
.nav-links a {
color: var(--ink-soft);
font-size: 14px;
font-weight: 650;
text-decoration: none;
transition: color 160ms ease;
}
.nav-links a:hover { color: var(--green-dark); }
.nav-links .nav-cta {
padding: 10px 16px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--surface);
color: var(--ink);
}
.nav-links .nav-cta:hover { border-color: var(--green); color: var(--green-dark); }
.theme-toggle {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 9px 13px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--surface);
color: var(--ink-soft);
font: inherit;
font-size: 13px;
font-weight: 700;
cursor: pointer;
transition: border-color 160ms ease, color 160ms ease, background 160ms ease;
}
.theme-toggle:hover { border-color: var(--green-dark); color: var(--green-dark); }
.theme-icon { color: var(--green-dark); font-size: 16px; line-height: 1; }
.hero { padding: 46px 0 94px; }
.brand-banner {
position: relative;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 26px;
background: white;
box-shadow: var(--shadow);
}
.brand-banner img { width: 100%; height: auto; }
.hero-copy {
display: grid;
grid-template-columns: 1.05fr 0.75fr;
align-items: end;
gap: 54px;
padding: 50px 10px 0;
}
.eyebrow {
display: inline-flex;
align-items: center;
gap: 9px;
margin: 0 0 16px;
color: var(--green-dark);
font-size: 12px;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.eyebrow::before {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--green);
content: "";
}
h1, h2, h3, p { margin-top: 0; }
h1 {
max-width: 690px;
margin-bottom: 0;
font-size: clamp(38px, 6vw, 66px);
line-height: 1.04;
letter-spacing: -0.055em;
}
.hero-aside p {
margin-bottom: 22px;
color: var(--ink-soft);
font-size: 17px;
line-height: 1.7;
}
.button-row { display: flex; flex-wrap: wrap; gap: 11px; }
.button {
display: inline-flex;
min-height: 46px;
align-items: center;
justify-content: center;
gap: 9px;
padding: 11px 18px;
border: 1px solid transparent;
border-radius: 999px;
background: var(--green);
color: white;
font-size: 14px;
font-weight: 750;
text-decoration: none;
transition: transform 160ms ease, background 160ms ease, box-shadow 160ms ease;
}
.button:hover { transform: translateY(-2px); background: var(--button-hover); box-shadow: 0 8px 20px rgba(20, 155, 114, 0.2); }
.button-secondary { border-color: var(--line); background: var(--surface); color: var(--ink); }
.button-secondary:hover { border-color: #b8d8ca; background: var(--green-pale); color: var(--green-dark); box-shadow: none; }
.arrow { font-size: 17px; line-height: 1; }
.section { padding: 82px 0; }
.section-white { border-block: 1px solid var(--line); background: var(--surface); }
.section-heading { max-width: 700px; margin-bottom: 36px; }
.section-heading h2 {
margin-bottom: 12px;
font-size: clamp(30px, 4vw, 44px);
line-height: 1.12;
letter-spacing: -0.045em;
}
.section-heading p { margin-bottom: 0; color: var(--muted); font-size: 17px; }
.features { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; }
.feature-card {
min-height: 226px;
padding: 27px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
box-shadow: 0 5px 20px rgba(16, 47, 80, 0.025);
}
.feature-icon {
width: 44px;
height: 44px;
display: grid;
place-items: center;
margin-bottom: 24px;
border-radius: 14px;
background: var(--green-pale);
color: var(--green-dark);
font-size: 20px;
font-weight: 800;
}
.feature-card:nth-child(2) .feature-icon { background: var(--blue-pale); color: var(--ink); }
.feature-card h3 { margin-bottom: 8px; font-size: 18px; letter-spacing: -0.02em; }
.feature-card p { margin-bottom: 0; color: var(--muted); font-size: 14px; }
.how-grid { display: grid; grid-template-columns: 0.85fr 1.15fr; gap: 70px; align-items: start; }
.how-intro { position: sticky; top: 118px; }
.how-intro p { color: var(--ink-soft); }
.steps-list { display: grid; gap: 0; }
.step {
display: grid;
grid-template-columns: 48px 1fr;
gap: 19px;
padding: 0 0 30px;
}
.step + .step { padding-top: 26px; border-top: 1px solid var(--line); }
.step-number {
width: 42px;
height: 42px;
display: grid;
place-items: center;
border: 1px solid var(--step-line);
border-radius: 50%;
background: var(--green-pale);
color: var(--green-dark);
font-size: 14px;
font-weight: 800;
}
.step h3 { margin: 3px 0 6px; font-size: 17px; }
.step p { margin-bottom: 0; color: var(--muted); font-size: 14px; }
.roadmap-list { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; padding: 0; list-style: none; }
.roadmap-list li {
display: flex;
align-items: flex-start;
gap: 15px;
padding: 19px 20px;
border: 1px solid var(--line);
border-radius: 15px;
background: var(--surface);
color: var(--ink-soft);
font-size: 14px;
}
.version {
flex: 0 0 auto;
min-width: 48px;
padding: 4px 8px;
border-radius: 8px;
background: var(--blue-pale);
color: var(--ink);
font-size: 12px;
font-weight: 850;
text-align: center;
}
.setup-section { background: var(--setup-bg); color: white; }
.setup-section .eyebrow { color: #8be0bc; }
.setup-section .section-heading p { color: #c0cedb; }
.setup-section .section-heading a { color: #8be0bc; }
.setup-layout { display: grid; grid-template-columns: 1fr 0.9fr; gap: 22px; align-items: start; }
.setup-card {
padding: 27px;
border: 1px solid rgba(255,255,255,0.14);
border-radius: var(--radius);
background: rgba(255,255,255,0.06);
}
.setup-card h3 { margin-bottom: 19px; font-size: 18px; }
.setup-steps { display: grid; gap: 20px; margin: 0; padding: 0; list-style: none; counter-reset: setup; }
.setup-steps li { position: relative; padding-left: 40px; counter-increment: setup; }
.setup-steps li::before {
position: absolute;
top: 1px;
left: 0;
width: 26px;
height: 26px;
display: grid;
place-items: center;
border-radius: 50%;
background: rgba(139, 224, 188, 0.15);
color: #8be0bc;
content: counter(setup);
font-size: 12px;
font-weight: 800;
}
.setup-steps strong { display: block; margin-bottom: 7px; font-size: 14px; }
.setup-steps p, .setup-card > p { margin-bottom: 9px; color: #c0cedb; font-size: 13px; }
.required-vars { margin: 8px 0 12px; padding-left: 18px; color: #c0cedb; font-size: 12px; }
.required-vars li { position: static; padding-left: 0; counter-increment: none; }
.required-vars li::before { content: none; }
.required-vars li + li { margin-top: 5px; }
.required-vars code { overflow-wrap: anywhere; }
code, pre { font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; }
code { color: inherit; }
.setup-card code:not(pre code) {
padding: 2px 5px;
border-radius: 5px;
background: rgba(255,255,255,0.1);
color: #d5f5e6;
overflow-wrap: anywhere;
}
pre {
overflow-x: auto;
margin: 9px 0 0;
padding: 13px 15px;
border: 1px solid rgba(255,255,255,0.12);
border-radius: 11px;
background: rgba(4, 18, 31, 0.7);
color: #e9f3fb;
font-size: 12px;
line-height: 1.7;
white-space: pre;
}
.webhook-card { border-color: rgba(139, 224, 188, 0.3); background: rgba(20, 155, 114, 0.11); }
.webhook-card h3 { display: flex; align-items: center; gap: 10px; }
.webhook-card h3::before { color: #8be0bc; content: "↗"; }
.webhook-card .callout {
margin: 15px 0;
padding: 12px 14px;
border-left: 3px solid #8be0bc;
border-radius: 0 9px 9px 0;
background: rgba(255,255,255,0.07);
color: #e4f4ed;
font-size: 13px;
}
.setup-card .subtle { margin-top: 13px; color: #aabaca; font-size: 12px; }
.source-link { display: inline-flex; align-items: center; gap: 8px; margin-top: 24px; color: #8be0bc; font-size: 14px; font-weight: 750; text-decoration: none; }
.source-link:hover { color: white; }
footer { padding: 26px 0; background: #0b233b; color: #b6c5d3; font-size: 13px; }
.footer-inner { display: flex; align-items: center; justify-content: space-between; gap: 20px; }
footer a { color: white; text-decoration: none; }
footer a:hover { color: #8be0bc; }
.footer-brand { color: white; font-weight: 800; letter-spacing: 0.1em; }
:focus-visible { outline: 3px solid #35bb8e; outline-offset: 4px; }
@media (max-width: 820px) {
.nav-links { gap: 18px; }
.hero-copy, .how-grid, .setup-layout { grid-template-columns: 1fr; gap: 26px; }
.hero-copy { padding: 36px 4px 0; }
.hero-aside p { max-width: 620px; }
.how-intro { position: static; }
.features { grid-template-columns: 1fr; }
.feature-card { min-height: auto; }
}
@media (max-width: 600px) {
.container { width: min(var(--content), calc(100% - 32px)); }
.nav { min-height: 66px; gap: 12px; }
.nav-links { gap: 12px; }
.nav-links a { font-size: 12px; }
.nav-links a:nth-child(1), .nav-links a:nth-child(2), .nav-links .nav-cta { display: none; }
.theme-toggle { padding: 8px 10px; font-size: 12px; }
.wordmark { font-size: 15px; }
.wordmark-mark { width: 30px; height: 30px; }
.hero { padding: 22px 0 64px; }
.brand-banner { border-radius: 16px; }
.hero-copy { padding-top: 29px; }
h1 { font-size: clamp(38px, 11vw, 54px); }
.section { padding: 62px 0; }
.roadmap-list { grid-template-columns: 1fr; }
.setup-card { padding: 21px; }
.footer-inner { align-items: flex-start; flex-direction: column; gap: 6px; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: 0.01ms !important; }
}
</style>
<script>
try {
const savedTheme = localStorage.getItem("steep-theme");
if (savedTheme === "light" || savedTheme === "dark") {
document.documentElement.dataset.theme = savedTheme;
}
} catch (_) {}
</script>
</head>
<body>
<header class="site-header">
<nav class="nav container" aria-label="Main navigation">
<a class="wordmark" href="#top" aria-label="Steep home"><span class="wordmark-mark">S</span> STEEP</a>
<div class="nav-links">
<a href="#features">Features</a>
<a href="#roadmap">Roadmap</a>
<a href="#setup">Get started</a>
<a class="nav-cta" href="https://gitea.qpismont.fr/qpismont/herald">View source <span class="arrow" aria-hidden="true">↗</span></a>
<button class="theme-toggle" id="theme-toggle" type="button" role="switch" aria-checked="true" aria-label="Dark theme">
<span class="theme-icon" id="theme-icon" aria-hidden="true">☾</span><span>Dark theme</span>
</button>
</div>
</nav>
</header>
<main id="top">
<section class="hero container" aria-labelledby="hero-title">
<div class="brand-banner">
<img src="banniere_readme.png" alt="Steep — Let your code steep">
</div>
<div class="hero-copy">
<div>
<p class="eyebrow">AI code review · built for Gitea</p>
<h1 id="hero-title">Code reviews with the full picture.</h1>
</div>
<div class="hero-aside">
<p>Steep explores pull requests inside an isolated Dev Container, then leaves focused, line-by-line feedback right where your team works.</p>
<div class="button-row">
<a class="button" href="#setup">Get started <span class="arrow" aria-hidden="true">↓</span></a>
<a class="button button-secondary" href="#how-it-works">How it works</a>
</div>
</div>
</div>
</section>
<section class="section section-white" id="features" aria-labelledby="features-title">
<div class="container">
<div class="section-heading">
<p class="eyebrow">Thoughtful by design</p>
<h2 id="features-title">Useful reviews. No noisy pipeline.</h2>
<p>Bring an AI reviewer into your Gitea workflow with a single comment.</p>
</div>
<div class="features">
<article class="feature-card">
<div class="feature-icon" aria-hidden="true">↳</div>
<h3>Reviews on demand</h3>
<p>Mention Steep in a pull-request comment and get an AI review streamed back to the PR. No CI hook to wire, no pipeline to learn.</p>
</article>
<article class="feature-card">
<div class="feature-icon" aria-hidden="true">◇</div>
<h3>Sandboxed by design</h3>
<p>Every review runs in an ephemeral Dev Container with read-only tools. The network is cut after setup, and the container is removed when the review ends.</p>
</article>
<article class="feature-card">
<div class="feature-icon" aria-hidden="true">⌁</div>
<h3>Lightweight & observable</h3>
<p>A lean Rust service designed for fast reviews, with Prometheus metrics and Sentry error tracking.</p>
</article>
</div>
</div>
</section>
<section class="section" id="how-it-works" aria-labelledby="how-title">
<div class="container how-grid">
<div class="how-intro">
<p class="eyebrow">From comment to context</p>
<div class="section-heading">
<h2 id="how-title">Your code stays in its environment.</h2>
<p>Steep gives the model tools to inspect the repository instead of sending the source code in the prompt.</p>
</div>
<a class="button button-secondary" href="#setup">Set up Steep <span class="arrow" aria-hidden="true">↓</span></a>
</div>
<div class="steps-list">
<article class="step">
<span class="step-number">01</span>
<div><h3>Steep hears your request</h3><p>A Gitea pull-request comment triggers a review when you mention the bot.</p></div>
</article>
<article class="step">
<span class="step-number">02</span>
<div><h3>A disposable workspace spins up</h3><p>Steep clones the pull-request head and starts the repository's Dev Container—or a safe default when none is configured.</p></div>
</article>
<article class="step">
<span class="step-number">03</span>
<div><h3>The model explores, read-only</h3><p>It can inspect files with tools such as <code>ls</code>, <code>read_file</code>, and <code>grep</code>. Setup hooks run first; then network access is removed.</p></div>
</article>
<article class="step">
<span class="step-number">04</span>
<div><h3>Feedback lands on the changed lines</h3><p>Steep posts actionable findings with severity labels, plus a concise summary of what the PR does well.</p></div>
</article>
</div>
</div>
</section>
<section class="section section-white" id="roadmap" aria-labelledby="roadmap-title">
<div class="container">
<div class="section-heading">
<p class="eyebrow">What’s brewing</p>
<h2 id="roadmap-title">The roadmap</h2>
<p>From safer reviews to an assistant that can help move work forward.</p>
</div>
<ul class="roadmap-list">
<li><span class="version">1.2</span><span>Sandboxed pull-request reviews.</span></li>
<li><span class="version">1.2.1</span><span>A default Dev Container when a repository has no configuration.</span></li>
<li><span class="version">1.3</span><span>Durable reviews: persist jobs in PostgreSQL, list them by state (running, pending, review done), and stream live updates over SSE.</span></li>
<li><span class="version">1.4</span><span>Configuration from a YAML file, replacing part of the environment variables.</span></li>
<li><span class="version">2.0</span><span>Type-safe action routing between reviews and implementation requests, implement requested changes from issues or existing PRs, then create or update a PR automatically.</span></li>
<li><span class="version">2.1</span><span>Project instructions from <code>AGENTS.md</code> and separate review/implementation models.</span></li>
<li><span class="version">2.2</span><span>Sub-agents that take on well-scoped parts of a task and run in parallel.</span></li>
<li><span class="version">3.0</span><span>Automatically investigate and fix project errors reported by Sentry.</span></li>
</ul>
</div>
</section>
<section class="section setup-section" id="setup" aria-labelledby="setup-title">
<div class="container">
<div class="section-heading">
<p class="eyebrow">A few minutes to get steeping</p>
<h2 id="setup-title">Bring Steep to your Gitea.</h2>
<p>Follow these steps in order. You will need a <a href="https://rustup.rs/">Rust toolchain</a>, a reachable Docker or Podman daemon, an OpenRouter API key, and a Gitea account for Steep.</p>
</div>
<div class="setup-layout">
<article class="setup-card">
<h3>1. Install and configure</h3>
<ol class="setup-steps">
<li>
<strong>Clone the source</strong>
<pre>git clone https://gitea.qpismont.fr/qpismont/herald.git
cd herald</pre>
</li>
<li>
<strong>Create a Gitea token</strong>
<p>Create a token for the account Steep should act as. Give it access to the repositories it will review, including permission to read pull requests and publish comments/reviews. Steep uses this token to identify the account; you will use that account's username when asking for a review.</p>
</li>
<li>
<strong>Configure every required setting</strong>
<pre>cp .env.example .env
qpismont marked this conversation as resolved
Review

[bug] Le guide demande de configurer neuf variables, mais omet DATABASE_URL, désormais obligatoire, puis passe directement au lancement sans expliquer comment appliquer les migrations. Un nouvel utilisateur suivant ces étapes ne peut pas démarrer le service. Ajoutez la configuration de PostgreSQL et l’étape migration apply.

**[bug]** Le guide demande de configurer neuf variables, mais omet `DATABASE_URL`, désormais obligatoire, puis passe directement au lancement sans expliquer comment appliquer les migrations. Un nouvel utilisateur suivant ces étapes ne peut pas démarrer le service. Ajoutez la configuration de PostgreSQL et l’étape `migration apply`.
$EDITOR .env</pre>
<p>Set all nine required variables:</p>
<ul class="required-vars">
<li><code>GITEA_URL</code> — base URL, e.g. <code>https://gitea.example.com</code> (no <code>/api/v1</code>)</li>
<li><code>GITEA_TOKEN</code> — the token created above</li>
<li><code>GITEA_TIMEOUT</code> — API timeout in seconds</li>
<li><code>OPEN_ROUTER_API_KEY</code> — your OpenRouter key</li>
<li><code>OPEN_ROUTER_MODEL</code> — an available OpenRouter model ID</li>
<li><code>OPEN_ROUTER_TIMEOUT</code> — request timeout in seconds</li>
<li><code>BOT_MAX_CONCURRENT</code> — maximum simultaneous reviews</li>
<li><code>HTTP_PORT</code> — web server port; use <code>3001</code> for the commands below</li>
<li><code>WEBHOOK_SIG_HEADER_SECRET</code> — generate this in the next step</li>
</ul>
<p class="subtle">Keep the example's numeric values for the timeouts and concurrency, or replace them with positive integers. Do not commit your completed <code>.env</code>.</p>
</li>
<li>
<strong>Generate and configure the webhook secret</strong>
<pre>openssl rand -hex 32</pre>
<p>Copy the output into <code>WEBHOOK_SIG_HEADER_SECRET</code> in <code>.env</code>. You will enter the exact same value in Gitea in the next step.</p>
</li>
<li>
<strong>Start the daemon, build, and launch Steep</strong>
<pre>podman system service --time=0 &amp;
export DOCKER_HOST=unix:///run/user/$(id -u)/podman/podman.sock
cargo build --release -p steep-server
./target/release/steep-server</pre>
<p class="subtle">Keep this process running. With Docker, omit the Podman command and use its socket; the default is <code>unix:///var/run/docker.sock</code>. Optional settings such as metrics and Sentry can be added later.</p>
</li>
</ol>
<a class="source-link" href="https://gitea.qpismont.fr/qpismont/herald">Explore the source on Gitea <span aria-hidden="true">↗</span></a>
</article>
<article class="setup-card webhook-card">
<h3>2. Add the Gitea webhook</h3>
<ol class="setup-steps">
<li>
<strong>Choose an address Gitea can reach</strong>
<p>In the repository's <strong>Settings → Webhooks</strong>, add a Gitea webhook. Set the target to Steep's public/reachable address and port:</p>
<div class="callout"><code>http://steep.example.com:3001/webhook</code><br>Replace the host and scheme with yours. Do not use <code>localhost</code> unless Gitea and Steep share that network.</div>
</li>
<li>
<strong>Select the event and set the secret</strong>
<p>Enable at least <strong>Pull Request Comment</strong>. Paste the generated value into Gitea's <strong>Secret</strong> field; it must exactly match <code>WEBHOOK_SIG_HEADER_SECRET</code>.</p>
</li>
<li>
<strong>Allow the connection</strong>
<p>Make sure Gitea can connect to Steep on port <code>3001</code> (or your configured <code>HTTP_PORT</code>), including through any firewall or reverse proxy. Steep must also be able to reach Gitea's API, OpenRouter, and the container daemon.</p>
</li>
<li>
<strong>Trigger a review</strong>
<p>Open a pull request and add a comment that starts with <code>@username</code>, using the username of the Gitea account behind <code>GITEA_TOKEN</code>. For example:</p>
<pre>@steep review this PR</pre>
<p class="subtle">The mention must be at the very start of the comment. If nothing happens, check the webhook's delivery history in Gitea and Steep's logs.</p>
</li>
</ol>
</article>
</div>
</div>
</section>
</main>
<footer>
<div class="container footer-inner">
<span><span class="footer-brand">STEEP</span> · Let your code steep.</span>
<span>Built with Rust · <a href="https://gitea.qpismont.fr/qpismont/herald">Source on Gitea</a></span>
</div>
</footer>
<script>
const themeToggle = document.getElementById("theme-toggle");
const themeIcon = document.getElementById("theme-icon");
const themeColor = document.querySelector('meta[name="theme-color"]');
function setTheme(theme, persist = true) {
document.documentElement.dataset.theme = theme;
themeToggle.setAttribute("aria-checked", String(theme === "dark"));
themeIcon.textContent = theme === "dark" ? "☾" : "☀";
themeColor.content = theme === "dark" ? "#0b1520" : "#f7f8f5";
if (persist) {
try {
localStorage.setItem("steep-theme", theme);
} catch (_) {}
}
}
setTheme(document.documentElement.dataset.theme || "dark", false);
themeToggle.addEventListener("click", () => {
setTheme(document.documentElement.dataset.theme === "dark" ? "light" : "dark");
});
</script>
</body>
</html>
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
set -euo pipefail
IMAGE="docker.io/tintounn/steep-website"
if [ -z "${CI_COMMIT_TAG:-}" ]; then
echo "Error: CI_COMMIT_TAG is not set" >&2
exit 1
fi
TAG="${CI_COMMIT_TAG}"
IMAGE_REF="${IMAGE}:${TAG}"
echo "Building ${IMAGE_REF}..."
buildah build \
--file public/Containerfile \
--tag "${IMAGE_REF}" \
public
echo "Pushing ${IMAGE_REF}..."
buildah push "${IMAGE_REF}"
echo "Done: ${IMAGE_REF}"
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
if [ -f "${ROOT_DIR}/.env" ]; then
set -a
# shellcheck disable=SC1091
. "${ROOT_DIR}/.env"
set +a
fi
cd "${ROOT_DIR}"
if [ -x "${ROOT_DIR}/steep-server" ]; then
exec "${ROOT_DIR}/steep-server" migration apply "$@"
else
exec cargo run -p steep-server -- migration apply "$@"
fi
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
if [ -f "${ROOT_DIR}/.env" ]; then
set -a
. "${ROOT_DIR}/.env"
set +a
fi
cd "${ROOT_DIR}"
if [ "$#" -eq 0 ]; then
echo "Usage: $0 <nom_de_la_migration>" >&2
echo "Exemple: $0 add_users_table" >&2
exit 1
fi
NAME="$1"
exec cargo run -p steep-server -- migration generate --name "${NAME}"