diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index b9310d6..dff506e 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,5 +1,5 @@ { - "name": "herald", + "name": "steep", "build": { "dockerfile": "Dockerfile", "args": { @@ -17,8 +17,8 @@ "extensions": ["fill-labs.dependi", "rust-lang.rust-analyzer", "tamasfe.even-better-toml"] } }, - "workspaceMount": "source=${localWorkspaceFolder},target=/workspaces/herald,type=bind", - "workspaceFolder": "/workspaces/herald", + "workspaceMount": "source=${localWorkspaceFolder},target=/workspaces/steep,type=bind", + "workspaceFolder": "/workspaces/steep", "runArgs": ["--userns=keep-id", "--security-opt", "label=disable"], "appPort": [3000] } diff --git a/.woodpecker/tests.yml b/.woodpecker/tests.yml index 1fd091b..ec1e054 100644 --- a/.woodpecker/tests.yml +++ b/.woodpecker/tests.yml @@ -26,4 +26,4 @@ steps: volumes: - /data/woodpecker-builds:/data commands: - - buildah bud -f Containerfile -t herald-ci . + - buildah bud -f Containerfile -t steep-ci . diff --git a/Cargo.lock b/Cargo.lock index 2ba0448..cfc72d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -485,6 +485,7 @@ dependencies = [ "thiserror", "tokio", "tokio-stream", + "tracing", ] [[package]] @@ -765,34 +766,6 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" -[[package]] -name = "herald-server" -version = "1.2.0" -dependencies = [ - "anyhow", - "axum", - "devcontainer-rs", - "dotenvy", - "futures-util", - "hex", - "metrics", - "metrics-exporter-prometheus", - "reqwest", - "ring", - "sentry", - "sentry-anyhow", - "serde", - "serde_json", - "tempfile", - "thiserror", - "tokio", - "tokio-util", - "tower", - "tower-http 0.7.1", - "tracing", - "tracing-subscriber", -] - [[package]] name = "hex" version = "0.4.3" @@ -2235,6 +2208,34 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "steep-server" +version = "1.2.0" +dependencies = [ + "anyhow", + "axum", + "devcontainer-rs", + "dotenvy", + "futures-util", + "hex", + "metrics", + "metrics-exporter-prometheus", + "reqwest", + "ring", + "sentry", + "sentry-anyhow", + "serde", + "serde_json", + "tempfile", + "thiserror", + "tokio", + "tokio-util", + "tower", + "tower-http 0.7.1", + "tracing", + "tracing-subscriber", +] + [[package]] name = "syn" version = "2.0.119" diff --git a/Cargo.toml b/Cargo.toml index 124b5c8..aeaa9f3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] members = [ - "crates/herald-server", + "crates/steep-server", "crates/devcontainer-rs", ] resolver = "3" diff --git a/Containerfile b/Containerfile index 24748c2..ccb90f5 100644 --- a/Containerfile +++ b/Containerfile @@ -5,14 +5,14 @@ WORKDIR /app COPY Cargo.toml Cargo.lock ./ COPY crates/ crates/ -RUN cargo build --release --package herald-server +RUN cargo build --release --package steep-server 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/herald-server`; libc, libm +# 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 \ ca-certificates \ @@ -24,8 +24,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ WORKDIR /app -COPY --from=builder /app/target/release/herald-server ./herald-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 # down gracefully. -CMD ["./herald-server"] +CMD ["./steep-server"] diff --git a/README.md b/README.md index dffffe8..2a00232 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,10 @@ -# Herald +![Steep — Let your code steep](banniere_readme.png) -Herald is a Gitea bot that performs automated AI-powered code reviews on pull requests using [OpenRouter](https://openrouter.ai/). +# Steep + +*Let your code steep.* + +Steep is a Gitea bot that performs automated AI-powered code reviews on pull requests using [OpenRouter](https://openrouter.ai/). ## Features @@ -10,18 +14,18 @@ Herald is a Gitea bot that performs automated AI-powered code reviews on pull re - Graceful shutdown — in-progress reviews finish before the process exits - Error tracking via Sentry - Prometheus metrics endpoint for monitoring -- Tiny memory footprint (~4MB) thanks to Rust +- Lightweight, performant Rust service with Prometheus metrics and Sentry error tracking ## Installation **Requirements:** Rust toolchain ([rustup.rs](https://rustup.rs)) ```sh -cargo build --release -./target/release/herald +cargo build --release -p steep-server +./target/release/steep-server ``` -Herald reads its configuration from environment variables (a `.env` file is supported): +Steep reads its configuration from environment variables (a `.env` file is supported): | Variable | Description | |---|---| @@ -38,15 +42,16 @@ Herald reads its configuration from environment variables (a `.env` file is supp | `SENTRY_DSN` | *(optional)* Sentry DSN for error tracking | | `RUST_LOG` | *(optional)* Log level, defaults to `info` | | `SANDBOX_MAX_ITERATIONS` | *(optional)* Maximum number of tool-calling iterations per sandboxed review. Defaults to `8` | -| `DOCKER_HOST` | *(optional)* Container daemon socket Herald 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 -Herald reviews pull requests inside an ephemeral +Steep reviews pull requests inside an ephemeral [Dev Container](https://containers.dev/). For each review it: 1. clones the pull request head into a temporary directory, -2. builds and starts the repository's devcontainer (`devcontainer-rs`), +2. builds and starts the repository's devcontainer (`devcontainer-rs`) — or, when +the repository has none, a default one based on `debian:stable-slim`, 3. reads the pull request diff and file list from the Gitea API with `GITEA_TOKEN` (so private repositories work), tells the model which files and lines changed — additions and deletions, with the line numbers of the new and @@ -65,12 +70,14 @@ Each comment is tagged with a severity — `bug`, `security`, `performance` or `maintainability` — shown at the start of the comment, and the summary also lists what the pull request does well. -Herald drives the container daemon through its socket: `DOCKER_HOST` (default +Steep drives the container daemon through its socket: `DOCKER_HOST` (default `unix:///var/run/docker.sock`), which covers both docker and podman's -Docker-compatible socket. The repository must contain a -`.devcontainer/devcontainer.json`. +Docker-compatible socket. When the repository contains a +`.devcontainer/devcontainer.json` (or `.devcontainer.json`), Steep uses it; +otherwise it falls back to a default devcontainer that pulls `debian:stable-slim` +and runs the review in `/workspace`. -Herald can therefore run inside a container with only that socket mounted (no +Steep can therefore run inside a container with only that socket mounted (no shared workspace directory is required): the clone is streamed to the daemon over the socket, like the build context, instead of being bind-mounted from a host path the daemon would have to see. This is the setup the `Containerfile` @@ -79,14 +86,13 @@ produces, e.g.: ```sh podman run --env-file=.env -p 3001:3001 \ -v /run/user/$(id -u)/podman/podman.sock:/var/run/docker.sock \ - herald:latest + steep:latest ``` -The `runArgs` of that file are read but deliberately **not** passed to the daemon: -they come from an untrusted pull request, and one of them (`--network host`) would -attach the container to another network and quietly -defeat the network cut described below. A devcontainer that relies on them -(`--gpus all`, `--cap-add`, `--shm-size`…) will not get them. +The `runArgs` of that file are ignored: they come from an untrusted pull request, +and one of them (`--network host`) would attach the container to another network +and quietly defeat the network cut described below. A devcontainer that relies on +them (`--gpus all`, `--cap-add`, `--shm-size`…) will not get them. Each sandbox is isolated: it gets its own image tag, container and network. The container starts with network access so the `postCreateCommand` / @@ -105,28 +111,28 @@ Open the project and reopen it in the container — the Rust toolchain is pre-in ```sh rustup toolchain install stable -cargo run +cargo run -p steep-server ``` Copy `.env.example` to `.env` and fill in your values before running. ## Metrics -Herald 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. -Set `METRICS_BIND_ADDR` (e.g. `0.0.0.0:9100`) to enable it. The metrics are then available at `http://:9100/metrics`. +Set `METRICS_BIND_ADDR` (e.g. `0.0.0.0:9100`) to enable it. The metrics are then available at `http://:9100/metrics`. The metric namespace changed from `herald_*` to `steep_*`; update any dashboards or alerts that use the old names. ### Exposed metrics | Metric | Type | Description | |---|---|---| -| `herald_webhooks_received_total` | counter | Total webhooks received (label: `event_type`) | -| `herald_webhooks_duplicate_total` | counter | Webhooks rejected as duplicates (label: `event_type`) | -| `herald_webhooks_channel_full_total` | counter | Webhooks dropped because the bot channel was full (label: `event_type`) | -| `herald_bot_tasks_active` | gauge | Bot tasks currently in progress | -| `herald_bot_tasks_completed_total` | counter | Bot tasks completed successfully (label: `event_type`) | -| `herald_bot_tasks_failed_total` | counter | Bot tasks that failed (label: `event_type`) | -| `herald_openrouter_cost_cents_total` | counter | Total OpenRouter cost in cents (divide by 100 for USD) | +| `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_channel_full_total` | counter | Webhooks dropped because the bot channel was full (label: `event_type`) | +| `steep_bot_tasks_active` | gauge | Bot tasks currently in progress | +| `steep_bot_tasks_completed_total` | counter | Bot tasks completed successfully (label: `event_type`) | +| `steep_bot_tasks_failed_total` | counter | Bot tasks that failed (label: `event_type`) | +| `steep_openrouter_cost_cents_total` | counter | Total OpenRouter cost in cents (divide by 100 for USD) | ### OTel collector example @@ -135,10 +141,10 @@ receivers: prometheus: config: scrape_configs: - - job_name: herald + - job_name: steep scrape_interval: 15s static_configs: - - targets: ["herald:9100"] + - targets: ["steep:9100"] service: pipelines: diff --git a/ROADMAP.md b/ROADMAP.md index e69de29..5cd299a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -0,0 +1,37 @@ +# Roadmap + +## 1.2 — Sandboxed reviews — delivered + +- Run each pull request review in an ephemeral Dev Container. +- Let the model explore the checked-out repository through read-only tools instead of sending the source code in the prompt. +- Anchor review comments to changed lines and classify findings by severity. + +## 1.2.1 — Default Dev Container — delivered + +- 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. + +## 1.3 — Typed action routing — planned + +- 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. +- 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. + +## 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 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. +- Keep implementation work isolated in a sandbox; do not modify the default branch directly. + +## 2.1 — `AGENTS.md` support and multi models — planned + +- Read applicable `AGENTS.md` files from the repository and use their project-specific instructions when carrying out 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. + +## 3.0 — Autonomous Sentry fixes — planned + +- Automatically discover project errors reported by Sentry, without waiting for a user comment. +- Investigate the errors in an isolated sandbox, implement candidate fixes, and validate them before reporting the results. diff --git a/banniere_readme.png b/banniere_readme.png new file mode 100644 index 0000000..2fea156 Binary files /dev/null and b/banniere_readme.png differ diff --git a/crates/devcontainer-rs/Cargo.toml b/crates/devcontainer-rs/Cargo.toml index 0294fe6..883b958 100644 --- a/crates/devcontainer-rs/Cargo.toml +++ b/crates/devcontainer-rs/Cargo.toml @@ -10,6 +10,7 @@ futures-util = { workspace = true } tar = "0.4" tokio = { workspace = true } tokio-stream = { workspace = true } +tracing = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } anyhow = { workspace = true } diff --git a/crates/devcontainer-rs/src/consts.rs b/crates/devcontainer-rs/src/consts.rs index b62ce35..9aceac6 100644 --- a/crates/devcontainer-rs/src/consts.rs +++ b/crates/devcontainer-rs/src/consts.rs @@ -22,3 +22,5 @@ pub(crate) const CONTEXT_CHUNKS: usize = 4; /// Lectures successives du code de sortie d'un exec, et attente entre elles. pub(crate) const EXIT_CODE_ATTEMPTS: usize = 10; pub(crate) const EXIT_CODE_DELAY: Duration = Duration::from_millis(20); + +pub(crate) const DEFAULT_IMAGE_NAME: &str = "debian:stable-slim"; diff --git a/crates/devcontainer-rs/src/container.rs b/crates/devcontainer-rs/src/container.rs index e93d9e5..7236af8 100644 --- a/crates/devcontainer-rs/src/container.rs +++ b/crates/devcontainer-rs/src/container.rs @@ -15,18 +15,19 @@ pub struct Container { workspace_folder: String, remote_user: Option, network: Option, - image: Option, + owned_image: Option, } impl Container { - /// Assemble un container démarré, avec son réseau et son image à nettoyer. + /// Assemble un container démarré, avec son réseau et, le cas échéant, l'image + /// construite pour lui et à nettoyer. pub(crate) fn new( runtime: ContainerRuntime, name: String, workspace_folder: String, remote_user: Option, network: Option, - image: Option, + owned_image: Option, ) -> Self { Self { runtime, @@ -34,7 +35,7 @@ impl Container { workspace_folder, remote_user, network, - image, + owned_image, } } @@ -84,7 +85,7 @@ impl Container { let _ = self.runtime.remove_network(network).await; } - if let Some(image) = &self.image { + if let Some(image) = &self.owned_image { let _ = self.runtime.remove_image(image).await; } diff --git a/crates/devcontainer-rs/src/devcontainer.rs b/crates/devcontainer-rs/src/devcontainer.rs index 173a06d..4d59d61 100644 --- a/crates/devcontainer-rs/src/devcontainer.rs +++ b/crates/devcontainer-rs/src/devcontainer.rs @@ -8,12 +8,17 @@ use std::{ }; use bollard::models::{ContainerCreateBody, HostConfig}; +use tracing::info; -use crate::{container::Container, errors::ContainerError, runtime::ContainerRuntime}; +use crate::{ + consts::DEFAULT_IMAGE_NAME, container::Container, errors::ContainerError, + runtime::ContainerRuntime, +}; #[derive(Debug)] pub struct DevContainer { - pub container_file_path: PathBuf, + pub image: Option, + pub container_file_path: Option, pub name: Option, pub build_args: HashMap, pub container_env: HashMap, @@ -24,47 +29,42 @@ pub struct DevContainer { } impl DevContainer { - /// Nom de l'image de base dérivé du nom du devcontainer. - pub fn image_name(&self) -> String { - let base = self.name.as_deref().unwrap_or("devcontainer"); - format!("devcontainer-rs/{}", sanitize(base)) - } - - /// Tag d'image unique pour une exécution de sandbox donnée. - /// - /// L'unicité est importante : deux sandboxes concurrentes (éventuellement pour des - /// dépôts différents partageant un nom de devcontainer) ne doivent pas se disputer le même tag. - pub fn image_tag(&self) -> String { - format!("{}:{}", self.image_name(), unique_suffix()) - } - - /// Dossier de workspace dans le container, par défaut `/workspaces/workspace`. + /// Dossier de workspace dans le container, par défaut `/workspace`. pub fn workspace_folder(&self) -> String { self.workspace_folder .clone() - .unwrap_or_else(|| "/workspaces/workspace".to_string()) + .unwrap_or_else(|| "/workspace".to_string()) } - /// Nom de container unique pour cette exécution. - pub fn container_name(&self) -> String { - let base = self.name.as_deref().unwrap_or("devcontainer"); - format!("devcontainer-rs-{}-{}", sanitize(base), unique_suffix()) + /// Tag de l'image construite localement à partir du Dockerfile. + fn local_image_tag(&self, id: &str) -> String { + format!("devcontainer-rs/{}:{}", self.sanitized_name(), id) + } + + /// Nom du container de cette sandbox. + fn container_name(&self, id: &str) -> String { + format!("devcontainer-rs-{}-{}", self.sanitized_name(), id) + } + + /// Nom du devcontainer nettoyé pour servir de nom d'image/container. + fn sanitized_name(&self) -> String { + sanitize(self.name.as_deref().unwrap_or("devcontainer")) } /// Construit l'image devcontainer sous `image_tag`. - pub async fn build( + async fn build( &self, runtime: &ContainerRuntime, + container_file_path: &Path, image_tag: &str, ) -> Result<(), ContainerError> { - let context = self.container_file_path.parent().ok_or_else(|| { + let context = container_file_path.parent().ok_or_else(|| { ContainerError::Unexpected(String::from( "the devcontainer file path has no parent directory", )) })?; - let dockerfile = self - .container_file_path + let dockerfile = container_file_path .file_name() .and_then(|name| name.to_str()) .ok_or_else(|| { @@ -78,91 +78,105 @@ impl DevContainer { .await } - /// Construit l'image, démarre le container, exécute les hooks puis coupe le réseau. - /// - /// En cas d'échec, le container, le réseau et l'image sont nettoyés avant de - /// renvoyer l'erreur, afin qu'aucune ressource ne soit laissée en place. pub async fn up( &self, runtime: &ContainerRuntime, workspace_dir: &Path, ) -> Result { - let image_tag = self.image_tag(); - self.build(runtime, &image_tag).await?; - - let name = self.container_name(); - let network = format!("{name}-net"); - - // Réseau dédié afin de pouvoir couper la connectivité après les hooks. - if let Err(err) = runtime.create_network(&network).await { - let _ = runtime.remove_image(&image_tag).await; - return Err(err); - } + let id = unique_id(); + let image = self + .image + .clone() + .unwrap_or_else(|| self.local_image_tag(&id)); + let owns_image = self.image.is_none(); + let container_name = self.container_name(&id); + let network = format!("{container_name}-net"); let workspace_folder = self.workspace_folder(); - let body = ContainerCreateBody { - image: Some(image_tag.clone()), - cmd: Some(vec![String::from("sleep"), String::from("infinity")]), - user: self.remote_user.clone(), - env: Some( - self.container_env - .iter() - .map(|(key, value)| format!("{key}={value}")) - .collect(), - ), - working_dir: Some(workspace_folder.clone()), - host_config: Some(HostConfig { - network_mode: Some(network.clone()), + + info!(image = %image, "Starting sandbox container"); + + let setup = async { + if owns_image { + let container_file_path = self.container_file_path.as_ref().ok_or_else(|| { + ContainerError::Unexpected(String::from( + "the devcontainer has neither an image nor a container file", + )) + })?; + + self.build(runtime, container_file_path, &image).await?; + } else { + runtime.pull_image(&image).await?; + } + + runtime.create_network(&network).await?; + + let body = ContainerCreateBody { + image: Some(image.clone()), + cmd: Some(vec![String::from("sleep"), String::from("infinity")]), + user: self.remote_user.clone(), + env: Some( + self.container_env + .iter() + .map(|(key, value)| format!("{key}={value}")) + .collect(), + ), + working_dir: Some(workspace_folder.clone()), + host_config: Some(HostConfig { + network_mode: Some(network.clone()), + ..Default::default() + }), ..Default::default() - }), - ..Default::default() - }; + }; - if let Err(err) = runtime.create_container(&name, body).await { - let _ = runtime.remove_network(&network).await; - let _ = runtime.remove_image(&image_tag).await; - return Err(err); + runtime.create_container(&container_name, body).await?; + runtime.start_container(&container_name).await?; + runtime + .upload_directory(&container_name, &workspace_folder, workspace_dir) + .await?; + + Ok::<(), ContainerError>(()) } + .await; - if let Err(err) = runtime.start_container(&name).await { - let _ = runtime.remove_container(&name).await; - let _ = runtime.remove_network(&network).await; - let _ = runtime.remove_image(&image_tag).await; - return Err(err); - } - - // Le clone est copié dans le container par le socket, pas monté depuis un - // chemin de l'hôte : le daemon n'a pas besoin de voir le clone pour le rendre - // visible dans la sandbox, ce qui permet à Herald de tourner dans un container - // (avec le socket monté) sans partager de dossier avec l'hôte. - if let Err(err) = runtime - .upload_directory(&name, &workspace_folder, workspace_dir) - .await - { - let _ = runtime.remove_container(&name).await; - let _ = runtime.remove_network(&network).await; - let _ = runtime.remove_image(&image_tag).await; + if let Err(err) = setup { + cleanup_failed_startup( + runtime, + &container_name, + &network, + owns_image.then_some(image.as_str()), + ) + .await; return Err(err); } let container = Container::new( runtime.clone(), - name, - self.workspace_folder(), + container_name.clone(), + workspace_folder, self.remote_user.clone(), Some(network.clone()), - Some(image_tag), + owns_image.then_some(image.clone()), ); - // Les hooks s'exécutent avec accès au réseau (installation de dépendances, etc.). - if let Err(err) = self.run_hooks(&container, runtime.timeout()).await { - let _ = container.remove().await; - return Err(err); - } + let post_setup = async { + self.run_hooks(&container, runtime.timeout()).await?; + runtime + .disconnect_network(&network, &container_name) + .await?; - // Coupe l'accès réseau pour le reste de la durée de vie de la sandbox. - if let Err(err) = runtime.disconnect_network(&network, container.name()).await { - let _ = container.remove().await; + Ok::<(), ContainerError>(()) + } + .await; + + if let Err(err) = post_setup { + cleanup_failed_startup( + runtime, + &container_name, + &network, + owns_image.then_some(image.as_str()), + ) + .await; return Err(err); } @@ -195,6 +209,36 @@ impl DevContainer { } } +async fn cleanup_failed_startup( + runtime: &ContainerRuntime, + container: &str, + network: &str, + owned_image: Option<&str>, +) { + let _ = runtime.remove_container(container).await; + let _ = runtime.remove_network(network).await; + + if let Some(image) = owned_image { + let _ = runtime.remove_image(image).await; + } +} + +impl Default for DevContainer { + fn default() -> Self { + Self { + name: Some("generic-devcontainer".to_string()), + image: Some(DEFAULT_IMAGE_NAME.to_string()), + post_create_command: None, + post_start_command: None, + build_args: HashMap::new(), + container_env: HashMap::new(), + container_file_path: None, + remote_user: None, + workspace_folder: None, + } + } +} + /// Nettoie une chaîne pour qu'elle puisse servir de nom d'image/container docker. fn sanitize(input: &str) -> String { let sanitized: String = input @@ -216,8 +260,8 @@ fn sanitize(input: &str) -> String { } } -/// Suffixe unique à une exécution de sandbox, combinant l'identifiant de processus et un horodatage. -fn unique_suffix() -> String { +/// Identifiant unique à une exécution de sandbox, combinant l'identifiant de processus et un horodatage. +fn unique_id() -> String { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|duration| duration.as_nanos()) @@ -256,27 +300,30 @@ mod tests { } #[test] - fn image_name_is_sanitized() { + fn local_image_tag_is_sanitized() { let dir = tempfile::tempdir().unwrap(); let dc = devcontainer(dir.path()); - assert_eq!(dc.image_name(), "devcontainer-rs/my-project"); - } - - #[test] - fn image_tags_are_unique() { - let dir = tempfile::tempdir().unwrap(); - let dc = devcontainer(dir.path()); - assert_ne!(dc.image_tag(), dc.image_tag()); + assert_eq!(dc.local_image_tag("abc"), "devcontainer-rs/my-project:abc"); } #[test] fn container_name_is_sanitized() { let dir = tempfile::tempdir().unwrap(); let dc = devcontainer(dir.path()); + assert_eq!(dc.container_name("abc"), "devcontainer-rs-my-project-abc"); + } - assert!( - dc.container_name() - .starts_with("devcontainer-rs-my-project-") - ); + #[test] + fn unique_ids_differ() { + assert_ne!(unique_id(), unique_id()); + } + + #[test] + fn default_devcontainer_pulls_a_base_image() { + let dc = DevContainer::default(); + + assert_eq!(dc.image.as_deref(), Some("debian:stable-slim")); + assert_eq!(dc.container_file_path, None); + assert_eq!(dc.workspace_folder(), "/workspace"); } } diff --git a/crates/devcontainer-rs/src/errors.rs b/crates/devcontainer-rs/src/errors.rs index ac0b400..b6a451d 100644 --- a/crates/devcontainer-rs/src/errors.rs +++ b/crates/devcontainer-rs/src/errors.rs @@ -47,6 +47,9 @@ pub enum ParseError { #[error("container file `{0}` does not exist or is not a regular file")] ContainerFileNotFound(PathBuf), + #[error("devcontainer file `{0}` declares neither `image` nor `build`")] + MissingImageOrBuild(PathBuf), + #[error("the devcontainer file path has no parent directory: `{0}`")] InvalidDevContainerPath(PathBuf), } diff --git a/crates/devcontainer-rs/src/lib.rs b/crates/devcontainer-rs/src/lib.rs index 7771ad6..b0ccb44 100644 --- a/crates/devcontainer-rs/src/lib.rs +++ b/crates/devcontainer-rs/src/lib.rs @@ -1,12 +1,13 @@ //! Primitives de cycle de vie de container pour un [`DevContainer`] analysé. //! -//! Cette crate pilote l'API du daemon de containers pour construire l'image -//! devcontainer, démarrer un container et y copier le workspace, exécuter les +//! Cette crate pilote l'API du daemon de containers pour préparer l'image +//! devcontainer (construite depuis un Dockerfile, ou tirée d'un registre), +//! démarrer un container et y copier le workspace, exécuter les //! hooks `postCreateCommand` / `postStartCommand` et lancer des commandes à //! 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 -//! container de Herald) : [`ContainerRuntime::connect`] suit `DOCKER_HOST` comme +//! container de Steep) : [`ContainerRuntime::connect`] suit `DOCKER_HOST` comme //! le fait le CLI `docker`, et retombe sur le socket local. Aucun binaire de //! runtime n'est donc requis dans l'image, et `podman` fonctionne de la même //! façon dès lors qu'il expose son socket compatible Docker. @@ -19,10 +20,9 @@ //! (par ex. `npm install`) ; une fois les hooks exécutés, le container est déconnecté //! du réseau pour le reste de sa durée de vie. Chaque commande est bornée par un timeout. //! -//! Les `runArgs` du `devcontainer.json` sont lus mais pas transmis au daemon : ils -//! viennent du dépôt, donc d'une pull request non fiable, et pourraient rattacher le -//! container à un autre réseau ou lui donner des privilèges qui annuleraient cette -//! isolation. +//! Les `runArgs` du `devcontainer.json` sont ignorés : ils viennent du dépôt, donc +//! d'une pull request non fiable, et pourraient rattacher le container à un autre +//! réseau ou lui donner des privilèges qui annuleraient cette isolation. mod consts; mod container; @@ -40,4 +40,4 @@ pub use errors::{ContainerError, ParseError}; pub use exec::ExecOutput; pub use path::normalize; pub use runtime::ContainerRuntime; -pub use schema::{DevContainerBuildSchema, DevContainerSchema, parse}; +pub use schema::parse; diff --git a/crates/devcontainer-rs/src/runtime.rs b/crates/devcontainer-rs/src/runtime.rs index e7fd8aa..63028c8 100644 --- a/crates/devcontainer-rs/src/runtime.rs +++ b/crates/devcontainer-rs/src/runtime.rs @@ -9,11 +9,13 @@ use bollard::{ exec::{CreateExecOptions, StartExecOptions, StartExecResults}, models::{BuildInfo, ContainerCreateBody, NetworkCreateRequest, NetworkDisconnectRequest}, query_parameters::{ - BuildImageOptions, CreateContainerOptions, RemoveContainerOptions, RemoveImageOptions, - StartContainerOptions, StopContainerOptions, UploadToContainerOptions, + BuildImageOptions, CreateContainerOptions, CreateImageOptionsBuilder, + RemoveContainerOptions, RemoveImageOptions, StartContainerOptions, StopContainerOptions, + UploadToContainerOptions, }, }; use futures_util::StreamExt; +use tracing::info; use crate::{ consts::{DEFAULT_COMMAND_TIMEOUT, DEFAULT_ENDPOINT, EXIT_CODE_ATTEMPTS, EXIT_CODE_DELAY}, @@ -133,6 +135,33 @@ impl ContainerRuntime { } } + pub(crate) async fn pull_image(&self, image: &str) -> Result<(), ContainerError> { + info!("Pulling image `{image}`"); + + let options = CreateImageOptionsBuilder::default() + .from_image(image) + .build(); + + let mut stream = self.docker.create_image(Some(options), None, None); + + let consume = async { + while let Some(info) = stream.next().await { + info?; + } + + Ok::<_, BollardError>(()) + }; + + match tokio::time::timeout(self.timeout, consume).await { + Ok(Ok(())) => Ok(()), + Ok(Err(source)) => Err(ContainerError::Request { source }), + Err(_) => Err(ContainerError::Timeout { + operation: format!("pull image `{image}`"), + timeout: self.timeout, + }), + } + } + /// Crée un container, sans le démarrer. pub(crate) async fn create_container( &self, diff --git a/crates/devcontainer-rs/src/schema.rs b/crates/devcontainer-rs/src/schema.rs index 033337a..de7d5e1 100644 --- a/crates/devcontainer-rs/src/schema.rs +++ b/crates/devcontainer-rs/src/schema.rs @@ -11,42 +11,39 @@ use serde::Deserialize; use crate::{devcontainer::DevContainer, errors::ParseError}; #[derive(Debug, Deserialize)] -pub struct DevContainerBuildSchema { +struct DevContainerBuildSchema { #[serde(default)] - pub dockerfile: String, + dockerfile: String, #[serde(default)] - pub args: HashMap, + args: HashMap, } #[derive(Debug, Deserialize)] -pub struct DevContainerSchema { +struct DevContainerSchema { #[serde(default)] - pub name: Option, - pub build: DevContainerBuildSchema, + name: Option, + + /// Image de base tirée du registre, alternative à `build`. + #[serde(default)] + image: Option, + + #[serde(default)] + build: Option, #[serde(rename = "workspaceFolder", default)] - pub workspace_folder: Option, + workspace_folder: Option, #[serde(rename = "containerEnv", default)] - pub container_env: HashMap, + container_env: HashMap, #[serde(rename = "postCreateCommand", default)] - pub post_create_command: Option, + post_create_command: Option, #[serde(rename = "postStartCommand", default)] - pub post_start_command: Option, + post_start_command: Option, #[serde(rename = "remoteUser", default)] - pub remote_user: Option, - - /// Arguments passés à `docker run`. - /// - /// Lus pour rester fidèle au format `devcontainer.json`, mais - /// **délibérément pas transmis** au runtime : ils viennent d'une pull - /// request non fiable et pourraient casser l'isolation de la sandbox, décrite - /// dans le doc de la crate. - #[serde(rename = "runArgs", default)] - pub run_args: Vec, + remote_user: Option, } impl TryFrom<(DevContainerSchema, PathBuf)> for DevContainer { @@ -59,34 +56,52 @@ impl TryFrom<(DevContainerSchema, PathBuf)> for DevContainer { .parent() .ok_or_else(|| ParseError::InvalidDevContainerPath(devcontainer_path.clone()))?; - let container_file_path = base_dir.join(schema.build.dockerfile); + let DevContainerSchema { + name, + image, + build, + workspace_folder, + container_env, + post_create_command, + post_start_command, + remote_user, + } = schema; - if !container_file_path.is_file() { - return Err(ParseError::ContainerFileNotFound(container_file_path)); - } + let (image, container_file_path, build_args) = match (image, build) { + (Some(image), _) => (Some(substitute_local_env(&image)), None, HashMap::new()), + (None, Some(build)) => { + let container_file_path = base_dir.join(&build.dockerfile); - let build_args = schema - .build - .args - .into_iter() - .map(|(k, v)| (k, substitute_local_env(&v))) - .collect(); + if !container_file_path.is_file() { + return Err(ParseError::ContainerFileNotFound(container_file_path)); + } - let container_env = schema - .container_env + let build_args = build + .args + .into_iter() + .map(|(k, v)| (k, substitute_local_env(&v))) + .collect(); + + (None, Some(container_file_path), build_args) + } + (None, None) => return Err(ParseError::MissingImageOrBuild(devcontainer_path)), + }; + + let container_env = container_env .into_iter() .map(|(k, v)| (k, substitute_local_env(&v))) .collect(); Ok(Self { + image, container_file_path, - name: schema.name, + name, build_args, container_env, - workspace_folder: schema.workspace_folder, - post_create_command: schema.post_create_command, - post_start_command: schema.post_start_command, - remote_user: schema.remote_user, + workspace_folder, + post_create_command, + post_start_command, + remote_user, }) } } @@ -97,7 +112,7 @@ impl TryFrom<(DevContainerSchema, PathBuf)> for DevContainer { /// La spécification veut que `${localEnv:VAR}` soit lu dans l'environnement du /// client ; ici le `devcontainer.json` vient d'une **pull request**, donc de code /// non fiable : lire l'environnement du processus lui permettrait de récupérer -/// `GITEA_TOKEN`, `OPEN_ROUTER_API_KEY` ou n'importe quel autre secret de Herald et +/// `GITEA_TOKEN`, `OPEN_ROUTER_API_KEY` ou n'importe quel autre secret de Steep et /// de l'exfiltrer depuis un `build.args`, un `containerEnv`, son Dockerfile ou un /// hook. L'environnement n'est donc **jamais** consulté : seule la valeur par /// défaut est utilisée, et une référence sans défaut devient une chaîne vide. @@ -188,19 +203,76 @@ mod tests { let config = parse(&devcontainer_path).await.unwrap(); assert_eq!(config.name.as_deref(), Some("test")); - assert_eq!(config.container_file_path, dockerfile_path); + assert_eq!(config.image, None); + assert_eq!(config.container_file_path, Some(dockerfile_path)); assert_eq!(config.build_args.get("VERSION").unwrap(), "1"); assert_eq!(config.container_env.get("RUST_LOG").unwrap(), "debug"); assert_eq!(config.workspace_folder.as_deref(), Some("/workspace")); assert_eq!(config.remote_user.as_deref(), Some("dev")); } + #[tokio::test] + async fn parses_image_based_devcontainer() { + let dir = tempfile::tempdir().unwrap(); + let devcontainer_path = dir.path().join("devcontainer.json"); + + fs::write( + &devcontainer_path, + r#"{ + "name": "image-based", + "image": "mcr.microsoft.com/devcontainers/base:debian" + }"#, + ) + .unwrap(); + + let config = parse(&devcontainer_path).await.unwrap(); + + assert_eq!( + config.image.as_deref(), + Some("mcr.microsoft.com/devcontainers/base:debian") + ); + assert_eq!(config.container_file_path, None); + } + + #[tokio::test] + async fn ignores_run_args() { + let dir = tempfile::tempdir().unwrap(); + let devcontainer_path = dir.path().join("devcontainer.json"); + + // `runArgs` vient d'une pull request non fiable : il doit être accepté par + // l'analyse mais jamais repris dans le devcontainer. + fs::write( + &devcontainer_path, + r#"{ + "image": "alpine", + "runArgs": ["--network", "host"] + }"#, + ) + .unwrap(); + + let config = parse(&devcontainer_path).await.unwrap(); + + assert_eq!(config.image.as_deref(), Some("alpine")); + } + + #[tokio::test] + async fn rejects_devcontainer_without_image_or_build() { + let dir = tempfile::tempdir().unwrap(); + let devcontainer_path = dir.path().join("devcontainer.json"); + + fs::write(&devcontainer_path, r#"{ "name": "empty" }"#).unwrap(); + + let err = parse(&devcontainer_path).await.unwrap_err(); + + assert!(matches!(err, ParseError::MissingImageOrBuild(_))); + } + #[test] fn local_env_references_are_never_read_from_the_environment() { unsafe { std::env::set_var("DEVCONTAINER_TEST_SECRET", "s3cret") }; // Le `devcontainer.json` vient d'une pull request : résoudre la référence - // depuis l'environnement de Herald y exposerait ses jetons. + // depuis l'environnement de Steep y exposerait ses jetons. assert_eq!( substitute_local_env("${localEnv:DEVCONTAINER_TEST_SECRET}"), "" diff --git a/crates/herald-server/Cargo.toml b/crates/steep-server/Cargo.toml similarity index 97% rename from crates/herald-server/Cargo.toml rename to crates/steep-server/Cargo.toml index 6a6ed9a..4165ef4 100644 --- a/crates/herald-server/Cargo.toml +++ b/crates/steep-server/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "herald-server" +name = "steep-server" version = "1.2.0" edition = "2024" diff --git a/crates/herald-server/src/api.rs b/crates/steep-server/src/api.rs similarity index 99% rename from crates/herald-server/src/api.rs rename to crates/steep-server/src/api.rs index 1b5a436..64ca4f2 100644 --- a/crates/herald-server/src/api.rs +++ b/crates/steep-server/src/api.rs @@ -47,7 +47,7 @@ pub async fn start(app_state: AppState, shutdown: CancellationToken) -> anyhow:: } async fn root() -> &'static str { - "Hi, i'm Herald :)" + "Hi, I'm Steep :)" } #[instrument(skip(app_state), fields(webhook_type), err)] diff --git a/crates/herald-server/src/bot.rs b/crates/steep-server/src/bot.rs similarity index 100% rename from crates/herald-server/src/bot.rs rename to crates/steep-server/src/bot.rs diff --git a/crates/herald-server/src/bot_actions/mod.rs b/crates/steep-server/src/bot_actions/mod.rs similarity index 100% rename from crates/herald-server/src/bot_actions/mod.rs rename to crates/steep-server/src/bot_actions/mod.rs diff --git a/crates/herald-server/src/bot_actions/review.rs b/crates/steep-server/src/bot_actions/review.rs similarity index 99% rename from crates/herald-server/src/bot_actions/review.rs rename to crates/steep-server/src/bot_actions/review.rs index 371f7a6..e0ac54f 100644 --- a/crates/herald-server/src/bot_actions/review.rs +++ b/crates/steep-server/src/bot_actions/review.rs @@ -875,7 +875,7 @@ mod tests { #[test] fn generated_lockfiles_are_ignored() { assert!(is_ignored("Cargo.lock")); - assert!(is_ignored("crates/herald-server/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")); diff --git a/crates/herald-server/src/consts.rs b/crates/steep-server/src/consts.rs similarity index 100% rename from crates/herald-server/src/consts.rs rename to crates/steep-server/src/consts.rs diff --git a/crates/herald-server/src/env.rs b/crates/steep-server/src/env.rs similarity index 100% rename from crates/herald-server/src/env.rs rename to crates/steep-server/src/env.rs diff --git a/crates/herald-server/src/errors.rs b/crates/steep-server/src/errors.rs similarity index 100% rename from crates/herald-server/src/errors.rs rename to crates/steep-server/src/errors.rs diff --git a/crates/herald-server/src/gitea.rs b/crates/steep-server/src/gitea.rs similarity index 100% rename from crates/herald-server/src/gitea.rs rename to crates/steep-server/src/gitea.rs diff --git a/crates/herald-server/src/main.rs b/crates/steep-server/src/main.rs similarity index 99% rename from crates/herald-server/src/main.rs rename to crates/steep-server/src/main.rs index c22d24f..b18de22 100644 --- a/crates/herald-server/src/main.rs +++ b/crates/steep-server/src/main.rs @@ -76,7 +76,7 @@ async fn run() -> anyhow::Result<()> { model = %config.open_router_model, gitea_url = %config.gitea_url, bot_name = %gitea_user.login, - "Starting Herald" + "Starting Steep" ); let open_router_client = OpenRouterClient::new( diff --git a/crates/herald-server/src/metrics.rs b/crates/steep-server/src/metrics.rs similarity index 64% rename from crates/herald-server/src/metrics.rs rename to crates/steep-server/src/metrics.rs index 44d3347..e23c36e 100644 --- a/crates/herald-server/src/metrics.rs +++ b/crates/steep-server/src/metrics.rs @@ -3,73 +3,72 @@ use std::{net::SocketAddr, str::FromStr}; use metrics::{Unit, counter, describe_counter, describe_gauge, gauge}; pub fn webhook_received(event_type: &str) { - counter!("herald_webhooks_received_total", "event_type" => event_type.to_string()).increment(1); + counter!("steep_webhooks_received_total", "event_type" => event_type.to_string()).increment(1); } pub fn webhook_duplicate(event_type: &str) { - counter!("herald_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) { - counter!("herald_webhooks_channel_full_total", "event_type" => event_type.to_string()) + counter!("steep_webhooks_channel_full_total", "event_type" => event_type.to_string()) .increment(1); } pub fn increment_task_active() { - gauge!("herald_bot_tasks_active").increment(1.0); + gauge!("steep_bot_tasks_active").increment(1.0); } pub fn decrement_task_active() { - gauge!("herald_bot_tasks_active").decrement(1.0); + gauge!("steep_bot_tasks_active").decrement(1.0); } pub fn task_completed(event_type: &str) { - counter!("herald_bot_tasks_completed_total", "event_type" => event_type.to_string()) + counter!("steep_bot_tasks_completed_total", "event_type" => event_type.to_string()) .increment(1); } pub fn task_failed(event_type: &str) { - counter!("herald_bot_tasks_failed_total", "event_type" => event_type.to_string()).increment(1); + counter!("steep_bot_tasks_failed_total", "event_type" => event_type.to_string()).increment(1); } pub fn openrouter_cost_usd(cost: f64) { - counter!("herald_openrouter_cost_cents_total").increment((cost * 100.0).round() as u64); + counter!("steep_openrouter_cost_cents_total").increment((cost * 100.0).round() as u64); } pub fn describe() { describe_counter!( - "herald_webhooks_received_total", + "steep_webhooks_received_total", Unit::Count, "Total webhooks received" ); describe_counter!( - "herald_webhooks_duplicate_total", + "steep_webhooks_duplicate_total", Unit::Count, "Webhooks rejected as duplicates" ); describe_counter!( - "herald_webhooks_channel_full_total", + "steep_webhooks_channel_full_total", Unit::Count, "Webhooks dropped because the bot channel was full" ); describe_gauge!( - "herald_bot_tasks_active", + "steep_bot_tasks_active", Unit::Count, "Bot tasks currently in progress" ); describe_counter!( - "herald_bot_tasks_completed_total", + "steep_bot_tasks_completed_total", Unit::Count, "Bot tasks completed successfully" ); describe_counter!( - "herald_bot_tasks_failed_total", + "steep_bot_tasks_failed_total", Unit::Count, "Bot tasks that failed" ); describe_counter!( - "herald_openrouter_cost_cents_total", + "steep_openrouter_cost_cents_total", Unit::Count, "Total OpenRouter cost in cents (divide by 100 for USD)" ); diff --git a/crates/herald-server/src/open_router.rs b/crates/steep-server/src/open_router.rs similarity index 98% rename from crates/herald-server/src/open_router.rs rename to crates/steep-server/src/open_router.rs index 3c5de7a..938c2fe 100644 --- a/crates/herald-server/src/open_router.rs +++ b/crates/steep-server/src/open_router.rs @@ -1,10 +1,10 @@ //! Minimal OpenRouter chat-completions client. //! -//! Herald only needs a non-streaming `POST /chat/completions` with optional +//! Steep only needs a non-streaming `POST /chat/completions` with optional //! tool calling, so the wire types are implemented in-tree instead of pulling a //! third-party SDK (and its own `reqwest` version) into the workspace. //! -//! Only the response fields Herald consumes are modelled: `content`, +//! Only the response fields Steep consumes are modelled: `content`, //! `tool_calls` and `usage.cost`. Unknown fields are ignored. use std::time::Duration; @@ -174,7 +174,7 @@ struct ChatRequest<'a> { tool_choice: &'a str, } -/// OpenRouter reasoning config; Herald always asks for reasoning. +/// OpenRouter reasoning config; Steep always asks for reasoning. #[derive(Serialize)] struct Reasoning { enabled: bool, diff --git a/crates/herald-server/src/sandbox/agent.rs b/crates/steep-server/src/sandbox/agent.rs similarity index 100% rename from crates/herald-server/src/sandbox/agent.rs rename to crates/steep-server/src/sandbox/agent.rs diff --git a/crates/herald-server/src/sandbox/instance.rs b/crates/steep-server/src/sandbox/instance.rs similarity index 94% rename from crates/herald-server/src/sandbox/instance.rs rename to crates/steep-server/src/sandbox/instance.rs index c2bda2d..880166c 100644 --- a/crates/herald-server/src/sandbox/instance.rs +++ b/crates/steep-server/src/sandbox/instance.rs @@ -50,12 +50,16 @@ impl Sandbox { clone_pull_request(clone_url, token, pull_request_number, &repo_dir).await?; make_readable(&repo_dir).await?; - let devcontainer_path = find_devcontainer(&repo_dir) - .with_context(|| format!("no devcontainer found in `{clone_url}`"))?; + let devcontainer = match find_devcontainer(&repo_dir) { + Some(path) => devcontainer_rs::parse(&path).await?, + None => { + info!( + "no devcontainer found in the pull request, falling back to the default image" + ); + devcontainer_rs::DevContainer::default() + } + }; - let devcontainer = devcontainer_rs::parse(&devcontainer_path).await?; - - info!(image = %devcontainer.image_tag(), "Building and starting sandbox container"); let container = devcontainer.up(runtime, &repo_dir).await?; let sandbox = Self { @@ -210,10 +214,10 @@ async fn clone_pull_request( /// Rend le clone lisible par les utilisateurs du container sandbox. /// -/// Le container peut ne pas avoir les mêmes uid que Herald (podman rootless +/// Le container peut ne pas avoir les mêmes uid que Steep (podman rootless /// mappe les uid à travers des plages subuid), et l'umask de l'opérateur peut être /// restrictif : sans cela, les outils de la sandbox échouent en `Permission -/// denied` sur des fichiers que Herald vient de cloner lui-même. +/// denied` sur des fichiers que Steep vient de cloner lui-même. async fn make_readable(repo_dir: &Path) -> anyhow::Result<()> { let output = tokio::process::Command::new("chmod") .args(["-R", "a+rX"]) diff --git a/crates/herald-server/src/sandbox/mod.rs b/crates/steep-server/src/sandbox/mod.rs similarity index 100% rename from crates/herald-server/src/sandbox/mod.rs rename to crates/steep-server/src/sandbox/mod.rs diff --git a/crates/herald-server/src/sandbox/tools.rs b/crates/steep-server/src/sandbox/tools.rs similarity index 100% rename from crates/herald-server/src/sandbox/tools.rs rename to crates/steep-server/src/sandbox/tools.rs diff --git a/crates/herald-server/src/state.rs b/crates/steep-server/src/state.rs similarity index 100% rename from crates/herald-server/src/state.rs rename to crates/steep-server/src/state.rs diff --git a/crates/herald-server/src/text.rs b/crates/steep-server/src/text.rs similarity index 100% rename from crates/herald-server/src/text.rs rename to crates/steep-server/src/text.rs diff --git a/logo_readme.png b/logo_readme.png new file mode 100644 index 0000000..1be8f8e Binary files /dev/null and b/logo_readme.png differ diff --git a/scripts/build.sh b/scripts/build.sh index a34d59f..0b151c7 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -IMAGE="tintounn/herald" +IMAGE="tintounn/steep" if [ -z "${CI_COMMIT_TAG:-}" ]; then echo "Error: CI_COMMIT_TAG is not set" >&2