Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78ad2bf701 | ||
|
|
04cc172848 |
+1
-3
@@ -1,5 +1,4 @@
|
|||||||
HTTP_PORT=3000
|
HTTP_PORT=3000
|
||||||
BOT_NAME=Herald
|
|
||||||
|
|
||||||
WEBHOOK_SIG_HEADER_SECRET=
|
WEBHOOK_SIG_HEADER_SECRET=
|
||||||
|
|
||||||
@@ -20,7 +19,6 @@ RUST_BACKTRACE=1
|
|||||||
|
|
||||||
METRICS_BIND_ADDR=
|
METRICS_BIND_ADDR=
|
||||||
|
|
||||||
# Sandboxed tool execution (optional)
|
# Sandboxed tool execution
|
||||||
SANDBOX_ENABLED=false
|
|
||||||
CONTAINER_RUNTIME=docker
|
CONTAINER_RUNTIME=docker
|
||||||
SANDBOX_MAX_ITERATIONS=8
|
SANDBOX_MAX_ITERATIONS=8
|
||||||
Generated
+25
-532
@@ -338,17 +338,6 @@ version = "0.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "chacha20"
|
|
||||||
version = "0.10.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
|
|
||||||
dependencies = [
|
|
||||||
"cfg-if",
|
|
||||||
"cpufeatures",
|
|
||||||
"rand_core 0.10.1",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "convert_case"
|
name = "convert_case"
|
||||||
version = "0.10.0"
|
version = "0.10.0"
|
||||||
@@ -374,15 +363,6 @@ version = "0.8.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "cpufeatures"
|
|
||||||
version = "0.3.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
|
|
||||||
dependencies = [
|
|
||||||
"libc",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crossbeam-epoch"
|
name = "crossbeam-epoch"
|
||||||
version = "0.9.20"
|
version = "0.9.20"
|
||||||
@@ -398,41 +378,6 @@ version = "0.8.22"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
|
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.119",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[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.119",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "debugid"
|
name = "debugid"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
@@ -459,37 +404,6 @@ version = "0.5.8"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||||
|
|
||||||
[[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.119",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[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.119",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "derive_more"
|
name = "derive_more"
|
||||||
version = "2.1.1"
|
version = "2.1.1"
|
||||||
@@ -521,7 +435,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror 2.0.20",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -552,24 +466,6 @@ version = "0.15.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
|
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]]
|
[[package]]
|
||||||
name = "encoding_rs"
|
name = "encoding_rs"
|
||||||
version = "0.8.35"
|
version = "0.8.35"
|
||||||
@@ -592,7 +488,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -749,10 +645,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"js-sys",
|
|
||||||
"libc",
|
"libc",
|
||||||
"wasi",
|
"wasi",
|
||||||
"wasm-bindgen",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -763,24 +657,10 @@ checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi 5.3.0",
|
"r-efi",
|
||||||
"wasip2",
|
"wasip2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "getrandom"
|
|
||||||
version = "0.4.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
|
||||||
dependencies = [
|
|
||||||
"cfg-if",
|
|
||||||
"js-sys",
|
|
||||||
"libc",
|
|
||||||
"r-efi 6.0.0",
|
|
||||||
"rand_core 0.10.1",
|
|
||||||
"wasm-bindgen",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gimli"
|
name = "gimli"
|
||||||
version = "0.32.3"
|
version = "0.32.3"
|
||||||
@@ -833,24 +713,21 @@ version = "1.2.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
"bytes",
|
|
||||||
"devcontainer-rs",
|
"devcontainer-rs",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"hex",
|
"hex",
|
||||||
"metrics",
|
"metrics",
|
||||||
"metrics-exporter-prometheus",
|
"metrics-exporter-prometheus",
|
||||||
"openrouter-rs",
|
"reqwest",
|
||||||
"reqwest 0.12.28",
|
|
||||||
"ring",
|
"ring",
|
||||||
"sentry",
|
"sentry",
|
||||||
"sentry-anyhow",
|
"sentry-anyhow",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror 2.0.20",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-stream",
|
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-http 0.7.1",
|
"tower-http 0.7.1",
|
||||||
@@ -953,22 +830,6 @@ dependencies = [
|
|||||||
"want",
|
"want",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "hyper-rustls"
|
|
||||||
version = "0.27.9"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
|
|
||||||
dependencies = [
|
|
||||||
"http 1.5.0",
|
|
||||||
"hyper",
|
|
||||||
"hyper-util",
|
|
||||||
"rustls",
|
|
||||||
"tokio",
|
|
||||||
"tokio-rustls",
|
|
||||||
"tower-service",
|
|
||||||
"webpki-roots",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper-tls"
|
name = "hyper-tls"
|
||||||
version = "0.6.0"
|
version = "0.6.0"
|
||||||
@@ -1091,12 +952,6 @@ dependencies = [
|
|||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ident_case"
|
|
||||||
version = "1.0.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "idna"
|
name = "idna"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
@@ -1232,12 +1087,6 @@ dependencies = [
|
|||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "lru-slab"
|
|
||||||
version = "0.1.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "matchers"
|
name = "matchers"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -1285,7 +1134,7 @@ dependencies = [
|
|||||||
"metrics",
|
"metrics",
|
||||||
"metrics-util",
|
"metrics-util",
|
||||||
"quanta",
|
"quanta",
|
||||||
"thiserror 2.0.20",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
@@ -1301,7 +1150,7 @@ dependencies = [
|
|||||||
"hashbrown 0.16.1",
|
"hashbrown 0.16.1",
|
||||||
"metrics",
|
"metrics",
|
||||||
"quanta",
|
"quanta",
|
||||||
"rand 0.9.5",
|
"rand",
|
||||||
"rand_xoshiro",
|
"rand_xoshiro",
|
||||||
"rapidhash",
|
"rapidhash",
|
||||||
"sketches-ddsketch",
|
"sketches-ddsketch",
|
||||||
@@ -1313,16 +1162,6 @@ version = "0.3.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
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]]
|
[[package]]
|
||||||
name = "miniz_oxide"
|
name = "miniz_oxide"
|
||||||
version = "0.8.9"
|
version = "0.8.9"
|
||||||
@@ -1562,26 +1401,6 @@ version = "1.21.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "openrouter-rs"
|
|
||||||
version = "0.14.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "042920aeb3955ae7c6c26118c646f9723c4ba39e65ceb663513c6973ebfc21dc"
|
|
||||||
dependencies = [
|
|
||||||
"derive_builder",
|
|
||||||
"dotenvy_macro",
|
|
||||||
"futures-util",
|
|
||||||
"http 1.5.0",
|
|
||||||
"reqwest 0.12.28",
|
|
||||||
"schemars",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"thiserror 1.0.69",
|
|
||||||
"tokio",
|
|
||||||
"tokio-util",
|
|
||||||
"urlencoding",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openssl"
|
name = "openssl"
|
||||||
version = "0.10.81"
|
version = "0.10.81"
|
||||||
@@ -1765,62 +1584,6 @@ dependencies = [
|
|||||||
"winapi",
|
"winapi",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "quinn"
|
|
||||||
version = "0.11.11"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
|
|
||||||
dependencies = [
|
|
||||||
"bytes",
|
|
||||||
"cfg_aliases",
|
|
||||||
"pin-project-lite",
|
|
||||||
"quinn-proto",
|
|
||||||
"quinn-udp",
|
|
||||||
"rustc-hash",
|
|
||||||
"rustls",
|
|
||||||
"socket2",
|
|
||||||
"thiserror 2.0.20",
|
|
||||||
"tokio",
|
|
||||||
"tracing",
|
|
||||||
"web-time",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "quinn-proto"
|
|
||||||
version = "0.11.17"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83"
|
|
||||||
dependencies = [
|
|
||||||
"bytes",
|
|
||||||
"getrandom 0.4.3",
|
|
||||||
"lru-slab",
|
|
||||||
"rand 0.10.2",
|
|
||||||
"rand_pcg",
|
|
||||||
"ring",
|
|
||||||
"rustc-hash",
|
|
||||||
"rustls",
|
|
||||||
"rustls-pki-types",
|
|
||||||
"slab",
|
|
||||||
"thiserror 2.0.20",
|
|
||||||
"tinyvec",
|
|
||||||
"tracing",
|
|
||||||
"web-time",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "quinn-udp"
|
|
||||||
version = "0.5.15"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
|
|
||||||
dependencies = [
|
|
||||||
"cfg_aliases",
|
|
||||||
"libc",
|
|
||||||
"once_cell",
|
|
||||||
"socket2",
|
|
||||||
"tracing",
|
|
||||||
"windows-sys 0.61.2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.47"
|
version = "1.0.47"
|
||||||
@@ -1836,12 +1599,6 @@ version = "5.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "r-efi"
|
|
||||||
version = "6.0.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand"
|
name = "rand"
|
||||||
version = "0.9.5"
|
version = "0.9.5"
|
||||||
@@ -1849,18 +1606,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
|
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"rand_chacha",
|
"rand_chacha",
|
||||||
"rand_core 0.9.5",
|
"rand_core",
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rand"
|
|
||||||
version = "0.10.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
|
|
||||||
dependencies = [
|
|
||||||
"chacha20",
|
|
||||||
"getrandom 0.4.3",
|
|
||||||
"rand_core 0.10.1",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1870,7 +1616,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ppv-lite86",
|
"ppv-lite86",
|
||||||
"rand_core 0.9.5",
|
"rand_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1882,28 +1628,13 @@ dependencies = [
|
|||||||
"getrandom 0.3.4",
|
"getrandom 0.3.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rand_core"
|
|
||||||
version = "0.10.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rand_pcg"
|
|
||||||
version = "0.10.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
|
|
||||||
dependencies = [
|
|
||||||
"rand_core 0.10.1",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand_xoshiro"
|
name = "rand_xoshiro"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f703f4665700daf5512dcca5f43afa6af89f09db47fb56be587f80636bda2d41"
|
checksum = "f703f4665700daf5512dcca5f43afa6af89f09db47fb56be587f80636bda2d41"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"rand_core 0.9.5",
|
"rand_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1933,26 +1664,6 @@ dependencies = [
|
|||||||
"bitflags",
|
"bitflags",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ref-cast"
|
|
||||||
version = "1.0.27"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3"
|
|
||||||
dependencies = [
|
|
||||||
"ref-cast-impl",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ref-cast-impl"
|
|
||||||
version = "1.0.27"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 3.0.4",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "regex"
|
name = "regex"
|
||||||
version = "1.13.1"
|
version = "1.13.1"
|
||||||
@@ -1990,53 +1701,11 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "reqwest"
|
name = "reqwest"
|
||||||
version = "0.12.28"
|
version = "0.13.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.23.1",
|
||||||
"bytes",
|
|
||||||
"futures-core",
|
|
||||||
"futures-util",
|
|
||||||
"http 1.5.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 0.6.11",
|
|
||||||
"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 0.22.1",
|
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
@@ -2058,12 +1727,14 @@ dependencies = [
|
|||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-native-tls",
|
"tokio-native-tls",
|
||||||
|
"tokio-util",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-http 0.6.11",
|
"tower-http 0.6.11",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"url",
|
"url",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
|
"wasm-streams",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2087,12 +1758,6 @@ version = "0.1.28"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb"
|
checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rustc-hash"
|
|
||||||
version = "2.1.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc_version"
|
name = "rustc_version"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
@@ -2112,21 +1777,7 @@ dependencies = [
|
|||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys",
|
"linux-raw-sys",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.52.0",
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rustls"
|
|
||||||
version = "0.23.43"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
|
|
||||||
dependencies = [
|
|
||||||
"once_cell",
|
|
||||||
"ring",
|
|
||||||
"rustls-pki-types",
|
|
||||||
"rustls-webpki",
|
|
||||||
"subtle",
|
|
||||||
"zeroize",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2135,21 +1786,9 @@ version = "1.15.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"web-time",
|
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rustls-webpki"
|
|
||||||
version = "0.103.15"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
|
|
||||||
dependencies = [
|
|
||||||
"ring",
|
|
||||||
"rustls-pki-types",
|
|
||||||
"untrusted",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustversion"
|
name = "rustversion"
|
||||||
version = "1.0.23"
|
version = "1.0.23"
|
||||||
@@ -2171,31 +1810,6 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "schemars"
|
|
||||||
version = "1.2.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a"
|
|
||||||
dependencies = [
|
|
||||||
"dyn-clone",
|
|
||||||
"ref-cast",
|
|
||||||
"schemars_derive",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "schemars_derive"
|
|
||||||
version = "1.2.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"serde_derive_internals",
|
|
||||||
"syn 3.0.4",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "scoped-tls"
|
name = "scoped-tls"
|
||||||
version = "1.0.1"
|
version = "1.0.1"
|
||||||
@@ -2246,7 +1860,7 @@ dependencies = [
|
|||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
"httpdate",
|
"httpdate",
|
||||||
"native-tls",
|
"native-tls",
|
||||||
"reqwest 0.13.4",
|
"reqwest",
|
||||||
"sentry-actix",
|
"sentry-actix",
|
||||||
"sentry-backtrace",
|
"sentry-backtrace",
|
||||||
"sentry-contexts",
|
"sentry-contexts",
|
||||||
@@ -2315,7 +1929,7 @@ version = "0.49.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d889520a375e5b93efb0a66def1630d21d168b0251eb55b286b03fa940ccfc84"
|
checksum = "d889520a375e5b93efb0a66def1630d21d168b0251eb55b286b03fa940ccfc84"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"rand 0.9.5",
|
"rand",
|
||||||
"sentry-types",
|
"sentry-types",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -2389,10 +2003,10 @@ checksum = "fc71f5ca55942d9b2901af95d5df5c5d65c164134c22a83682cac3d0b6c7ef2d"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"debugid",
|
"debugid",
|
||||||
"hex",
|
"hex",
|
||||||
"rand 0.9.5",
|
"rand",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror 2.0.20",
|
"thiserror",
|
||||||
"time",
|
"time",
|
||||||
"url",
|
"url",
|
||||||
"uuid",
|
"uuid",
|
||||||
@@ -2428,17 +2042,6 @@ dependencies = [
|
|||||||
"syn 3.0.4",
|
"syn 3.0.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "serde_derive_internals"
|
|
||||||
version = "0.30.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 3.0.4",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_json"
|
name = "serde_json"
|
||||||
version = "1.0.151"
|
version = "1.0.151"
|
||||||
@@ -2534,29 +2137,6 @@ version = "1.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
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"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"unicode-ident",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "2.0.119"
|
version = "2.0.119"
|
||||||
@@ -2606,19 +2186,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastrand",
|
"fastrand",
|
||||||
"getrandom 0.4.3",
|
"getrandom 0.3.4",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix",
|
"rustix",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.52.0",
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "thiserror"
|
|
||||||
version = "1.0.69"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
|
||||||
dependencies = [
|
|
||||||
"thiserror-impl 1.0.69",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2627,18 +2198,7 @@ version = "2.0.20"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
|
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror-impl 2.0.20",
|
"thiserror-impl",
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "thiserror-impl"
|
|
||||||
version = "1.0.69"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 2.0.119",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2701,21 +2261,6 @@ dependencies = [
|
|||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tinyvec"
|
|
||||||
version = "1.12.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f"
|
|
||||||
dependencies = [
|
|
||||||
"tinyvec_macros",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tinyvec_macros"
|
|
||||||
version = "0.1.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio"
|
name = "tokio"
|
||||||
version = "1.53.1"
|
version = "1.53.1"
|
||||||
@@ -2754,27 +2299,6 @@ dependencies = [
|
|||||||
"tokio",
|
"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.19"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b"
|
|
||||||
dependencies = [
|
|
||||||
"futures-core",
|
|
||||||
"pin-project-lite",
|
|
||||||
"tokio",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-util"
|
name = "tokio-util"
|
||||||
version = "0.7.19"
|
version = "0.7.19"
|
||||||
@@ -2929,12 +2453,6 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "unicase"
|
|
||||||
version = "2.9.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
version = "1.0.24"
|
version = "1.0.24"
|
||||||
@@ -3001,12 +2519,6 @@ dependencies = [
|
|||||||
"serde_derive",
|
"serde_derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "urlencoding"
|
|
||||||
version = "2.1.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "utf8-zero"
|
name = "utf8-zero"
|
||||||
version = "0.8.1"
|
version = "0.8.1"
|
||||||
@@ -3123,9 +2635,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-streams"
|
name = "wasm-streams"
|
||||||
version = "0.4.2"
|
version = "0.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
|
checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
@@ -3144,16 +2656,6 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "web-time"
|
|
||||||
version = "1.1.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
|
|
||||||
dependencies = [
|
|
||||||
"js-sys",
|
|
||||||
"wasm-bindgen",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "webpki-root-certs"
|
name = "webpki-root-certs"
|
||||||
version = "1.0.9"
|
version = "1.0.9"
|
||||||
@@ -3163,15 +2665,6 @@ dependencies = [
|
|||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "webpki-roots"
|
|
||||||
version = "1.0.9"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
|
|
||||||
dependencies = [
|
|
||||||
"rustls-pki-types",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winapi"
|
name = "winapi"
|
||||||
version = "0.3.9"
|
version = "0.3.9"
|
||||||
|
|||||||
+1
-4
@@ -6,16 +6,14 @@ members = [
|
|||||||
resolver = "3"
|
resolver = "3"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
reqwest = { version = "0.13", default-features = false, features = ["json", "native-tls", "stream"] }
|
||||||
tokio = { version = "1.53", features = ["full"] }
|
tokio = { version = "1.53", features = ["full"] }
|
||||||
tokio-stream = "0.1"
|
|
||||||
tokio-util = "0.7"
|
tokio-util = "0.7"
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
sentry = { version = "0.49", features = ["tower-axum-matched-path"] }
|
sentry = { version = "0.49", features = ["tower-axum-matched-path"] }
|
||||||
sentry-anyhow = { version = "0.49", features = ["backtrace"] }
|
sentry-anyhow = { version = "0.49", features = ["backtrace"] }
|
||||||
openrouter-rs = "0.14"
|
|
||||||
dotenvy = "0.15"
|
dotenvy = "0.15"
|
||||||
tower = "0.5"
|
tower = "0.5"
|
||||||
tower-http = { version = "0.7", features = ["trace"] }
|
tower-http = { version = "0.7", features = ["trace"] }
|
||||||
@@ -26,7 +24,6 @@ anyhow = { version = "1", features = ["backtrace"] }
|
|||||||
thiserror = "2.0"
|
thiserror = "2.0"
|
||||||
ring = "0.17"
|
ring = "0.17"
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
bytes = "1.1"
|
|
||||||
metrics = "0.24"
|
metrics = "0.24"
|
||||||
metrics-exporter-prometheus = { version = "0.18", default-features = false, features = ["http-listener"] }
|
metrics-exporter-prometheus = { version = "0.18", default-features = false, features = ["http-listener"] }
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ Herald reads its configuration from environment variables (a `.env` file is supp
|
|||||||
| Variable | Description |
|
| Variable | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `HTTP_PORT` | Port to listen on |
|
| `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 |
|
| `WEBHOOK_SIG_HEADER_SECRET` | Gitea webhook secret for signature verification |
|
||||||
| `OPEN_ROUTER_API_KEY` | OpenRouter API key |
|
| `OPEN_ROUTER_API_KEY` | OpenRouter API key |
|
||||||
| `OPEN_ROUTER_MODEL` | Model to use (e.g. `deepseek/deepseek-v4-flash`) |
|
| `OPEN_ROUTER_MODEL` | Model to use (e.g. `deepseek/deepseek-v4-flash`) |
|
||||||
@@ -38,20 +37,24 @@ 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. |
|
| `METRICS_BIND_ADDR` | *(optional)* Bind address for the Prometheus metrics endpoint (e.g. `0.0.0.0:9100`). If unset, the metrics exporter is disabled. |
|
||||||
| `SENTRY_DSN` | *(optional)* Sentry DSN for error tracking |
|
| `SENTRY_DSN` | *(optional)* Sentry DSN for error tracking |
|
||||||
| `RUST_LOG` | *(optional)* Log level, defaults to `info` |
|
| `RUST_LOG` | *(optional)* Log level, defaults to `info` |
|
||||||
| `SANDBOX_ENABLED` | *(optional)* Run reviews inside a devcontainer sandbox so the model can explore the repository with tools. Defaults to `false` |
|
|
||||||
| `CONTAINER_RUNTIME` | *(optional)* Container runtime binary used for the sandbox (`docker` or `podman`). Defaults to `docker` |
|
| `CONTAINER_RUNTIME` | *(optional)* Container runtime binary used for the sandbox (`docker` or `podman`). Defaults to `docker` |
|
||||||
| `SANDBOX_MAX_ITERATIONS` | *(optional)* Maximum number of tool-calling iterations per sandboxed review. Defaults to `8` |
|
| `SANDBOX_MAX_ITERATIONS` | *(optional)* Maximum number of tool-calling iterations per sandboxed review. Defaults to `8` |
|
||||||
|
|
||||||
## Sandboxed reviews
|
## Sandboxed reviews
|
||||||
|
|
||||||
When `SANDBOX_ENABLED=true`, Herald reviews pull requests inside an ephemeral
|
Herald reviews pull requests inside an ephemeral
|
||||||
[Dev Container](https://containers.dev/). For each review it:
|
[Dev Container](https://containers.dev/). For each review it:
|
||||||
|
|
||||||
1. clones the pull request head into a temporary directory,
|
1. clones the pull request head into a temporary directory,
|
||||||
2. builds and starts the repository's devcontainer (`devcontainer-rs`),
|
2. builds and starts the repository's devcontainer (`devcontainer-rs`),
|
||||||
3. lets the model explore the repository with read-only tools (`ls`, `read_file`,
|
3. reads the pull request diff and file list from the Gitea API with
|
||||||
`grep`, `find`) executed inside the container,
|
`GITEA_TOKEN` (so private repositories work), tells the model which files and
|
||||||
4. posts the review and removes the container and the temporary clone.
|
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`, `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.
|
||||||
|
|
||||||
The container runtime is selected with `CONTAINER_RUNTIME` (`docker` or
|
The container runtime is selected with `CONTAINER_RUNTIME` (`docker` or
|
||||||
`podman`). The repository must contain a `.devcontainer/devcontainer.json`.
|
`podman`). The repository must contain a `.devcontainer/devcontainer.json`.
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
//! Container lifecycle primitives for a parsed [`DevContainer`].
|
//! Primitives de cycle de vie de container pour un [`DevContainer`] analysé.
|
||||||
//!
|
//!
|
||||||
//! This module shells out to a container runtime (`docker` or `podman`) to build
|
//! Ce module invoque un runtime de containers (`docker` ou `podman`) pour construire
|
||||||
//! the devcontainer image, start a container with the workspace mounted, run the
|
//! l'image devcontainer, démarrer un container avec le workspace monté, exécuter les
|
||||||
//! `postCreateCommand` / `postStartCommand` hooks and execute commands inside the
|
//! hooks `postCreateCommand` / `postStartCommand` et lancer des commandes à l'intérieur
|
||||||
//! running container.
|
//! du container en cours d'exécution.
|
||||||
//!
|
//!
|
||||||
//! It is intentionally runtime-agnostic: any binary exposing the `docker` CLI
|
//! Il est volontairement agnostique du runtime : tout binaire exposant l'interface
|
||||||
//! surface (including `podman`) can be used via [`ContainerRuntime::new`].
|
//! CLI `docker` (y compris `podman`) peut être utilisé via [`ContainerRuntime::new`].
|
||||||
//!
|
//!
|
||||||
//! # Isolation
|
//! # Isolation
|
||||||
//!
|
//!
|
||||||
//! Each sandbox gets its own image tag, its own container and its own network.
|
//! Chaque sandbox dispose de son propre tag d'image, de son propre container et de
|
||||||
//! The container starts attached to that network so the `postCreateCommand` /
|
//! son propre réseau. Le container démarre attaché à ce réseau afin que les hooks
|
||||||
//! `postStartCommand` hooks can fetch dependencies (e.g. `npm install`); once the
|
//! `postCreateCommand` / `postStartCommand` puissent récupérer des dépendances
|
||||||
//! hooks have run, the container is disconnected from the network for the rest of
|
//! (par ex. `npm install`) ; une fois les hooks exécutés, le container est déconnecté
|
||||||
//! its lifetime. Every command is bounded by a timeout.
|
//! du réseau pour le reste de sa durée de vie. Chaque commande est bornée par un timeout.
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
@@ -26,16 +26,16 @@ use tokio::process::Command;
|
|||||||
|
|
||||||
use crate::DevContainer;
|
use crate::DevContainer;
|
||||||
|
|
||||||
/// Timeout applied to build/run/stop/remove operations.
|
/// Timeout appliqué aux opérations de build/run/stop/remove.
|
||||||
const DEFAULT_COMMAND_TIMEOUT: Duration = Duration::from_secs(600);
|
const DEFAULT_COMMAND_TIMEOUT: Duration = Duration::from_secs(600);
|
||||||
|
|
||||||
/// Timeout applied to commands executed inside a running container.
|
/// Timeout appliqué aux commandes exécutées dans un container en cours d'exécution.
|
||||||
const DEFAULT_EXEC_TIMEOUT: Duration = Duration::from_secs(60);
|
const DEFAULT_EXEC_TIMEOUT: Duration = Duration::from_secs(60);
|
||||||
|
|
||||||
/// Result of a command executed inside a container.
|
/// Résultat d'une commande exécutée dans un container.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ExecOutput {
|
pub struct ExecOutput {
|
||||||
/// Exit code, or `-1` if the process was terminated by a signal.
|
/// Code de sortie, ou `-1` si le processus a été terminé par un signal.
|
||||||
pub status: i32,
|
pub status: i32,
|
||||||
pub stdout: String,
|
pub stdout: String,
|
||||||
pub stderr: String,
|
pub stderr: String,
|
||||||
@@ -46,7 +46,7 @@ impl ExecOutput {
|
|||||||
self.status == 0
|
self.status == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Turns a non-zero exit code into a [`ContainerError::Command`].
|
/// Transforme un code de sortie non nul en [`ContainerError::Command`].
|
||||||
pub fn ensure_success(self, program: &str, args: &[String]) -> Result<Self, ContainerError> {
|
pub fn ensure_success(self, program: &str, args: &[String]) -> Result<Self, ContainerError> {
|
||||||
if self.success() {
|
if self.success() {
|
||||||
return Ok(self);
|
return Ok(self);
|
||||||
@@ -85,7 +85,7 @@ pub enum ContainerError {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A container runtime binary exposing the `docker` CLI surface.
|
/// Un binaire de runtime de containers exposant l'interface CLI `docker`.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ContainerRuntime {
|
pub struct ContainerRuntime {
|
||||||
program: String,
|
program: String,
|
||||||
@@ -108,7 +108,7 @@ impl ContainerRuntime {
|
|||||||
Self::new("podman")
|
Self::new("podman")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Overrides the timeout applied to build/run/stop/remove operations.
|
/// Remplace le timeout appliqué aux opérations de build/run/stop/remove.
|
||||||
pub fn with_timeout(mut self, timeout: Duration) -> Self {
|
pub fn with_timeout(mut self, timeout: Duration) -> Self {
|
||||||
self.timeout = timeout;
|
self.timeout = timeout;
|
||||||
self
|
self
|
||||||
@@ -122,7 +122,7 @@ impl ContainerRuntime {
|
|||||||
self.timeout
|
self.timeout
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Checks that the runtime binary is present and responsive.
|
/// Vérifie que le binaire du runtime est présent et répond.
|
||||||
pub async fn available(&self) -> bool {
|
pub async fn available(&self) -> bool {
|
||||||
Command::new(&self.program)
|
Command::new(&self.program)
|
||||||
.arg("version")
|
.arg("version")
|
||||||
@@ -135,15 +135,15 @@ impl ContainerRuntime {
|
|||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Runs the runtime with the given arguments, capturing stdout/stderr.
|
/// Exécute le runtime avec les arguments donnés, en capturant stdout/stderr.
|
||||||
///
|
///
|
||||||
/// Only spawn failures and timeouts are reported as errors; a non-zero exit
|
/// Seuls les échecs de lancement (spawn) et les timeouts sont des erreurs ; un
|
||||||
/// code is returned in the [`ExecOutput`] so callers can decide how to react.
|
/// code non nul est renvoyé dans [`ExecOutput`] afin que les appelants réagissent.
|
||||||
pub async fn run(&self, args: &[String]) -> Result<ExecOutput, ContainerError> {
|
pub async fn run(&self, args: &[String]) -> Result<ExecOutput, ContainerError> {
|
||||||
self.run_with_timeout(args, self.timeout).await
|
self.run_with_timeout(args, self.timeout).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Like [`run`](Self::run) with an explicit timeout.
|
/// Comme [`run`](Self::run), mais avec un timeout explicite.
|
||||||
pub async fn run_with_timeout(
|
pub async fn run_with_timeout(
|
||||||
&self,
|
&self,
|
||||||
args: &[String],
|
args: &[String],
|
||||||
@@ -174,7 +174,7 @@ impl ContainerRuntime {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A running devcontainer.
|
/// Un devcontainer en cours d'exécution.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Container {
|
pub struct Container {
|
||||||
runtime: ContainerRuntime,
|
runtime: ContainerRuntime,
|
||||||
@@ -194,10 +194,10 @@ impl Container {
|
|||||||
&self.workspace_folder
|
&self.workspace_folder
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Executes a command inside the container, returning its output.
|
/// Exécute une commande dans le container et renvoie sa sortie.
|
||||||
///
|
///
|
||||||
/// The command is passed as an argv vector (no shell), so no quoting or
|
/// La commande est transmise sous forme de vecteur d'arguments (argv, sans shell),
|
||||||
/// interpolation is performed.
|
/// donc aucun échappement ni interpolation n'est effectué.
|
||||||
pub async fn exec(&self, cmd: &[&str]) -> Result<ExecOutput, ContainerError> {
|
pub async fn exec(&self, cmd: &[&str]) -> Result<ExecOutput, ContainerError> {
|
||||||
self.exec_with_timeout(cmd, DEFAULT_EXEC_TIMEOUT).await
|
self.exec_with_timeout(cmd, DEFAULT_EXEC_TIMEOUT).await
|
||||||
}
|
}
|
||||||
@@ -220,12 +220,12 @@ impl Container {
|
|||||||
self.runtime.run_with_timeout(&args, timeout).await
|
self.runtime.run_with_timeout(&args, timeout).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Executes a shell script inside the container via `sh -c`.
|
/// Exécute un script shell dans le container via `sh -c`.
|
||||||
pub async fn exec_shell(&self, script: &str) -> Result<ExecOutput, ContainerError> {
|
pub async fn exec_shell(&self, script: &str) -> Result<ExecOutput, ContainerError> {
|
||||||
self.exec(&["sh", "-c", script]).await
|
self.exec(&["sh", "-c", script]).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stops the container.
|
/// Arrête le container.
|
||||||
pub async fn stop(&self) -> Result<(), ContainerError> {
|
pub async fn stop(&self) -> Result<(), ContainerError> {
|
||||||
let args = vec!["stop".to_string(), self.name.clone()];
|
let args = vec!["stop".to_string(), self.name.clone()];
|
||||||
self.runtime
|
self.runtime
|
||||||
@@ -235,9 +235,9 @@ impl Container {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Removes the container, its network and its image.
|
/// Supprime le container, son réseau et son image.
|
||||||
///
|
///
|
||||||
/// Network and image removal are best-effort: they may already be gone.
|
/// 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> {
|
pub async fn remove(&self) -> Result<(), ContainerError> {
|
||||||
let args = vec![
|
let args = vec![
|
||||||
"rm".to_string(),
|
"rm".to_string(),
|
||||||
@@ -265,21 +265,21 @@ impl Container {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl DevContainer {
|
impl DevContainer {
|
||||||
/// Base image name derived from the devcontainer name.
|
/// Nom de l'image de base dérivé du nom du devcontainer.
|
||||||
pub fn image_name(&self) -> String {
|
pub fn image_name(&self) -> String {
|
||||||
let base = self.name.as_deref().unwrap_or("devcontainer");
|
let base = self.name.as_deref().unwrap_or("devcontainer");
|
||||||
format!("devcontainer-rs/{}", sanitize(base))
|
format!("devcontainer-rs/{}", sanitize(base))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Unique image tag for a single sandbox run.
|
/// Tag d'image unique pour une exécution de sandbox donnée.
|
||||||
///
|
///
|
||||||
/// Uniqueness matters: two concurrent sandboxes (possibly for different
|
/// L'unicité est importante : deux sandboxes concurrentes (éventuellement pour des
|
||||||
/// repositories sharing a devcontainer name) must not race on a shared tag.
|
/// 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 {
|
pub fn image_tag(&self) -> String {
|
||||||
format!("{}:{}", self.image_name(), unique_suffix())
|
format!("{}:{}", self.image_name(), unique_suffix())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Arguments passed to `docker build` (everything after the `build` verb).
|
/// Arguments passés à `docker build` (tout ce qui suit le verbe `build`).
|
||||||
pub fn build_args(&self, image_tag: &str) -> Vec<String> {
|
pub fn build_args(&self, image_tag: &str) -> Vec<String> {
|
||||||
let context = self
|
let context = self
|
||||||
.container_file_path
|
.container_file_path
|
||||||
@@ -302,7 +302,7 @@ impl DevContainer {
|
|||||||
args
|
args
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Arguments passed to `docker run` (everything after the `run` verb).
|
/// Arguments passés à `docker run` (tout ce qui suit le verbe `run`).
|
||||||
pub fn run_args(
|
pub fn run_args(
|
||||||
&self,
|
&self,
|
||||||
workspace_dir: &Path,
|
workspace_dir: &Path,
|
||||||
@@ -340,27 +340,27 @@ impl DevContainer {
|
|||||||
args.extend(self.run_args.iter().cloned());
|
args.extend(self.run_args.iter().cloned());
|
||||||
|
|
||||||
args.push(image_tag.to_string());
|
args.push(image_tag.to_string());
|
||||||
// Keep the container alive so we can `exec` into it.
|
// Maintient le container en vie pour pouvoir y exécuter `exec`.
|
||||||
args.push("sleep".to_string());
|
args.push("sleep".to_string());
|
||||||
args.push("infinity".to_string());
|
args.push("infinity".to_string());
|
||||||
|
|
||||||
args
|
args
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Workspace folder inside the container, defaulting to `/workspaces/workspace`.
|
/// Dossier de workspace dans le container, par défaut `/workspaces/workspace`.
|
||||||
pub fn workspace_folder(&self) -> String {
|
pub fn workspace_folder(&self) -> String {
|
||||||
self.workspace_folder
|
self.workspace_folder
|
||||||
.clone()
|
.clone()
|
||||||
.unwrap_or_else(|| "/workspaces/workspace".to_string())
|
.unwrap_or_else(|| "/workspaces/workspace".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Unique container name for this run.
|
/// Nom de container unique pour cette exécution.
|
||||||
pub fn container_name(&self) -> String {
|
pub fn container_name(&self) -> String {
|
||||||
let base = self.name.as_deref().unwrap_or("devcontainer");
|
let base = self.name.as_deref().unwrap_or("devcontainer");
|
||||||
format!("devcontainer-rs-{}-{}", sanitize(base), unique_suffix())
|
format!("devcontainer-rs-{}-{}", sanitize(base), unique_suffix())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Builds the devcontainer image under `image_tag`.
|
/// Construit l'image devcontainer sous `image_tag`.
|
||||||
pub async fn build(
|
pub async fn build(
|
||||||
&self,
|
&self,
|
||||||
runtime: &ContainerRuntime,
|
runtime: &ContainerRuntime,
|
||||||
@@ -377,12 +377,12 @@ impl DevContainer {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Builds the image, starts the container with the workspace mounted, runs
|
/// Construit l'image, démarre le container avec le workspace monté, exécute les
|
||||||
/// the `postCreateCommand` / `postStartCommand` hooks with network access,
|
/// hooks `postCreateCommand` / `postStartCommand` avec accès au réseau, puis
|
||||||
/// then disconnects the container from the network.
|
/// déconnecte le container du réseau.
|
||||||
///
|
///
|
||||||
/// On any failure the container, network and image are cleaned up before
|
/// En cas d'échec, le container, le réseau et l'image sont nettoyés avant de
|
||||||
/// returning, so no resource is leaked.
|
/// renvoyer l'erreur, afin qu'aucune ressource ne soit laissée en place.
|
||||||
pub async fn up(
|
pub async fn up(
|
||||||
&self,
|
&self,
|
||||||
runtime: &ContainerRuntime,
|
runtime: &ContainerRuntime,
|
||||||
@@ -394,7 +394,7 @@ impl DevContainer {
|
|||||||
let name = self.container_name();
|
let name = self.container_name();
|
||||||
let network = format!("{name}-net");
|
let network = format!("{name}-net");
|
||||||
|
|
||||||
// Dedicated network so connectivity can be cut after the hooks.
|
// Réseau dédié afin de pouvoir couper la connectivité après les hooks.
|
||||||
let args = vec!["network".to_string(), "create".to_string(), network.clone()];
|
let args = vec!["network".to_string(), "create".to_string(), network.clone()];
|
||||||
if let Err(err) = runtime
|
if let Err(err) = runtime
|
||||||
.run(&args)
|
.run(&args)
|
||||||
@@ -429,13 +429,13 @@ impl DevContainer {
|
|||||||
image: Some(image_tag),
|
image: Some(image_tag),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Hooks run with network access (dependency installation, etc.).
|
// Les hooks s'exécutent avec accès au réseau (installation de dépendances, etc.).
|
||||||
if let Err(err) = self.run_hooks(&container).await {
|
if let Err(err) = self.run_hooks(&container).await {
|
||||||
let _ = container.remove().await;
|
let _ = container.remove().await;
|
||||||
return Err(err);
|
return Err(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cut network access for the rest of the sandbox lifetime.
|
// Coupe l'accès réseau pour le reste de la durée de vie de la sandbox.
|
||||||
let args = vec![
|
let args = vec![
|
||||||
"network".to_string(),
|
"network".to_string(),
|
||||||
"disconnect".to_string(),
|
"disconnect".to_string(),
|
||||||
@@ -455,8 +455,8 @@ impl DevContainer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn run_hooks(&self, container: &Container) -> Result<(), ContainerError> {
|
async fn run_hooks(&self, container: &Container) -> Result<(), ContainerError> {
|
||||||
// Hooks may install dependencies, so they get the long command timeout
|
// Les hooks peuvent installer des dépendances, ils utilisent donc le timeout
|
||||||
// rather than the short one used for tool execution.
|
// long des commandes plutôt que le court réservé à l'exécution des outils.
|
||||||
let timeout = container.runtime.timeout();
|
let timeout = container.runtime.timeout();
|
||||||
|
|
||||||
for command in [&self.post_create_command, &self.post_start_command]
|
for command in [&self.post_create_command, &self.post_start_command]
|
||||||
@@ -476,7 +476,7 @@ impl DevContainer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sanitizes a string so it can be used as a docker image/container name.
|
/// Nettoie une chaîne pour qu'elle puisse servir de nom d'image/container docker.
|
||||||
fn sanitize(input: &str) -> String {
|
fn sanitize(input: &str) -> String {
|
||||||
let sanitized: String = input
|
let sanitized: String = input
|
||||||
.chars()
|
.chars()
|
||||||
@@ -497,7 +497,7 @@ fn sanitize(input: &str) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Suffix unique to a sandbox run, combining the process id and a timestamp.
|
/// Suffixe unique à une exécution de sandbox, combinant l'identifiant de processus et un horodatage.
|
||||||
fn unique_suffix() -> String {
|
fn unique_suffix() -> String {
|
||||||
let nanos = SystemTime::now()
|
let nanos = SystemTime::now()
|
||||||
.duration_since(UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
@@ -507,8 +507,8 @@ fn unique_suffix() -> String {
|
|||||||
format!("{}-{}", std::process::id(), nanos)
|
format!("{}-{}", std::process::id(), nanos)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lexically normalizes a path, resolving `.` and `..` without touching the
|
/// Normalise lexicalement un chemin, en résolvant `.` et `..` sans toucher au
|
||||||
/// filesystem. Returns `None` if the path escapes its root.
|
/// système de fichiers. Renvoie `None` si le chemin sort de sa racine.
|
||||||
pub fn normalize(path: &Path) -> Option<PathBuf> {
|
pub fn normalize(path: &Path) -> Option<PathBuf> {
|
||||||
use std::path::Component;
|
use std::path::Component;
|
||||||
|
|
||||||
|
|||||||
@@ -119,9 +119,9 @@ impl TryFrom<(DevContainerSchema, PathBuf)> for DevContainer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolves `${localEnv:VAR}` and `${localEnv:VAR:default}` references using the
|
/// Résout les références `${localEnv:VAR}` et `${localEnv:VAR:default}` à l'aide de
|
||||||
/// current process environment, as described by the devcontainer specification.
|
/// l'environnement du processus courant, comme décrit par la spécification devcontainer.
|
||||||
/// Unresolved variables without a default expand to an empty string.
|
/// Les variables non résolues sans valeur par défaut sont remplacées par une chaîne vide.
|
||||||
fn substitute_local_env(input: &str) -> String {
|
fn substitute_local_env(input: &str) -> String {
|
||||||
const PREFIX: &str = "${localEnv:";
|
const PREFIX: &str = "${localEnv:";
|
||||||
|
|
||||||
|
|||||||
@@ -6,14 +6,12 @@ edition = "2024"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
reqwest = { workspace = true }
|
reqwest = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
tokio-stream = { workspace = true }
|
|
||||||
tokio-util = { workspace = true }
|
tokio-util = { workspace = true }
|
||||||
futures-util = { workspace = true }
|
futures-util = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
sentry = { workspace = true }
|
sentry = { workspace = true }
|
||||||
sentry-anyhow = { workspace = true }
|
sentry-anyhow = { workspace = true }
|
||||||
openrouter-rs = { workspace = true }
|
|
||||||
dotenvy = { workspace = true }
|
dotenvy = { workspace = true }
|
||||||
tower = { workspace = true }
|
tower = { workspace = true }
|
||||||
tower-http = { workspace = true }
|
tower-http = { workspace = true }
|
||||||
@@ -24,7 +22,6 @@ anyhow = { workspace = true }
|
|||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
ring = { workspace = true }
|
ring = { workspace = true }
|
||||||
hex = { workspace = true }
|
hex = { workspace = true }
|
||||||
bytes = { workspace = true }
|
|
||||||
metrics = { workspace = true }
|
metrics = { workspace = true }
|
||||||
metrics-exporter-prometheus = { workspace = true }
|
metrics-exporter-prometheus = { workspace = true }
|
||||||
devcontainer-rs = { path = "../devcontainer-rs" }
|
devcontainer-rs = { path = "../devcontainer-rs" }
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use crate::{
|
|||||||
open_router::OpenRouterClient,
|
open_router::OpenRouterClient,
|
||||||
sandbox::SandboxConfig,
|
sandbox::SandboxConfig,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::{Deserialize, Deserializer};
|
||||||
use std::{collections::HashSet, sync::Arc};
|
use std::{collections::HashSet, sync::Arc};
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
@@ -21,15 +21,52 @@ pub struct ReviewResult {
|
|||||||
pub struct ReviewItem {
|
pub struct ReviewItem {
|
||||||
pub filename: String,
|
pub filename: String,
|
||||||
pub line: Option<u64>,
|
pub line: Option<u64>,
|
||||||
|
#[serde(default, deserialize_with = "deserialize_side")]
|
||||||
|
pub side: Option<ReviewSide>,
|
||||||
pub message: String,
|
pub message: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Which version of the file a review comment is anchored on.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ReviewSide {
|
||||||
|
/// The line was added by the pull request: `line` is a line number of the
|
||||||
|
/// new version of the file.
|
||||||
|
Added,
|
||||||
|
/// The line was removed by the pull request: `line` is a line number of the
|
||||||
|
/// old version of the file.
|
||||||
|
Removed,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReviewSide {
|
||||||
|
/// Reads the side the model asked for, tolerating casing and synonyms.
|
||||||
|
fn parse(raw: &str) -> Option<Self> {
|
||||||
|
match raw.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"added" | "add" | "new" | "right" => Some(Self::Added),
|
||||||
|
"removed" | "remove" | "deleted" | "delete" | "old" | "left" => Some(Self::Removed),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads the side the model asked for. An unreadable value is ignored instead of
|
||||||
|
/// failing the whole review: the side is then resolved from the changed lines.
|
||||||
|
fn deserialize_side<'de, D>(deserializer: D) -> Result<Option<ReviewSide>, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let raw = Option::<serde_json::Value>::deserialize(deserializer)?;
|
||||||
|
|
||||||
|
Ok(raw
|
||||||
|
.as_ref()
|
||||||
|
.and_then(serde_json::Value::as_str)
|
||||||
|
.and_then(ReviewSide::parse))
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct Bot {
|
pub struct Bot {
|
||||||
bot_name: String,
|
bot_name: String,
|
||||||
gitea_api: GiteaAPI,
|
gitea_api: GiteaAPI,
|
||||||
open_router_client: OpenRouterClient,
|
open_router_client: OpenRouterClient,
|
||||||
http_client: reqwest::Client,
|
|
||||||
max_concurrent: usize,
|
max_concurrent: usize,
|
||||||
open_router_model: String,
|
open_router_model: String,
|
||||||
sandbox: SandboxConfig,
|
sandbox: SandboxConfig,
|
||||||
@@ -41,7 +78,6 @@ impl Bot {
|
|||||||
bot_name: String,
|
bot_name: String,
|
||||||
gitea_api: GiteaAPI,
|
gitea_api: GiteaAPI,
|
||||||
open_router_client: OpenRouterClient,
|
open_router_client: OpenRouterClient,
|
||||||
http_client: reqwest::Client,
|
|
||||||
max_concurrent: usize,
|
max_concurrent: usize,
|
||||||
open_router_model: String,
|
open_router_model: String,
|
||||||
sandbox: SandboxConfig,
|
sandbox: SandboxConfig,
|
||||||
@@ -50,7 +86,6 @@ impl Bot {
|
|||||||
bot_name,
|
bot_name,
|
||||||
gitea_api,
|
gitea_api,
|
||||||
open_router_client,
|
open_router_client,
|
||||||
http_client,
|
|
||||||
max_concurrent,
|
max_concurrent,
|
||||||
open_router_model,
|
open_router_model,
|
||||||
sandbox,
|
sandbox,
|
||||||
@@ -123,7 +158,6 @@ impl Bot {
|
|||||||
WebhookType::Review(review_payload) => crate::bot_actions::review::exec_review(
|
WebhookType::Review(review_payload) => crate::bot_actions::review::exec_review(
|
||||||
&self.gitea_api,
|
&self.gitea_api,
|
||||||
&self.open_router_client,
|
&self.open_router_client,
|
||||||
&self.http_client,
|
|
||||||
&self.open_router_model,
|
&self.open_router_model,
|
||||||
&self.sandbox,
|
&self.sandbox,
|
||||||
tools,
|
tools,
|
||||||
|
|||||||
@@ -1,30 +1,18 @@
|
|||||||
use futures_util::stream::TryStreamExt;
|
|
||||||
use openrouter_rs::types::Tool;
|
|
||||||
use tokio::io::AsyncReadExt;
|
|
||||||
use tokio_util::io::StreamReader;
|
|
||||||
use tracing::{info, instrument, warn};
|
use tracing::{info, instrument, warn};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
bot::ReviewResult,
|
bot::{ReviewResult, ReviewSide},
|
||||||
consts::{BOT_PROCESS_MSG, MAX_DIFF_SIZE, REVIEW_PROMPT, SANDBOX_SYSTEM_PROMPT},
|
consts::{BOT_PROCESS_MSG, REVIEW_PROMPT, SANDBOX_SYSTEM_PROMPT},
|
||||||
gitea::{GiteaAPI, ReviewPayload},
|
gitea::{GiteaAPI, PullRequestFile, ReviewPayload},
|
||||||
metrics,
|
metrics,
|
||||||
open_router::OpenRouterClient,
|
open_router::{OpenRouterClient, Tool},
|
||||||
sandbox::{Sandbox, SandboxConfig, agent},
|
sandbox::{Sandbox, SandboxConfig, agent},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[instrument(skip(
|
#[instrument(skip(gitea_api, open_router_client, sandbox_config, tools, review_payload))]
|
||||||
gitea_api,
|
|
||||||
open_router_client,
|
|
||||||
http_client,
|
|
||||||
sandbox_config,
|
|
||||||
tools,
|
|
||||||
review_payload
|
|
||||||
))]
|
|
||||||
pub async fn exec_review(
|
pub async fn exec_review(
|
||||||
gitea_api: &GiteaAPI,
|
gitea_api: &GiteaAPI,
|
||||||
open_router_client: &OpenRouterClient,
|
open_router_client: &OpenRouterClient,
|
||||||
http_client: &reqwest::Client,
|
|
||||||
model: &str,
|
model: &str,
|
||||||
sandbox_config: &SandboxConfig,
|
sandbox_config: &SandboxConfig,
|
||||||
tools: Vec<Tool>,
|
tools: Vec<Tool>,
|
||||||
@@ -46,18 +34,32 @@ pub async fn exec_review(
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let bot_result: Result<ReviewResult, anyhow::Error> = async {
|
let bot_result: Result<ReviewResult, anyhow::Error> = async {
|
||||||
let git_diff =
|
let full_name = &review_payload.repository.full_name;
|
||||||
download_git_diff(http_client, &review_payload.pull_request.diff_url).await?;
|
let index = review_payload.pull_request.number;
|
||||||
|
|
||||||
let diff_for_llm = format_diff_for_review(&git_diff);
|
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);
|
||||||
|
resolve_filenames(&mut changed_lines, &files);
|
||||||
|
|
||||||
|
let changes = format_changes(&files, &changed_lines);
|
||||||
|
|
||||||
let bot_request = REVIEW_PROMPT
|
let bot_request = REVIEW_PROMPT
|
||||||
.replace("{subject}", &review_payload.pull_request.title)
|
.replace("{subject}", &review_payload.pull_request.title)
|
||||||
.replace("{comment}", &review_payload.comment.body)
|
.replace("{comment}", &review_payload.comment.body)
|
||||||
.replace("{diff}", &diff_for_llm);
|
.replace("{changes}", &changes);
|
||||||
|
|
||||||
let (message, cost) = if sandbox_config.enabled {
|
let (message, cost) = run_sandboxed_review(
|
||||||
run_sandboxed_review(
|
|
||||||
gitea_api,
|
gitea_api,
|
||||||
open_router_client,
|
open_router_client,
|
||||||
sandbox_config,
|
sandbox_config,
|
||||||
@@ -65,13 +67,10 @@ pub async fn exec_review(
|
|||||||
&review_payload,
|
&review_payload,
|
||||||
&bot_request,
|
&bot_request,
|
||||||
)
|
)
|
||||||
.await?
|
.await?;
|
||||||
} else {
|
|
||||||
let chat_result = open_router_client.chat(&bot_request).await?;
|
|
||||||
(chat_result.message, chat_result.cost)
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut review_result = serde_json::from_str::<ReviewResult>(&message)?;
|
let mut review_result = serde_json::from_str::<ReviewResult>(&message)?;
|
||||||
|
resolve_review_sides(&mut review_result, &changed_lines);
|
||||||
|
|
||||||
review_result.cost = cost;
|
review_result.cost = cost;
|
||||||
if let Some(cost) = review_result.cost {
|
if let Some(cost) = review_result.cost {
|
||||||
@@ -183,83 +182,350 @@ fn review_result_to_markdown(review_result: &ReviewResult) -> String {
|
|||||||
md
|
md
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn download_git_diff(http_client: &reqwest::Client, url: &str) -> anyhow::Result<String> {
|
/// The lines a pull request changed, per file.
|
||||||
let response = http_client.get(url).send().await?;
|
///
|
||||||
let stream = response.bytes_stream().map_err(std::io::Error::other);
|
/// 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<ChangedFile>;
|
||||||
|
|
||||||
let mut buf = Vec::with_capacity(MAX_DIFF_SIZE);
|
struct ChangedFile {
|
||||||
StreamReader::new(stream)
|
filename: String,
|
||||||
.take((MAX_DIFF_SIZE + 1) as u64)
|
added: Vec<u64>,
|
||||||
.read_to_end(&mut buf)
|
removed: Vec<u64>,
|
||||||
.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 {
|
impl ChangedFile {
|
||||||
let mut output = String::new();
|
fn new(filename: &str) -> Self {
|
||||||
let mut current_file: Option<&str> = None;
|
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<ReviewSide> {
|
||||||
|
if self.added.contains(&line) {
|
||||||
|
Some(ReviewSide::Added)
|
||||||
|
} else if self.removed.contains(&line) {
|
||||||
|
Some(ReviewSide::Removed)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lists the lines changed by the diff, per file, on both sides.
|
||||||
|
///
|
||||||
|
/// Only the line numbers are kept: the model reads the code itself through the
|
||||||
|
/// sandbox tools.
|
||||||
|
fn parse_changed_lines(git_diff: &str) -> ChangedLines {
|
||||||
|
let mut files = Vec::new();
|
||||||
|
let mut current_file: Option<String> = None;
|
||||||
|
let mut in_hunk = false;
|
||||||
|
let mut old_line: u64 = 0;
|
||||||
let mut new_line: u64 = 0;
|
let mut new_line: u64 = 0;
|
||||||
|
|
||||||
for line in git_diff.lines() {
|
for line in git_diff.lines() {
|
||||||
if let Some(rest) = line.strip_prefix("diff --git a/") {
|
if line.starts_with("diff --git ") {
|
||||||
if let Some(end) = rest.find(' ') {
|
current_file = None;
|
||||||
current_file = Some(&rest[..end]);
|
in_hunk = false;
|
||||||
}
|
|
||||||
new_line = 0;
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if line.starts_with("---") || line.starts_with("+++") {
|
// `--- a/x` and `+++ b/x` only appear before the first hunk of a file.
|
||||||
continue;
|
// 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("+++ ")) {
|
||||||
if line.starts_with("@@") && line.contains('+') {
|
// A header is never a content line: `+++ /dev/null` on a deleted
|
||||||
if let Some(start) = parse_hunk_new_start(line) {
|
// file keeps the path of the other side in place.
|
||||||
new_line = start;
|
if let Some(path) = header_file_path(line) {
|
||||||
|
current_file = Some(path);
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some(filename) = current_file else {
|
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;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
if line.starts_with(' ') {
|
match line.as_bytes().first() {
|
||||||
|
Some(b' ') => {
|
||||||
|
old_line += 1;
|
||||||
new_line += 1;
|
new_line += 1;
|
||||||
|
}
|
||||||
|
Some(b'-') => {
|
||||||
|
changed_file(&mut files, filename).removed.push(old_line);
|
||||||
|
old_line += 1;
|
||||||
|
}
|
||||||
|
Some(b'+') => {
|
||||||
|
changed_file(&mut files, filename).added.push(new_line);
|
||||||
|
new_line += 1;
|
||||||
|
}
|
||||||
|
// `\ No newline at end of file`, and anything unexpected: a line
|
||||||
|
// that advances neither side.
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
files
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Path of the file on one side of the diff, from a `--- a/<path>` or
|
||||||
|
/// `+++ b/<path>` header line.
|
||||||
|
///
|
||||||
|
/// These lines are the only unambiguous source for the path: the `diff --git`
|
||||||
|
/// line is cut at the first space, and it names the old path of a renamed file.
|
||||||
|
/// `None` for `/dev/null`, which leaves the path of the other side in place.
|
||||||
|
fn header_file_path(line: &str) -> Option<String> {
|
||||||
|
let (prefix, raw) = match line.strip_prefix("--- ") {
|
||||||
|
Some(raw) => ("a/", raw),
|
||||||
|
None => ("b/", line.strip_prefix("+++ ")?),
|
||||||
|
};
|
||||||
|
|
||||||
|
let path = decode_git_path(raw);
|
||||||
|
|
||||||
|
Some(String::from(path.strip_prefix(prefix)?))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes a path as git writes it in a diff header: git wraps it in quotes and
|
||||||
|
/// escapes the bytes that need it (`\303\251` for `é`) when the path contains
|
||||||
|
/// non-printable or non-ASCII characters.
|
||||||
|
fn decode_git_path(raw: &str) -> String {
|
||||||
|
let Some(quoted) = raw.strip_prefix('"').and_then(|raw| raw.strip_suffix('"')) else {
|
||||||
|
return String::from(raw);
|
||||||
|
};
|
||||||
|
|
||||||
|
let bytes = quoted.as_bytes();
|
||||||
|
let mut decoded = Vec::with_capacity(bytes.len());
|
||||||
|
let mut index = 0;
|
||||||
|
|
||||||
|
while let Some(byte) = bytes.get(index) {
|
||||||
|
index += 1;
|
||||||
|
|
||||||
|
if *byte != b'\\' {
|
||||||
|
decoded.push(*byte);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(code) = line.strip_prefix('+') {
|
match bytes.get(index) {
|
||||||
use std::fmt::Write;
|
// Octal escapes are the ones that matter for a path: git uses them
|
||||||
let _ = writeln!(output, "{filename}:{new_line}:{code}");
|
// for every non-ASCII byte.
|
||||||
new_line += 1;
|
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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
output
|
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()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_hunk_new_start(hunk_header: &str) -> Option<u64> {
|
/// Replaces the paths parsed from the diff with the exact paths reported by the
|
||||||
let plus_part = hunk_header.split('+').nth(1)?;
|
/// API, which are the ones the model sees in the sandbox.
|
||||||
let num_str = plus_part.split(|c: char| !c.is_ascii_digit()).next()?;
|
fn resolve_filenames(changed_lines: &mut ChangedLines, files: &[PullRequestFile]) {
|
||||||
num_str.parse::<u64>().ok()
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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();
|
||||||
|
|
||||||
|
if !files.is_empty() {
|
||||||
|
let described = files
|
||||||
|
.iter()
|
||||||
|
.map(describe_file)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
|
|
||||||
|
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::<Vec<_>>()
|
||||||
|
.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_line_numbers(lines: &[u64]) -> String {
|
||||||
|
lines
|
||||||
|
.iter()
|
||||||
|
.map(u64::to_string)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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)]
|
#[cfg(test)]
|
||||||
#[test]
|
mod tests {
|
||||||
fn test_format_diff_for_review() {
|
use super::*;
|
||||||
let diff = concat!(
|
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",
|
"diff --git a/src/foo.rs b/src/foo.rs\n",
|
||||||
"--- a/src/foo.rs\n",
|
"--- a/src/foo.rs\n",
|
||||||
"+++ b/src/foo.rs\n",
|
"+++ b/src/foo.rs\n",
|
||||||
"@@ -1,3 +1,6 @@\n",
|
"@@ -1,4 +1,6 @@\n",
|
||||||
" fn main() {\n",
|
" fn main() {\n",
|
||||||
"+ let x = 1;\n",
|
"+ let x = 1;\n",
|
||||||
|
"- let removed = 0;\n",
|
||||||
" println!(\"hello\");\n",
|
" println!(\"hello\");\n",
|
||||||
"+ let y = 2;\n",
|
"+ let y = 2;\n",
|
||||||
"+ let z = 3;\n",
|
"+ let z = 3;\n",
|
||||||
@@ -274,14 +540,324 @@ fn test_format_diff_for_review() {
|
|||||||
"+ let b = 20;\n",
|
"+ let b = 20;\n",
|
||||||
);
|
);
|
||||||
|
|
||||||
let result = format_diff_for_review(diff);
|
/// A pull request that only deletes a file.
|
||||||
let expected = concat!(
|
const DELETION_ONLY: &str = concat!(
|
||||||
"src/foo.rs:2: let x = 1;\n",
|
"diff --git a/src/old.rs b/src/old.rs\n",
|
||||||
"src/foo.rs:4: let y = 2;\n",
|
"deleted file mode 100644\n",
|
||||||
"src/foo.rs:5: let z = 3;\n",
|
"--- a/src/old.rs\n",
|
||||||
"src/bar.rs:11: let a = 10;\n",
|
"+++ /dev/null\n",
|
||||||
"src/bar.rs:13: let b = 20;\n",
|
"@@ -1,3 +0,0 @@\n",
|
||||||
|
"-fn a() {}\n",
|
||||||
|
"-fn b() {}\n",
|
||||||
|
"-fn c() {}\n",
|
||||||
);
|
);
|
||||||
|
|
||||||
assert_eq!(result, expected);
|
/// 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<u64>, side: Option<ReviewSide>) -> ReviewItem {
|
||||||
|
ReviewItem {
|
||||||
|
filename: String::from(filename),
|
||||||
|
line,
|
||||||
|
side,
|
||||||
|
message: String::from("message"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn review_result(reviews: Vec<ReviewItem>) -> 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 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::<Vec<_>>();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
sides,
|
||||||
|
vec![
|
||||||
|
Some(ReviewSide::Added),
|
||||||
|
Some(ReviewSide::Removed),
|
||||||
|
Some(ReviewSide::Added)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_line_changed_on_both_sides_defaults_to_added() {
|
||||||
|
let changed_lines = parse_changed_lines(DIFF);
|
||||||
|
let mut review_result = review_result(vec![review("src/foo.rs", Some(2), None)]);
|
||||||
|
|
||||||
|
resolve_review_sides(&mut review_result, &changed_lines);
|
||||||
|
|
||||||
|
assert_eq!(review_result.reviews[0].side, Some(ReviewSide::Added));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reviews_outside_changed_lines_are_dropped() {
|
||||||
|
let changed_lines = parse_changed_lines(DIFF);
|
||||||
|
let mut review_result = review_result(vec![
|
||||||
|
// valid
|
||||||
|
review("src/foo.rs", Some(2), Some(ReviewSide::Added)),
|
||||||
|
// a line that exists but is not part of the change
|
||||||
|
review("src/foo.rs", Some(3), None),
|
||||||
|
// a line beyond the change
|
||||||
|
review("src/foo.rs", Some(999), None),
|
||||||
|
// a changed line, but on the wrong side
|
||||||
|
review("src/foo.rs", Some(4), Some(ReviewSide::Removed)),
|
||||||
|
// a line changed in another file
|
||||||
|
review("src/foo.rs", Some(11), None),
|
||||||
|
// an unknown file
|
||||||
|
review("src/baz.rs", Some(1), None),
|
||||||
|
// no line at all
|
||||||
|
review("src/bar.rs", None, None),
|
||||||
|
]);
|
||||||
|
|
||||||
|
resolve_review_sides(&mut review_result, &changed_lines);
|
||||||
|
|
||||||
|
assert_eq!(review_result.reviews.len(), 1);
|
||||||
|
assert_eq!(review_result.reviews[0].filename, "src/foo.rs");
|
||||||
|
assert_eq!(review_result.reviews[0].line, Some(2));
|
||||||
|
assert_eq!(review_result.reviews[0].side, Some(ReviewSide::Added));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_deleted_line_is_kept_as_a_removed_anchor() {
|
||||||
|
let changed_lines = parse_changed_lines(DELETION_ONLY);
|
||||||
|
let mut review_result = review_result(vec![
|
||||||
|
review("src/old.rs", Some(2), Some(ReviewSide::Removed)),
|
||||||
|
review("src/old.rs", Some(2), Some(ReviewSide::Added)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
resolve_review_sides(&mut review_result, &changed_lines);
|
||||||
|
|
||||||
|
assert_eq!(review_result.reviews.len(), 1);
|
||||||
|
assert_eq!(review_result.reviews[0].side, Some(ReviewSide::Removed));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn odd_sides_from_the_model_are_tolerated() {
|
||||||
|
let changed_lines = parse_changed_lines(DIFF);
|
||||||
|
let mut review_result: ReviewResult = serde_json::from_str(
|
||||||
|
r#"{
|
||||||
|
"reviews": [
|
||||||
|
{ "filename": "src/foo.rs", "line": 4, "side": "Added", "message": "a" },
|
||||||
|
{ "filename": "src/foo.rs", "line": 2, "side": "new", "message": "b" },
|
||||||
|
{ "filename": "src/foo.rs", "line": 2, "side": "REMOVED", "message": "c" },
|
||||||
|
{ "filename": "src/foo.rs", "line": 2, "side": "banana", "message": "d" },
|
||||||
|
{ "filename": "src/foo.rs", "line": 5, "message": "e" }
|
||||||
|
],
|
||||||
|
"comment": ""
|
||||||
|
}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
resolve_review_sides(&mut review_result, &changed_lines);
|
||||||
|
|
||||||
|
let sides = review_result
|
||||||
|
.reviews
|
||||||
|
.iter()
|
||||||
|
.map(|review| review.side)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
sides,
|
||||||
|
vec![
|
||||||
|
Some(ReviewSide::Added),
|
||||||
|
Some(ReviewSide::Added),
|
||||||
|
Some(ReviewSide::Removed),
|
||||||
|
Some(ReviewSide::Added),
|
||||||
|
Some(ReviewSide::Added)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,25 +27,29 @@ pub const REVIEW_PROMPT: &str = "
|
|||||||
|
|
||||||
This is the user comment: \"{comment}\"
|
This is the user comment: \"{comment}\"
|
||||||
|
|
||||||
The code changes (only added lines, with line numbers):
|
The pull request changes these files and lines:
|
||||||
|
|
||||||
{diff}
|
{changes}
|
||||||
|
|
||||||
Please review the code changes and provide feedback.
|
`added` line numbers refer to the new version of the file, `removed` line
|
||||||
|
numbers to the old version, as they appear in the diff.
|
||||||
|
|
||||||
IMPORTANT: the `line` field must be the line number shown before each line.
|
The code is not provided: read the files you need with the available tools
|
||||||
The provided code has the format: `filename:line:code`
|
before answering. Review only the listed lines.
|
||||||
|
|
||||||
Return your feedback, in french, with only this json format, reviews must contain each review
|
Return your feedback, in french, with only this json format, reviews must contain each review
|
||||||
All fields are mandatory.
|
All fields are mandatory.
|
||||||
(filename field must contain the full path with extension) and comment must contain a final summary:
|
(filename field must contain the full path with extension; line must be one of the
|
||||||
|
listed line numbers for that file, and side must be \"added\" when the line comes
|
||||||
|
from the `added` list or \"removed\" when it comes from the `removed` list)
|
||||||
|
and comment must contain a final summary:
|
||||||
|
|
||||||
{
|
{
|
||||||
\"reviews\": [
|
\"reviews\": [
|
||||||
{
|
{
|
||||||
\"filename\": \"\",
|
\"filename\": \"\",
|
||||||
\"line\": ,
|
\"line\": ,
|
||||||
\"code\": \"\",
|
\"side\": \"\",
|
||||||
\"message\": \"\"
|
\"message\": \"\"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ pub struct EnvConfig {
|
|||||||
pub gitea_timeout: u64,
|
pub gitea_timeout: u64,
|
||||||
pub metrics_bind_addr: Option<String>,
|
pub metrics_bind_addr: Option<String>,
|
||||||
pub container_runtime: String,
|
pub container_runtime: String,
|
||||||
pub sandbox_enabled: bool,
|
|
||||||
pub sandbox_max_iterations: usize,
|
pub sandbox_max_iterations: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,9 +29,6 @@ pub fn load_config() -> anyhow::Result<EnvConfig> {
|
|||||||
let metrics_bind_addr = std::env::var("METRICS_BIND_ADDR").ok();
|
let metrics_bind_addr = std::env::var("METRICS_BIND_ADDR").ok();
|
||||||
let container_runtime =
|
let container_runtime =
|
||||||
std::env::var("CONTAINER_RUNTIME").unwrap_or_else(|_| "docker".to_string());
|
std::env::var("CONTAINER_RUNTIME").unwrap_or_else(|_| "docker".to_string());
|
||||||
let sandbox_enabled = std::env::var("SANDBOX_ENABLED")
|
|
||||||
.map(|value| matches!(value.as_str(), "1" | "true" | "yes"))
|
|
||||||
.unwrap_or(false);
|
|
||||||
let sandbox_max_iterations = std::env::var("SANDBOX_MAX_ITERATIONS")
|
let sandbox_max_iterations = std::env::var("SANDBOX_MAX_ITERATIONS")
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|value| value.parse().ok())
|
.and_then(|value| value.parse().ok())
|
||||||
@@ -50,7 +46,6 @@ pub fn load_config() -> anyhow::Result<EnvConfig> {
|
|||||||
gitea_timeout,
|
gitea_timeout,
|
||||||
metrics_bind_addr,
|
metrics_bind_addr,
|
||||||
container_runtime,
|
container_runtime,
|
||||||
sandbox_enabled,
|
|
||||||
sandbox_max_iterations,
|
sandbox_max_iterations,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,23 @@
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use futures_util::stream::TryStreamExt;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{Value, json};
|
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, 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)]
|
#[derive(Clone)]
|
||||||
pub struct GiteaAPI {
|
pub struct GiteaAPI {
|
||||||
@@ -135,6 +148,70 @@ impl GiteaAPI {
|
|||||||
Ok(())
|
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<String> {
|
||||||
|
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<Vec<PullRequestFile>> {
|
||||||
|
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::<Vec<PullRequestFile>>().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))]
|
#[instrument(skip(self, review_result))]
|
||||||
pub async fn post_pull_request_review(
|
pub async fn post_pull_request_review(
|
||||||
&self,
|
&self,
|
||||||
@@ -148,19 +225,27 @@ impl GiteaAPI {
|
|||||||
self.base_url, full_name, index
|
self.base_url, full_name, index
|
||||||
);
|
);
|
||||||
|
|
||||||
let comments = &review_result
|
let comments = review_result
|
||||||
.reviews
|
.reviews
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|r| r.line.is_some())
|
.filter_map(|review| {
|
||||||
.map(|r| {
|
let line = review.line?;
|
||||||
let path = r.filename.clone();
|
let path = review.filename.clone();
|
||||||
let line = r.line.unwrap_or(0);
|
let body = review.message.clone();
|
||||||
let body = r.message.clone();
|
|
||||||
|
|
||||||
json!({
|
// 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,
|
"path": path,
|
||||||
"new_position": line,
|
"new_position": line,
|
||||||
"body": body
|
"body": body
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
@@ -184,6 +269,23 @@ impl GiteaAPI {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reads a response body, refusing to buffer more than [`MAX_DIFF_SIZE`].
|
||||||
|
async fn read_capped(response: reqwest::Response) -> anyhow::Result<String> {
|
||||||
|
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)]
|
#[derive(Debug)]
|
||||||
pub enum WebhookType {
|
pub enum WebhookType {
|
||||||
Review(ReviewPayload),
|
Review(ReviewPayload),
|
||||||
@@ -213,7 +315,6 @@ pub struct ReviewPayload {
|
|||||||
|
|
||||||
#[derive(Deserialize, Debug)]
|
#[derive(Deserialize, Debug)]
|
||||||
pub struct PullRequest {
|
pub struct PullRequest {
|
||||||
pub diff_url: String,
|
|
||||||
pub number: u64,
|
pub number: u64,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
}
|
}
|
||||||
@@ -234,6 +335,18 @@ pub struct Repository {
|
|||||||
pub full_name: String,
|
pub full_name: 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<String>,
|
||||||
|
/// `added`, `modified`, `deleted`, `renamed`…
|
||||||
|
pub status: String,
|
||||||
|
}
|
||||||
|
|
||||||
impl WebhookType {
|
impl WebhookType {
|
||||||
pub fn from_event(event: &str, bot_name: &str, json: Value) -> Result<Self, AppError> {
|
pub fn from_event(event: &str, bot_name: &str, json: Value) -> Result<Self, AppError> {
|
||||||
let wb = match event {
|
let wb = match event {
|
||||||
|
|||||||
@@ -79,15 +79,14 @@ async fn run() -> anyhow::Result<()> {
|
|||||||
let shutdown = CancellationToken::new();
|
let shutdown = CancellationToken::new();
|
||||||
|
|
||||||
let sandbox = SandboxConfig {
|
let sandbox = SandboxConfig {
|
||||||
enabled: config.sandbox_enabled,
|
|
||||||
runtime: devcontainer_rs::ContainerRuntime::new(config.container_runtime.clone()),
|
runtime: devcontainer_rs::ContainerRuntime::new(config.container_runtime.clone()),
|
||||||
max_iterations: config.sandbox_max_iterations,
|
max_iterations: config.sandbox_max_iterations,
|
||||||
};
|
};
|
||||||
|
|
||||||
if sandbox.enabled && !sandbox.runtime.available().await {
|
if !sandbox.runtime.available().await {
|
||||||
warn!(
|
warn!(
|
||||||
runtime = sandbox.runtime.program(),
|
runtime = sandbox.runtime.program(),
|
||||||
"Sandbox is enabled but the container runtime is not available"
|
"Container runtime is not available, every review will fail"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +94,6 @@ async fn run() -> anyhow::Result<()> {
|
|||||||
gitea_user.login,
|
gitea_user.login,
|
||||||
gitea_api,
|
gitea_api,
|
||||||
open_router_client,
|
open_router_client,
|
||||||
reqwest::Client::new(),
|
|
||||||
config.bot_max_concurrent,
|
config.bot_max_concurrent,
|
||||||
config.open_router_model.clone(),
|
config.open_router_model.clone(),
|
||||||
sandbox,
|
sandbox,
|
||||||
|
|||||||
@@ -1,63 +1,253 @@
|
|||||||
|
//! 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 std::time::Duration;
|
||||||
|
|
||||||
use openrouter_rs::{
|
use anyhow::Context;
|
||||||
Message,
|
use serde::{Deserialize, Serialize};
|
||||||
api::chat::ChatCompletionRequest,
|
use serde_json::Value;
|
||||||
types::{Tool, ToolCall},
|
|
||||||
};
|
|
||||||
use tracing::instrument;
|
use tracing::instrument;
|
||||||
|
|
||||||
pub struct ChatResult {
|
/// OpenRouter API root, version prefix included.
|
||||||
pub message: String,
|
const BASE_URL: &str = "https://openrouter.ai/api/v1";
|
||||||
pub cost: Option<f64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/// The model decides on its own which tool to call.
|
||||||
|
const TOOL_CHOICE_AUTO: &str = "auto";
|
||||||
|
|
||||||
|
/// Result of a completion that may contain tool calls.
|
||||||
pub struct ToolChatResult {
|
pub struct ToolChatResult {
|
||||||
pub message: Option<String>,
|
pub message: Option<String>,
|
||||||
pub tool_calls: Vec<ToolCall>,
|
pub tool_calls: Vec<ToolCall>,
|
||||||
pub cost: Option<f64>,
|
pub cost: Option<f64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub tool_calls: Option<Vec<ToolCall>>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub tool_call_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Message {
|
||||||
|
pub fn new(role: Role, content: impl Into<String>) -> 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<String>,
|
||||||
|
tool_calls: Vec<ToolCall>,
|
||||||
|
) -> 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<String>, content: impl Into<String>) -> 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<String>, description: impl Into<String>, 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<Choice>,
|
||||||
|
#[serde(default)]
|
||||||
|
usage: Option<Usage>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Choice {
|
||||||
|
message: ResponseMessage,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ResponseMessage {
|
||||||
|
#[serde(default)]
|
||||||
|
content: Option<Content>,
|
||||||
|
#[serde(default)]
|
||||||
|
tool_calls: Option<Vec<ToolCall>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<ContentPart>),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ContentPart {
|
||||||
|
#[serde(default)]
|
||||||
|
text: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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::<Vec<_>>()
|
||||||
|
.join(""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Usage {
|
||||||
|
#[serde(default)]
|
||||||
|
cost: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Error payload returned by OpenRouter for a failed request.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ErrorResponse {
|
||||||
|
error: ErrorDetail,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ErrorDetail {
|
||||||
|
message: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct OpenRouterClient {
|
pub struct OpenRouterClient {
|
||||||
client: openrouter_rs::OpenRouterClient,
|
client: reqwest::Client,
|
||||||
|
api_key: String,
|
||||||
model: String,
|
model: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl OpenRouterClient {
|
impl OpenRouterClient {
|
||||||
pub fn new(token: &str, model: &str, timeout: u64) -> anyhow::Result<Self> {
|
pub fn new(token: &str, model: &str, timeout: u64) -> anyhow::Result<Self> {
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
client: openrouter_rs::OpenRouterClient::builder()
|
client: reqwest::Client::builder()
|
||||||
.api_key(token)
|
|
||||||
.http_client(
|
|
||||||
reqwest::Client::builder()
|
|
||||||
.timeout(Duration::from_secs(timeout))
|
.timeout(Duration::from_secs(timeout))
|
||||||
.build()?,
|
.build()?,
|
||||||
)
|
api_key: String::from(token),
|
||||||
.build()?,
|
|
||||||
model: String::from(model),
|
model: String::from(model),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(self), err)]
|
|
||||||
pub async fn chat(&self, msg: &str) -> anyhow::Result<ChatResult> {
|
|
||||||
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),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sends a conversation with tool definitions and returns either a final
|
/// Sends a conversation with tool definitions and returns either a final
|
||||||
/// message or the tool calls requested by the model.
|
/// message or the tool calls requested by the model.
|
||||||
#[instrument(skip(self, messages, tools), err)]
|
#[instrument(skip(self, messages, tools), err)]
|
||||||
@@ -66,27 +256,186 @@ impl OpenRouterClient {
|
|||||||
messages: Vec<Message>,
|
messages: Vec<Message>,
|
||||||
tools: Vec<Tool>,
|
tools: Vec<Tool>,
|
||||||
) -> anyhow::Result<ToolChatResult> {
|
) -> anyhow::Result<ToolChatResult> {
|
||||||
let request = ChatCompletionRequest::builder()
|
let response = self.complete(&messages, &tools).await?;
|
||||||
.model(&self.model)
|
|
||||||
.enable_reasoning()
|
|
||||||
.messages(messages)
|
|
||||||
.tools(tools)
|
|
||||||
.tool_choice_auto()
|
|
||||||
.build()?;
|
|
||||||
|
|
||||||
let response = self.client.chat().create(&request).await?;
|
let cost = response.usage.and_then(|usage| usage.cost);
|
||||||
let choice = response
|
let message = response
|
||||||
.choices
|
.choices
|
||||||
.first()
|
.into_iter()
|
||||||
.ok_or_else(|| anyhow::anyhow!("No choices in response"))?;
|
.next()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("No choices in response"))?
|
||||||
|
.message;
|
||||||
|
|
||||||
Ok(ToolChatResult {
|
Ok(ToolChatResult {
|
||||||
message: choice.content().map(String::from),
|
message: message.content.map(Content::into_text),
|
||||||
tool_calls: choice
|
tool_calls: message.tool_calls.unwrap_or_default(),
|
||||||
.tool_calls()
|
cost,
|
||||||
.map(<[ToolCall]>::to_vec)
|
|
||||||
.unwrap_or_default(),
|
|
||||||
cost: response.usage.and_then(|u| u.cost),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn complete(&self, messages: &[Message], tools: &[Tool]) -> anyhow::Result<ChatResponse> {
|
||||||
|
let request = ChatRequest {
|
||||||
|
model: &self.model,
|
||||||
|
messages,
|
||||||
|
reasoning: Reasoning { enabled: true },
|
||||||
|
tools,
|
||||||
|
tool_choice: TOOL_CHOICE_AUTO,
|
||||||
|
};
|
||||||
|
|
||||||
|
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::<ChatResponse>()
|
||||||
|
.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::<ErrorResponse>(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: TOOL_CHOICE_AUTO,
|
||||||
|
})
|
||||||
|
.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 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(" <html>bad gateway</html> "),
|
||||||
|
"<html>bad gateway</html>"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,15 +6,11 @@
|
|||||||
//! exhausted.
|
//! exhausted.
|
||||||
|
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use openrouter_rs::{
|
|
||||||
Message,
|
|
||||||
types::{Role, Tool, ToolCall},
|
|
||||||
};
|
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use tracing::{debug, warn};
|
use tracing::{debug, warn};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
open_router::OpenRouterClient,
|
open_router::{Message, OpenRouterClient, Role, Tool, ToolCall},
|
||||||
sandbox::{Sandbox, tools},
|
sandbox::{Sandbox, tools},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -105,10 +101,22 @@ fn parse_args(call: &ToolCall) -> anyhow::Result<Value> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn parse_args_accepts_empty_arguments() {
|
fn parse_args_accepts_empty_arguments() {
|
||||||
let call = ToolCall::new("id", "ls", "");
|
let call = tool_call("ls", "");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parse_args(&call).unwrap(),
|
parse_args(&call).unwrap(),
|
||||||
Value::Object(serde_json::Map::new())
|
Value::Object(serde_json::Map::new())
|
||||||
@@ -117,13 +125,13 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_args_parses_json_object() {
|
fn parse_args_parses_json_object() {
|
||||||
let call = ToolCall::new("id", "ls", r#"{"path":"src"}"#);
|
let call = tool_call("ls", r#"{"path":"src"}"#);
|
||||||
assert_eq!(parse_args(&call).unwrap()["path"], "src");
|
assert_eq!(parse_args(&call).unwrap()["path"], "src");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_args_rejects_invalid_json() {
|
fn parse_args_rejects_invalid_json() {
|
||||||
let call = ToolCall::new("id", "ls", "not json");
|
let call = tool_call("ls", "not json");
|
||||||
assert!(parse_args(&call).is_err());
|
assert!(parse_args(&call).is_err());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,8 +25,6 @@ const DEVCONTAINER_PATHS: [&str; 2] = [".devcontainer/devcontainer.json", ".devc
|
|||||||
/// Sandbox-related runtime configuration.
|
/// Sandbox-related runtime configuration.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct SandboxConfig {
|
pub struct SandboxConfig {
|
||||||
/// Whether the bot should run its tools inside a sandbox container.
|
|
||||||
pub enabled: bool,
|
|
||||||
/// Container runtime binary to drive (e.g. `docker`, `podman`).
|
/// Container runtime binary to drive (e.g. `docker`, `podman`).
|
||||||
pub runtime: ContainerRuntime,
|
pub runtime: ContainerRuntime,
|
||||||
/// Maximum number of tool-calling iterations per agent run.
|
/// Maximum number of tool-calling iterations per agent run.
|
||||||
|
|||||||
@@ -10,11 +10,10 @@ use std::path::Path;
|
|||||||
|
|
||||||
use anyhow::{Context, bail};
|
use anyhow::{Context, bail};
|
||||||
use devcontainer_rs::{ExecOutput, normalize};
|
use devcontainer_rs::{ExecOutput, normalize};
|
||||||
use openrouter_rs::types::Tool;
|
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
use super::Sandbox;
|
use super::Sandbox;
|
||||||
use crate::gitea::WebhookType;
|
use crate::{gitea::WebhookType, open_router::Tool};
|
||||||
|
|
||||||
/// Tools available to the model for a given webhook action.
|
/// Tools available to the model for a given webhook action.
|
||||||
///
|
///
|
||||||
@@ -216,7 +215,6 @@ mod tests {
|
|||||||
WebhookType::Review(ReviewPayload {
|
WebhookType::Review(ReviewPayload {
|
||||||
action: "created".to_string(),
|
action: "created".to_string(),
|
||||||
pull_request: PullRequest {
|
pull_request: PullRequest {
|
||||||
diff_url: "https://example.com/diff".to_string(),
|
|
||||||
number: 1,
|
number: 1,
|
||||||
title: "My PR".to_string(),
|
title: "My PR".to_string(),
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user