diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 1b89658..d28c98e 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,4 +1,4 @@ -FROM debian:trixie +FROM rust:1.98-trixie ARG USERNAME=dev ARG USER_UID=1000 @@ -18,11 +18,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && rm -rf /var/lib/apt/lists/* RUN groupadd --gid ${USER_GID:-1000} $USERNAME \ - && useradd --uid ${USER_UID:-1000} --gid ${USER_GID:-1000} -m $USERNAME + && useradd --uid ${USER_UID:-1000} --gid ${USER_GID:-1000} -m $USERNAME \ + && rustup component add clippy \ + && rustup component add rustfmt + + USER $USERNAME WORKDIR /home/$USERNAME - -ENV PATH="/home/${USERNAME}/.cargo/bin:${PATH}" - -RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index a0fc52a..b9310d6 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -19,5 +19,6 @@ }, "workspaceMount": "source=${localWorkspaceFolder},target=/workspaces/herald,type=bind", "workspaceFolder": "/workspaces/herald", + "runArgs": ["--userns=keep-id", "--security-opt", "label=disable"], "appPort": [3000] } diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 89343aa..0000000 --- a/.dockerignore +++ /dev/null @@ -1,4 +0,0 @@ -target/ -.env -.devcontainer/ -docs/ diff --git a/.env.example b/.env.example index 06a8c62..9f25ae2 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,4 @@ HTTP_PORT=3000 -BOT_NAME=Herald WEBHOOK_SIG_HEADER_SECRET= @@ -18,5 +17,8 @@ SENTRY_DSN= RUST_LOG=info RUST_BACKTRACE=1 +METRICS_BIND_ADDR= -METRICS_BIND_ADDR= \ No newline at end of file +# Sandboxed tool execution +# DOCKER_HOST= +SANDBOX_MAX_ITERATIONS=8 diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 660eb93..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "rust-analyzer.check.command": "clippy" -} \ No newline at end of file diff --git a/.woodpecker/tests.yml b/.woodpecker/tests.yml index b151ff1..1fd091b 100644 --- a/.woodpecker/tests.yml +++ b/.woodpecker/tests.yml @@ -3,13 +3,27 @@ when: - push steps: + - name: fmt + image: rust:1.98 + commands: + - rustup component add rustfmt + - cargo fmt --all -- --check + - name: clippy - image: rust:1.96 + image: rust:1.98 commands: - rustup component add clippy - - cargo clippy + - cargo clippy --workspace --all-targets --all-features -- -D warnings - name: test - image: rust:1.96 + image: rust:1.98 commands: - - cargo test + - cargo test --workspace --all-targets + + - name: container-build + image: quay.io/buildah/stable + privileged: true + volumes: + - /data/woodpecker-builds:/data + commands: + - buildah bud -f Containerfile -t herald-ci . diff --git a/.zed/settings.json b/.zed/settings.json new file mode 100644 index 0000000..c873cae --- /dev/null +++ b/.zed/settings.json @@ -0,0 +1,22 @@ +{ + "languages": { + "Rust": { + "format_on_save": "on", + "formatter": "language_server" + } + }, + "lsp": { + "rust-analyzer": { + "initialization_options": { + "check": { + "command": "clippy", + "extraArgs": [ + "--", + "-D", + "warnings" + ] + } + } + } + } +} diff --git a/Cargo.lock b/Cargo.lock index 7f4d25b..2ba0448 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,9 +4,9 @@ version = 4 [[package]] name = "actix-codec" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a" +checksum = "31404e1443b7b7bcaa311c1af456775cc3f668e34573f1503d7ce4844327629e" dependencies = [ "bitflags", "bytes", @@ -21,9 +21,9 @@ dependencies = [ [[package]] name = "actix-http" -version = "3.12.1" +version = "3.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93acb4a42f64936f9b8cae4a433b237599dd6eb6ed06124eb67132ef8cc90662" +checksum = "11004b0e9b44b4eb3d15e0c3132b96fb178c7e50a74758b2f17bb9cc9a7fb4f6" dependencies = [ "actix-codec", "actix-rt", @@ -34,7 +34,7 @@ dependencies = [ "bytestring", "derive_more", "encoding_rs", - "foldhash 0.1.5", + "foldhash", "futures-core", "http 0.2.12", "httparse", @@ -66,9 +66,9 @@ dependencies = [ [[package]] name = "actix-rt" -version = "2.11.0" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92589714878ca59a7626ea19734f0e07a6a875197eec751bb5d3f99e64998c63" +checksum = "6a16bf2f19c2ad84842bdfe6f3665620e93197d5c607889bfa3aaac45e762fd1" dependencies = [ "futures-core", "tokio", @@ -76,9 +76,9 @@ dependencies = [ [[package]] name = "actix-server" -version = "2.6.0" +version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a65064ea4a457eaf07f2fba30b4c695bf43b721790e9530d26cb6f9019ff7502" +checksum = "5d44ae8a6516f4ac7bfc7b61aabcd286104e96b4b24c747ce220832a016056d9" dependencies = [ "actix-rt", "actix-service", @@ -86,7 +86,7 @@ dependencies = [ "futures-core", "futures-util", "mio", - "socket2 0.5.10", + "socket2", "tokio", "tracing", ] @@ -113,9 +113,9 @@ dependencies = [ [[package]] name = "actix-web" -version = "4.13.0" +version = "4.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff87453bc3b56e9b2b23c1cc0b1be8797184accf51d2abe0f8a33ec275d316bf" +checksum = "bbacab3593b6b4f7be815076fc52d60a83c873426824675417e2abdd229e2e36" dependencies = [ "actix-codec", "actix-http", @@ -129,7 +129,7 @@ dependencies = [ "cfg-if", "derive_more", "encoding_rs", - "foldhash 0.1.5", + "foldhash", "futures-core", "futures-util", "impl-more", @@ -144,7 +144,7 @@ dependencies = [ "serde_json", "serde_urlencoded", "smallvec", - "socket2 0.6.3", + "socket2", "time", "tracing", "url", @@ -167,27 +167,27 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] [[package]] name = "android_system_properties" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" dependencies = [ "libc", ] [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "atomic-waker" @@ -205,7 +205,7 @@ dependencies = [ "bytes", "form_urlencoded", "futures-util", - "http 1.4.0", + "http 1.5.0", "http-body", "http-body-util", "hyper", @@ -236,7 +236,7 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", - "http 1.4.0", + "http 1.5.0", "http-body", "http-body-util", "mime", @@ -268,6 +268,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "base64ct" version = "1.8.3" @@ -276,9 +282,9 @@ checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "block2" @@ -290,16 +296,59 @@ dependencies = [ ] [[package]] -name = "bumpalo" -version = "3.20.2" +name = "bollard" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "dbe8358268799ebb3e4df23cb9d47f4c72bbc4f5247e2fa6a1bf7b6c0baea220" +dependencies = [ + "base64 0.22.1", + "bollard-stubs", + "bytes", + "futures-core", + "futures-util", + "hex", + "http 1.5.0", + "http-body-util", + "hyper", + "hyper-named-pipe", + "hyper-util", + "hyperlocal", + "log", + "pin-project-lite", + "serde", + "serde_derive", + "serde_json", + "serde_urlencoded", + "thiserror", + "tokio", + "tokio-util", + "tower-service", + "url", + "winapi", +] + +[[package]] +name = "bollard-stubs" +version = "1.53.1-rc.29.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce412eb6f7096743011dc3cb5c674caeb24ced61d8c498fe07cf7998a4fea889" +dependencies = [ + "serde", + "serde_json", + "serde_repr", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "bytestring" @@ -312,9 +361,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.62" +version = "1.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" dependencies = [ "find-msvc-tools", "shlex", @@ -328,9 +377,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "convert_case" @@ -372,41 +421,6 @@ version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" -[[package]] -name = "darling" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" -dependencies = [ - "darling_core", - "darling_macro", -] - -[[package]] -name = "darling_core" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 2.0.117", -] - -[[package]] -name = "darling_macro" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" -dependencies = [ - "darling_core", - "quote", - "syn 2.0.117", -] - [[package]] name = "debugid" version = "0.8.0" @@ -419,9 +433,9 @@ dependencies = [ [[package]] name = "der" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" +checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d" dependencies = [ "pem-rfc7468", "zeroize", @@ -432,40 +446,6 @@ name = "deranged" version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "powerfmt", -] - -[[package]] -name = "derive_builder" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" -dependencies = [ - "derive_builder_macro", -] - -[[package]] -name = "derive_builder_core" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" -dependencies = [ - "darling", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "derive_builder_macro" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" -dependencies = [ - "derive_builder_core", - "syn 2.0.117", -] [[package]] name = "derive_more" @@ -486,10 +466,27 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.117", + "syn 2.0.119", "unicode-xid", ] +[[package]] +name = "devcontainer-rs" +version = "0.1.0" +dependencies = [ + "anyhow", + "bollard", + "bytes", + "futures-util", + "serde", + "serde_json", + "tar", + "tempfile", + "thiserror", + "tokio", + "tokio-stream", +] + [[package]] name = "dispatch2" version = "0.3.1" @@ -502,13 +499,13 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.4", ] [[package]] @@ -517,24 +514,6 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" -[[package]] -name = "dotenvy_macro" -version = "0.15.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb0235d912a8c749f4e0c9f18ca253b4c28cfefc1d2518096016d6e3230b6424" -dependencies = [ - "dotenvy", - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "dyn-clone" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" - [[package]] name = "encoding_rs" version = "0.8.35" @@ -573,15 +552,25 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" [[package]] name = "findshlibs" @@ -601,12 +590,6 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - [[package]] name = "foldhash" version = "0.2.0" @@ -639,9 +622,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", @@ -649,44 +632,44 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.4", ] [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", "futures-io", @@ -720,10 +703,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", - "js-sys", "libc", "wasi", - "wasm-bindgen", ] [[package]] @@ -733,11 +714,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", - "js-sys", "libc", "r-efi", "wasip2", - "wasm-bindgen", ] [[package]] @@ -748,16 +727,16 @@ checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" [[package]] name = "h2" -version = "0.4.14" +version = "0.4.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" dependencies = [ "atomic-waker", "bytes", "fnv", "futures-core", "futures-sink", - "http 1.4.0", + "http 1.5.0", "indexmap", "slab", "tokio", @@ -777,7 +756,7 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ - "foldhash 0.2.0", + "foldhash", ] [[package]] @@ -787,30 +766,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] -name = "herald" -version = "1.1.0" +name = "herald-server" +version = "1.2.0" dependencies = [ "anyhow", "axum", - "bytes", + "devcontainer-rs", "dotenvy", "futures-util", "hex", "metrics", "metrics-exporter-prometheus", - "openrouter-rs", - "reqwest 0.12.28", + "reqwest", "ring", "sentry", "sentry-anyhow", "serde", "serde_json", - "thiserror 2.0.18", + "tempfile", + "thiserror", "tokio", - "tokio-stream", "tokio-util", "tower", - "tower-http", + "tower-http 0.7.1", "tracing", "tracing-subscriber", ] @@ -845,9 +823,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", "itoa", @@ -855,23 +833,23 @@ dependencies = [ [[package]] name = "http-body" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", - "http 1.4.0", + "http 1.5.0", ] [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" dependencies = [ "bytes", "futures-core", - "http 1.4.0", + "http 1.5.0", "http-body", "pin-project-lite", ] @@ -890,16 +868,16 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.9.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" dependencies = [ "atomic-waker", "bytes", "futures-channel", "futures-core", "h2", - "http 1.4.0", + "http 1.5.0", "http-body", "httparse", "httpdate", @@ -911,19 +889,17 @@ dependencies = [ ] [[package]] -name = "hyper-rustls" -version = "0.27.9" +name = "hyper-named-pipe" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +checksum = "fab3637d6b04a8037af8a266fdf6cf92ea957e8c53981a2bf6136572531025bf" dependencies = [ - "http 1.4.0", + "hex", "hyper", "hyper-util", - "rustls", + "pin-project-lite", "tokio", - "tokio-rustls", "tower-service", - "webpki-roots", ] [[package]] @@ -948,28 +924,43 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", - "http 1.4.0", + "http 1.5.0", "http-body", "hyper", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.3", + "socket2", "tokio", "tower-service", "tracing", ] [[package]] -name = "icu_collections" -version = "2.2.0" +name = "hyperlocal" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7" +dependencies = [ + "hex", + "http-body-util", + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", @@ -981,9 +972,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", @@ -994,9 +985,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -1008,16 +999,17 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_properties" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -1028,15 +1020,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.2.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", @@ -1047,12 +1039,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "ident_case" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" - [[package]] name = "idna" version = "1.1.0" @@ -1076,15 +1062,15 @@ dependencies = [ [[package]] name = "impl-more" -version = "0.1.9" +version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2" +checksum = "277ff51754a3f68f12f58446c5d006aa8baa4914ea273cce24a599cfaff33d4f" [[package]] name = "indexmap" -version = "2.14.0" +version = "2.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" dependencies = [ "equivalent", "hashbrown 0.17.1", @@ -1092,9 +1078,9 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.12.0" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" [[package]] name = "itoa" @@ -1104,13 +1090,12 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.98" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] @@ -1139,9 +1124,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.186" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "linux-raw-sys" @@ -1151,9 +1136,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "local-waker" @@ -1172,9 +1157,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "loom" @@ -1189,12 +1174,6 @@ dependencies = [ "tracing-subscriber", ] -[[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - [[package]] name = "matchers" version = "0.2.0" @@ -1212,9 +1191,9 @@ checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "metrics" @@ -1232,7 +1211,7 @@ version = "0.18.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1db0d8f1fc9e62caebd0319e11eaec5822b0186c171568f0480b46a0137f9108" dependencies = [ - "base64", + "base64 0.22.1", "evmap", "http-body-util", "hyper", @@ -1242,7 +1221,7 @@ dependencies = [ "metrics", "metrics-util", "quanta", - "thiserror 2.0.18", + "thiserror", "tokio", "tracing", ] @@ -1270,16 +1249,6 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" -[[package]] -name = "mime_guess" -version = "2.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" -dependencies = [ - "mime", - "unicase", -] - [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1291,9 +1260,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.0" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", "log", @@ -1519,31 +1488,11 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" -[[package]] -name = "openrouter-rs" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25918cfeef40cfd3adea348b268496ee5206eb0408f2aacfe4bdfef2bfbe3bf9" -dependencies = [ - "derive_builder", - "dotenvy_macro", - "futures-util", - "http 1.4.0", - "reqwest 0.12.28", - "schemars", - "serde", - "serde_json", - "thiserror 1.0.69", - "tokio", - "tokio-util", - "urlencoding", -] - [[package]] name = "openssl" -version = "0.10.80" +version = "0.10.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" dependencies = [ "bitflags", "cfg-if", @@ -1561,7 +1510,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -1572,9 +1521,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" -version = "0.9.116" +version = "0.9.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" dependencies = [ "cc", "libc", @@ -1653,7 +1602,7 @@ checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -1664,21 +1613,21 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pkg-config" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "portable-atomic" -version = "1.14.0" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" [[package]] name = "potential_utf" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ "zerovec", ] @@ -1700,9 +1649,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] @@ -1722,66 +1671,11 @@ dependencies = [ "winapi", ] -[[package]] -name = "quinn" -version = "0.11.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" -dependencies = [ - "bytes", - "cfg_aliases", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash", - "rustls", - "socket2 0.6.3", - "thiserror 2.0.18", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" -dependencies = [ - "bytes", - "getrandom 0.3.4", - "lru-slab", - "rand", - "ring", - "rustc-hash", - "rustls", - "rustls-pki-types", - "slab", - "thiserror 2.0.18", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-udp" -version = "0.5.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" -dependencies = [ - "cfg_aliases", - "libc", - "once_cell", - "socket2 0.6.3", - "tracing", - "windows-sys 0.60.2", -] - [[package]] name = "quote" -version = "1.0.45" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -1794,9 +1688,9 @@ checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] name = "rand" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha", "rand_core", @@ -1857,31 +1751,11 @@ dependencies = [ "bitflags", ] -[[package]] -name = "ref-cast" -version = "1.0.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" -dependencies = [ - "ref-cast-impl", -] - -[[package]] -name = "ref-cast-impl" -version = "1.0.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "regex" -version = "1.12.4" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -1891,9 +1765,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -1914,63 +1788,19 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "reqwest" -version = "0.12.28" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" dependencies = [ - "base64", - "bytes", - "futures-core", - "futures-util", - "http 1.4.0", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-util", - "js-sys", - "log", - "mime_guess", - "percent-encoding", - "pin-project-lite", - "quinn", - "rustls", - "rustls-pki-types", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tokio-rustls", - "tokio-util", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-streams", - "web-sys", - "webpki-roots", -] - -[[package]] -name = "reqwest" -version = "0.13.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" -dependencies = [ - "base64", + "base64 0.23.1", "bytes", "futures-channel", "futures-core", "futures-util", - "h2", - "http 1.4.0", + "http 1.5.0", "http-body", "http-body-util", "hyper", - "hyper-rustls", "hyper-tls", "hyper-util", "js-sys", @@ -1984,12 +1814,14 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-native-tls", + "tokio-util", "tower", - "tower-http", + "tower-http 0.6.11", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams", "web-sys", ] @@ -2009,15 +1841,9 @@ dependencies = [ [[package]] name = "rustc-demangle" -version = "0.1.27" +version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" - -[[package]] -name = "rustc-hash" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" [[package]] name = "rustc_version" @@ -2041,46 +1867,20 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "rustls" -version = "0.23.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" -dependencies = [ - "once_cell", - "ring", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - [[package]] name = "rustls-pki-types" -version = "1.14.1" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ - "web-time", "zeroize", ] -[[package]] -name = "rustls-webpki" -version = "0.103.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" -dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", -] - [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "ryu" @@ -2097,31 +1897,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "schemars" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" -dependencies = [ - "dyn-clone", - "ref-cast", - "schemars_derive", - "serde", - "serde_json", -] - -[[package]] -name = "schemars_derive" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" -dependencies = [ - "proc-macro2", - "quote", - "serde_derive_internals", - "syn 2.0.117", -] - [[package]] name = "scoped-tls" version = "1.0.1" @@ -2165,19 +1940,20 @@ checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "sentry" -version = "0.48.2" +version = "0.49.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "931a20b0da02350676e3d6d3c9028d58eaa448cf42a866712eec5845a505421e" +checksum = "76a88b65feb368d9dde5d531a2b59b25016e36fd6e4978432371a3ee77746ca2" dependencies = [ "cfg_aliases", "httpdate", "native-tls", - "reqwest 0.13.4", + "reqwest", "sentry-actix", "sentry-backtrace", "sentry-contexts", "sentry-core", "sentry-debug-images", + "sentry-log", "sentry-panic", "sentry-tower", "sentry-tracing", @@ -2187,9 +1963,9 @@ dependencies = [ [[package]] name = "sentry-actix" -version = "0.48.2" +version = "0.49.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ffb8fd78b8f4527146013ab52293e03242770a31dcd97eee4b82b7f770ffab3" +checksum = "6b59b6298f8b1c621e7e711050f7ae9620b972215eb2822db2bfc4b061ed0cd2" dependencies = [ "actix-http", "actix-web", @@ -2200,9 +1976,9 @@ dependencies = [ [[package]] name = "sentry-anyhow" -version = "0.48.2" +version = "0.49.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b8bb4a03e7cf5562ea7ff658b2a254c06d2e09b2ec7383dfd5fc7025d40227b" +checksum = "34e4c4b2e5bf7bb63f8223eb4f6d24f64e2c18b4d01fdeb3d561a951e057425b" dependencies = [ "anyhow", "sentry-backtrace", @@ -2211,9 +1987,9 @@ dependencies = [ [[package]] name = "sentry-backtrace" -version = "0.48.2" +version = "0.49.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "911ee36abf5b7fa335fccd5f54361ba9c16baea5f0c3bb361a687b6c195c21cf" +checksum = "2c51511d67ed670266a93afeaa26a08019b04ad1420cb9191da30f2338d22c48" dependencies = [ "backtrace", "regex", @@ -2222,9 +1998,9 @@ dependencies = [ [[package]] name = "sentry-contexts" -version = "0.48.2" +version = "0.49.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b9d7d469e9e22741c17ca23fb8b42d79861590eb7cf330f3da34fc1e4bc1bc6" +checksum = "4b757ac1f335856e8d7f5062a1fd9bc07a05a7eb3cc48247db79bf2618a490bd" dependencies = [ "hostname", "libc", @@ -2236,9 +2012,9 @@ dependencies = [ [[package]] name = "sentry-core" -version = "0.48.2" +version = "0.49.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "545dc562b6758d646ac19e1407f4ebc26d452111386743e03323464bc48bb2e0" +checksum = "d889520a375e5b93efb0a66def1630d21d168b0251eb55b286b03fa940ccfc84" dependencies = [ "rand", "sentry-types", @@ -2249,19 +2025,30 @@ dependencies = [ [[package]] name = "sentry-debug-images" -version = "0.48.2" +version = "0.49.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "660e9def38a573a869a182f7e90f58aaaa460f38b92b31fd1755ec537193bb48" +checksum = "656487fd5f7b7c0105b2e82171982aac64489e0cb679436038febf070f2f76d6" dependencies = [ "findshlibs", "sentry-core", ] [[package]] -name = "sentry-panic" -version = "0.48.2" +name = "sentry-log" +version = "0.49.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "772d9de150c8ca910c835353c85f434457348fdd21208f9b3da3574202b1dc5d" +checksum = "ba6c1bee99be05938885266fe5c95e0e4a2f46722cae205519f26e4ebdddd733" +dependencies = [ + "bitflags", + "log", + "sentry-core", +] + +[[package]] +name = "sentry-panic" +version = "0.49.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e0fe456a7380e9df875a1ef4df1e468d35b19b9051599845fab9d8f0c71c4ae" dependencies = [ "sentry-backtrace", "sentry-core", @@ -2269,12 +2056,12 @@ dependencies = [ [[package]] name = "sentry-tower" -version = "0.48.2" +version = "0.49.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2abea154597936d5df2d39fbe8aac16d584de6b3572c70c39558764d9d2efe15" +checksum = "d3481bd6aabb99f331e5f32c0fba7c2c22f41282219e7011e100263cb47e335f" dependencies = [ "axum", - "http 1.4.0", + "http 1.5.0", "pin-project", "sentry-core", "tower-layer", @@ -2284,9 +2071,9 @@ dependencies = [ [[package]] name = "sentry-tracing" -version = "0.48.2" +version = "0.49.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c51ec9620a4d398dcdf7ee90effbf8d8691cfa24e91978bfa8565cac039d4980" +checksum = "77796d14eedbef22c2fe78e9c69fb09f14c7ad607e624d48dce92eedc17a136e" dependencies = [ "bitflags", "sentry-backtrace", @@ -2297,16 +2084,16 @@ dependencies = [ [[package]] name = "sentry-types" -version = "0.48.2" +version = "0.49.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "041359745a44dd2e14fe21b7510fe7ca8b5beffce6636a0b52e5bc7d5f736887" +checksum = "fc71f5ca55942d9b2901af95d5df5c5d65c164134c22a83682cac3d0b6c7ef2d" dependencies = [ "debugid", "hex", "rand", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror", "time", "url", "uuid", @@ -2314,9 +2101,9 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -2324,40 +2111,29 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", -] - -[[package]] -name = "serde_derive_internals" -version = "0.29.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "syn 3.0.4", ] [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -2377,6 +2153,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -2400,9 +2187,9 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook-registry" @@ -2428,25 +2215,15 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "socket2" -version = "0.5.10" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - -[[package]] -name = "socket2" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", @@ -2458,23 +2235,11 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - [[package]] name = "syn" -version = "1.0.109" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" dependencies = [ "proc-macro2", "quote", @@ -2483,9 +2248,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.117" +version = "3.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" dependencies = [ "proc-macro2", "quote", @@ -2509,7 +2274,18 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", +] + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", ] [[package]] @@ -2527,61 +2303,40 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.69" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl 1.0.69", -] - -[[package]] -name = "thiserror" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" -dependencies = [ - "thiserror-impl 2.0.18", + "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.69" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "syn 3.0.4", ] [[package]] name = "thread_local" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" dependencies = [ "cfg-if", ] [[package]] name = "time" -version = "0.3.47" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", - "itoa", "num-conv", "powerfmt", "serde_core", @@ -2591,15 +2346,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" dependencies = [ "num-conv", "time-core", @@ -2607,29 +2362,14 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", "zerovec", ] -[[package]] -name = "tinyvec" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - [[package]] name = "tokio" version = "1.53.1" @@ -2642,20 +2382,20 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.3", + "socket2", "tokio-macros", "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.7.0" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.4", ] [[package]] @@ -2668,21 +2408,11 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-rustls" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" -dependencies = [ - "rustls", - "tokio", -] - [[package]] name = "tokio-stream" -version = "0.1.18" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" dependencies = [ "futures-core", "pin-project-lite", @@ -2691,13 +2421,14 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.18" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ "bytes", "futures-core", "futures-sink", + "libc", "pin-project-lite", "tokio", ] @@ -2727,16 +2458,32 @@ dependencies = [ "bitflags", "bytes", "futures-util", - "http 1.4.0", + "http 1.5.0", "http-body", "pin-project-lite", "tower", "tower-layer", "tower-service", - "tracing", "url", ] +[[package]] +name = "tower-http" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c" +dependencies = [ + "bitflags", + "bytes", + "http 1.5.0", + "http-body", + "percent-encoding", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "tower-layer" version = "0.3.3" @@ -2769,7 +2516,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -2826,12 +2573,6 @@ dependencies = [ "libc", ] -[[package]] -name = "unicase" -version = "2.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" - [[package]] name = "unicode-ident" version = "1.0.24" @@ -2858,11 +2599,11 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "ureq" -version = "3.3.0" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +checksum = "972d7902c8735f2695410b8aed7df6ed12a47394aa1c8d7af49f0497b731a94d" dependencies = [ - "base64", + "base64 0.23.1", "der", "log", "native-tls", @@ -2875,12 +2616,12 @@ dependencies = [ [[package]] name = "ureq-proto" -version = "0.6.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +checksum = "da5f78b09e6941e1a0f2e30e695e4b120377b54d5e0aec11b594bb57b3971613" dependencies = [ - "base64", - "http 1.4.0", + "base64 0.23.1", + "http 1.5.0", "httparse", "log", ] @@ -2898,12 +2639,6 @@ dependencies = [ "serde_derive", ] -[[package]] -name = "urlencoding" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" - [[package]] name = "utf8-zero" version = "0.8.1" @@ -2918,9 +2653,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.23.3" +version = "1.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" dependencies = [ "js-sys", "serde_core", @@ -2956,18 +2691,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.3+wasi-0.2.9" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.121" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" dependencies = [ "cfg-if", "once_cell", @@ -2978,9 +2713,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.71" +version = "0.4.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" dependencies = [ "js-sys", "wasm-bindgen", @@ -2988,9 +2723,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.121" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2998,31 +2733,31 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.121" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.121" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" dependencies = [ "unicode-ident", ] [[package]] name = "wasm-streams" -version = "0.4.2" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" dependencies = [ "futures-util", "js-sys", @@ -3033,19 +2768,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.98" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "web-time" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" dependencies = [ "js-sys", "wasm-bindgen", @@ -3053,18 +2778,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.7" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" -dependencies = [ - "rustls-pki-types", -] - -[[package]] -name = "webpki-roots" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" dependencies = [ "rustls-pki-types", ] @@ -3112,16 +2828,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -3139,31 +2846,14 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -3172,96 +2862,48 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "wit-bindgen" version = "0.57.1" @@ -3270,15 +2912,25 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "writeable" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] [[package]] name = "yoke" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -3293,28 +2945,28 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -3334,21 +2986,21 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", "synstructure", ] [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[package]] name = "zerotrie" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "yoke", @@ -3357,9 +3009,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.6" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ "yoke", "zerofrom", @@ -3368,17 +3020,17 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.3" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.4", ] [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index 3340e25..124b5c8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,32 +1,33 @@ -[package] -name = "herald" -version = "1.1.0" -edition = "2024" +[workspace] +members = [ + "crates/herald-server", + "crates/devcontainer-rs", +] +resolver = "3" -[profile.release] -debug = 1 - -[dependencies] -reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +[workspace.dependencies] +reqwest = { version = "0.13", default-features = false, features = ["json", "native-tls", "stream"] } tokio = { version = "1.53", features = ["full"] } tokio-stream = "0.1" tokio-util = "0.7" futures-util = "0.3" serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } -sentry = { version = "0.48", features = ["tower-axum-matched-path"] } -sentry-anyhow = { version = "0.48", features = ["backtrace"] } -openrouter-rs = "0.12" +sentry = { version = "0.49", features = ["tower-axum-matched-path"] } +sentry-anyhow = { version = "0.49", features = ["backtrace"] } dotenvy = "0.15" tower = "0.5" -tower-http = {version = "0.6", features = ["trace"] } +tower-http = { version = "0.7", features = ["trace"] } tracing = "0.1" -tracing-subscriber = { version = "0.3", features=["env-filter"] } +tracing-subscriber = { version = "0.3", features = ["env-filter"] } axum = "0.8" -anyhow = { version = "1.0", features = ["backtrace"] } +anyhow = { version = "1", features = ["backtrace"] } thiserror = "2.0" ring = "0.17" hex = "0.4" bytes = "1.1" metrics = "0.24" metrics-exporter-prometheus = { version = "0.18", default-features = false, features = ["http-listener"] } + +[profile.release] +debug = 1 diff --git a/Containerfile b/Containerfile index 6508b0c..24748c2 100644 --- a/Containerfile +++ b/Containerfile @@ -1,12 +1,31 @@ -FROM rust:1.96 as builder +FROM rust:1.98-trixie as builder WORKDIR /app -COPY . . -RUN cargo build --release + +COPY Cargo.toml Cargo.lock ./ +COPY crates/ crates/ + +RUN cargo build --release --package herald-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 +# and libgcc_s come from the base image. +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + git \ + libssl3t64 \ + libzstd1 \ + zlib1g \ + && rm -rf /var/lib/apt/lists/* + + WORKDIR /app -COPY --from=builder /app/target/release/herald . -CMD [ "./herald" ] +COPY --from=builder /app/target/release/herald-server ./herald-server + +# Exec form, so the binary is PID 1 and receives the SIGTERM it handles to shut +# down gracefully. +CMD ["./herald-server"] diff --git a/README.md b/README.md index 1c1de1e..dffffe8 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,6 @@ Herald reads its configuration from environment variables (a `.env` file is supp | Variable | Description | |---|---| | `HTTP_PORT` | Port to listen on | -| `BOT_NAME` | The bot's Gitea username (used to detect mentions) | | `WEBHOOK_SIG_HEADER_SECRET` | Gitea webhook secret for signature verification | | `OPEN_ROUTER_API_KEY` | OpenRouter API key | | `OPEN_ROUTER_MODEL` | Model to use (e.g. `deepseek/deepseek-v4-flash`) | @@ -38,12 +37,69 @@ Herald reads its configuration from environment variables (a `.env` file is supp | `METRICS_BIND_ADDR` | *(optional)* Bind address for the Prometheus metrics endpoint (e.g. `0.0.0.0:9100`). If unset, the metrics exporter is disabled. | | `SENTRY_DSN` | *(optional)* Sentry DSN for error tracking | | `RUST_LOG` | *(optional)* Log level, defaults to `info` | +| `SANDBOX_MAX_ITERATIONS` | *(optional)* Maximum number of tool-calling iterations per sandboxed review. Defaults to `8` | +| `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` | + +## Sandboxed reviews + +Herald 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`), +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 + old versions of the file respectively — then lets it explore the repository + with read-only tools (`ls`, `file_size`, `read_file`, `grep`, `find`) run inside + the container: the code itself is not sent, so the model reads it at those lines, +4. posts the review, anchoring each comment on the added or removed line it + refers to, and removes the container and the temporary clone. + +Generated files are left out of the changes handed to the model: lockfiles +(`Cargo.lock`, `package-lock.json`, `yarn.lock`, `go.sum`…) are machine-written +dependency churn whose thousands of lines would drown the code under review, and +they are never a place where a comment belongs. + +Each comment is tagged with a severity — `bug`, `security`, `performance` or +`maintainability` — shown at the start of the comment, and the summary also lists +what the pull request does well. + +Herald 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`. + +Herald can therefore run inside a container with only that socket mounted (no +shared workspace directory is required): the clone is streamed to the daemon over +the socket, like the build context, instead of being bind-mounted from a host +path the daemon would have to see. This is the setup the `Containerfile` +produces, e.g.: + +```sh +podman run --env-file=.env -p 3001:3001 \ + -v /run/user/$(id -u)/podman/podman.sock:/var/run/docker.sock \ + herald: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. + +Each sandbox is isolated: it gets its own image tag, container and network. The +container starts with network access so the `postCreateCommand` / +`postStartCommand` hooks can install dependencies (e.g. `npm install`); once the +hooks have run, the container is disconnected from the network for the rest of +the review. Every container command is bounded by a timeout, and the container, +network and image are removed when the review ends (including on failure). ## 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). -Open the project and reopen it in the container — the Rust toolchain is pre-installed. +Open the project and reopen it in the container — the Rust toolchain is pre-installed, along with rootless podman, so sandboxed reviews can be exercised locally: start its API socket with `podman system service --time=0 &` and set `DOCKER_HOST=unix:///run/user/$(id -u)/podman/podman.sock`. **Without Dev Container**, you just need a Rust toolchain: diff --git a/crates/devcontainer-rs/Cargo.toml b/crates/devcontainer-rs/Cargo.toml new file mode 100644 index 0000000..0294fe6 --- /dev/null +++ b/crates/devcontainer-rs/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "devcontainer-rs" +version = "0.1.0" +edition = "2024" + +[dependencies] +bollard = "0.21" +bytes = { workspace = true } +futures-util = { workspace = true } +tar = "0.4" +tokio = { workspace = true } +tokio-stream = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +anyhow = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +tempfile = "3" diff --git a/crates/devcontainer-rs/src/consts.rs b/crates/devcontainer-rs/src/consts.rs new file mode 100644 index 0000000..b62ce35 --- /dev/null +++ b/crates/devcontainer-rs/src/consts.rs @@ -0,0 +1,24 @@ +//! Constantes de la crate : endpoint du daemon, timeouts et découpage du +//! contexte de build. + +use std::time::Duration; + +/// Endpoint affiché dans les logs quand `DOCKER_HOST` n'est pas défini. +pub(crate) const DEFAULT_ENDPOINT: &str = "unix:///var/run/docker.sock"; + +/// Timeout appliqué aux opérations de build/run/stop/remove. +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. +pub(crate) const DEFAULT_EXEC_TIMEOUT: Duration = Duration::from_secs(60); + +/// Taille des morceaux du contexte de build envoyés au daemon. +pub(crate) const CONTEXT_CHUNK_SIZE: usize = 64 * 1024; + +/// Nombre de morceaux pouvant attendre dans le canal : c'est ce qui borne la +/// mémoire occupée par le contexte, quelle que soit sa taille. +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); diff --git a/crates/devcontainer-rs/src/container.rs b/crates/devcontainer-rs/src/container.rs new file mode 100644 index 0000000..e93d9e5 --- /dev/null +++ b/crates/devcontainer-rs/src/container.rs @@ -0,0 +1,93 @@ +//! Un devcontainer en cours d'exécution : inspection et exécution de commandes. + +use std::time::Duration; + +use crate::{ + consts::DEFAULT_EXEC_TIMEOUT, errors::ContainerError, exec::ExecOutput, + runtime::ContainerRuntime, +}; + +/// Un devcontainer en cours d'exécution. +#[derive(Debug, Clone)] +pub struct Container { + runtime: ContainerRuntime, + name: String, + workspace_folder: String, + remote_user: Option, + network: Option, + image: Option, +} + +impl Container { + /// Assemble un container démarré, avec son réseau et son image à nettoyer. + pub(crate) fn new( + runtime: ContainerRuntime, + name: String, + workspace_folder: String, + remote_user: Option, + network: Option, + image: Option, + ) -> Self { + Self { + runtime, + name, + workspace_folder, + remote_user, + network, + image, + } + } + + pub fn name(&self) -> &str { + &self.name + } + + pub fn workspace_folder(&self) -> &str { + &self.workspace_folder + } + + /// Exécute une commande dans le container et renvoie sa sortie. + /// + /// La commande est transmise sous forme de vecteur d'arguments (argv, sans shell), + /// donc aucun échappement ni interpolation n'est effectué. + pub async fn exec(&self, cmd: &[&str]) -> Result { + self.exec_with_timeout(cmd, DEFAULT_EXEC_TIMEOUT).await + } + + pub(crate) async fn exec_with_timeout( + &self, + cmd: &[&str], + timeout: Duration, + ) -> Result { + self.runtime + .exec(&self.name, cmd, self.remote_user.as_deref(), timeout) + .await + } + + /// Exécute un script shell dans le container via `sh -c`. + pub async fn exec_shell(&self, script: &str) -> Result { + self.exec(&["sh", "-c", script]).await + } + + /// Arrête le container. + pub async fn stop(&self) -> Result<(), ContainerError> { + self.runtime.stop_container(&self.name).await + } + + /// Supprime le container, son réseau et son image. + /// + /// La suppression du réseau et de l'image est best-effort : ils peuvent déjà être absents. + pub async fn remove(&self) -> Result<(), ContainerError> { + self.runtime.remove_container(&self.name).await?; + + if let Some(network) = &self.network { + let _ = self.runtime.remove_network(network).await; + } + + if let Some(image) = &self.image { + let _ = self.runtime.remove_image(image).await; + } + + Ok(()) + } +} diff --git a/crates/devcontainer-rs/src/context.rs b/crates/devcontainer-rs/src/context.rs new file mode 100644 index 0000000..8c80d46 --- /dev/null +++ b/crates/devcontainer-rs/src/context.rs @@ -0,0 +1,166 @@ +//! Empaquetage en flux du contexte de build envoyé au daemon. + +use std::{ + io::{BufWriter, Write}, + path::Path, +}; + +use bytes::Bytes; +use futures_util::Stream; +use tokio::sync::mpsc; +use tokio_stream::wrappers::ReceiverStream; + +use crate::consts::{CONTEXT_CHUNK_SIZE, CONTEXT_CHUNKS}; + +/// Empaquette le contexte de build dans un tar, **en flux**. +/// +/// Le CLI `docker build` empaquette le contexte puis l'envoie ; le faire d'un coup +/// chargerait tout le dossier en mémoire, ce qui devient intenable dès que le +/// `.devcontainer` embarque des binaires. Ici l'archive est écrite par une tâche +/// bloquante et poussée morceau par morceau, avec la contre-pression du canal : +/// si le daemon lit lentement, l'empaquetage ralentit. +pub(crate) fn tar_directory_stream( + dir: &Path, +) -> impl Stream> + Send + 'static { + let (sender, receiver) = mpsc::channel(CONTEXT_CHUNKS); + let failures = sender.clone(); + let dir = dir.to_path_buf(); + + tokio::task::spawn_blocking(move || { + let writer = BufWriter::with_capacity(CONTEXT_CHUNK_SIZE, ChannelWriter { sender }); + let mut builder = tar::Builder::new(writer); + + let result = builder + .append_dir_all(".", &dir) + .and_then(|()| builder.finish()) + // `finish` écrit les blocs de fin d'archive ; il reste à vider le tampon + // pour que le daemon reçoive tout, y compris une archive vide. + .and_then(|()| builder.get_mut().flush()); + + if let Err(error) = result { + // L'échec est remonté au daemon par le flux : sinon il ne verrait qu'un + // tar tronqué et signalerait une erreur incompréhensible. + let _ = failures.blocking_send(Err(error)); + } + }); + + ReceiverStream::new(receiver) +} + +/// Écrit les morceaux du tar dans un canal, en attendant qu'il se vide. +/// +/// Utilisé depuis une tâche bloquante, seul contexte où `blocking_send` est autorisé. +struct ChannelWriter { + sender: mpsc::Sender>, +} + +impl Write for ChannelWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.sender + .blocking_send(Ok(Bytes::copy_from_slice(buf))) + .map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::BrokenPipe, + "the daemon dropped the build context", + ) + })?; + + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + use futures_util::StreamExt; + + /// Rassemble le flux du contexte en un tar complet, pour l'inspecter. + async fn context_tar(dir: &Path) -> Vec { + let mut tar = Vec::new(); + let mut chunks = tar_directory_stream(dir); + + while let Some(chunk) = chunks.next().await { + tar.extend_from_slice(&chunk.unwrap()); + } + + tar + } + + #[tokio::test] + async fn tar_context_holds_the_devcontainer_files() { + let dir = tempfile::tempdir().unwrap(); + let devcontainer_dir = dir.path().join(".devcontainer"); + fs::create_dir(&devcontainer_dir).unwrap(); + fs::write(devcontainer_dir.join("Dockerfile"), "FROM alpine\n").unwrap(); + fs::write(devcontainer_dir.join("devcontainer.json"), "{}").unwrap(); + + let tar = context_tar(&devcontainer_dir).await; + + let mut archive = tar::Archive::new(tar.as_slice()); + let names = archive + .entries() + .unwrap() + .map(|entry| entry.unwrap().path().unwrap().display().to_string()) + .collect::>(); + + assert!(names.iter().any(|name| name.ends_with("Dockerfile"))); + assert!(names.iter().any(|name| name.ends_with("devcontainer.json"))); + } + + #[tokio::test] + async fn tar_context_keeps_the_executable_bit() { + let dir = tempfile::tempdir().unwrap(); + let script = dir.path().join("setup.sh"); + fs::write(&script, "#!/bin/sh\n").unwrap(); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap(); + } + + let tar = context_tar(dir.path()).await; + + let mut archive = tar::Archive::new(tar.as_slice()); + let mode = archive + .entries() + .unwrap() + .map(|entry| entry.unwrap()) + .find(|entry| entry.path().unwrap().ends_with("setup.sh")) + .unwrap() + .header() + .mode() + .unwrap(); + + assert_eq!(mode & 0o111, 0o111); + } + + #[tokio::test] + async fn tar_context_is_sent_in_chunks() { + let dir = tempfile::tempdir().unwrap(); + fs::write( + dir.path().join("big.bin"), + vec![0_u8; CONTEXT_CHUNK_SIZE * 3], + ) + .unwrap(); + + let mut chunks = tar_directory_stream(dir.path()); + let mut count = 0; + + while let Some(chunk) = chunks.next().await { + assert!(chunk.unwrap().len() <= CONTEXT_CHUNK_SIZE); + count += 1; + } + + assert!( + count > 1, + "the context should be streamed, not buffered in one piece" + ); + } +} diff --git a/crates/devcontainer-rs/src/devcontainer.rs b/crates/devcontainer-rs/src/devcontainer.rs new file mode 100644 index 0000000..173a06d --- /dev/null +++ b/crates/devcontainer-rs/src/devcontainer.rs @@ -0,0 +1,282 @@ +//! Représentation analysée d'un `devcontainer.json` et son cycle de vie : +//! nommage, build, démarrage et hooks. + +use std::{ + collections::HashMap, + path::{Path, PathBuf}, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use bollard::models::{ContainerCreateBody, HostConfig}; + +use crate::{container::Container, errors::ContainerError, runtime::ContainerRuntime}; + +#[derive(Debug)] +pub struct DevContainer { + pub container_file_path: PathBuf, + pub name: Option, + pub build_args: HashMap, + pub container_env: HashMap, + pub workspace_folder: Option, + pub post_create_command: Option, + pub post_start_command: Option, + pub remote_user: Option, +} + +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`. + pub fn workspace_folder(&self) -> String { + self.workspace_folder + .clone() + .unwrap_or_else(|| "/workspaces/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()) + } + + /// Construit l'image devcontainer sous `image_tag`. + pub async fn build( + &self, + runtime: &ContainerRuntime, + image_tag: &str, + ) -> Result<(), ContainerError> { + let context = self.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 + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| { + ContainerError::Unexpected(String::from( + "the devcontainer file name is not valid UTF-8", + )) + })?; + + runtime + .build_image(context, dockerfile, image_tag, &self.build_args) + .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 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()), + ..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); + } + + 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; + return Err(err); + } + + let container = Container::new( + runtime.clone(), + name, + self.workspace_folder(), + self.remote_user.clone(), + Some(network.clone()), + Some(image_tag), + ); + + // 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); + } + + // 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; + return Err(err); + } + + Ok(container) + } + + async fn run_hooks( + &self, + container: &Container, + timeout: Duration, + ) -> Result<(), ContainerError> { + for command in [&self.post_create_command, &self.post_start_command] + .into_iter() + .flatten() + { + let output = container + .exec_with_timeout(&["sh", "-c", command], timeout) + .await?; + + if !output.success() { + return Err(ContainerError::Unexpected(format!( + "hook `{command}` failed with status {}: {}", + output.status, + output.stderr.trim() + ))); + } + } + + Ok(()) + } +} + +/// Nettoie une chaîne pour qu'elle puisse servir de nom d'image/container docker. +fn sanitize(input: &str) -> String { + let sanitized: String = input + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' { + c.to_ascii_lowercase() + } else { + '-' + } + }) + .collect(); + + let trimmed = sanitized.trim_matches(|c| c == '-' || c == '.' || c == '_'); + if trimmed.is_empty() { + "devcontainer".to_string() + } else { + trimmed.to_string() + } +} + +/// Suffixe unique à une exécution de sandbox, combinant l'identifiant de processus et un horodatage. +fn unique_suffix() -> String { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or(0); + + format!("{}-{}", std::process::id(), nanos) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn devcontainer(dir: &Path) -> DevContainer { + let devcontainer_path = dir.join("devcontainer.json"); + let dockerfile_path = dir.join("Dockerfile"); + + fs::write(&dockerfile_path, "FROM alpine\n").unwrap(); + fs::write( + &devcontainer_path, + r#"{ + "name": "My Project", + "build": { + "dockerfile": "Dockerfile", + "args": { "VERSION": "1" } + }, + "workspaceFolder": "/workspaces/my-project", + "containerEnv": { "RUST_LOG": "debug" }, + "remoteUser": "dev" + }"#, + ) + .unwrap(); + + let runtime = tokio::runtime::Runtime::new().unwrap(); + runtime.block_on(crate::parse(&devcontainer_path)).unwrap() + } + + #[test] + fn image_name_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()); + } + + #[test] + fn container_name_is_sanitized() { + let dir = tempfile::tempdir().unwrap(); + let dc = devcontainer(dir.path()); + + assert!( + dc.container_name() + .starts_with("devcontainer-rs-my-project-") + ); + } +} diff --git a/crates/devcontainer-rs/src/errors.rs b/crates/devcontainer-rs/src/errors.rs new file mode 100644 index 0000000..ac0b400 --- /dev/null +++ b/crates/devcontainer-rs/src/errors.rs @@ -0,0 +1,52 @@ +//! Erreurs de la crate : échecs du daemon de containers et échecs d'analyse du +//! `devcontainer.json`. + +use std::{path::PathBuf, time::Duration}; + +use bollard::errors::Error as BollardError; + +#[derive(Debug, thiserror::Error)] +pub enum ContainerError { + /// Le daemon est injoignable, ou a refusé une requête. + #[error("container daemon request failed: {source}")] + Request { + #[from] + source: BollardError, + }, + + /// Une opération a dépassé son timeout. + #[error("`{operation}` timed out after {timeout:?}")] + Timeout { + operation: String, + timeout: Duration, + }, + + /// La construction de l'image a échoué. + #[error("image build failed: {message}")] + Build { message: String }, + + /// Le daemon a répondu autre chose que ce qui était attendu. + #[error("{0}")] + Unexpected(String), +} + +#[derive(Debug, thiserror::Error)] +pub enum ParseError { + #[error("failed to read devcontainer file `{path}`: {source}")] + Read { + path: PathBuf, + source: std::io::Error, + }, + + #[error("invalid devcontainer JSON in `{path}`: {source}")] + Json { + path: PathBuf, + source: serde_json::Error, + }, + + #[error("container file `{0}` does not exist or is not a regular file")] + ContainerFileNotFound(PathBuf), + + #[error("the devcontainer file path has no parent directory: `{0}`")] + InvalidDevContainerPath(PathBuf), +} diff --git a/crates/devcontainer-rs/src/exec.rs b/crates/devcontainer-rs/src/exec.rs new file mode 100644 index 0000000..58d2615 --- /dev/null +++ b/crates/devcontainer-rs/src/exec.rs @@ -0,0 +1,16 @@ +//! Résultat d'une commande exécutée dans un container. + +/// Résultat d'une commande exécutée dans un container. +#[derive(Debug, Clone)] +pub struct ExecOutput { + /// Code de sortie, ou `-1` si le processus a été terminé par un signal. + pub status: i32, + pub stdout: String, + pub stderr: String, +} + +impl ExecOutput { + pub fn success(&self) -> bool { + self.status == 0 + } +} diff --git a/crates/devcontainer-rs/src/lib.rs b/crates/devcontainer-rs/src/lib.rs new file mode 100644 index 0000000..7771ad6 --- /dev/null +++ b/crates/devcontainer-rs/src/lib.rs @@ -0,0 +1,43 @@ +//! 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 +//! 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 +//! 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. +//! +//! # Isolation +//! +//! Chaque sandbox dispose de son propre tag d'image, de son propre container et de +//! son propre réseau. Le container démarre attaché à ce réseau afin que les hooks +//! `postCreateCommand` / `postStartCommand` puissent récupérer des dépendances +//! (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. + +mod consts; +mod container; +mod context; +mod devcontainer; +mod errors; +mod exec; +mod path; +mod runtime; +mod schema; + +pub use container::Container; +pub use devcontainer::DevContainer; +pub use errors::{ContainerError, ParseError}; +pub use exec::ExecOutput; +pub use path::normalize; +pub use runtime::ContainerRuntime; +pub use schema::{DevContainerBuildSchema, DevContainerSchema, parse}; diff --git a/crates/devcontainer-rs/src/path.rs b/crates/devcontainer-rs/src/path.rs new file mode 100644 index 0000000..4524c0e --- /dev/null +++ b/crates/devcontainer-rs/src/path.rs @@ -0,0 +1,40 @@ +//! Normalisation lexicale de chemins, sans accès au système de fichiers. + +use std::path::{Path, PathBuf}; + +/// Normalise lexicalement un chemin, en résolvant `.` et `..` sans toucher au +/// système de fichiers. Renvoie `None` si le chemin sort de sa racine. +pub fn normalize(path: &Path) -> Option { + use std::path::Component; + + let mut out = PathBuf::new(); + for component in path.components() { + match component { + Component::RootDir => out.push("/"), + Component::CurDir => {} + Component::ParentDir => { + if !out.pop() { + return None; + } + } + Component::Normal(part) => out.push(part), + Component::Prefix(_) => return None, + } + } + + Some(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalize_rejects_escaping_paths() { + assert_eq!( + normalize(Path::new("/workspaces/project/src/../main.rs")), + Some(PathBuf::from("/workspaces/project/main.rs")) + ); + assert_eq!(normalize(Path::new("/workspaces/../../etc/passwd")), None); + } +} diff --git a/crates/devcontainer-rs/src/runtime.rs b/crates/devcontainer-rs/src/runtime.rs new file mode 100644 index 0000000..e7fd8aa --- /dev/null +++ b/crates/devcontainer-rs/src/runtime.rs @@ -0,0 +1,376 @@ +//! Client de l'API du daemon de containers et opérations de cycle de vie. + +use std::{collections::HashMap, path::Path, time::Duration}; + +use bollard::{ + Docker, body_try_stream, + container::LogOutput, + errors::Error as BollardError, + exec::{CreateExecOptions, StartExecOptions, StartExecResults}, + models::{BuildInfo, ContainerCreateBody, NetworkCreateRequest, NetworkDisconnectRequest}, + query_parameters::{ + BuildImageOptions, CreateContainerOptions, RemoveContainerOptions, RemoveImageOptions, + StartContainerOptions, StopContainerOptions, UploadToContainerOptions, + }, +}; +use futures_util::StreamExt; + +use crate::{ + consts::{DEFAULT_COMMAND_TIMEOUT, DEFAULT_ENDPOINT, EXIT_CODE_ATTEMPTS, EXIT_CODE_DELAY}, + context::tar_directory_stream, + errors::ContainerError, + exec::ExecOutput, +}; + +/// Un client de l'API du daemon de containers. +#[derive(Debug, Clone)] +pub struct ContainerRuntime { + docker: Docker, + endpoint: String, + timeout: Duration, +} + +impl ContainerRuntime { + /// Se connecte au daemon désigné par `DOCKER_HOST`, ou au socket local par défaut. + pub fn connect() -> Result { + let endpoint = + std::env::var("DOCKER_HOST").unwrap_or_else(|_| String::from(DEFAULT_ENDPOINT)); + + Ok(Self { + docker: Docker::connect_with_defaults()?, + endpoint, + timeout: DEFAULT_COMMAND_TIMEOUT, + }) + } + + /// Remplace le timeout appliqué aux opérations de build/run/stop/remove. + pub fn with_timeout(mut self, timeout: Duration) -> Self { + self.timeout = timeout; + self + } + + /// Endpoint du daemon, pour les logs. + pub fn endpoint(&self) -> &str { + &self.endpoint + } + + pub fn timeout(&self) -> Duration { + self.timeout + } + + /// Vérifie que le daemon est joignable et répond. + pub async fn available(&self) -> bool { + self.docker.ping().await.is_ok() + } + + /// Exécute une requête du daemon en appliquant le timeout des commandes. + async fn request( + &self, + operation: &str, + request: impl Future>, + ) -> Result { + match tokio::time::timeout(self.timeout, request).await { + Ok(Ok(value)) => Ok(value), + Ok(Err(source)) => Err(ContainerError::Request { source }), + Err(_) => Err(ContainerError::Timeout { + operation: String::from(operation), + timeout: self.timeout, + }), + } + } + + /// Construit l'image `tag` depuis le contexte `context_dir`. + pub(crate) async fn build_image( + &self, + context_dir: &Path, + dockerfile: &str, + tag: &str, + build_args: &HashMap, + ) -> Result<(), ContainerError> { + let options = BuildImageOptions { + dockerfile: String::from(dockerfile), + t: Some(String::from(tag)), + buildargs: Some(build_args.clone()), + rm: true, + ..Default::default() + }; + + let context = tar_directory_stream(context_dir); + let mut stream = self + .docker + .build_image(options, None, Some(body_try_stream(context))); + + // Le flux porte la progression du build : la dernière erreur signalée par le + // daemon fait échouer l'opération. + let consume = async { + let mut failure = None; + + while let Some(info) = stream.next().await { + let info: BuildInfo = info?; + + if let Some(message) = info.error_detail.and_then(|detail| detail.message) { + failure = Some(message); + } + } + + Ok::<_, BollardError>(failure) + }; + + let failure = match tokio::time::timeout(self.timeout, consume).await { + Ok(Ok(failure)) => failure, + Ok(Err(source)) => return Err(ContainerError::Request { source }), + Err(_) => { + return Err(ContainerError::Timeout { + operation: format!("build image `{tag}`"), + timeout: self.timeout, + }); + } + }; + + match failure { + Some(message) => Err(ContainerError::Build { message }), + None => Ok(()), + } + } + + /// Crée un container, sans le démarrer. + pub(crate) async fn create_container( + &self, + name: &str, + body: ContainerCreateBody, + ) -> Result<(), ContainerError> { + let options = CreateContainerOptions { + name: Some(String::from(name)), + ..Default::default() + }; + + self.request( + "create container", + self.docker.create_container(Some(options), body), + ) + .await?; + + Ok(()) + } + + pub(crate) async fn upload_directory( + &self, + container: &str, + destination: &str, + directory: &Path, + ) -> Result<(), ContainerError> { + let options = UploadToContainerOptions { + path: String::from(destination), + ..Default::default() + }; + + self.request( + "upload workspace", + self.docker.upload_to_container( + container, + Some(options), + body_try_stream(tar_directory_stream(directory)), + ), + ) + .await?; + + Ok(()) + } + + pub(crate) async fn start_container(&self, name: &str) -> Result<(), ContainerError> { + self.request( + "start container", + self.docker + .start_container(name, None::), + ) + .await?; + + Ok(()) + } + + /// Exécute une commande dans un container et renvoie sa sortie. + pub(crate) async fn exec( + &self, + container: &str, + cmd: &[&str], + user: Option<&str>, + timeout: Duration, + ) -> Result { + let config = CreateExecOptions { + cmd: Some(cmd.iter().map(|arg| String::from(*arg)).collect()), + user: user.map(String::from), + attach_stdout: Some(true), + attach_stderr: Some(true), + ..Default::default() + }; + + let created = self + .request("create exec", self.docker.create_exec(container, config)) + .await?; + + let started = self + .request( + "start exec", + self.docker + .start_exec(&created.id, None::), + ) + .await?; + + let StartExecResults::Attached { mut output, .. } = started else { + return Err(ContainerError::Unexpected(String::from( + "the daemon detached an exec that was not requested as detached", + ))); + }; + + let mut stdout = String::new(); + let mut stderr = String::new(); + + // Le timeout couvre l'exécution de la commande elle-même, pas seulement sa + // mise en place. + let collect = async { + while let Some(message) = output.next().await { + match message? { + LogOutput::StdOut { message } | LogOutput::Console { message } => { + stdout.push_str(&String::from_utf8_lossy(&message)); + } + LogOutput::StdErr { message } => { + stderr.push_str(&String::from_utf8_lossy(&message)); + } + LogOutput::StdIn { .. } => {} + } + } + + Ok::<_, BollardError>(()) + }; + + match tokio::time::timeout(timeout, collect).await { + Ok(Ok(())) => {} + Ok(Err(source)) => return Err(ContainerError::Request { source }), + Err(_) => { + // Le processus continue de tourner dans le container : sans arrêt, il + // consommerait CPU et mémoire et pourrait encore modifier le workspace + // pendant toute la durée de vie de la sandbox. L'API n'offre pas de + // moyen de tuer un exec, on arrête donc le container qui le porte. + let _ = self.stop_container(container).await; + + return Err(ContainerError::Timeout { + operation: format!("exec {}", cmd.join(" ")), + timeout, + }); + } + } + + // Le code de sortie n'est pas forcément publié au moment où le flux se ferme : + // on laisse au daemon le temps de le renseigner, sans bloquer indéfiniment. + // Sans cela, une commande réussie peut être rapportée en échec (code -1) et + // l'outil renvoie une erreur au modèle à la place du contenu. + let mut inspected = self + .request("inspect exec", self.docker.inspect_exec(&created.id)) + .await?; + + for _ in 0..EXIT_CODE_ATTEMPTS { + if !inspected.running.unwrap_or(false) { + break; + } + + tokio::time::sleep(EXIT_CODE_DELAY).await; + + inspected = self + .request("inspect exec", self.docker.inspect_exec(&created.id)) + .await?; + } + + Ok(ExecOutput { + status: inspected.exit_code.unwrap_or(-1) as i32, + stdout, + stderr, + }) + } + + pub(crate) async fn stop_container(&self, name: &str) -> Result<(), ContainerError> { + let options = StopContainerOptions { + t: Some(1), + ..Default::default() + }; + + self.request( + "stop container", + self.docker.stop_container(name, Some(options)), + ) + .await?; + + Ok(()) + } + + /// Supprime un container, ses volumes anonymes et le processus qui y tourne. + pub(crate) async fn remove_container(&self, name: &str) -> Result<(), ContainerError> { + let options = RemoveContainerOptions { + force: true, + v: true, + ..Default::default() + }; + + self.request( + "remove container", + self.docker.remove_container(name, Some(options)), + ) + .await?; + + Ok(()) + } + + pub(crate) async fn remove_image(&self, tag: &str) -> Result<(), ContainerError> { + let options = RemoveImageOptions { + force: true, + ..Default::default() + }; + + self.request( + "remove image", + self.docker.remove_image(tag, Some(options), None), + ) + .await?; + + Ok(()) + } + + /// Crée le réseau dédié d'une sandbox. + pub(crate) async fn create_network(&self, name: &str) -> Result<(), ContainerError> { + let request = NetworkCreateRequest { + name: String::from(name), + ..Default::default() + }; + + self.request("create network", self.docker.create_network(request)) + .await?; + + Ok(()) + } + + /// Détache un container d'un réseau, ce qui coupe sa connectivité. + pub(crate) async fn disconnect_network( + &self, + network: &str, + container: &str, + ) -> Result<(), ContainerError> { + let request = NetworkDisconnectRequest { + container: String::from(container), + ..Default::default() + }; + + self.request( + "disconnect network", + self.docker.disconnect_network(network, request), + ) + .await?; + + Ok(()) + } + + pub(crate) async fn remove_network(&self, name: &str) -> Result<(), ContainerError> { + self.request("remove network", self.docker.remove_network(name)) + .await?; + + Ok(()) + } +} diff --git a/crates/devcontainer-rs/src/schema.rs b/crates/devcontainer-rs/src/schema.rs new file mode 100644 index 0000000..033337a --- /dev/null +++ b/crates/devcontainer-rs/src/schema.rs @@ -0,0 +1,230 @@ +//! Schéma du `devcontainer.json`, analyse du fichier et résolution des +//! références `${localEnv:…}`. + +use std::{ + collections::HashMap, + path::{Path, PathBuf}, +}; + +use serde::Deserialize; + +use crate::{devcontainer::DevContainer, errors::ParseError}; + +#[derive(Debug, Deserialize)] +pub struct DevContainerBuildSchema { + #[serde(default)] + pub dockerfile: String, + #[serde(default)] + pub args: HashMap, +} + +#[derive(Debug, Deserialize)] +pub struct DevContainerSchema { + #[serde(default)] + pub name: Option, + pub build: DevContainerBuildSchema, + + #[serde(rename = "workspaceFolder", default)] + pub workspace_folder: Option, + + #[serde(rename = "containerEnv", default)] + pub container_env: HashMap, + + #[serde(rename = "postCreateCommand", default)] + pub post_create_command: Option, + + #[serde(rename = "postStartCommand", default)] + pub 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, +} + +impl TryFrom<(DevContainerSchema, PathBuf)> for DevContainer { + type Error = ParseError; + + fn try_from( + (schema, devcontainer_path): (DevContainerSchema, PathBuf), + ) -> Result { + let base_dir = devcontainer_path + .parent() + .ok_or_else(|| ParseError::InvalidDevContainerPath(devcontainer_path.clone()))?; + + let container_file_path = base_dir.join(schema.build.dockerfile); + + if !container_file_path.is_file() { + return Err(ParseError::ContainerFileNotFound(container_file_path)); + } + + let build_args = schema + .build + .args + .into_iter() + .map(|(k, v)| (k, substitute_local_env(&v))) + .collect(); + + let container_env = schema + .container_env + .into_iter() + .map(|(k, v)| (k, substitute_local_env(&v))) + .collect(); + + Ok(Self { + container_file_path, + name: schema.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, + }) + } +} + +/// Résout les références `${localEnv:VAR}` et `${localEnv:VAR:default}` d'un +/// `devcontainer.json`. +/// +/// 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 +/// 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. +fn substitute_local_env(input: &str) -> String { + const PREFIX: &str = "${localEnv:"; + + let mut out = String::with_capacity(input.len()); + let mut rest = input; + + while let Some(start) = rest.find(PREFIX) { + out.push_str(&rest[..start]); + let after = &rest[start + PREFIX.len()..]; + + match after.find('}') { + Some(end) => { + let inner = &after[..end]; + // La clé est lue pour délimiter la référence, pas pour la résoudre. + let (_key, default) = match inner.split_once(':') { + Some((key, default)) => (key, Some(default)), + None => (inner, None), + }; + + out.push_str(default.unwrap_or("")); + + rest = &after[end + 1..]; + } + None => { + out.push_str(PREFIX); + rest = after; + } + } + } + + out.push_str(rest); + out +} + +pub async fn parse(path: impl AsRef) -> Result { + let path = path.as_ref().to_path_buf(); + let contents = tokio::fs::read_to_string(&path) + .await + .map_err(|source| ParseError::Read { + path: path.clone(), + source, + })?; + + let schema = serde_json::from_str::(&contents).map_err(|source| { + ParseError::Json { + path: path.clone(), + source, + } + })?; + + DevContainer::try_from((schema, path)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[tokio::test] + async fn parses_devcontainer_file() { + let dir = tempfile::tempdir().unwrap(); + let devcontainer_path = dir.path().join("devcontainer.json"); + let dockerfile_path = dir.path().join("Dockerfile"); + + fs::write(&dockerfile_path, "FROM alpine\n").unwrap(); + fs::write( + &devcontainer_path, + r#"{ + "name": "test", + "build": { + "dockerfile": "Dockerfile", + "args": { + "VERSION": "1" + } + }, + "workspaceFolder": "/workspace", + "containerEnv": { + "RUST_LOG": "debug" + }, + "remoteUser": "dev" + }"#, + ) + .unwrap(); + + 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.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")); + } + + #[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. + assert_eq!( + substitute_local_env("${localEnv:DEVCONTAINER_TEST_SECRET}"), + "" + ); + assert_eq!( + substitute_local_env("token=${localEnv:DEVCONTAINER_TEST_SECRET}"), + "token=" + ); + } + + #[test] + fn local_env_defaults_are_used() { + assert_eq!( + substitute_local_env("${localEnv:DEVCONTAINER_TEST_MISSING:fallback}"), + "fallback" + ); + assert_eq!( + substitute_local_env("${localEnv:DEVCONTAINER_TEST_MISSING}"), + "" + ); + assert_eq!(substitute_local_env("uid=${localEnv:UID:1000}"), "uid=1000"); + assert_eq!( + substitute_local_env("no variables here"), + "no variables here" + ); + } +} diff --git a/crates/herald-server/Cargo.toml b/crates/herald-server/Cargo.toml new file mode 100644 index 0000000..6a6ed9a --- /dev/null +++ b/crates/herald-server/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "herald-server" +version = "1.2.0" +edition = "2024" + +[dependencies] +reqwest = { workspace = true } +tokio = { workspace = true } +tokio-util = { workspace = true } +futures-util = { workspace = true } +serde_json = { workspace = true } +serde = { workspace = true } +sentry = { workspace = true } +sentry-anyhow = { workspace = true } +dotenvy = { workspace = true } +tower = { workspace = true } +tower-http = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +axum = { workspace = true } +anyhow = { workspace = true } +thiserror = { workspace = true } +ring = { workspace = true } +hex = { workspace = true } +metrics = { workspace = true } +metrics-exporter-prometheus = { workspace = true } +devcontainer-rs = { path = "../devcontainer-rs" } +tempfile = "3" \ No newline at end of file diff --git a/src/api.rs b/crates/herald-server/src/api.rs similarity index 100% rename from src/api.rs rename to crates/herald-server/src/api.rs diff --git a/src/bot.rs b/crates/herald-server/src/bot.rs similarity index 63% rename from src/bot.rs rename to crates/herald-server/src/bot.rs index 470990f..596e1c8 100644 --- a/src/bot.rs +++ b/crates/herald-server/src/bot.rs @@ -1,7 +1,10 @@ use crate::{ - gitea::{GiteaAPI, WebhookType}, metrics, open_router::OpenRouterClient, + gitea::{GiteaAPI, WebhookType}, + metrics, + open_router::OpenRouterClient, + sandbox::SandboxConfig, }; -use serde::Deserialize; +use serde::{Deserialize, Deserializer}; use std::{collections::HashSet, sync::Arc}; use tokio::sync::Mutex; use tokio_util::sync::CancellationToken; @@ -18,18 +21,111 @@ pub struct ReviewResult { pub struct ReviewItem { pub filename: String, pub line: Option, - pub code: String, + #[serde(default, deserialize_with = "deserialize_side")] + pub side: Option, + #[serde(default, deserialize_with = "deserialize_severity")] + pub severity: Option, 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 { + 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 { + 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, D::Error> +where + D: Deserializer<'de>, +{ + let raw = Option::::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, D::Error> +where + D: Deserializer<'de>, +{ + let raw = Option::::deserialize(deserializer)?; + + Ok(raw + .as_ref() + .and_then(serde_json::Value::as_str) + .and_then(ReviewSeverity::parse)) +} + #[derive(Clone)] pub struct Bot { bot_name: String, gitea_api: GiteaAPI, open_router_client: OpenRouterClient, - http_client: reqwest::Client, max_concurrent: usize, open_router_model: String, + sandbox: SandboxConfig, actions_handled: Arc>>, } @@ -38,17 +134,17 @@ impl Bot { bot_name: String, gitea_api: GiteaAPI, open_router_client: OpenRouterClient, - http_client: reqwest::Client, max_concurrent: usize, open_router_model: String, + sandbox: SandboxConfig, ) -> Self { Self { bot_name, gitea_api, open_router_client, - http_client, max_concurrent, open_router_model, + sandbox, actions_handled: Arc::new(Mutex::new(HashSet::new())), } } @@ -112,12 +208,15 @@ impl Bot { } }; + let tools = crate::sandbox::tools::for_webhook(&webhook); + let exec_result = match webhook { WebhookType::Review(review_payload) => crate::bot_actions::review::exec_review( &self.gitea_api, &self.open_router_client, - &self.http_client, &self.open_router_model, + &self.sandbox, + tools, review_payload, ), } diff --git a/src/bot_actions/mod.rs b/crates/herald-server/src/bot_actions/mod.rs similarity index 100% rename from src/bot_actions/mod.rs rename to crates/herald-server/src/bot_actions/mod.rs diff --git a/crates/herald-server/src/bot_actions/review.rs b/crates/herald-server/src/bot_actions/review.rs new file mode 100644 index 0000000..371f7a6 --- /dev/null +++ b/crates/herald-server/src/bot_actions/review.rs @@ -0,0 +1,1161 @@ +use std::str::FromStr; + +use tracing::{info, instrument, warn}; + +use crate::{ + bot::{ReviewItem, ReviewResult, ReviewSeverity, ReviewSide}, + consts::{BOT_PROCESS_MSG, REVIEW_PROMPT, SANDBOX_SYSTEM_PROMPT}, + gitea::{GiteaAPI, PullRequestFile, ReviewPayload}, + metrics, + open_router::{OpenRouterClient, Tool}, + sandbox::{Sandbox, SandboxConfig, agent}, + text::excerpt, +}; + +#[instrument(skip(gitea_api, open_router_client, sandbox_config, tools, review_payload))] +pub async fn exec_review( + gitea_api: &GiteaAPI, + open_router_client: &OpenRouterClient, + model: &str, + sandbox_config: &SandboxConfig, + tools: Vec, + review_payload: ReviewPayload, +) -> anyhow::Result<()> { + tracing::info!( + repo = %review_payload.repository.full_name, + pr = review_payload.pull_request.number, + action = %review_payload.action, + "Starting review" + ); + + let new_comment = gitea_api + .comment( + &BOT_PROCESS_MSG.replace("{model}", model), + &review_payload.repository.full_name, + review_payload.pull_request.number, + ) + .await?; + + let bot_result: Result = async { + let full_name = &review_payload.repository.full_name; + let index = review_payload.pull_request.number; + + let git_diff = gitea_api.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 gitea_api.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}", &review_payload.pull_request.title) + .replace("{comment}", &review_payload.comment.body) + .replace("{changes}", &changes); + + let (mut review_result, cost) = run_sandboxed_review( + gitea_api, + open_router_client, + sandbox_config, + tools, + &review_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 final_review_markdown = review_result_to_markdown(&review_result); + + gitea_api + .post_pull_request_review( + &review_result, + &final_review_markdown, + &review_payload.repository.full_name, + review_payload.pull_request.number, + ) + .await?; + + Ok(review_result) + } + .await; + + match bot_result { + Ok(_) => { + gitea_api + .delete_comment(&review_payload.repository.full_name, new_comment.id) + .await + } + Err(e) => { + gitea_api + .edit_comment( + &format!("Error while reviewing: {}", e), + &review_payload.repository.full_name, + new_comment.id, + ) + .await + } + } +} + +/// Runs the review inside a sandbox container, letting the model explore the +/// repository with tools before answering. +/// +/// The answer of the model is parsed by [`ReviewResult::from_str`], which the +/// agent loop enforces: an answer that is not a review is sent back to the model +/// for correction. +async fn run_sandboxed_review( + gitea_api: &GiteaAPI, + open_router_client: &OpenRouterClient, + sandbox_config: &SandboxConfig, + tools: Vec, + review_payload: &ReviewPayload, + bot_request: &str, +) -> anyhow::Result<(ReviewResult, Option)> { + let sandbox = Sandbox::create( + &sandbox_config.runtime, + &review_payload.repository.clone_url, + gitea_api.token(), + review_payload.pull_request.number, + ) + .await?; + + let result = agent::run( + open_router_client, + &sandbox, + tools, + SANDBOX_SYSTEM_PROMPT, + bot_request, + sandbox_config.max_iterations, + ) + .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)) +} + +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::>() + .join("\n") +} + +/// 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`). +type ChangedLines = Vec; + +struct ChangedFile { + filename: String, + added: Vec, + removed: Vec, +} + +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. + 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. + fn side_of(&self, line: u64) -> Option { + 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. +fn parse_changed_lines(git_diff: &str) -> ChangedLines { + let mut files = Vec::new(); + let mut current_file: Option = 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/` or +/// `+++ b/` 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 { + 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. +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. +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. +fn drop_generated_files(changed_lines: &mut ChangedLines) { + changed_lines.retain(|file| !is_ignored(&file.filename)); +} + +/// Renders the changes for the model: the files the pull request touches, then +/// the lines to review per file. +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::>() + .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), + } +} + +/// 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] +} + +/// Renders the changed lines as `filename: added 1, 2 / removed 3`, one file per +/// line, keeping only the sides the file actually has. +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::>() + .join("\n") +} + +fn format_line_numbers(lines: &[u64]) -> String { + lines + .iter() + .map(u64::to_string) + .collect::>() + .join(", ") +} + +/// 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 { + let error = match serde_json::from_str::(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::(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]) +} + +/// 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. +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" + ); + } +} + +/// 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::ReviewItem; + + /// Additions, a removal and a line changed on both sides. + 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. + 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. + 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. + 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. + 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`). + 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", + ); + + fn review(filename: &str, line: Option, side: Option) -> ReviewItem { + ReviewItem { + filename: String::from(filename), + line, + side, + severity: None, + message: String::from("message"), + } + } + + fn review_result(reviews: Vec) -> ReviewResult { + ReviewResult { + reviews, + comment: String::new(), + cost: None, + } + } + + 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), + } + } + + #[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 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 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 generated_lockfiles_are_ignored() { + assert!(is_ignored("Cargo.lock")); + assert!(is_ignored("crates/herald-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"); + } + + #[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)"); + } + + #[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::>(); + + 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 a_raw_answer_is_parsed() { + let answer = r#"{"reviews": [], "comment": "ok"}"#; + + assert_eq!(answer.parse::().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::().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::().unwrap().comment, "ok"); + } + + #[test] + fn an_answer_without_json_is_rejected() { + assert!("Je n'ai rien relevé.".parse::().is_err()); + } + + #[test] + fn an_answer_that_is_an_object_but_not_a_review_is_rejected() { + assert!(r#"{"message": "LGTM"}"#.parse::().is_err()); + } + + #[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::>(); + + assert_eq!( + sides, + vec![ + Some(ReviewSide::Added), + Some(ReviewSide::Added), + Some(ReviewSide::Removed), + Some(ReviewSide::Added), + Some(ReviewSide::Added) + ] + ); + } + + #[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::>(); + + assert_eq!( + severities, + vec![ + Some(ReviewSeverity::Bug), + Some(ReviewSeverity::Security), + Some(ReviewSeverity::Performance), + None, + ] + ); + } + + #[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.")); + } +} diff --git a/crates/herald-server/src/consts.rs b/crates/herald-server/src/consts.rs new file mode 100644 index 0000000..3e21f3a --- /dev/null +++ b/crates/herald-server/src/consts.rs @@ -0,0 +1,82 @@ +pub const GITEA_SIG_HEADER_NAME: &str = "x-gitea-signature"; +pub const GITEA_EVENT_TYPE_HEADER_NAME: &str = "x-gitea-event-type"; + +pub const MAX_WEBHOOK_BODY_SIZE: usize = 1024 * 1024; // 1 MiB +pub const MAX_DIFF_SIZE: usize = 1024 * 1024; // 1 MiB + +pub const BOT_PROCESS_MSG: &str = " + Review in progress with the model \"{model}\"... +"; + +pub const SANDBOX_SYSTEM_PROMPT: &str = " + You are a senior software engineer reviewing a pull request inside an + isolated sandbox. + + The repository is checked out in your working directory. Explore it with the + provided read-only tools (ls, file_size, read_file, grep, find); paths are + relative to the repository root. Every line you read is prefixed with its + absolute line number: that is the number you must cite to anchor a comment. + + Read before you assert: a review anchored on a line you did not read is + worthless, so never comment on code you have not seen. Gather enough context + to be confident, but do not re-read what you already have. + + When you answer, send the requested JSON only: a raw JSON object, no markdown + code fence, nothing before or after it. +"; + +pub const REVIEW_PROMPT: &str = " + You are a senior software engineer reviewing a pull request. + + Judge the changes for correctness, security, resource use and + maintainability. Report real problems; do not invent issues, and do not + report pure formatting a formatter would fix. + + Be exhaustive: one review per distinct issue, and cover every changed file + that has something to report. Do not stop at the first few findings, and do + not merge several issues into a single review. + + This is the pull request subject: \"{subject}\" + + This is the user comment: \"{comment}\" + If the user asks about something precise, address that first. + + The pull request changes these files and lines: + + {changes} + + The code is not provided. Read the files you need with the available tools + before answering. + + `added` line numbers refer to the new version of the file, which is what your + working directory contains now. `removed` line numbers refer to the old + version, which is not in your working directory: you cannot read a removed + line, only the code around where it used to be. + + Every review must anchor on one of the listed line numbers: + - filename: the full path exactly as listed above, + - line: one of the numbers listed for that file, + - side: \"added\" for a number from the `added` list, \"removed\" for one from + the `removed` list, + - severity: exactly one of \"bug\", \"security\", \"performance\" or + \"maintainability\" — a bug is wrong behaviour, security a vulnerability, + performance a resource problem, maintainability everything else. + + Answer in french, with the raw json object only: no markdown code fence, no + text before or after it. All fields are mandatory. The `comment` field must + hold a short summary that lists the issues, and also what the pull request + does well: the author should get the compliments too. + + { + \"reviews\": [ + { + \"filename\": \"\", + \"line\": 0, + \"side\": \"\", + \"severity\": \"\", + \"message\": \"\" + } + ], + \"comment\": \"\" + } +"; diff --git a/src/env.rs b/crates/herald-server/src/env.rs similarity index 91% rename from src/env.rs rename to crates/herald-server/src/env.rs index 8aed750..a7752de 100644 --- a/src/env.rs +++ b/crates/herald-server/src/env.rs @@ -12,6 +12,7 @@ pub struct EnvConfig { pub gitea_token: String, pub gitea_timeout: u64, pub metrics_bind_addr: Option, + pub sandbox_max_iterations: usize, } pub fn load_config() -> anyhow::Result { @@ -25,6 +26,10 @@ pub fn load_config() -> anyhow::Result { 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 { http_port, @@ -37,6 +42,7 @@ pub fn load_config() -> anyhow::Result { gitea_token, gitea_timeout, metrics_bind_addr, + sandbox_max_iterations, }) } diff --git a/src/errors.rs b/crates/herald-server/src/errors.rs similarity index 100% rename from src/errors.rs rename to crates/herald-server/src/errors.rs diff --git a/src/gitea.rs b/crates/herald-server/src/gitea.rs similarity index 67% rename from src/gitea.rs rename to crates/herald-server/src/gitea.rs index 8477b4b..7dbbfcc 100644 --- a/src/gitea.rs +++ b/crates/herald-server/src/gitea.rs @@ -1,14 +1,28 @@ use std::time::Duration; +use futures_util::stream::TryStreamExt; use serde::Deserialize; use serde_json::{Value, json}; -use tracing::instrument; +use tokio::io::AsyncReadExt; +use tokio_util::io::StreamReader; +use tracing::{instrument, warn}; -use crate::{bot::ReviewResult, errors::AppError}; +use crate::{ + bot::{ReviewResult, ReviewSeverity, ReviewSide}, + consts::MAX_DIFF_SIZE, + errors::AppError, +}; + +/// Page size requested when listing the files of a pull request. +const FILE_PAGE_SIZE: u64 = 50; + +/// Maximum number of pages fetched for a pull request file list. +const MAX_FILE_PAGES: u64 = 10; #[derive(Clone)] pub struct GiteaAPI { base_url: String, + token: String, client: reqwest::Client, } @@ -22,6 +36,7 @@ impl GiteaAPI { Ok(Self { base_url: String::from(base_url), + token: String::from(token), client: reqwest::Client::builder() .timeout(Duration::from_secs(timeout)) .default_headers(default_headers) @@ -29,6 +44,11 @@ impl GiteaAPI { }) } + /// API token used to authenticate against Gitea. + pub fn token(&self) -> &str { + &self.token + } + #[instrument(skip(self))] pub async fn get_authorized_user(&self) -> anyhow::Result { let url = format!("{}/api/v1/user", self.base_url); @@ -119,6 +139,70 @@ impl GiteaAPI { Ok(()) } + /// Raw unified diff of a pull request. + /// + /// The API endpoint is used rather than the `diff_url` carried by the + /// webhook: that one points at a web route, which is session authenticated + /// and therefore does not serve private repositories to an API token. + #[instrument(skip(self))] + pub async fn pull_request_diff(&self, full_name: &str, index: u64) -> anyhow::Result { + let url = format!( + "{}/api/v1/repos/{}/pulls/{}.diff", + self.base_url, full_name, index + ); + + let res = self.client.get(url).send().await?; + + if !res.status().is_success() { + return Err(anyhow::anyhow!( + "Failed to download pull request diff: {}", + res.status() + )); + } + + read_capped(res).await + } + + /// Files changed by a pull request, with their exact path and status. + #[instrument(skip(self))] + pub async fn pull_request_files( + &self, + full_name: &str, + index: u64, + ) -> anyhow::Result> { + let mut files = Vec::new(); + + for page in 1..=MAX_FILE_PAGES { + let url = format!( + "{}/api/v1/repos/{}/pulls/{}/files?limit={FILE_PAGE_SIZE}&page={page}", + self.base_url, full_name, index + ); + + let res = self.client.get(url).send().await?; + + if !res.status().is_success() { + return Err(anyhow::anyhow!( + "Failed to list pull request files: {}", + res.status() + )); + } + + let page_files = res.json::>().await?; + + // The instance may cap the page size below the requested one, so a + // short page is not the end of the list: an empty one is. + if page_files.is_empty() { + return Ok(files); + } + + files.extend(page_files); + } + + warn!(files = files.len(), "Pull request file list was truncated"); + + Ok(files) + } + #[instrument(skip(self, review_result))] pub async fn post_pull_request_review( &self, @@ -132,19 +216,31 @@ impl GiteaAPI { self.base_url, full_name, index ); - let comments = &review_result + let comments = review_result .reviews .iter() - .filter(|r| r.line.is_some()) - .map(|r| { - let path = r.filename.clone(); - let line = r.line.unwrap_or(0); - let body = r.message.clone(); + .filter_map(|review| { + let line = review.line?; + let path = review.filename.clone(); + let severity = review + .severity + .unwrap_or(ReviewSeverity::Maintainability) + .label(); + let body = format!("**[{severity}]** {}", review.message); - json!({ - "path": path, - "new_position": line, - "body": body + // A line removed by the pull request only exists in the old + // version of the file, so it is anchored with `old_position`. + Some(match review.side { + Some(ReviewSide::Removed) => json!({ + "path": path, + "old_position": line, + "body": body + }), + _ => json!({ + "path": path, + "new_position": line, + "body": body + }), }) }) .collect::>(); @@ -168,6 +264,23 @@ impl GiteaAPI { } } +/// Reads a response body, refusing to buffer more than [`MAX_DIFF_SIZE`]. +async fn read_capped(response: reqwest::Response) -> anyhow::Result { + let stream = response.bytes_stream().map_err(std::io::Error::other); + + let mut buf = Vec::with_capacity(MAX_DIFF_SIZE); + StreamReader::new(stream) + .take((MAX_DIFF_SIZE + 1) as u64) + .read_to_end(&mut buf) + .await?; + + if buf.len() > MAX_DIFF_SIZE { + anyhow::bail!("Pull request diff exceeds the maximum allowed size of 1 MiB"); + } + + Ok(String::from_utf8_lossy(&buf).into_owned()) +} + #[derive(Debug)] pub enum WebhookType { Review(ReviewPayload), @@ -197,8 +310,6 @@ pub struct ReviewPayload { #[derive(Deserialize, Debug)] pub struct PullRequest { - pub id: u64, - pub diff_url: String, pub number: u64, pub title: String, } @@ -207,18 +318,29 @@ pub struct PullRequest { pub struct Comment { pub id: u64, pub body: String, - pub user: User, } #[derive(Deserialize, Debug)] pub struct User { - pub id: u64, pub login: String, } #[derive(Deserialize, Debug)] pub struct Repository { pub full_name: String, + pub clone_url: String, +} + +/// A file changed by a pull request, as reported by the API. +#[derive(Deserialize, Debug)] +pub struct PullRequestFile { + /// Path of the file in the new version of the repository. + pub filename: String, + /// Previous path, for a renamed file. + #[serde(default)] + pub previous_filename: Option, + /// `added`, `modified`, `deleted`, `renamed`… + pub status: String, } impl WebhookType { @@ -264,7 +386,8 @@ mod tests { "title": "My PR" }, "repository": { - "full_name": "owner/repo" + "full_name": "owner/repo", + "clone_url": "https://github.com/owner/repo.git" }, "comment": { "id": 7, @@ -282,10 +405,8 @@ mod tests { match result.unwrap() { WebhookType::Review(payload) => { assert_eq!(payload.action, "created"); - assert_eq!(payload.pull_request.id, 42); assert_eq!(payload.comment.id, 7); assert_eq!(payload.comment.body, "@test_bot LGTM"); - assert_eq!(payload.comment.user.id, 100); } } } @@ -329,7 +450,8 @@ mod tests { "title": "My PR" }, "repository": { - "full_name": "owner/repo" + "full_name": "owner/repo", + "clone_url": "https://github.com/owner/repo.git" }, "comment": { "id": 1, @@ -361,7 +483,8 @@ mod tests { "title": "My PR" }, "repository": { - "full_name": "owner/repo" + "full_name": "owner/repo", + "clone_url": "https://github.com/owner/repo.git" }, "comment": { "id": 12, @@ -375,10 +498,12 @@ mod tests { let payload: ReviewPayload = serde_json::from_value(json).unwrap(); assert_eq!(payload.action, "created"); - assert_eq!(payload.pull_request.id, 99); assert_eq!(payload.comment.id, 12); assert_eq!(payload.comment.body, "Needs work"); - assert_eq!(payload.comment.user.id, 200); + assert_eq!( + payload.repository.clone_url, + "https://github.com/owner/repo.git" + ); } #[test] @@ -399,7 +524,8 @@ mod tests { "title": "My PR" }, "repository": { - "full_name": "owner/repo" + "full_name": "owner/repo", + "clone_url": "https://github.com/owner/repo.git" }, "comment": { "id": 1, @@ -426,7 +552,8 @@ mod tests { "title": "My PR" }, "repository": { - "full_name": "owner/repo" + "full_name": "owner/repo", + "clone_url": "https://github.com/owner/repo.git" }, "comment": { "id": 1, diff --git a/src/main.rs b/crates/herald-server/src/main.rs similarity index 60% rename from src/main.rs rename to crates/herald-server/src/main.rs index 27d0554..c22d24f 100644 --- a/src/main.rs +++ b/crates/herald-server/src/main.rs @@ -1,4 +1,12 @@ -use crate::{bot::Bot, gitea::{GiteaAPI, WebhookType}, open_router::OpenRouterClient, state::AppState}; +use std::time::Duration; + +use crate::{ + bot::Bot, + gitea::{GiteaAPI, WebhookType}, + open_router::OpenRouterClient, + sandbox::SandboxConfig, + state::AppState, +}; use dotenvy::dotenv; use tokio::signal::unix::{SignalKind, signal}; @@ -15,7 +23,15 @@ mod errors; mod gitea; mod metrics; mod open_router; +mod sandbox; mod state; +mod text; + +/// Délai laissé aux reviews en cours et aux serveurs pour s'arrêter proprement. +/// +/// Sans lui, une review prise dans une sandbox récalcitrante garderait le processus +/// en vie jusqu'à ce que le superviseur le tue. +const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(60); fn main() -> anyhow::Result<()> { dotenv().ok(); @@ -31,14 +47,12 @@ fn main() -> anyhow::Result<()> { let _sentry_guard = if let Ok(sentry_dsn) = env::try_get_env("SENTRY_DSN") { info!("Initialize sentry"); - Some(sentry::init(( - sentry_dsn, - sentry::ClientOptions { - release: sentry::release_name!(), - send_default_pii: true, - ..Default::default() - }, - ))) + Some(sentry::init( + sentry::ClientOptions::new() + .dsn(&sentry_dsn) + .maybe_release(sentry::release_name!()) + .send_default_pii(true), + )) } else { warn!("SENTRY_DSN not set, sentry will not be initialized"); None @@ -73,13 +87,25 @@ async fn run() -> anyhow::Result<()> { let shutdown = CancellationToken::new(); + let sandbox = SandboxConfig { + runtime: devcontainer_rs::ContainerRuntime::connect()?, + max_iterations: config.sandbox_max_iterations, + }; + + if !sandbox.runtime.available().await { + warn!( + endpoint = sandbox.runtime.endpoint(), + "Container daemon is not reachable, every review will fail" + ); + } + let bot = Bot::new( gitea_user.login, gitea_api, open_router_client, - reqwest::Client::new(), config.bot_max_concurrent, config.open_router_model.clone(), + sandbox, ); let (tx, rx) = tokio::sync::mpsc::channel::(config.bot_max_concurrent * 2); @@ -102,11 +128,28 @@ async fn run() -> anyhow::Result<()> { anyhow::Ok(()) }; - tokio::try_join!( - bot.start(rx, shutdown.clone()), - api::start(app_state, shutdown.clone()), - signal - )?; + let shutdown_deadline = async { + shutdown.cancelled().await; + tokio::time::sleep(SHUTDOWN_TIMEOUT).await; + }; + + tokio::select! { + result = async { + tokio::try_join!( + bot.start(rx, shutdown.clone()), + api::start(app_state, shutdown.clone()), + signal + ) + } => { + result?; + } + () = shutdown_deadline => { + warn!( + timeout = ?SHUTDOWN_TIMEOUT, + "Shutdown did not finish in time, exiting anyway" + ); + } + } info!("Shutdown complete"); diff --git a/src/metrics.rs b/crates/herald-server/src/metrics.rs similarity index 56% rename from src/metrics.rs rename to crates/herald-server/src/metrics.rs index 23698d8..44d3347 100644 --- a/src/metrics.rs +++ b/crates/herald-server/src/metrics.rs @@ -1,10 +1,9 @@ use std::{net::SocketAddr, str::FromStr}; -use metrics::{Unit, describe_counter, describe_gauge, counter, gauge}; +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!("herald_webhooks_received_total", "event_type" => event_type.to_string()).increment(1); } pub fn webhook_duplicate(event_type: &str) { @@ -31,8 +30,7 @@ pub fn task_completed(event_type: &str) { } pub fn task_failed(event_type: &str) { - counter!("herald_bot_tasks_failed_total", "event_type" => event_type.to_string()) - .increment(1); + counter!("herald_bot_tasks_failed_total", "event_type" => event_type.to_string()).increment(1); } pub fn openrouter_cost_usd(cost: f64) { @@ -40,13 +38,41 @@ pub fn openrouter_cost_usd(cost: f64) { } pub fn describe() { - describe_counter!("herald_webhooks_received_total", Unit::Count, "Total webhooks received"); - describe_counter!("herald_webhooks_duplicate_total", Unit::Count, "Webhooks rejected as duplicates"); - describe_counter!("herald_webhooks_channel_full_total", Unit::Count, "Webhooks dropped because the bot channel was full"); - describe_gauge!("herald_bot_tasks_active", Unit::Count, "Bot tasks currently in progress"); - describe_counter!("herald_bot_tasks_completed_total", Unit::Count, "Bot tasks completed successfully"); - describe_counter!("herald_bot_tasks_failed_total", Unit::Count, "Bot tasks that failed"); - describe_counter!("herald_openrouter_cost_cents_total", Unit::Count, "Total OpenRouter cost in cents (divide by 100 for USD)"); + describe_counter!( + "herald_webhooks_received_total", + Unit::Count, + "Total webhooks received" + ); + describe_counter!( + "herald_webhooks_duplicate_total", + Unit::Count, + "Webhooks rejected as duplicates" + ); + describe_counter!( + "herald_webhooks_channel_full_total", + Unit::Count, + "Webhooks dropped because the bot channel was full" + ); + describe_gauge!( + "herald_bot_tasks_active", + Unit::Count, + "Bot tasks currently in progress" + ); + describe_counter!( + "herald_bot_tasks_completed_total", + Unit::Count, + "Bot tasks completed successfully" + ); + describe_counter!( + "herald_bot_tasks_failed_total", + Unit::Count, + "Bot tasks that failed" + ); + describe_counter!( + "herald_openrouter_cost_cents_total", + Unit::Count, + "Total OpenRouter cost in cents (divide by 100 for USD)" + ); } pub fn install(bind_addr: &str) -> anyhow::Result<()> { diff --git a/crates/herald-server/src/open_router.rs b/crates/herald-server/src/open_router.rs new file mode 100644 index 0000000..3c5de7a --- /dev/null +++ b/crates/herald-server/src/open_router.rs @@ -0,0 +1,473 @@ +//! Minimal OpenRouter chat-completions client. +//! +//! Herald 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`, +//! `tool_calls` and `usage.cost`. Unknown fields are ignored. + +use std::time::Duration; + +use anyhow::Context; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use tracing::instrument; + +/// OpenRouter API root, version prefix included. +const BASE_URL: &str = "https://openrouter.ai/api/v1"; + +/// What the model is allowed to do on a given turn. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ToolChoice { + /// The model decides whether to call a tool. + Auto, + /// The model must answer, without calling any tool. The tools stay declared, + /// so the conversation remains the same shape as on the other turns. + None, +} + +impl ToolChoice { + fn as_str(self) -> &'static str { + match self { + Self::Auto => "auto", + Self::None => "none", + } + } +} + +/// Result of a completion that may contain tool calls. +pub struct ToolChatResult { + pub message: Option, + pub tool_calls: Vec, + pub cost: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum Role { + System, + User, + Assistant, + Tool, +} + +/// A single turn of the conversation. +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct Message { + pub role: Role, + #[serde(skip_serializing_if = "Option::is_none")] + pub content: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_calls: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_call_id: Option, +} + +impl Message { + pub fn new(role: Role, content: impl Into) -> Self { + Self { + role, + content: Some(content.into()), + tool_calls: None, + tool_call_id: None, + } + } + + /// Assistant turn requesting tool calls. Models often answer with tool + /// calls but no text, in which case the content is sent as `null`. + pub fn assistant_with_tool_calls( + content: impl Into, + tool_calls: Vec, + ) -> Self { + let content = content.into(); + + Self { + role: Role::Assistant, + content: (!content.is_empty()).then_some(content), + tool_calls: Some(tool_calls), + tool_call_id: None, + } + } + + /// Result of a tool call, linked to the request by `tool_call_id`. + pub fn tool_response(tool_call_id: impl Into, content: impl Into) -> Self { + Self { + role: Role::Tool, + content: Some(content.into()), + tool_calls: None, + tool_call_id: Some(tool_call_id.into()), + } + } +} + +/// A tool the model may call. +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct Tool { + #[serde(rename = "type", default = "function_type")] + pub kind: String, + pub function: FunctionDefinition, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct FunctionDefinition { + pub name: String, + pub description: String, + /// JSON schema describing the accepted arguments. + pub parameters: Value, +} + +impl Tool { + pub fn new(name: impl Into, description: impl Into, parameters: Value) -> Self { + Self { + kind: function_type(), + function: FunctionDefinition { + name: name.into(), + description: description.into(), + parameters, + }, + } + } +} + +/// A tool call requested by the model. +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct ToolCall { + pub id: String, + #[serde(rename = "type", default = "function_type")] + pub kind: String, + pub function: FunctionCall, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct FunctionCall { + pub name: String, + /// Arguments as a JSON-encoded string. Kept verbatim so that echoing the + /// call back into the conversation does not re-encode or corrupt it. + pub arguments: String, +} + +impl ToolCall { + pub fn id(&self) -> &str { + &self.id + } + + pub fn name(&self) -> &str { + &self.function.name + } + + pub fn arguments_json(&self) -> &str { + &self.function.arguments + } +} + +fn function_type() -> String { + String::from("function") +} + +#[derive(Serialize)] +struct ChatRequest<'a> { + model: &'a str, + messages: &'a [Message], + reasoning: Reasoning, + tools: &'a [Tool], + tool_choice: &'a str, +} + +/// OpenRouter reasoning config; Herald always asks for reasoning. +#[derive(Serialize)] +struct Reasoning { + enabled: bool, +} + +#[derive(Deserialize)] +struct ChatResponse { + #[serde(default)] + choices: Vec, + #[serde(default)] + usage: Option, +} + +#[derive(Deserialize)] +struct Choice { + message: ResponseMessage, +} + +#[derive(Deserialize)] +struct ResponseMessage { + #[serde(default)] + content: Option, + #[serde(default)] + tool_calls: Option>, +} + +/// Message content is a plain string for most models and a list of typed parts +/// for others; both are collapsed to text. +#[derive(Deserialize)] +#[serde(untagged)] +enum Content { + Text(String), + Parts(Vec), +} + +#[derive(Deserialize)] +struct ContentPart { + #[serde(default)] + text: Option, +} + +impl Content { + fn into_text(self) -> String { + match self { + Self::Text(text) => text, + Self::Parts(parts) => parts + .into_iter() + .filter_map(|part| part.text) + .collect::>() + .join(""), + } + } +} + +#[derive(Deserialize)] +struct Usage { + #[serde(default)] + cost: Option, +} + +/// Error payload returned by OpenRouter for a failed request. +#[derive(Deserialize)] +struct ErrorResponse { + error: ErrorDetail, +} + +#[derive(Deserialize)] +struct ErrorDetail { + message: String, +} + +#[derive(Clone)] +pub struct OpenRouterClient { + client: reqwest::Client, + api_key: String, + model: String, +} + +impl OpenRouterClient { + pub fn new(token: &str, model: &str, timeout: u64) -> anyhow::Result { + Ok(Self { + client: reqwest::Client::builder() + .timeout(Duration::from_secs(timeout)) + .build()?, + api_key: String::from(token), + model: String::from(model), + }) + } + + /// Sends a conversation with tool definitions and returns either a final + /// message or the tool calls requested by the model. + /// + /// The conversation is borrowed rather than taken by value: the caller keeps + /// appending to it between iterations, and a copy per iteration would be pure + /// waste. + #[instrument(skip(self, messages, tools), err)] + pub async fn chat_with_tools( + &self, + messages: &[Message], + tools: &[Tool], + tool_choice: ToolChoice, + ) -> anyhow::Result { + let response = self.complete(messages, tools, tool_choice).await?; + + let cost = response.usage.and_then(|usage| usage.cost); + let message = response + .choices + .into_iter() + .next() + .ok_or_else(|| anyhow::anyhow!("No choices in response"))? + .message; + + Ok(ToolChatResult { + message: message.content.map(Content::into_text), + tool_calls: message.tool_calls.unwrap_or_default(), + cost, + }) + } + + async fn complete( + &self, + messages: &[Message], + tools: &[Tool], + tool_choice: ToolChoice, + ) -> anyhow::Result { + let request = ChatRequest { + model: &self.model, + messages, + reasoning: Reasoning { enabled: true }, + tools, + tool_choice: tool_choice.as_str(), + }; + + let response = self + .client + .post(format!("{BASE_URL}/chat/completions")) + .bearer_auth(&self.api_key) + .json(&request) + .send() + .await + .context("failed to reach OpenRouter")?; + + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + anyhow::bail!("OpenRouter returned {status}: {}", error_message(&body)); + } + + response + .json::() + .await + .context("invalid OpenRouter response") + } +} + +/// Extracts the message from an OpenRouter error body, falling back to the raw +/// body when it is not the expected JSON shape. +fn error_message(body: &str) -> String { + serde_json::from_str::(body) + .map(|response| response.error.message) + .unwrap_or_else(|_| body.trim().to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + /// Builds a tool call the way the API returns one, so the fixture also + /// covers deserialization. + fn tool_call(id: &str, name: &str, arguments: &str) -> ToolCall { + serde_json::from_value(json!({ + "id": id, + "type": "function", + "function": { "name": name, "arguments": arguments } + })) + .unwrap() + } + + #[test] + fn tool_call_arguments_are_kept_verbatim() { + let call = tool_call("call_1", "grep", r#"{"pattern":"fn main"}"#); + let message = Message::assistant_with_tool_calls("", vec![call]); + + let serialized = serde_json::to_value(&message).unwrap(); + + assert_eq!(serialized["content"], Value::Null); + assert_eq!(serialized["tool_calls"][0]["type"], "function"); + assert_eq!(serialized["tool_calls"][0]["function"]["name"], "grep"); + assert_eq!( + serialized["tool_calls"][0]["function"]["arguments"], + r#"{"pattern":"fn main"}"# + ); + } + + #[test] + fn tool_response_carries_role_and_tool_call_id() { + let message = Message::tool_response("call_1", "src/main.rs"); + let serialized = serde_json::to_value(&message).unwrap(); + + assert_eq!(serialized["role"], "tool"); + assert_eq!(serialized["tool_call_id"], "call_1"); + assert_eq!(serialized["content"], "src/main.rs"); + } + + #[test] + fn request_serializes_tool_choice_and_reasoning() { + let messages = [Message::new(Role::User, "hi")]; + let tools = [Tool::new("ls", "List files", json!({"type": "object"}))]; + + let request = serde_json::to_value(ChatRequest { + model: "some/model", + messages: &messages, + reasoning: Reasoning { enabled: true }, + tools: &tools, + tool_choice: ToolChoice::Auto.as_str(), + }) + .unwrap(); + + assert_eq!(request["model"], "some/model"); + assert_eq!(request["reasoning"]["enabled"], true); + assert_eq!(request["tool_choice"], "auto"); + assert_eq!(request["tools"][0]["function"]["name"], "ls"); + assert_eq!(request["messages"][0]["role"], "user"); + } + + #[test] + fn a_forced_answer_turn_disables_the_tools() { + assert_eq!(ToolChoice::None.as_str(), "none"); + assert_eq!(ToolChoice::Auto.as_str(), "auto"); + } + + #[test] + fn response_parses_tool_calls_and_cost() { + let response: ChatResponse = serde_json::from_value(json!({ + "choices": [{ + "message": { + "role": "assistant", + "content": null, + "tool_calls": [{ + "id": "call_1", + "type": "function", + "function": { "name": "ls", "arguments": "{\"path\":\"src\"}" } + }] + } + }], + "usage": { "cost": 0.0021 } + })) + .unwrap(); + + assert_eq!(response.usage.and_then(|usage| usage.cost), Some(0.0021)); + + let message = response.choices.into_iter().next().unwrap().message; + assert!(message.content.is_none()); + + let calls = message.tool_calls.unwrap(); + assert_eq!(calls[0].name(), "ls"); + assert_eq!(calls[0].id(), "call_1"); + assert_eq!(calls[0].arguments_json(), r#"{"path":"src"}"#); + } + + #[test] + fn response_parses_parts_content() { + let response: ChatResponse = serde_json::from_value(json!({ + "choices": [{ + "message": { + "role": "assistant", + "content": [ + { "type": "text", "text": "hello " }, + { "type": "text", "text": "world" } + ] + } + }] + })) + .unwrap(); + + assert!(response.usage.is_none()); + + let message = response.choices.into_iter().next().unwrap().message; + assert_eq!(message.content.unwrap().into_text(), "hello world"); + } + + #[test] + fn error_message_prefers_api_message() { + let body = r#"{"error":{"message":"No auth credentials found","code":401}}"#; + assert_eq!(error_message(body), "No auth credentials found"); + } + + #[test] + fn error_message_falls_back_to_raw_body() { + assert_eq!( + error_message(" bad gateway "), + "bad gateway" + ); + } +} diff --git a/crates/herald-server/src/sandbox/agent.rs b/crates/herald-server/src/sandbox/agent.rs new file mode 100644 index 0000000..7de2702 --- /dev/null +++ b/crates/herald-server/src/sandbox/agent.rs @@ -0,0 +1,316 @@ +//! Tool-calling loop driving the model against a [`Sandbox`]. +//! +//! The loop sends the conversation and the available tools to OpenRouter, +//! executes any requested tool call inside the sandbox and feeds the results +//! back, until the model produces a final message or the iteration budget is +//! exhausted. + +use std::str::FromStr; +use std::time::Instant; + +use anyhow::Context; +use serde_json::Value; +use tracing::{info, warn}; + +use crate::{ + open_router::{Message, OpenRouterClient, Role, Tool, ToolCall, ToolChoice}, + sandbox::{Sandbox, tools}, + text::{excerpt, truncate_bytes}, +}; + +/// Number of characters of the arguments of a tool call kept in the logs. +const MAX_LOGGED_ARGUMENTS: usize = 200; + +/// Number of characters of a tool result kept in the logs. +/// +/// Sizes alone make a broken tool invisible: a command that fails returns a short +/// `error: …` instead of the expected content, which is exactly what this excerpt +/// makes obvious. +const MAX_LOGGED_OUTPUT: usize = 120; + +/// Maximum number of bytes of a tool result handed to the model. +/// +/// A result stays in the conversation and is re-sent on every following +/// iteration, so an unbounded one (a whole file, a match on every line) inflates +/// the context and the cost of the whole rest of the run — and can overflow the +/// model context window outright. +const MAX_TOOL_OUTPUT_BYTES: usize = 32 * 1024; + +/// Asked of the model when its answer does not satisfy the caller's contract. +const RETRY_PROMPT: &str = " + Your answer is not valid: {error} + + Answer again with the requested format only: a raw json object, without any + markdown code fence and without any text before or after it. +"; + +/// Asked of the model on the last turn, in place of one more exploration turn. +/// +/// Without it, a model still exploring on its last turn would ask for another +/// tool, and the run would end on the iteration budget with no answer at all. +const FINAL_PROMPT: &str = " + You are out of turns: this is your last one. Answer now, with the requested + format, from what you have already gathered, without calling any tool. +"; + +/// Final output of an agent run. +pub struct AgentResult { + /// Answer of the model, parsed into the type the caller asked for. + pub answer: A, + pub cost: Option, + 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( + open_router: &OpenRouterClient, + sandbox: &Sandbox, + tool_definitions: Vec, + system_prompt: &str, + user_prompt: &str, + max_iterations: usize, +) -> anyhow::Result> +where + A: FromStr, + A::Err: std::fmt::Display, +{ + let mut messages = vec![ + Message::new(Role::System, system_prompt), + Message::new(Role::User, user_prompt), + ]; + + info!( + tools = %tool_definitions + .iter() + .map(|tool| tool.function.name.as_str()) + .collect::>() + .join(", "), + max_iterations, + "Starting tool-calling loop" + ); + + let mut total_cost = 0.0_f64; + let mut has_cost = false; + let mut last_rejection: Option = None; + + for iteration in 1..=max_iterations { + // Le dernier tour n'est plus un tour d'exploration : le modèle doit rendre + // sa réponse, avec ce qu'il a déjà vu. + let last = iteration == max_iterations; + + if last { + info!(iteration, "Last turn: asking for the final answer"); + messages.push(Message::new(Role::User, FINAL_PROMPT)); + } + + let started = Instant::now(); + let response = open_router + .chat_with_tools( + &messages, + &tool_definitions, + if last { + ToolChoice::None + } else { + ToolChoice::Auto + }, + ) + .await?; + + if let Some(cost) = response.cost { + total_cost += cost; + has_cost = true; + } + + info!( + iteration, + max_iterations, + last_turn = last, + tool_calls = response.tool_calls.len(), + answer_bytes = response.message.as_deref().map_or(0, str::len), + elapsed_ms = started.elapsed().as_millis() as u64, + "Model answered" + ); + + // Au dernier tour, les outils sont refusés : même si le fournisseur les + // renvoie malgré tout, on ne les exécute pas et on tente la réponse. + if last || response.tool_calls.is_empty() { + let answer = response.message.unwrap_or_default(); + + match answer.parse::() { + Ok(parsed) => { + return Ok(AgentResult { + answer: parsed, + cost: has_cost.then_some(total_cost), + iterations: iteration, + }); + } + Err(err) => { + warn!(iteration, %err, "Model answer was rejected, asking for another one"); + + // Keep the rejected answer in the history, so that the model + // sees what it has to fix. + messages.push(Message::new(Role::Assistant, answer)); + messages.push(Message::new( + Role::User, + RETRY_PROMPT.replace("{error}", &err.to_string()), + )); + + last_rejection = Some(err.to_string()); + continue; + } + } + } + + messages.push(Message::assistant_with_tool_calls( + response.message.unwrap_or_default(), + response.tool_calls.clone(), + )); + + for call in &response.tool_calls { + let started = Instant::now(); + let (content, truncated) = execute(sandbox, call).await; + + info!( + iteration, + tool = call.name(), + arguments = %excerpt(call.arguments_json(), MAX_LOGGED_ARGUMENTS), + output_bytes = content.len(), + truncated, + // Les sauts de ligne casseraient la lisibilité d'une ligne de log. + output = %excerpt(&content, MAX_LOGGED_OUTPUT).replace('\n', " "), + elapsed_ms = started.elapsed().as_millis() as u64, + "Tool call finished" + ); + + messages.push(Message::tool_response(call.id(), content)); + } + } + + warn!( + max_iterations, + "Agent used its last turn without a valid answer" + ); + + match last_rejection { + Some(error) => anyhow::bail!( + "agent exceeded the maximum number of iterations ({max_iterations}), \ + last answer rejected: {error}" + ), + None => anyhow::bail!("agent exceeded the maximum number of iterations ({max_iterations})"), + } +} + +/// Runs a tool call and returns its result for the model, along with whether the +/// result had to be truncated. +async fn execute(sandbox: &Sandbox, call: &ToolCall) -> (String, bool) { + let args = match parse_args(call) { + Ok(args) => args, + Err(err) => return cap_output(format!("error: {err}")), + }; + + match tools::dispatch(sandbox, call.name(), &args).await { + Ok(output) => cap_output(output), + Err(err) => cap_output(format!("error: {err}")), + } +} + +/// Bounds [`MAX_TOOL_OUTPUT_BYTES`] of a tool result, telling the model what was +/// dropped so that it can narrow its request. +fn cap_output(mut output: String) -> (String, bool) { + let total = output.len(); + + if !truncate_bytes(&mut output, MAX_TOOL_OUTPUT_BYTES) { + return (output, false); + } + + let kept = output.len(); + output.push_str(&format!( + "\n… output truncated: {total} bytes in total, the first {kept} are shown. \ + Narrow the request (path, pattern or line range) to see the rest." + )); + + (output, true) +} + +fn parse_args(call: &ToolCall) -> anyhow::Result { + let raw = call.arguments_json().trim(); + if raw.is_empty() { + return Ok(Value::Object(serde_json::Map::new())); + } + + serde_json::from_str(raw) + .with_context(|| format!("invalid arguments for tool `{}`", call.name())) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + /// Builds a tool call the way the API returns one, so the fixture also + /// covers deserialization. + fn tool_call(name: &str, arguments: &str) -> ToolCall { + serde_json::from_value(json!({ + "id": "call_1", + "type": "function", + "function": { "name": name, "arguments": arguments } + })) + .unwrap() + } + + #[test] + fn parse_args_accepts_empty_arguments() { + let call = tool_call("ls", ""); + assert_eq!( + parse_args(&call).unwrap(), + Value::Object(serde_json::Map::new()) + ); + } + + #[test] + fn parse_args_parses_json_object() { + let call = tool_call("ls", r#"{"path":"src"}"#); + assert_eq!(parse_args(&call).unwrap()["path"], "src"); + } + + #[test] + fn parse_args_rejects_invalid_json() { + let call = tool_call("ls", "not json"); + assert!(parse_args(&call).is_err()); + } + + #[test] + fn a_small_tool_output_is_kept_as_is() { + let (content, truncated) = cap_output(String::from("src/main.rs")); + + assert_eq!(content, "src/main.rs"); + assert!(!truncated); + } + + #[test] + fn a_large_tool_output_is_truncated_and_told_to_the_model() { + let (content, truncated) = cap_output("a".repeat(MAX_TOOL_OUTPUT_BYTES + 1)); + + assert!(truncated); + assert!(content.starts_with(&"a".repeat(MAX_TOOL_OUTPUT_BYTES))); + assert!(content.contains("output truncated")); + } + + #[test] + fn a_truncated_tool_output_stays_valid_utf8() { + let (content, truncated) = cap_output("é".repeat(MAX_TOOL_OUTPUT_BYTES)); + + assert!(truncated); + assert!(content.starts_with('é')); + } +} diff --git a/crates/herald-server/src/sandbox/instance.rs b/crates/herald-server/src/sandbox/instance.rs new file mode 100644 index 0000000..c2bda2d --- /dev/null +++ b/crates/herald-server/src/sandbox/instance.rs @@ -0,0 +1,288 @@ +//! Le [`Sandbox`] : clone d'une pull request exécuté dans un devcontainer +//! éphémère, et sa configuration. + +use std::{ + path::{Path, PathBuf}, + process::Stdio, +}; + +use anyhow::Context; +use devcontainer_rs::{Container, ContainerRuntime, ExecOutput}; +use tempfile::TempDir; +use tracing::{info, instrument}; + +/// Devcontainer locations recognized within a repository, in priority order. +const DEVCONTAINER_PATHS: [&str; 2] = [".devcontainer/devcontainer.json", ".devcontainer.json"]; + +/// Sandbox-related runtime configuration. +#[derive(Clone)] +pub struct SandboxConfig { + /// Client du daemon de containers qui exécute la sandbox. + pub runtime: ContainerRuntime, + /// Maximum number of tool-calling iterations per agent run. + pub max_iterations: usize, +} + +/// A cloned repository running inside an ephemeral devcontainer. +pub struct Sandbox { + // Owns the temporary directory; dropping it cleans up the clone. + _workspace: TempDir, + /// Path of the clone, as copied into the container. + repo_dir: PathBuf, + container: Container, +} + +impl Sandbox { + /// Clones the pull request head, builds the devcontainer and starts it. + /// + /// The clone is PR-aware: it fetches `refs/pull//head`, which works + /// for both same-repository and forked pull requests. + #[instrument(skip(runtime, token), fields(pr = pull_request_number))] + pub async fn create( + runtime: &ContainerRuntime, + clone_url: &str, + token: &str, + pull_request_number: u64, + ) -> anyhow::Result { + let workspace = tempfile::tempdir().context("failed to create sandbox workspace")?; + let repo_dir = workspace.path().join("repo"); + + 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 = 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 { + _workspace: workspace, + repo_dir, + container, + }; + + if let Err(err) = sandbox.check_workspace().await { + let _ = sandbox.container.remove().await; + return Err(err); + } + + Ok(sandbox) + } + + /// Vérifie que le clone est bien visible dans le container. + /// + /// Sans ce contrôle, un workspace vide — par exemple un daemon qui n'a pas pu + /// recevoir le clone — fait échouer chaque outil ; le modèle enchaîne alors les + /// appels ratés jusqu'au budget d'itérations, sans jamais pouvoir reviewer quoi + /// que ce soit. + async fn check_workspace(&self) -> anyhow::Result<()> { + let workspace_folder = self.workspace_folder(); + let probe = self + .container + .exec(&["ls", "-A", "--", workspace_folder]) + .await + .with_context(|| format!("failed to list `{workspace_folder}` in the sandbox"))?; + + if !probe.success() { + let details = self.diagnose_workspace().await; + + anyhow::bail!( + "the sandbox cannot list `{workspace_folder}`: {} ({details})", + probe.stderr.trim() + ); + } + + if probe.stdout.trim().is_empty() { + anyhow::bail!( + "the sandbox workspace `{workspace_folder}` is empty: the container daemon does not see the clone" + ); + } + + info!( + clone = %self.repo_dir.display(), + workspace = %workspace_folder, + entries = probe.stdout.lines().count(), + "Sandbox workspace is readable" + ); + + Ok(()) + } + + /// Rassemble de quoi expliquer un refus d'accès au workspace. + /// + /// Un `EACCES` a deux causes possibles, indistinguables dans le message de + /// `ls` : les permissions du clone, ou un confinement du noyau qui bloque + /// l'accès. L'identité de l'utilisateur d'exec et les permissions du point de + /// montage permettent de trancher. + async fn diagnose_workspace(&self) -> String { + let mut details = Vec::new(); + + if let Ok(output) = self.container.exec(&["id"]).await { + details.push(output.stdout.trim().to_string()); + } + + if let Ok(output) = self + .container + .exec(&["ls", "-ld", "--", self.workspace_folder()]) + .await + && output.success() + { + details.push(output.stdout.trim().to_string()); + } + + details.join(", ") + } + + /// Executes a command in the container as an argv vector (no shell). + pub async fn exec(&self, cmd: &[&str]) -> anyhow::Result { + Ok(self.container.exec(cmd).await?) + } + + /// Path of the repository inside the container. + pub fn workspace_folder(&self) -> &str { + self.container.workspace_folder() + } + + /// Stops and removes the container. The temporary clone is removed on drop. + pub async fn cleanup(self) -> anyhow::Result<()> { + self.container.remove().await?; + Ok(()) + } +} + +fn find_devcontainer(repo_dir: &Path) -> Option { + DEVCONTAINER_PATHS + .iter() + .map(|relative| repo_dir.join(relative)) + .find(|candidate| candidate.is_file()) +} + +async fn clone_pull_request( + repo_url: &str, + token: &str, + pull_request_number: u64, + dest: &Path, +) -> anyhow::Result<()> { + let dest = dest.display().to_string(); + + run_git( + token, + &[ + "clone".to_string(), + "--depth".to_string(), + "1".to_string(), + repo_url.to_string(), + dest.clone(), + ], + ) + .await?; + + run_git( + token, + &[ + "-C".to_string(), + dest.clone(), + "fetch".to_string(), + "--depth".to_string(), + "1".to_string(), + "origin".to_string(), + format!("refs/pull/{pull_request_number}/head"), + ], + ) + .await?; + + run_git( + token, + &[ + "-C".to_string(), + dest, + "checkout".to_string(), + "FETCH_HEAD".to_string(), + ], + ) + .await?; + + Ok(()) +} + +/// Rend le clone lisible par les utilisateurs du container sandbox. +/// +/// Le container peut ne pas avoir les mêmes uid que Herald (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. +async fn make_readable(repo_dir: &Path) -> anyhow::Result<()> { + let output = tokio::process::Command::new("chmod") + .args(["-R", "a+rX"]) + .arg(repo_dir) + .stdin(Stdio::null()) + .output() + .await + .context("failed to spawn chmod")?; + + if !output.status.success() { + anyhow::bail!( + "chmod failed on `{}`: {}", + repo_dir.display(), + String::from_utf8_lossy(&output.stderr).trim() + ); + } + + Ok(()) +} + +/// Runs git with the token injected through `http.extraHeader`, keeping the +/// secret out of the process arguments. +async fn run_git(token: &str, args: &[String]) -> anyhow::Result<()> { + let output = tokio::process::Command::new("git") + .args(args) + .env("GIT_CONFIG_COUNT", "1") + .env("GIT_CONFIG_KEY_0", "http.extraHeader") + .env( + "GIT_CONFIG_VALUE_0", + format!("Authorization: token {token}"), + ) + .env("GIT_TERMINAL_PROMPT", "0") + .stdin(Stdio::null()) + .output() + .await + .context("failed to spawn git")?; + + if !output.status.success() { + anyhow::bail!( + "git {} failed: {}", + args.join(" "), + String::from_utf8_lossy(&output.stderr).trim() + ); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn find_devcontainer_prefers_dot_devcontainer_dir() { + let dir = tempfile::tempdir().unwrap(); + let nested = dir.path().join(".devcontainer"); + std::fs::create_dir(&nested).unwrap(); + std::fs::write(nested.join("devcontainer.json"), "{}").unwrap(); + std::fs::write(dir.path().join(".devcontainer.json"), "{}").unwrap(); + + assert_eq!( + find_devcontainer(dir.path()), + Some(nested.join("devcontainer.json")) + ); + } + + #[test] + fn find_devcontainer_returns_none_when_absent() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!(find_devcontainer(dir.path()), None); + } +} diff --git a/crates/herald-server/src/sandbox/mod.rs b/crates/herald-server/src/sandbox/mod.rs new file mode 100644 index 0000000..a2eced6 --- /dev/null +++ b/crates/herald-server/src/sandbox/mod.rs @@ -0,0 +1,13 @@ +//! Sandboxed tool execution for the AI bot. +//! +//! A [`Sandbox`] clones a pull request into a temporary directory, builds and +//! starts its devcontainer (via `devcontainer-rs`) and exposes command +//! execution inside the resulting container. The [`tools`] module maps model +//! tool calls to commands run in that container, and [`agent`] drives the +//! tool-calling loop against OpenRouter. + +pub mod agent; +mod instance; +pub mod tools; + +pub use instance::{Sandbox, SandboxConfig}; diff --git a/crates/herald-server/src/sandbox/tools.rs b/crates/herald-server/src/sandbox/tools.rs new file mode 100644 index 0000000..3d4f5a0 --- /dev/null +++ b/crates/herald-server/src/sandbox/tools.rs @@ -0,0 +1,303 @@ +//! Tool definitions exposed to the model and their execution inside a +//! [`Sandbox`]. +//! +//! Every tool is read-only and confined to the repository workspace: paths are +//! resolved relative to the container workspace folder and rejected if they +//! escape it. Commands are executed as argv vectors (never through a shell), +//! so tool arguments cannot be used for shell injection. + +use std::path::Path; + +use anyhow::{Context, bail}; +use devcontainer_rs::{ExecOutput, normalize}; +use serde_json::{Value, json}; + +use super::Sandbox; +use crate::{gitea::WebhookType, open_router::Tool}; + +/// Tools available to the model for a given webhook action. +/// +/// The match is exhaustive on [`WebhookType`], so adding a new action forces a +/// decision here about which tools that action may use. +pub fn for_webhook(webhook: &WebhookType) -> Vec { + match webhook { + WebhookType::Review(_) => review_tools(), + } +} + +/// Read-only tools used to explore a repository during a review. +fn review_tools() -> Vec { + vec![ + Tool::new( + "ls", + "List the entries of a directory inside the repository.", + json!({ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Directory path relative to the repository root. Defaults to the repository root." + } + } + }), + ), + Tool::new( + "file_size", + "Get the size of a file inside the repository in bytes.", + json!({ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "File path relative to the repository root." + } + } + }), + ), + Tool::new( + "read_file", + "Read the content of a text file inside the repository. Every line is \ + prefixed with its absolute line number, even when only a range is read.", + json!({ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "File path relative to the repository root." + }, + "start_line": { + "type": "integer", + "description": "First line to read (1-based, inclusive). Defaults to the first line." + }, + "end_line": { + "type": "integer", + "description": "Last line to read (1-based, inclusive). Defaults to the last line." + } + }, + "required": ["path"] + }), + ), + Tool::new( + "grep", + "Search for a regular expression across the repository files.", + json!({ + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "Extended regular expression to search for." + }, + "path": { + "type": "string", + "description": "File or directory to search in, relative to the repository root. Defaults to the repository root." + } + }, + "required": ["pattern"] + }), + ), + Tool::new( + "find", + "Find files by name pattern inside the repository.", + json!({ + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "Glob pattern matched against file names, e.g. `*.rs`." + }, + "path": { + "type": "string", + "description": "Directory to search in, relative to the repository root. Defaults to the repository root." + } + }, + "required": ["pattern"] + }), + ), + ] +} + +/// Executes a tool call and returns its textual result. +/// +/// Errors are returned as `Err` so the caller can decide whether to surface +/// them to the model or abort. +pub async fn dispatch(sandbox: &Sandbox, name: &str, args: &Value) -> anyhow::Result { + match name { + "ls" => ls(sandbox, args).await, + "read_file" => read_file(sandbox, args).await, + "file_size" => file_size(sandbox, args).await, + "grep" => grep(sandbox, args).await, + "find" => find(sandbox, args).await, + other => bail!("unknown tool `{other}`"), + } +} + +async fn file_size(sandbox: &Sandbox, args: &Value) -> anyhow::Result { + let path = resolve(sandbox, required_str(args, "path")?)?; + let size = sandbox.exec(&["du", "-b", "--", &path]).await?; + into_stdout(size) +} + +async fn ls(sandbox: &Sandbox, args: &Value) -> anyhow::Result { + let path = resolve(sandbox, optional_str(args, "path").unwrap_or("."))?; + let output = sandbox.exec(&["ls", "-la", "--", &path]).await?; + into_stdout(output) +} + +async fn read_file(sandbox: &Sandbox, args: &Value) -> anyhow::Result { + let path = resolve(sandbox, required_str(args, "path")?)?; + + let start = args.get("start_line").and_then(Value::as_u64); + let end = args.get("end_line").and_then(Value::as_u64); + + let (first_line, output) = if start.is_none() && end.is_none() { + (1, sandbox.exec(&["cat", "--", &path]).await?) + } else { + let start = start.unwrap_or(1); + let end = end + .map(|line| line.to_string()) + .unwrap_or_else(|| "$".to_string()); + let range = format!("{start},{end}p"); + + ( + start, + sandbox.exec(&["sed", "-n", &range, "--", &path]).await?, + ) + }; + + Ok(number_lines(&into_stdout(output)?, first_line)) +} + +/// Préfixe chaque ligne par son numéro. +/// +/// Le modèle doit citer une ligne précise pour ancrer son commentaire : sans +/// numéros, il les compte lui-même et se décale de quelques lignes, ce qui place le +/// commentaire à côté du code visé. +fn number_lines(content: &str, first_line: u64) -> String { + content + .lines() + .enumerate() + .map(|(offset, line)| format!("{}:{line}", first_line + offset as u64)) + .collect::>() + .join("\n") +} + +async fn grep(sandbox: &Sandbox, args: &Value) -> anyhow::Result { + let pattern = required_str(args, "pattern")?; + let path = resolve(sandbox, optional_str(args, "path").unwrap_or("."))?; + + let output = sandbox + .exec(&["grep", "-rn", "-E", "--", pattern, &path]) + .await?; + + // grep exits with 1 when there is no match, which is not an error. + match output.status { + 0 | 1 => Ok(output.stdout), + _ => bail!("grep failed: {}", output.stderr.trim()), + } +} + +async fn find(sandbox: &Sandbox, args: &Value) -> anyhow::Result { + let pattern = required_str(args, "pattern")?; + let path = resolve(sandbox, optional_str(args, "path").unwrap_or("."))?; + + let output = sandbox + .exec(&["find", &path, "-type", "f", "-name", pattern]) + .await?; + + into_stdout(output) +} + +/// Resolves a tool path against the container workspace folder, rejecting any +/// path that escapes it. +fn resolve(sandbox: &Sandbox, path: &str) -> anyhow::Result { + let workspace = sandbox.workspace_folder(); + let candidate = Path::new(workspace).join(path); + + let normalized = + normalize(&candidate).with_context(|| format!("path `{path}` escapes the workspace"))?; + + if !normalized.starts_with(workspace) { + bail!("path `{path}` escapes the workspace"); + } + + Ok(normalized.display().to_string()) +} + +fn required_str<'a>(args: &'a Value, key: &str) -> anyhow::Result<&'a str> { + args.get(key) + .and_then(Value::as_str) + .with_context(|| format!("`{key}` is required")) +} + +fn optional_str<'a>(args: &'a Value, key: &str) -> Option<&'a str> { + args.get(key).and_then(Value::as_str) +} + +fn into_stdout(output: ExecOutput) -> anyhow::Result { + if output.success() { + Ok(output.stdout) + } else { + bail!( + "command failed (status {}): {}", + output.status, + output.stderr.trim() + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::gitea::{Comment, PullRequest, Repository, ReviewPayload}; + + fn review_webhook() -> WebhookType { + WebhookType::Review(ReviewPayload { + action: "created".to_string(), + pull_request: PullRequest { + number: 1, + title: "My PR".to_string(), + }, + repository: Repository { + full_name: "owner/repo".to_string(), + clone_url: "https://github.com/owner/repo.git".to_string(), + }, + comment: Comment { + id: 1, + body: "@bot review".to_string(), + }, + }) + } + + #[test] + fn review_webhook_exposes_read_only_tools() { + let names: Vec = for_webhook(&review_webhook()) + .into_iter() + .map(|tool| tool.function.name) + .collect(); + + assert_eq!(names, vec!["ls", "file_size", "read_file", "grep", "find"]); + } + + #[test] + fn required_str_reports_missing_key() { + let err = required_str(&json!({}), "path").unwrap_err(); + assert!(err.to_string().contains("path")); + } + + #[test] + fn lines_are_numbered_from_the_first_one() { + assert_eq!(number_lines("a\nb\n", 1), "1:a\n2:b"); + } + + #[test] + fn a_range_keeps_the_absolute_line_numbers() { + // Un extrait lu à partir de la ligne 12 doit garder la numérotation du + // fichier : sinon le modèle citerait des lignes décalées. + assert_eq!(number_lines("x\ny", 12), "12:x\n13:y"); + } + + #[test] + fn an_empty_read_stays_empty() { + assert_eq!(number_lines("", 1), ""); + } +} diff --git a/src/state.rs b/crates/herald-server/src/state.rs similarity index 100% rename from src/state.rs rename to crates/herald-server/src/state.rs diff --git a/crates/herald-server/src/text.rs b/crates/herald-server/src/text.rs new file mode 100644 index 0000000..e0471d8 --- /dev/null +++ b/crates/herald-server/src/text.rs @@ -0,0 +1,81 @@ +//! Small text helpers shared by the modules that log what the model answered and +//! bound what the tools return to it. + +/// First `limit` characters of `text`, for logs. +/// +/// Cuts on character boundaries, so the excerpt stays valid UTF-8, and marks a +/// truncation with an ellipsis. +pub fn excerpt(text: &str, limit: usize) -> String { + let mut chars = text.chars(); + let excerpt = chars.by_ref().take(limit).collect::(); + + if chars.next().is_some() { + return format!("{excerpt}…"); + } + + excerpt +} + +/// Truncates `text` in place to at most `limit` bytes, cutting on a character +/// boundary so the result stays valid UTF-8. +/// +/// Returns `true` when something was dropped. +pub fn truncate_bytes(text: &mut String, limit: usize) -> bool { + if text.len() <= limit { + return false; + } + + let mut end = limit; + while !text.is_char_boundary(end) { + end -= 1; + } + + text.truncate(end); + + true +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_short_text_is_kept_as_is() { + assert_eq!(excerpt("abc", 3), "abc"); + } + + #[test] + fn a_long_text_is_truncated_and_marked() { + assert_eq!(excerpt("abcdef", 3), "abc…"); + } + + #[test] + fn truncation_cuts_on_character_boundaries() { + assert_eq!(excerpt("ééé", 2), "éé…"); + } + + #[test] + fn a_short_text_is_not_truncated() { + let mut text = String::from("abc"); + + assert!(!truncate_bytes(&mut text, 3)); + assert_eq!(text, "abc"); + } + + #[test] + fn a_long_text_is_truncated_within_the_limit() { + let mut text = String::from("abcdef"); + + assert!(truncate_bytes(&mut text, 4)); + assert_eq!(text, "abcd"); + } + + #[test] + fn byte_truncation_never_splits_a_character() { + let mut text = String::from("ééé"); + + // The limit falls in the middle of the second `é`: it is dropped. + assert!(truncate_bytes(&mut text, 3)); + assert_eq!(text, "é"); + } +} diff --git a/src/bot_actions/review.rs b/src/bot_actions/review.rs deleted file mode 100644 index c156aa2..0000000 --- a/src/bot_actions/review.rs +++ /dev/null @@ -1,214 +0,0 @@ -use futures_util::stream::TryStreamExt; -use tokio::io::AsyncReadExt; -use tokio_util::io::StreamReader; -use tracing::instrument; - -use crate::{ - bot::ReviewResult, consts::{BOT_PROCESS_MSG, MAX_DIFF_SIZE, REVIEW_PROMPT}, gitea::{GiteaAPI, ReviewPayload}, metrics, open_router::OpenRouterClient, -}; - -#[instrument(skip(gitea_api, open_router_client, http_client, review_payload))] -pub async fn exec_review( - gitea_api: &GiteaAPI, - open_router_client: &OpenRouterClient, - http_client: &reqwest::Client, - model: &str, - review_payload: ReviewPayload, -) -> anyhow::Result<()> { - tracing::info!( - repo = %review_payload.repository.full_name, - pr = review_payload.pull_request.number, - action = %review_payload.action, - "Starting review" - ); - - let new_comment = gitea_api - .comment( - &BOT_PROCESS_MSG.replace("{model}", model), - &review_payload.repository.full_name, - review_payload.pull_request.number, - ) - .await?; - - let bot_result: Result = async { - let git_diff = - download_git_diff(http_client, &review_payload.pull_request.diff_url).await?; - - let diff_for_llm = format_diff_for_review(&git_diff); - - let bot_request = REVIEW_PROMPT - .replace("{subject}", &review_payload.pull_request.title) - .replace("{comment}", &review_payload.comment.body) - .replace("{diff}", &diff_for_llm); - - let chat_result = open_router_client.chat(&bot_request).await?; - let mut review_result = serde_json::from_str::(&chat_result.message)?; - - review_result.cost = chat_result.cost; - if let Some(cost) = review_result.cost { - metrics::openrouter_cost_usd(cost); - } - - let final_review_markdown = review_result_to_markdown(&review_result); - - gitea_api - .post_pull_request_review( - &review_result, - &final_review_markdown, - &review_payload.repository.full_name, - review_payload.pull_request.number, - ) - .await?; - - Ok(review_result) - } - .await; - - match bot_result { - Ok(_) => { - gitea_api - .delete_comment(&review_payload.repository.full_name, new_comment.id) - .await - } - Err(e) => { - gitea_api - .edit_comment( - &format!("Error while reviewing: {}", e), - &review_payload.repository.full_name, - new_comment.id, - ) - .await - } - } -} - -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() - )); - - 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 -} - -async fn download_git_diff(http_client: &reqwest::Client, url: &str) -> anyhow::Result { - let response = http_client.get(url).send().await?; - let stream = response.bytes_stream().map_err(std::io::Error::other); - - let mut buf = Vec::with_capacity(MAX_DIFF_SIZE); - StreamReader::new(stream) - .take((MAX_DIFF_SIZE + 1) as u64) - .read_to_end(&mut buf) - .await?; - - if buf.len() > MAX_DIFF_SIZE { - anyhow::bail!("Git diff exceeds the maximum allowed size of 1 Mo"); - } - - Ok(String::from_utf8_lossy(&buf).into_owned()) -} - -fn format_diff_for_review(git_diff: &str) -> String { - let mut output = String::new(); - let mut current_file: Option<&str> = None; - let mut new_line: u64 = 0; - - for line in git_diff.lines() { - if let Some(rest) = line.strip_prefix("diff --git a/") { - if let Some(end) = rest.find(' ') { - current_file = Some(&rest[..end]); - } - new_line = 0; - continue; - } - - if line.starts_with("---") || line.starts_with("+++") { - continue; - } - - if line.starts_with("@@") && line.contains('+') { - if let Some(start) = parse_hunk_new_start(line) { - new_line = start; - } - continue; - } - - let Some(filename) = current_file else { - continue; - }; - - if line.starts_with(' ') { - new_line += 1; - continue; - } - - if let Some(code) = line.strip_prefix('+') { - use std::fmt::Write; - let _ = writeln!(output, "{filename}:{new_line}:{code}"); - new_line += 1; - } - } - - output -} - -fn parse_hunk_new_start(hunk_header: &str) -> Option { - let plus_part = hunk_header.split('+').nth(1)?; - let num_str = plus_part.split(|c: char| !c.is_ascii_digit()).next()?; - num_str.parse::().ok() -} - -#[cfg(test)] -#[test] -fn test_format_diff_for_review() { - let diff = concat!( - "diff --git a/src/foo.rs b/src/foo.rs\n", - "--- a/src/foo.rs\n", - "+++ b/src/foo.rs\n", - "@@ -1,3 +1,6 @@\n", - " fn main() {\n", - "+ let x = 1;\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", - ); - - let result = format_diff_for_review(diff); - let expected = concat!( - "src/foo.rs:2: let x = 1;\n", - "src/foo.rs:4: let y = 2;\n", - "src/foo.rs:5: let z = 3;\n", - "src/bar.rs:11: let a = 10;\n", - "src/bar.rs:13: let b = 20;\n", - ); - - assert_eq!(result, expected); -} diff --git a/src/consts.rs b/src/consts.rs deleted file mode 100644 index 9a38974..0000000 --- a/src/consts.rs +++ /dev/null @@ -1,44 +0,0 @@ -pub const GITEA_SIG_HEADER_NAME: &str = "x-gitea-signature"; -pub const GITEA_EVENT_TYPE_HEADER_NAME: &str = "x-gitea-event-type"; - -pub const MAX_WEBHOOK_BODY_SIZE: usize = 1024 * 1024; // 1 MiB -pub const MAX_DIFF_SIZE: usize = 1024 * 1024; // 1 MiB - -pub const BOT_PROCESS_MSG: &str = " - Review in progress with the model \"{model}\"... -"; - -pub const REVIEW_PROMPT: &str = " - You are a senior software engineer reviewing code changes. - - Check good practices and code quality. - - This is the pull request subject: \"{subject}\" - - This is the user comment: \"{comment}\" - - The code changes (only added lines, with line numbers): - - {diff} - - Please review the code changes and provide feedback. - - IMPORTANT: the `line` field must be the line number shown before each line. - The provided code has the format: `filename:line:code` - - Return your feedback, in french, with only this json format, reviews must contain each review - All fields are mandatory. - (filename field must contain the full path with extension) and comment must contain a final summary: - - { - \"reviews\": [ - { - \"filename\": \"\", - \"line\": , - \"code\": \"\", - \"message\": \"\" - } - ], - \"comment\": \"\" - } -"; diff --git a/src/open_router.rs b/src/open_router.rs deleted file mode 100644 index 4320511..0000000 --- a/src/open_router.rs +++ /dev/null @@ -1,50 +0,0 @@ -use std::time::Duration; - -use openrouter_rs::{Message, api::chat::ChatCompletionRequest}; -use tracing::instrument; - -pub struct ChatResult { - pub message: String, - pub cost: Option, -} - -#[derive(Clone)] -pub struct OpenRouterClient { - client: openrouter_rs::OpenRouterClient, - model: String, -} - -impl OpenRouterClient { - pub fn new(token: &str, model: &str, timeout: u64) -> anyhow::Result { - Ok(Self { - client: openrouter_rs::OpenRouterClient::builder() - .api_key(token) - .http_client( - reqwest::Client::builder() - .timeout(Duration::from_secs(timeout)) - .build()?, - ) - .build()?, - model: String::from(model), - }) - } - - #[instrument(skip(self), err)] - pub async fn chat(&self, msg: &str) -> anyhow::Result { - let request = ChatCompletionRequest::builder() - .model(&self.model) - .enable_reasoning() - .messages(vec![Message::new(openrouter_rs::types::Role::User, msg)]) - .build()?; - - let response = self.client.chat().create(&request).await?; - - Ok(ChatResult { - message: response.choices[0] - .content() - .map(String::from) - .ok_or(anyhow::anyhow!("No content"))?, - cost: response.usage.and_then(|u| u.cost), - }) - } -}