Postgres persistance + job queue + api with sse #9
No files matched your search
@@ -1,4 +1,4 @@
|
|||||||
FROM rust:1.98-trixie
|
FROM rust:1.99-trixie
|
||||||
|
|
||||||
ARG USERNAME=dev
|
ARG USERNAME=dev
|
||||||
ARG USER_UID=1000
|
ARG USER_UID=1000
|
||||||
@@ -15,6 +15,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
cmake \
|
cmake \
|
||||||
pkg-config \
|
pkg-config \
|
||||||
clang \
|
clang \
|
||||||
|
valkey-server \
|
||||||
|
valkey-tools \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
RUN groupadd --gid ${USER_GID:-1000} $USERNAME \
|
RUN groupadd --gid ${USER_GID:-1000} $USERNAME \
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ steps:
|
|||||||
- /data/woodpecker-builds:/data
|
- /data/woodpecker-builds:/data
|
||||||
commands:
|
commands:
|
||||||
- echo $DOCKER_PASSWORD | buildah login docker.io -u $DOCKER_USERNAME --password-stdin
|
- echo $DOCKER_PASSWORD | buildah login docker.io -u $DOCKER_USERNAME --password-stdin
|
||||||
- chmod +x scripts/build.sh
|
- chmod +x scripts/build.sh scripts/build-website.sh
|
||||||
- bash scripts/build.sh
|
- bash scripts/build.sh
|
||||||
|
- bash scripts/build-website.sh
|
||||||
environment:
|
environment:
|
||||||
DOCKER_USERNAME:
|
DOCKER_USERNAME:
|
||||||
from_secret: docker_username
|
from_secret: docker_username
|
||||||
|
|||||||
@@ -4,19 +4,19 @@ when:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: fmt
|
- name: fmt
|
||||||
image: rust:1.98
|
image: rust:1.99
|
||||||
commands:
|
commands:
|
||||||
- rustup component add rustfmt
|
- rustup component add rustfmt
|
||||||
- cargo fmt --all -- --check
|
- cargo fmt --all -- --check
|
||||||
|
|
||||||
- name: clippy
|
- name: clippy
|
||||||
image: rust:1.98
|
image: rust:1.99
|
||||||
commands:
|
commands:
|
||||||
- rustup component add clippy
|
- rustup component add clippy
|
||||||
- cargo clippy --workspace --all-targets --all-features -- -D warnings
|
- cargo clippy --workspace --all-targets --all-features -- -D warnings
|
||||||
|
|
||||||
- name: test
|
- name: test
|
||||||
image: rust:1.98
|
image: rust:1.99
|
||||||
commands:
|
commands:
|
||||||
- cargo test --workspace --all-targets
|
- cargo test --workspace --all-targets
|
||||||
|
|
||||||
|
|||||||
@@ -189,12 +189,75 @@ version = "1.0.104"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "apalis"
|
||||||
|
version = "0.7.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "504d52557a16b7b202941660f339c9182910d498cac40f93d15f6b31e6bef290"
|
||||||
|
dependencies = [
|
||||||
|
"apalis-core",
|
||||||
|
"futures",
|
||||||
|
"pin-project-lite",
|
||||||
|
"serde",
|
||||||
|
"thiserror",
|
||||||
|
"tower",
|
||||||
|
"tracing",
|
||||||
|
"tracing-futures",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "apalis-core"
|
||||||
|
version = "0.7.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3948d2051795c769e6a44a46549879af39d5e8e4fb113e0011cf99f7cd21b657"
|
||||||
|
dependencies = [
|
||||||
|
"futures",
|
||||||
|
"futures-timer",
|
||||||
|
"pin-project-lite",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror",
|
||||||
|
"tower",
|
||||||
|
"ulid",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "apalis-redis"
|
||||||
|
version = "0.7.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "871367e3dfff2accda824ccc6e4f46f8f155c865065a450c466a7f2835541d55"
|
||||||
|
dependencies = [
|
||||||
|
"apalis-core",
|
||||||
|
"chrono",
|
||||||
|
"futures",
|
||||||
|
"log",
|
||||||
|
"redis",
|
||||||
|
"serde",
|
||||||
|
"thiserror",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "arc-swap"
|
||||||
|
version = "1.9.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b"
|
||||||
|
dependencies = [
|
||||||
|
"rustversion",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "atomic-waker"
|
name = "atomic-waker"
|
||||||
version = "1.1.2"
|
version = "1.1.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "autocfg"
|
||||||
|
version = "1.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "axum"
|
name = "axum"
|
||||||
version = "0.8.9"
|
version = "0.8.9"
|
||||||
@@ -247,6 +310,15 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "backon"
|
||||||
|
version = "1.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef"
|
||||||
|
dependencies = [
|
||||||
|
"fastrand",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "backtrace"
|
name = "backtrace"
|
||||||
version = "0.3.76"
|
version = "0.3.76"
|
||||||
@@ -381,6 +453,32 @@ 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 = "chrono"
|
||||||
|
version = "0.4.45"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
||||||
|
dependencies = [
|
||||||
|
"iana-time-zone",
|
||||||
|
"num-traits",
|
||||||
|
"serde",
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "combine"
|
||||||
|
version = "4.6.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"futures-core",
|
||||||
|
"memchr",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "convert_case"
|
name = "convert_case"
|
||||||
version = "0.10.0"
|
version = "0.10.0"
|
||||||
@@ -621,6 +719,21 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures"
|
||||||
|
version = "0.3.34"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3"
|
||||||
|
dependencies = [
|
||||||
|
"futures-channel",
|
||||||
|
"futures-core",
|
||||||
|
"futures-executor",
|
||||||
|
"futures-io",
|
||||||
|
"futures-sink",
|
||||||
|
"futures-task",
|
||||||
|
"futures-util",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-channel"
|
name = "futures-channel"
|
||||||
version = "0.3.34"
|
version = "0.3.34"
|
||||||
@@ -637,6 +750,17 @@ version = "0.3.34"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
|
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-executor"
|
||||||
|
version = "0.3.34"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
"futures-task",
|
||||||
|
"futures-util",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-io"
|
name = "futures-io"
|
||||||
version = "0.3.34"
|
version = "0.3.34"
|
||||||
@@ -666,12 +790,19 @@ version = "0.3.34"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
|
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-timer"
|
||||||
|
version = "3.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-util"
|
name = "futures-util"
|
||||||
version = "0.3.34"
|
version = "0.3.34"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
|
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-io",
|
"futures-io",
|
||||||
"futures-macro",
|
"futures-macro",
|
||||||
@@ -929,6 +1060,30 @@ dependencies = [
|
|||||||
"tower-service",
|
"tower-service",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iana-time-zone"
|
||||||
|
version = "0.1.65"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
|
||||||
|
dependencies = [
|
||||||
|
"android_system_properties",
|
||||||
|
"core-foundation-sys",
|
||||||
|
"iana-time-zone-haiku",
|
||||||
|
"js-sys",
|
||||||
|
"log",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"windows-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iana-time-zone-haiku"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "icu_collections"
|
name = "icu_collections"
|
||||||
version = "2.3.0"
|
version = "2.3.0"
|
||||||
@@ -1281,12 +1436,40 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-bigint"
|
||||||
|
version = "0.4.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
|
||||||
|
dependencies = [
|
||||||
|
"num-integer",
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-conv"
|
name = "num-conv"
|
||||||
version = "0.2.2"
|
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 = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-integer"
|
||||||
|
version = "0.1.47"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-traits"
|
||||||
|
version = "0.2.19"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||||
|
dependencies = [
|
||||||
|
"autocfg",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2"
|
name = "objc2"
|
||||||
version = "0.6.4"
|
version = "0.6.4"
|
||||||
@@ -1715,6 +1898,31 @@ dependencies = [
|
|||||||
"bitflags",
|
"bitflags",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "redis"
|
||||||
|
version = "0.32.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "014cc767fefab6a3e798ca45112bccad9c6e0e218fbd49720042716c73cfef44"
|
||||||
|
dependencies = [
|
||||||
|
"arc-swap",
|
||||||
|
"backon",
|
||||||
|
"bytes",
|
||||||
|
"cfg-if",
|
||||||
|
"combine",
|
||||||
|
"futures-channel",
|
||||||
|
"futures-util",
|
||||||
|
"itoa",
|
||||||
|
"num-bigint",
|
||||||
|
"percent-encoding",
|
||||||
|
"pin-project-lite",
|
||||||
|
"ryu",
|
||||||
|
"sha1_smol",
|
||||||
|
"socket2",
|
||||||
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redox_syscall"
|
name = "redox_syscall"
|
||||||
version = "0.5.18"
|
version = "0.5.18"
|
||||||
@@ -2149,6 +2357,12 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sha1_smol"
|
||||||
|
version = "1.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sharded-slab"
|
name = "sharded-slab"
|
||||||
version = "0.1.7"
|
version = "0.1.7"
|
||||||
@@ -2210,9 +2424,11 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "steep-server"
|
name = "steep-server"
|
||||||
version = "1.2.0"
|
version = "1.3.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"apalis",
|
||||||
|
"apalis-redis",
|
||||||
"axum",
|
"axum",
|
||||||
"devcontainer-rs",
|
"devcontainer-rs",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
@@ -2220,6 +2436,7 @@ dependencies = [
|
|||||||
"hex",
|
"hex",
|
||||||
"metrics",
|
"metrics",
|
||||||
"metrics-exporter-prometheus",
|
"metrics-exporter-prometheus",
|
||||||
|
"redis",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"ring",
|
"ring",
|
||||||
"sentry",
|
"sentry",
|
||||||
@@ -2445,6 +2662,7 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -2530,6 +2748,15 @@ dependencies = [
|
|||||||
"valuable",
|
"valuable",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tracing-futures"
|
||||||
|
version = "0.2.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "97d095ae15e245a057c8e8451bab9b3ee1e1f68e9ba2b4fbc18d0ac5237835f2"
|
||||||
|
dependencies = [
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tracing-log"
|
name = "tracing-log"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -2565,6 +2792,16 @@ version = "0.2.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ulid"
|
||||||
|
version = "1.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe"
|
||||||
|
dependencies = [
|
||||||
|
"rand",
|
||||||
|
"web-time",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uname"
|
name = "uname"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
@@ -2777,6 +3014,16 @@ 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"
|
||||||
@@ -2808,6 +3055,41 @@ version = "0.4.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-core"
|
||||||
|
version = "0.62.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||||
|
dependencies = [
|
||||||
|
"windows-implement",
|
||||||
|
"windows-interface",
|
||||||
|
"windows-link",
|
||||||
|
"windows-result",
|
||||||
|
"windows-strings",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-implement"
|
||||||
|
version = "0.60.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-interface"
|
||||||
|
version = "0.59.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-link"
|
name = "windows-link"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
@@ -2823,6 +3105,15 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-strings"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.52.0"
|
version = "0.52.0"
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ hex = "0.4"
|
|||||||
bytes = "1.1"
|
bytes = "1.1"
|
||||||
metrics = "0.24"
|
metrics = "0.24"
|
||||||
metrics-exporter-prometheus = { version = "0.18", default-features = false, features = ["http-listener"] }
|
metrics-exporter-prometheus = { version = "0.18", default-features = false, features = ["http-listener"] }
|
||||||
|
apalis = { version = "0.7", features = ["catch-panic", "limit"] }
|
||||||
|
apalis-redis = "0.7"
|
||||||
|
redis = { version = "0.32", features = ["tokio-comp", "connection-manager"] }
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
debug = 1
|
debug = 1
|
||||||
@@ -1,4 +1,4 @@
|
|||||||

|

|
||||||
|
|
||||||
# Steep
|
# Steep
|
||||||
|
|
||||||
@@ -38,6 +38,7 @@ Steep reads its configuration from environment variables (a `.env` file is suppo
|
|||||||
| `GITEA_URL` | Base URL of your Gitea instance |
|
| `GITEA_URL` | Base URL of your Gitea instance |
|
||||||
| `GITEA_TOKEN` | Gitea API token |
|
| `GITEA_TOKEN` | Gitea API token |
|
||||||
| `GITEA_TIMEOUT` | Gitea API request timeout in seconds |
|
| `GITEA_TIMEOUT` | Gitea API request timeout in seconds |
|
||||||
|
| `REDIS_URL` | Redis connection URL used for the durable review queue (e.g. `redis://127.0.0.1:6379`) |
|
||||||
| `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` |
|
||||||
@@ -128,8 +129,9 @@ Set `METRICS_BIND_ADDR` (e.g. `0.0.0.0:9100`) to enable it. The metrics are then
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `steep_webhooks_received_total` | counter | Total webhooks received (label: `event_type`) |
|
| `steep_webhooks_received_total` | counter | Total webhooks received (label: `event_type`) |
|
||||||
| `steep_webhooks_duplicate_total` | counter | Webhooks rejected as duplicates (label: `event_type`) |
|
| `steep_webhooks_duplicate_total` | counter | Webhooks rejected as duplicates (label: `event_type`) |
|
||||||
| `steep_webhooks_channel_full_total` | counter | Webhooks dropped because the bot channel was full (label: `event_type`) |
|
| `steep_webhooks_enqueue_failed_total` | counter | Webhooks dropped because enqueueing to Redis failed (label: `event_type`) |
|
||||||
| `steep_bot_tasks_active` | gauge | Bot tasks currently in progress |
|
| `steep_bot_tasks_active` | gauge | Bot tasks currently in progress |
|
||||||
|
| `steep_bot_tasks_skipped_total` | counter | Reviews skipped because they were already done (label: `event_type`) |
|
||||||
| `steep_bot_tasks_completed_total` | counter | Bot tasks completed successfully (label: `event_type`) |
|
| `steep_bot_tasks_completed_total` | counter | Bot tasks completed successfully (label: `event_type`) |
|
||||||
| `steep_bot_tasks_failed_total` | counter | Bot tasks that failed (label: `event_type`) |
|
| `steep_bot_tasks_failed_total` | counter | Bot tasks that failed (label: `event_type`) |
|
||||||
| `steep_openrouter_cost_cents_total` | counter | Total OpenRouter cost in cents (divide by 100 for USD) |
|
| `steep_openrouter_cost_cents_total` | counter | Total OpenRouter cost in cents (divide by 100 for USD) |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "steep-server"
|
name = "steep-server"
|
||||||
version = "1.2.0"
|
version = "1.3.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
@@ -26,3 +26,6 @@ metrics = { workspace = true }
|
|||||||
metrics-exporter-prometheus = { workspace = true }
|
metrics-exporter-prometheus = { workspace = true }
|
||||||
devcontainer-rs = { path = "../devcontainer-rs" }
|
devcontainer-rs = { path = "../devcontainer-rs" }
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
apalis = { workspace = true }
|
||||||
|
apalis-redis = { workspace = true }
|
||||||
|
redis = { workspace = true }
|
||||||
@@ -14,7 +14,10 @@ use tracing::{info, instrument};
|
|||||||
|
|
||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
|
use apalis::prelude::Storage;
|
||||||
|
|
||||||
use crate::consts::{GITEA_EVENT_TYPE_HEADER_NAME, GITEA_SIG_HEADER_NAME, MAX_WEBHOOK_BODY_SIZE};
|
use crate::consts::{GITEA_EVENT_TYPE_HEADER_NAME, GITEA_SIG_HEADER_NAME, MAX_WEBHOOK_BODY_SIZE};
|
||||||
|
use crate::dedup::{Dedup, WEBHOOK_TTL};
|
||||||
use crate::errors::AppError;
|
use crate::errors::AppError;
|
||||||
use crate::gitea::WebhookType;
|
use crate::gitea::WebhookType;
|
||||||
use crate::metrics;
|
use crate::metrics;
|
||||||
@@ -61,15 +64,22 @@ async fn webhook(
|
|||||||
metrics::webhook_received(event_type);
|
metrics::webhook_received(event_type);
|
||||||
|
|
||||||
let event_id = wb.event_id();
|
let event_id = wb.event_id();
|
||||||
if app_state.bot.check_and_mark(event_id).await {
|
let webhook_key = Dedup::webhook_key(event_id);
|
||||||
|
if !app_state
|
||||||
|
.dedup
|
||||||
|
.claim(&webhook_key, WEBHOOK_TTL)
|
||||||
|
.await
|
||||||
|
.map_err(anyhow::Error::from)?
|
||||||
|
{
|
||||||
metrics::webhook_duplicate(event_type);
|
metrics::webhook_duplicate(event_type);
|
||||||
return Err(AppError::AlreadyProcessedErr);
|
return Err(AppError::AlreadyProcessedErr);
|
||||||
}
|
}
|
||||||
|
|
||||||
if app_state.bot_tx.try_send(wb).is_err() {
|
let mut storage = app_state.storage.clone();
|
||||||
app_state.bot.unmark(event_id).await;
|
if let Err(err) = storage.push(wb).await {
|
||||||
metrics::webhook_channel_full(event_type);
|
app_state.dedup.release(&webhook_key).await.ok();
|
||||||
return Err(AppError::ChannelFullErr);
|
metrics::webhook_enqueue_failed(event_type);
|
||||||
|
return Err(AppError::EnqueueErr(anyhow::Error::from(err)));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok((StatusCode::CREATED, "Task started"))
|
Ok((StatusCode::CREATED, "Task started"))
|
||||||
@@ -109,7 +119,7 @@ where
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
let webhook = parse_webhook(&type_header, &app_state.bot.name(), &body_bytes)?;
|
let webhook = parse_webhook(&type_header, &app_state.bot_name, &body_bytes)?;
|
||||||
Ok(WebhookExtract(webhook))
|
Ok(WebhookExtract(webhook))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
|
dedup::{Dedup, REVIEW_TTL},
|
||||||
gitea::{GiteaAPI, WebhookType},
|
gitea::{GiteaAPI, WebhookType},
|
||||||
metrics,
|
metrics,
|
||||||
open_router::OpenRouterClient,
|
open_router::OpenRouterClient,
|
||||||
sandbox::SandboxConfig,
|
sandbox::SandboxConfig,
|
||||||
};
|
};
|
||||||
|
use apalis::prelude::{Data, Error, Monitor, WorkerBuilder, WorkerBuilderExt, WorkerFactoryFn};
|
||||||
|
use apalis_redis::RedisStorage;
|
||||||
use serde::{Deserialize, Deserializer};
|
use serde::{Deserialize, Deserializer};
|
||||||
use std::{collections::HashSet, sync::Arc};
|
|
||||||
use tokio::sync::Mutex;
|
|
||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
use tracing::{error, info, instrument};
|
use tracing::{error, info, instrument};
|
||||||
|
|
||||||
@@ -120,87 +121,64 @@ where
|
|||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct Bot {
|
pub struct Bot {
|
||||||
bot_name: String,
|
|
||||||
gitea_api: GiteaAPI,
|
gitea_api: GiteaAPI,
|
||||||
open_router_client: OpenRouterClient,
|
open_router_client: OpenRouterClient,
|
||||||
max_concurrent: usize,
|
max_concurrent: usize,
|
||||||
open_router_model: String,
|
open_router_model: String,
|
||||||
sandbox: SandboxConfig,
|
sandbox: SandboxConfig,
|
||||||
actions_handled: Arc<Mutex<HashSet<u64>>>,
|
dedup: Dedup,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Bot {
|
impl Bot {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
bot_name: String,
|
|
||||||
gitea_api: GiteaAPI,
|
gitea_api: GiteaAPI,
|
||||||
open_router_client: OpenRouterClient,
|
open_router_client: OpenRouterClient,
|
||||||
max_concurrent: usize,
|
max_concurrent: usize,
|
||||||
open_router_model: String,
|
open_router_model: String,
|
||||||
sandbox: SandboxConfig,
|
sandbox: SandboxConfig,
|
||||||
|
dedup: Dedup,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
bot_name,
|
|
||||||
gitea_api,
|
gitea_api,
|
||||||
open_router_client,
|
open_router_client,
|
||||||
max_concurrent,
|
max_concurrent,
|
||||||
open_router_model,
|
open_router_model,
|
||||||
sandbox,
|
sandbox,
|
||||||
actions_handled: Arc::new(Mutex::new(HashSet::new())),
|
dedup,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn name(&self) -> String {
|
/// Consomme la file Redis jusqu'à l'annulation de `shutdown`.
|
||||||
self.bot_name.clone()
|
///
|
||||||
}
|
/// Les jobs en cours sont laissés se terminer, et ceux qui n'ont pas encore
|
||||||
|
/// été acquittés restent dans Redis : ils seront repris au prochain démarrage.
|
||||||
pub async fn start(
|
pub async fn start(
|
||||||
&self,
|
&self,
|
||||||
mut rx: tokio::sync::mpsc::Receiver<WebhookType>,
|
storage: RedisStorage<WebhookType>,
|
||||||
shutdown: CancellationToken,
|
shutdown: CancellationToken,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
info!("Bot started");
|
info!("Bot started");
|
||||||
|
|
||||||
let sem = Arc::new(tokio::sync::Semaphore::new(self.max_concurrent));
|
let worker = WorkerBuilder::new("steep-review")
|
||||||
let mut tasks = tokio::task::JoinSet::new();
|
.concurrency(self.max_concurrent)
|
||||||
|
.data(self.clone())
|
||||||
|
.backend(storage)
|
||||||
|
.build_fn(handle_review);
|
||||||
|
|
||||||
loop {
|
Monitor::new()
|
||||||
let wb = tokio::select! {
|
.register(worker)
|
||||||
biased;
|
.run_with_signal(async move {
|
||||||
_ = shutdown.cancelled() => break,
|
shutdown.cancelled().await;
|
||||||
msg = rx.recv() => match msg {
|
Ok(())
|
||||||
Some(wb) => wb,
|
})
|
||||||
None => break,
|
.await?;
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
while let Some(res) = tasks.try_join_next() {
|
|
||||||
if let Err(e) = res {
|
|
||||||
error!("Task panicked: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
info!(queued = rx.len(), active = tasks.len(), "Webhook received");
|
|
||||||
let permit = sem.clone().acquire_owned().await?;
|
|
||||||
let self_clone = self.clone();
|
|
||||||
metrics::increment_task_active();
|
|
||||||
|
|
||||||
tasks.spawn(async move {
|
|
||||||
self_clone.exec(wb).await;
|
|
||||||
drop(permit);
|
|
||||||
metrics::decrement_task_active();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks.join_all().await;
|
|
||||||
|
|
||||||
info!("Bot shutting down complete");
|
info!("Bot shutting down complete");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(self, webhook), fields(repo, pr))]
|
#[instrument(skip(self, webhook), fields(repo, pr))]
|
||||||
pub async fn exec(&self, webhook: WebhookType) {
|
pub async fn exec(&self, webhook: WebhookType) -> anyhow::Result<()> {
|
||||||
let event_type_str = webhook.event_type_str();
|
|
||||||
|
|
||||||
match &webhook {
|
match &webhook {
|
||||||
WebhookType::Review(p) => {
|
WebhookType::Review(p) => {
|
||||||
tracing::Span::current().record("repo", &p.repository.full_name);
|
tracing::Span::current().record("repo", &p.repository.full_name);
|
||||||
@@ -210,111 +188,55 @@ impl Bot {
|
|||||||
|
|
||||||
|
qpismont marked this conversation as resolved
|
|||||||
let tools = crate::sandbox::tools::for_webhook(&webhook);
|
let tools = crate::sandbox::tools::for_webhook(&webhook);
|
||||||
|
|
||||||
|
qpismont marked this conversation as resolved
Herald
commented
[bug] Si la mise à jour du statut échoue, le job est quand même traité. Il reste alors **[bug]** Si la mise à jour du statut échoue, le job est quand même traité. Il reste alors `Pending` et sera récupéré au prochain sondage, ce qui peut lancer plusieurs reviews concurrentes pour le même événement. Ne lancez pas le traitement si cette transition échoue, ou garantissez une prise en charge atomique.
|
|||||||
let exec_result = match webhook {
|
match webhook {
|
||||||
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,
|
||||||
|
qpismont marked this conversation as resolved
Herald
commented
[bug] En cas d’échec de **[bug]** En cas d’échec de `mark_processing`, le job est tout de même traité. Il reste alors Pending en base et pourra être repris après redémarrage — ou par une autre instance — pendant que cette exécution peut déjà publier la review. N’exécutez pas le job si sa transition d’état échoue.
|
|||||||
&self.open_router_model,
|
&self.open_router_model,
|
||||||
&self.sandbox,
|
&self.sandbox,
|
||||||
tools,
|
tools,
|
||||||
review_payload,
|
review_payload,
|
||||||
),
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.await;
|
|
||||||
|
|
||||||
match exec_result {
|
async fn handle_review(webhook: WebhookType, bot: Data<Bot>) -> Result<(), Error> {
|
||||||
Ok(_) => {
|
let event_type = webhook.event_type_str();
|
||||||
metrics::task_completed(event_type_str);
|
let review_key = Dedup::review_key(webhook.event_id());
|
||||||
|
|
||||||
|
match bot.dedup.is_marked(&review_key).await {
|
||||||
|
Ok(true) => {
|
||||||
|
metrics::task_skipped(event_type);
|
||||||
|
info!("Review already done, skipping");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Ok(false) => {}
|
||||||
|
Err(err) => error!(%err, "Dedup lookup failed, reviewing anyway"),
|
||||||
|
}
|
||||||
|
|
||||||
|
metrics::increment_task_active();
|
||||||
|
let result = bot.exec(webhook).await;
|
||||||
|
metrics::decrement_task_active();
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(()) => {
|
||||||
|
metrics::task_completed(event_type);
|
||||||
info!("Task completed");
|
info!("Task completed");
|
||||||
|
|
||||||
|
if let Err(err) = bot.dedup.mark(&review_key, REVIEW_TTL).await {
|
||||||
|
error!(%err, "Failed to mark review as done");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
metrics::task_failed(event_type_str);
|
metrics::task_failed(event_type);
|
||||||
error!(%err, "Task error");
|
error!(%err, "Task error");
|
||||||
sentry_anyhow::capture_anyhow(&err);
|
sentry_anyhow::capture_anyhow(&err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn check_and_mark(&self, event_id: u64) -> bool {
|
Ok(())
|
||||||
let mut action_handled_lock = self.actions_handled.lock().await;
|
|
||||||
|
|
||||||
!action_handled_lock.insert(event_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn unmark(&self, event_id: u64) {
|
|
||||||
let mut action_handled_lock = self.actions_handled.lock().await;
|
|
||||||
|
|
||||||
action_handled_lock.remove(&event_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn make_actions_handled() -> Arc<Mutex<HashSet<u64>>> {
|
|
||||||
Arc::new(Mutex::new(HashSet::new()))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn check_and_mark(actions_handled: &Arc<Mutex<HashSet<u64>>>, event_id: u64) -> bool {
|
|
||||||
let mut lock = actions_handled.lock().await;
|
|
||||||
!lock.insert(event_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn unmark(actions_handled: &Arc<Mutex<HashSet<u64>>>, event_id: u64) {
|
|
||||||
let mut lock = actions_handled.lock().await;
|
|
||||||
lock.remove(&event_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_check_and_mark_first_call_returns_false() {
|
|
||||||
let actions_handled = make_actions_handled();
|
|
||||||
assert!(!check_and_mark(&actions_handled, 1).await);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_check_and_mark_second_call_returns_true() {
|
|
||||||
let actions_handled = make_actions_handled();
|
|
||||||
check_and_mark(&actions_handled, 1).await;
|
|
||||||
assert!(check_and_mark(&actions_handled, 1).await);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_check_and_mark_different_ids_are_independent() {
|
|
||||||
let actions_handled = make_actions_handled();
|
|
||||||
check_and_mark(&actions_handled, 1).await;
|
|
||||||
assert!(!check_and_mark(&actions_handled, 2).await);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_unmark_allows_reprocessing() {
|
|
||||||
let actions_handled = make_actions_handled();
|
|
||||||
check_and_mark(&actions_handled, 1).await;
|
|
||||||
unmark(&actions_handled, 1).await;
|
|
||||||
assert!(!check_and_mark(&actions_handled, 1).await);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_unmark_nonexistent_id_is_noop() {
|
|
||||||
let actions_handled = make_actions_handled();
|
|
||||||
unmark(&actions_handled, 99).await;
|
|
||||||
assert!(!check_and_mark(&actions_handled, 99).await);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_check_and_mark_concurrent_same_id() {
|
|
||||||
let actions_handled = make_actions_handled();
|
|
||||||
let actions_handled2 = Arc::clone(&actions_handled);
|
|
||||||
|
|
||||||
let t1 = tokio::spawn(async move { check_and_mark(&actions_handled, 42).await });
|
|
||||||
let t2 = tokio::spawn(async move { check_and_mark(&actions_handled2, 42).await });
|
|
||||||
|
|
||||||
let (r1, r2) = tokio::join!(t1, t2);
|
|
||||||
let results = [r1.unwrap(), r2.unwrap()];
|
|
||||||
|
|
||||||
// exactement un seul des deux doit retourner false (non traité)
|
|
||||||
assert_eq!(results.iter().filter(|&&r| !r).count(), 1);
|
|
||||||
// l'autre doit retourner true (déjà traité)
|
|
||||||
assert_eq!(results.iter().filter(|&&r| r).count(), 1);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use redis::aio::ConnectionManager;
|
||||||
|
|
||||||
|
pub const WEBHOOK_TTL: Duration = Duration::from_secs(24 * 60 * 60);
|
||||||
|
pub const REVIEW_TTL: Duration = Duration::from_secs(7 * 24 * 60 * 60);
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Dedup {
|
||||||
|
conn: ConnectionManager,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Dedup {
|
||||||
|
pub fn new(conn: ConnectionManager) -> Self {
|
||||||
|
Self { conn }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn webhook_key(event_id: u64) -> String {
|
||||||
|
format!("steep:webhook:{event_id}")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn review_key(event_id: u64) -> String {
|
||||||
|
format!("steep:review:{event_id}")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn claim(&self, key: &str, ttl: Duration) -> redis::RedisResult<bool> {
|
||||||
|
let set: Option<String> = redis::cmd("SET")
|
||||||
|
.arg(key)
|
||||||
|
.arg(1)
|
||||||
|
.arg("NX")
|
||||||
|
.arg("EX")
|
||||||
|
.arg(ttl.as_secs())
|
||||||
|
.query_async(&mut self.conn.clone())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(set.is_some())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn mark(&self, key: &str, ttl: Duration) -> redis::RedisResult<()> {
|
||||||
|
let _: String = redis::cmd("SET")
|
||||||
|
.arg(key)
|
||||||
|
.arg(1)
|
||||||
|
.arg("EX")
|
||||||
|
.arg(ttl.as_secs())
|
||||||
|
.query_async(&mut self.conn.clone())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn is_marked(&self, key: &str) -> redis::RedisResult<bool> {
|
||||||
|
let exists: i64 = redis::cmd("EXISTS")
|
||||||
|
.arg(key)
|
||||||
|
.query_async(&mut self.conn.clone())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(exists > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn release(&self, key: &str) -> redis::RedisResult<()> {
|
||||||
|
let _: i64 = redis::cmd("DEL")
|
||||||
|
.arg(key)
|
||||||
|
.query_async(&mut self.conn.clone())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn webhook_key_is_namespaced() {
|
||||||
|
assert_eq!(Dedup::webhook_key(42), "steep:webhook:42");
|
||||||
|
qpismont marked this conversation as resolved
Herald
commented
[performance] **[performance]** `Review::all()` charge toutes les reviews et leurs champs, notamment le Markdown, à chaque rafraîchissement du dashboard. Cette requête et la réponse JSON grossissent sans limite avec l’historique. Ajoutez une pagination ou une limite et ne chargez que les champs nécessaires à la liste.
|
|||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn review_key_is_namespaced() {
|
||||||
|
assert_eq!(Dedup::review_key(42), "steep:review:42");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_two_keys_for_the_same_event_differ() {
|
||||||
|
assert_ne!(Dedup::webhook_key(7), Dedup::review_key(7));
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn redis_dedup() -> Dedup {
|
||||||
|
let url = std::env::var("REDIS_URL").expect("REDIS_URL must be set");
|
||||||
|
Dedup::new(apalis_redis::connect(url).await.expect("connect to redis"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "requires a running Redis (REDIS_URL)"]
|
||||||
|
async fn claim_then_claim_is_a_duplicate() {
|
||||||
|
let dedup = redis_dedup().await;
|
||||||
|
let key = "steep:test:claim";
|
||||||
|
dedup.release(key).await.unwrap();
|
||||||
|
|
||||||
|
assert!(dedup.claim(key, Duration::from_secs(30)).await.unwrap());
|
||||||
|
assert!(!dedup.claim(key, Duration::from_secs(30)).await.unwrap());
|
||||||
|
|
||||||
|
dedup.release(key).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "requires a running Redis (REDIS_URL)"]
|
||||||
|
async fn mark_then_is_marked() {
|
||||||
|
let dedup = redis_dedup().await;
|
||||||
|
let key = "steep:test:mark";
|
||||||
|
dedup.release(key).await.unwrap();
|
||||||
|
|
||||||
|
assert!(!dedup.is_marked(key).await.unwrap());
|
||||||
|
dedup.mark(key, Duration::from_secs(30)).await.unwrap();
|
||||||
|
assert!(dedup.is_marked(key).await.unwrap());
|
||||||
|
|
||||||
|
dedup.release(key).await.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ pub struct EnvConfig {
|
|||||||
pub gitea_url: String,
|
pub gitea_url: String,
|
||||||
pub gitea_token: String,
|
pub gitea_token: String,
|
||||||
pub gitea_timeout: u64,
|
pub gitea_timeout: u64,
|
||||||
|
pub redis_url: String,
|
||||||
pub metrics_bind_addr: Option<String>,
|
pub metrics_bind_addr: Option<String>,
|
||||||
pub sandbox_max_iterations: usize,
|
pub sandbox_max_iterations: usize,
|
||||||
}
|
}
|
||||||
@@ -25,6 +26,7 @@ pub fn load_config() -> anyhow::Result<EnvConfig> {
|
|||||||
let gitea_url = try_get_env("GITEA_URL")?;
|
let gitea_url = try_get_env("GITEA_URL")?;
|
||||||
let gitea_token = try_get_env("GITEA_TOKEN")?;
|
let gitea_token = try_get_env("GITEA_TOKEN")?;
|
||||||
let gitea_timeout = try_get_env("GITEA_TIMEOUT")?.parse()?;
|
let gitea_timeout = try_get_env("GITEA_TIMEOUT")?.parse()?;
|
||||||
|
let redis_url = try_get_env("REDIS_URL")?;
|
||||||
let metrics_bind_addr = std::env::var("METRICS_BIND_ADDR").ok();
|
let metrics_bind_addr = std::env::var("METRICS_BIND_ADDR").ok();
|
||||||
let sandbox_max_iterations = std::env::var("SANDBOX_MAX_ITERATIONS")
|
let sandbox_max_iterations = std::env::var("SANDBOX_MAX_ITERATIONS")
|
||||||
.ok()
|
.ok()
|
||||||
@@ -41,6 +43,7 @@ pub fn load_config() -> anyhow::Result<EnvConfig> {
|
|||||||
gitea_url,
|
gitea_url,
|
||||||
gitea_token,
|
gitea_token,
|
||||||
gitea_timeout,
|
gitea_timeout,
|
||||||
|
redis_url,
|
||||||
metrics_bind_addr,
|
metrics_bind_addr,
|
||||||
sandbox_max_iterations,
|
sandbox_max_iterations,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
use anyhow::anyhow;
|
|
||||||
use axum::response::IntoResponse;
|
use axum::response::IntoResponse;
|
||||||
use reqwest::StatusCode;
|
use reqwest::StatusCode;
|
||||||
|
|
||||||
@@ -22,8 +21,8 @@ pub enum AppError {
|
|||||||
#[error("WebHook have bad action")]
|
#[error("WebHook have bad action")]
|
||||||
InvalidActionErr,
|
InvalidActionErr,
|
||||||
|
|
||||||
#[error("Channel full")]
|
#[error("Could not enqueue task")]
|
||||||
ChannelFullErr,
|
EnqueueErr(#[source] anyhow::Error),
|
||||||
|
|
||||||
#[error("Already processed")]
|
#[error("Already processed")]
|
||||||
AlreadyProcessedErr,
|
AlreadyProcessedErr,
|
||||||
@@ -62,11 +61,11 @@ impl IntoResponse for AppError {
|
|||||||
"WebHook sig header is invalid".to_string(),
|
"WebHook sig header is invalid".to_string(),
|
||||||
),
|
),
|
||||||
AppError::AlreadyProcessedErr => (StatusCode::OK, "Already processed".to_string()),
|
AppError::AlreadyProcessedErr => (StatusCode::OK, "Already processed".to_string()),
|
||||||
AppError::ChannelFullErr => {
|
AppError::EnqueueErr(err) => {
|
||||||
sentry_anyhow::capture_anyhow(&anyhow!("Max concurrent tasks reached"));
|
sentry_anyhow::capture_anyhow(&err);
|
||||||
(
|
(
|
||||||
StatusCode::SERVICE_UNAVAILABLE,
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
"Max concurrent tasks reached".to_string(),
|
"Could not enqueue task".to_string(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
AppError::Other(err) => {
|
AppError::Other(err) => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use futures_util::stream::TryStreamExt;
|
use futures_util::stream::TryStreamExt;
|
||||||
use serde::Deserialize;
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use tokio::io::AsyncReadExt;
|
use tokio::io::AsyncReadExt;
|
||||||
use tokio_util::io::StreamReader;
|
use tokio_util::io::StreamReader;
|
||||||
@@ -281,7 +281,7 @@ async fn read_capped(response: reqwest::Response) -> anyhow::Result<String> {
|
|||||||
Ok(String::from_utf8_lossy(&buf).into_owned())
|
Ok(String::from_utf8_lossy(&buf).into_owned())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub enum WebhookType {
|
pub enum WebhookType {
|
||||||
Review(ReviewPayload),
|
Review(ReviewPayload),
|
||||||
}
|
}
|
||||||
@@ -300,7 +300,7 @@ impl WebhookType {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Debug)]
|
#[derive(Deserialize, Serialize, Debug)]
|
||||||
pub struct ReviewPayload {
|
pub struct ReviewPayload {
|
||||||
pub action: String,
|
pub action: String,
|
||||||
pub pull_request: PullRequest,
|
pub pull_request: PullRequest,
|
||||||
@@ -308,13 +308,13 @@ pub struct ReviewPayload {
|
|||||||
pub comment: Comment,
|
pub comment: Comment,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Debug)]
|
#[derive(Deserialize, Serialize, Debug)]
|
||||||
pub struct PullRequest {
|
pub struct PullRequest {
|
||||||
pub number: u64,
|
pub number: u64,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Debug)]
|
#[derive(Deserialize, Serialize, Debug)]
|
||||||
pub struct Comment {
|
pub struct Comment {
|
||||||
pub id: u64,
|
pub id: u64,
|
||||||
pub body: String,
|
pub body: String,
|
||||||
@@ -325,7 +325,7 @@ pub struct User {
|
|||||||
pub login: String,
|
pub login: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Debug)]
|
#[derive(Deserialize, Serialize, Debug)]
|
||||||
pub struct Repository {
|
pub struct Repository {
|
||||||
pub full_name: String,
|
pub full_name: String,
|
||||||
pub clone_url: String,
|
pub clone_url: String,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
bot::Bot,
|
bot::Bot,
|
||||||
|
dedup::Dedup,
|
||||||
gitea::{GiteaAPI, WebhookType},
|
gitea::{GiteaAPI, WebhookType},
|
||||||
open_router::OpenRouterClient,
|
open_router::OpenRouterClient,
|
||||||
sandbox::SandboxConfig,
|
sandbox::SandboxConfig,
|
||||||
@@ -18,6 +19,7 @@ mod api;
|
|||||||
mod bot;
|
mod bot;
|
||||||
mod bot_actions;
|
mod bot_actions;
|
||||||
mod consts;
|
mod consts;
|
||||||
|
mod dedup;
|
||||||
mod env;
|
mod env;
|
||||||
mod errors;
|
mod errors;
|
||||||
mod gitea;
|
mod gitea;
|
||||||
@@ -99,19 +101,24 @@ async fn run() -> anyhow::Result<()> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let redis_conn = apalis_redis::connect(config.redis_url.clone()).await?;
|
||||||
|
let storage: apalis_redis::RedisStorage<WebhookType> =
|
||||||
|
apalis_redis::RedisStorage::new(redis_conn.clone());
|
||||||
|
let dedup = Dedup::new(redis_conn);
|
||||||
|
|
||||||
let bot = Bot::new(
|
let bot = Bot::new(
|
||||||
gitea_user.login,
|
|
||||||
gitea_api,
|
gitea_api,
|
||||||
open_router_client,
|
open_router_client,
|
||||||
config.bot_max_concurrent,
|
config.bot_max_concurrent,
|
||||||
config.open_router_model.clone(),
|
config.open_router_model.clone(),
|
||||||
sandbox,
|
sandbox,
|
||||||
|
dedup.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
let (tx, rx) = tokio::sync::mpsc::channel::<WebhookType>(config.bot_max_concurrent * 2);
|
|
||||||
let app_state = AppState {
|
let app_state = AppState {
|
||||||
bot_tx: tx,
|
storage: storage.clone(),
|
||||||
bot: bot.clone(),
|
bot_name: gitea_user.login.clone(),
|
||||||
|
dedup,
|
||||||
config,
|
config,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -136,7 +143,7 @@ async fn run() -> anyhow::Result<()> {
|
|||||||
tokio::select! {
|
tokio::select! {
|
||||||
result = async {
|
result = async {
|
||||||
tokio::try_join!(
|
tokio::try_join!(
|
||||||
bot.start(rx, shutdown.clone()),
|
bot.start(storage, shutdown.clone()),
|
||||||
api::start(app_state, shutdown.clone()),
|
api::start(app_state, shutdown.clone()),
|
||||||
signal
|
signal
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ pub fn webhook_duplicate(event_type: &str) {
|
|||||||
counter!("steep_webhooks_duplicate_total", "event_type" => event_type.to_string()).increment(1);
|
counter!("steep_webhooks_duplicate_total", "event_type" => event_type.to_string()).increment(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn webhook_channel_full(event_type: &str) {
|
pub fn webhook_enqueue_failed(event_type: &str) {
|
||||||
counter!("steep_webhooks_channel_full_total", "event_type" => event_type.to_string())
|
counter!("steep_webhooks_enqueue_failed_total", "event_type" => event_type.to_string())
|
||||||
.increment(1);
|
.increment(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,6 +23,10 @@ pub fn decrement_task_active() {
|
|||||||
gauge!("steep_bot_tasks_active").decrement(1.0);
|
gauge!("steep_bot_tasks_active").decrement(1.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn task_skipped(event_type: &str) {
|
||||||
|
counter!("steep_bot_tasks_skipped_total", "event_type" => event_type.to_string()).increment(1);
|
||||||
|
}
|
||||||
|
|
||||||
pub fn task_completed(event_type: &str) {
|
pub fn task_completed(event_type: &str) {
|
||||||
counter!("steep_bot_tasks_completed_total", "event_type" => event_type.to_string())
|
counter!("steep_bot_tasks_completed_total", "event_type" => event_type.to_string())
|
||||||
.increment(1);
|
.increment(1);
|
||||||
@@ -48,15 +52,20 @@ pub fn describe() {
|
|||||||
"Webhooks rejected as duplicates"
|
"Webhooks rejected as duplicates"
|
||||||
);
|
);
|
||||||
describe_counter!(
|
describe_counter!(
|
||||||
"steep_webhooks_channel_full_total",
|
"steep_webhooks_enqueue_failed_total",
|
||||||
Unit::Count,
|
Unit::Count,
|
||||||
"Webhooks dropped because the bot channel was full"
|
"Webhooks dropped because enqueueing to Redis failed"
|
||||||
);
|
);
|
||||||
describe_gauge!(
|
describe_gauge!(
|
||||||
"steep_bot_tasks_active",
|
"steep_bot_tasks_active",
|
||||||
Unit::Count,
|
Unit::Count,
|
||||||
"Bot tasks currently in progress"
|
"Bot tasks currently in progress"
|
||||||
);
|
);
|
||||||
|
describe_counter!(
|
||||||
|
"steep_bot_tasks_skipped_total",
|
||||||
|
Unit::Count,
|
||||||
|
"Reviews skipped because they were already done"
|
||||||
|
);
|
||||||
describe_counter!(
|
describe_counter!(
|
||||||
"steep_bot_tasks_completed_total",
|
"steep_bot_tasks_completed_total",
|
||||||
Unit::Count,
|
Unit::Count,
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
use crate::{bot::Bot, env::EnvConfig, gitea::WebhookType};
|
use apalis_redis::RedisStorage;
|
||||||
|
|
||||||
|
use crate::{dedup::Dedup, env::EnvConfig, gitea::WebhookType};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub bot_tx: tokio::sync::mpsc::Sender<WebhookType>,
|
pub storage: RedisStorage<WebhookType>,
|
||||||
pub bot: Bot,
|
pub bot_name: String,
|
||||||
|
pub dedup: Dedup,
|
||||||
pub config: EnvConfig,
|
pub config: EnvConfig,
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
:80 {
|
||||||
|
root * /usr/share/caddy
|
||||||
|
encode zstd gzip
|
||||||
|
file_server
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
FROM caddy:2-alpine
|
||||||
|
|
||||||
|
COPY Caddyfile /etc/caddy/Caddyfile
|
||||||
|
COPY index.html favicon.png banniere_readme.png /usr/share/caddy/
|
||||||
|
After Width: | Height: | Size: 206 KiB |
@@ -0,0 +1,644 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="theme-color" content="#0b1520">
|
||||||
|
<link rel="icon" type="image/png" sizes="512x512" href="favicon.png">
|
||||||
|
<link rel="apple-touch-icon" href="favicon.png">
|
||||||
|
<meta name="description" content="Steep is an open-source Gitea bot that reviews pull requests in isolated Dev Containers.">
|
||||||
|
<title>Steep — Let your code steep</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
--paper: #0b1520;
|
||||||
|
--surface: #111f2d;
|
||||||
|
--ink: #edf4f7;
|
||||||
|
--ink-soft: #c2cfd9;
|
||||||
|
--muted: #94a8b8;
|
||||||
|
--line: #263b4d;
|
||||||
|
--green: #087b5b;
|
||||||
|
--green-dark: #5bd4a7;
|
||||||
|
--green-pale: #153a32;
|
||||||
|
--blue-pale: #1a3044;
|
||||||
|
--shadow: 0 18px 50px rgba(0, 0, 0, 0.24);
|
||||||
|
--button-hover: #066449;
|
||||||
|
--brand-mark-bg: #149b72;
|
||||||
|
--brand-mark-fg: #07111b;
|
||||||
|
--step-line: #23604c;
|
||||||
|
--setup-bg: #07111b;
|
||||||
|
--radius: 20px;
|
||||||
|
--content: 1160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="light"] {
|
||||||
|
color-scheme: light;
|
||||||
|
--paper: #f7f8f5;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--ink: #102f50;
|
||||||
|
--ink-soft: #38536c;
|
||||||
|
--muted: #718092;
|
||||||
|
--line: #e1e8e5;
|
||||||
|
--green: #149b72;
|
||||||
|
--green-dark: #087b5b;
|
||||||
|
--green-pale: #e8f5ef;
|
||||||
|
--blue-pale: #edf3f8;
|
||||||
|
--shadow: 0 18px 50px rgba(16, 47, 80, 0.08);
|
||||||
|
--button-hover: #087b5b;
|
||||||
|
--brand-mark-bg: #102f50;
|
||||||
|
--brand-mark-fg: #ffffff;
|
||||||
|
--step-line: #cce8da;
|
||||||
|
--setup-bg: #102f50;
|
||||||
|
}
|
||||||
|
|
||||||
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
|
html { scroll-behavior: smooth; scroll-padding-top: 90px; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--paper);
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.65;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
a { color: inherit; }
|
||||||
|
img { display: block; max-width: 100%; }
|
||||||
|
.container { width: min(var(--content), calc(100% - 48px)); margin-inline: auto; }
|
||||||
|
|
||||||
|
.site-header {
|
||||||
|
position: sticky;
|
||||||
|
z-index: 5;
|
||||||
|
top: 0;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: var(--paper);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
}
|
||||||
|
.nav {
|
||||||
|
min-height: 76px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 28px;
|
||||||
|
}
|
||||||
|
.wordmark {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 11px;
|
||||||
|
color: var(--ink);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 850;
|
||||||
|
letter-spacing: 0.13em;
|
||||||
|
}
|
||||||
|
.wordmark-mark {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 11px;
|
||||||
|
background: var(--brand-mark-bg);
|
||||||
|
color: var(--brand-mark-fg);
|
||||||
|
font-size: 17px;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
.nav-links { display: flex; align-items: center; gap: 30px; }
|
||||||
|
.nav-links a {
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 650;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 160ms ease;
|
||||||
|
}
|
||||||
|
.nav-links a:hover { color: var(--green-dark); }
|
||||||
|
.nav-links .nav-cta {
|
||||||
|
padding: 10px 16px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.nav-links .nav-cta:hover { border-color: var(--green); color: var(--green-dark); }
|
||||||
|
.theme-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 9px 13px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 160ms ease, color 160ms ease, background 160ms ease;
|
||||||
|
}
|
||||||
|
.theme-toggle:hover { border-color: var(--green-dark); color: var(--green-dark); }
|
||||||
|
.theme-icon { color: var(--green-dark); font-size: 16px; line-height: 1; }
|
||||||
|
|
||||||
|
.hero { padding: 46px 0 94px; }
|
||||||
|
.brand-banner {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 26px;
|
||||||
|
background: white;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.brand-banner img { width: 100%; height: auto; }
|
||||||
|
.hero-copy {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.05fr 0.75fr;
|
||||||
|
align-items: end;
|
||||||
|
gap: 54px;
|
||||||
|
padding: 50px 10px 0;
|
||||||
|
}
|
||||||
|
.eyebrow {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
color: var(--green-dark);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.eyebrow::before {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--green);
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
h1, h2, h3, p { margin-top: 0; }
|
||||||
|
h1 {
|
||||||
|
max-width: 690px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
font-size: clamp(38px, 6vw, 66px);
|
||||||
|
line-height: 1.04;
|
||||||
|
letter-spacing: -0.055em;
|
||||||
|
}
|
||||||
|
.hero-aside p {
|
||||||
|
margin-bottom: 22px;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-size: 17px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
.button-row { display: flex; flex-wrap: wrap; gap: 11px; }
|
||||||
|
.button {
|
||||||
|
display: inline-flex;
|
||||||
|
min-height: 46px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 9px;
|
||||||
|
padding: 11px 18px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--green);
|
||||||
|
color: white;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 750;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: transform 160ms ease, background 160ms ease, box-shadow 160ms ease;
|
||||||
|
}
|
||||||
|
.button:hover { transform: translateY(-2px); background: var(--button-hover); box-shadow: 0 8px 20px rgba(20, 155, 114, 0.2); }
|
||||||
|
.button-secondary { border-color: var(--line); background: var(--surface); color: var(--ink); }
|
||||||
|
.button-secondary:hover { border-color: #b8d8ca; background: var(--green-pale); color: var(--green-dark); box-shadow: none; }
|
||||||
|
.arrow { font-size: 17px; line-height: 1; }
|
||||||
|
|
||||||
|
.section { padding: 82px 0; }
|
||||||
|
.section-white { border-block: 1px solid var(--line); background: var(--surface); }
|
||||||
|
.section-heading { max-width: 700px; margin-bottom: 36px; }
|
||||||
|
.section-heading h2 {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: clamp(30px, 4vw, 44px);
|
||||||
|
line-height: 1.12;
|
||||||
|
letter-spacing: -0.045em;
|
||||||
|
}
|
||||||
|
.section-heading p { margin-bottom: 0; color: var(--muted); font-size: 17px; }
|
||||||
|
.features { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; }
|
||||||
|
.feature-card {
|
||||||
|
min-height: 226px;
|
||||||
|
padding: 27px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--surface);
|
||||||
|
box-shadow: 0 5px 20px rgba(16, 47, 80, 0.025);
|
||||||
|
}
|
||||||
|
.feature-icon {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: var(--green-pale);
|
||||||
|
color: var(--green-dark);
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.feature-card:nth-child(2) .feature-icon { background: var(--blue-pale); color: var(--ink); }
|
||||||
|
.feature-card h3 { margin-bottom: 8px; font-size: 18px; letter-spacing: -0.02em; }
|
||||||
|
.feature-card p { margin-bottom: 0; color: var(--muted); font-size: 14px; }
|
||||||
|
|
||||||
|
.how-grid { display: grid; grid-template-columns: 0.85fr 1.15fr; gap: 70px; align-items: start; }
|
||||||
|
.how-intro { position: sticky; top: 118px; }
|
||||||
|
.how-intro p { color: var(--ink-soft); }
|
||||||
|
.steps-list { display: grid; gap: 0; }
|
||||||
|
.step {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 48px 1fr;
|
||||||
|
gap: 19px;
|
||||||
|
padding: 0 0 30px;
|
||||||
|
}
|
||||||
|
.step + .step { padding-top: 26px; border-top: 1px solid var(--line); }
|
||||||
|
.step-number {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid var(--step-line);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--green-pale);
|
||||||
|
color: var(--green-dark);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.step h3 { margin: 3px 0 6px; font-size: 17px; }
|
||||||
|
.step p { margin-bottom: 0; color: var(--muted); font-size: 14px; }
|
||||||
|
|
||||||
|
.roadmap-list { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; padding: 0; list-style: none; }
|
||||||
|
.roadmap-list li {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 15px;
|
||||||
|
padding: 19px 20px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 15px;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.version {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: 48px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--blue-pale);
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 850;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-section { background: var(--setup-bg); color: white; }
|
||||||
|
.setup-section .eyebrow { color: #8be0bc; }
|
||||||
|
.setup-section .section-heading p { color: #c0cedb; }
|
||||||
|
.setup-section .section-heading a { color: #8be0bc; }
|
||||||
|
.setup-layout { display: grid; grid-template-columns: 1fr 0.9fr; gap: 22px; align-items: start; }
|
||||||
|
.setup-card {
|
||||||
|
padding: 27px;
|
||||||
|
border: 1px solid rgba(255,255,255,0.14);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
}
|
||||||
|
.setup-card h3 { margin-bottom: 19px; font-size: 18px; }
|
||||||
|
.setup-steps { display: grid; gap: 20px; margin: 0; padding: 0; list-style: none; counter-reset: setup; }
|
||||||
|
.setup-steps li { position: relative; padding-left: 40px; counter-increment: setup; }
|
||||||
|
.setup-steps li::before {
|
||||||
|
position: absolute;
|
||||||
|
top: 1px;
|
||||||
|
left: 0;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(139, 224, 188, 0.15);
|
||||||
|
color: #8be0bc;
|
||||||
|
content: counter(setup);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.setup-steps strong { display: block; margin-bottom: 7px; font-size: 14px; }
|
||||||
|
.setup-steps p, .setup-card > p { margin-bottom: 9px; color: #c0cedb; font-size: 13px; }
|
||||||
|
.required-vars { margin: 8px 0 12px; padding-left: 18px; color: #c0cedb; font-size: 12px; }
|
||||||
|
.required-vars li { position: static; padding-left: 0; counter-increment: none; }
|
||||||
|
.required-vars li::before { content: none; }
|
||||||
|
.required-vars li + li { margin-top: 5px; }
|
||||||
|
.required-vars code { overflow-wrap: anywhere; }
|
||||||
|
code, pre { font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; }
|
||||||
|
code { color: inherit; }
|
||||||
|
.setup-card code:not(pre code) {
|
||||||
|
padding: 2px 5px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
color: #d5f5e6;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
pre {
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 9px 0 0;
|
||||||
|
padding: 13px 15px;
|
||||||
|
border: 1px solid rgba(255,255,255,0.12);
|
||||||
|
border-radius: 11px;
|
||||||
|
background: rgba(4, 18, 31, 0.7);
|
||||||
|
color: #e9f3fb;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.7;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
.webhook-card { border-color: rgba(139, 224, 188, 0.3); background: rgba(20, 155, 114, 0.11); }
|
||||||
|
.webhook-card h3 { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.webhook-card h3::before { color: #8be0bc; content: "↗"; }
|
||||||
|
.webhook-card .callout {
|
||||||
|
margin: 15px 0;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-left: 3px solid #8be0bc;
|
||||||
|
border-radius: 0 9px 9px 0;
|
||||||
|
background: rgba(255,255,255,0.07);
|
||||||
|
color: #e4f4ed;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.setup-card .subtle { margin-top: 13px; color: #aabaca; font-size: 12px; }
|
||||||
|
.source-link { display: inline-flex; align-items: center; gap: 8px; margin-top: 24px; color: #8be0bc; font-size: 14px; font-weight: 750; text-decoration: none; }
|
||||||
|
.source-link:hover { color: white; }
|
||||||
|
|
||||||
|
footer { padding: 26px 0; background: #0b233b; color: #b6c5d3; font-size: 13px; }
|
||||||
|
.footer-inner { display: flex; align-items: center; justify-content: space-between; gap: 20px; }
|
||||||
|
footer a { color: white; text-decoration: none; }
|
||||||
|
footer a:hover { color: #8be0bc; }
|
||||||
|
.footer-brand { color: white; font-weight: 800; letter-spacing: 0.1em; }
|
||||||
|
|
||||||
|
:focus-visible { outline: 3px solid #35bb8e; outline-offset: 4px; }
|
||||||
|
@media (max-width: 820px) {
|
||||||
|
.nav-links { gap: 18px; }
|
||||||
|
.hero-copy, .how-grid, .setup-layout { grid-template-columns: 1fr; gap: 26px; }
|
||||||
|
.hero-copy { padding: 36px 4px 0; }
|
||||||
|
.hero-aside p { max-width: 620px; }
|
||||||
|
.how-intro { position: static; }
|
||||||
|
.features { grid-template-columns: 1fr; }
|
||||||
|
.feature-card { min-height: auto; }
|
||||||
|
}
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.container { width: min(var(--content), calc(100% - 32px)); }
|
||||||
|
.nav { min-height: 66px; gap: 12px; }
|
||||||
|
.nav-links { gap: 12px; }
|
||||||
|
.nav-links a { font-size: 12px; }
|
||||||
|
.nav-links a:nth-child(1), .nav-links a:nth-child(2), .nav-links .nav-cta { display: none; }
|
||||||
|
.theme-toggle { padding: 8px 10px; font-size: 12px; }
|
||||||
|
.wordmark { font-size: 15px; }
|
||||||
|
.wordmark-mark { width: 30px; height: 30px; }
|
||||||
|
.hero { padding: 22px 0 64px; }
|
||||||
|
.brand-banner { border-radius: 16px; }
|
||||||
|
.hero-copy { padding-top: 29px; }
|
||||||
|
h1 { font-size: clamp(38px, 11vw, 54px); }
|
||||||
|
.section { padding: 62px 0; }
|
||||||
|
.roadmap-list { grid-template-columns: 1fr; }
|
||||||
|
.setup-card { padding: 21px; }
|
||||||
|
.footer-inner { align-items: flex-start; flex-direction: column; gap: 6px; }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: 0.01ms !important; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<script>
|
||||||
|
try {
|
||||||
|
const savedTheme = localStorage.getItem("steep-theme");
|
||||||
|
if (savedTheme === "light" || savedTheme === "dark") {
|
||||||
|
document.documentElement.dataset.theme = savedTheme;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="site-header">
|
||||||
|
<nav class="nav container" aria-label="Main navigation">
|
||||||
|
<a class="wordmark" href="#top" aria-label="Steep home"><span class="wordmark-mark">S</span> STEEP</a>
|
||||||
|
<div class="nav-links">
|
||||||
|
<a href="#features">Features</a>
|
||||||
|
<a href="#roadmap">Roadmap</a>
|
||||||
|
<a href="#setup">Get started</a>
|
||||||
|
<a class="nav-cta" href="https://gitea.qpismont.fr/qpismont/herald">View source <span class="arrow" aria-hidden="true">↗</span></a>
|
||||||
|
<button class="theme-toggle" id="theme-toggle" type="button" role="switch" aria-checked="true" aria-label="Dark theme">
|
||||||
|
<span class="theme-icon" id="theme-icon" aria-hidden="true">☾</span><span>Dark theme</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main id="top">
|
||||||
|
<section class="hero container" aria-labelledby="hero-title">
|
||||||
|
<div class="brand-banner">
|
||||||
|
<img src="banniere_readme.png" alt="Steep — Let your code steep">
|
||||||
|
</div>
|
||||||
|
<div class="hero-copy">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">AI code review · built for Gitea</p>
|
||||||
|
<h1 id="hero-title">Code reviews with the full picture.</h1>
|
||||||
|
</div>
|
||||||
|
<div class="hero-aside">
|
||||||
|
<p>Steep explores pull requests inside an isolated Dev Container, then leaves focused, line-by-line feedback right where your team works.</p>
|
||||||
|
<div class="button-row">
|
||||||
|
<a class="button" href="#setup">Get started <span class="arrow" aria-hidden="true">↓</span></a>
|
||||||
|
<a class="button button-secondary" href="#how-it-works">How it works</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section section-white" id="features" aria-labelledby="features-title">
|
||||||
|
<div class="container">
|
||||||
|
<div class="section-heading">
|
||||||
|
<p class="eyebrow">Thoughtful by design</p>
|
||||||
|
<h2 id="features-title">Useful reviews. No noisy pipeline.</h2>
|
||||||
|
<p>Bring an AI reviewer into your Gitea workflow with a single comment.</p>
|
||||||
|
</div>
|
||||||
|
<div class="features">
|
||||||
|
<article class="feature-card">
|
||||||
|
<div class="feature-icon" aria-hidden="true">↳</div>
|
||||||
|
<h3>Reviews on demand</h3>
|
||||||
|
<p>Mention Steep in a pull-request comment and get an AI review streamed back to the PR. No CI hook to wire, no pipeline to learn.</p>
|
||||||
|
</article>
|
||||||
|
<article class="feature-card">
|
||||||
|
<div class="feature-icon" aria-hidden="true">◇</div>
|
||||||
|
<h3>Sandboxed by design</h3>
|
||||||
|
<p>Every review runs in an ephemeral Dev Container with read-only tools. The network is cut after setup, and the container is removed when the review ends.</p>
|
||||||
|
</article>
|
||||||
|
<article class="feature-card">
|
||||||
|
<div class="feature-icon" aria-hidden="true">⌁</div>
|
||||||
|
<h3>Lightweight & observable</h3>
|
||||||
|
<p>A lean Rust service designed for fast reviews, with Prometheus metrics and Sentry error tracking.</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section" id="how-it-works" aria-labelledby="how-title">
|
||||||
|
<div class="container how-grid">
|
||||||
|
<div class="how-intro">
|
||||||
|
<p class="eyebrow">From comment to context</p>
|
||||||
|
<div class="section-heading">
|
||||||
|
<h2 id="how-title">Your code stays in its environment.</h2>
|
||||||
|
<p>Steep gives the model tools to inspect the repository instead of sending the source code in the prompt.</p>
|
||||||
|
</div>
|
||||||
|
<a class="button button-secondary" href="#setup">Set up Steep <span class="arrow" aria-hidden="true">↓</span></a>
|
||||||
|
</div>
|
||||||
|
<div class="steps-list">
|
||||||
|
<article class="step">
|
||||||
|
<span class="step-number">01</span>
|
||||||
|
<div><h3>Steep hears your request</h3><p>A Gitea pull-request comment triggers a review when you mention the bot.</p></div>
|
||||||
|
</article>
|
||||||
|
<article class="step">
|
||||||
|
<span class="step-number">02</span>
|
||||||
|
<div><h3>A disposable workspace spins up</h3><p>Steep clones the pull-request head and starts the repository's Dev Container—or a safe default when none is configured.</p></div>
|
||||||
|
</article>
|
||||||
|
<article class="step">
|
||||||
|
<span class="step-number">03</span>
|
||||||
|
<div><h3>The model explores, read-only</h3><p>It can inspect files with tools such as <code>ls</code>, <code>read_file</code>, and <code>grep</code>. Setup hooks run first; then network access is removed.</p></div>
|
||||||
|
</article>
|
||||||
|
<article class="step">
|
||||||
|
<span class="step-number">04</span>
|
||||||
|
<div><h3>Feedback lands on the changed lines</h3><p>Steep posts actionable findings with severity labels, plus a concise summary of what the PR does well.</p></div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section section-white" id="roadmap" aria-labelledby="roadmap-title">
|
||||||
|
<div class="container">
|
||||||
|
<div class="section-heading">
|
||||||
|
<p class="eyebrow">What’s brewing</p>
|
||||||
|
<h2 id="roadmap-title">The roadmap</h2>
|
||||||
|
<p>From safer reviews to an assistant that can help move work forward.</p>
|
||||||
|
</div>
|
||||||
|
<ul class="roadmap-list">
|
||||||
|
<li><span class="version">1.2</span><span>Sandboxed pull-request reviews.</span></li>
|
||||||
|
<li><span class="version">1.2.1</span><span>A default Dev Container when a repository has no configuration.</span></li>
|
||||||
|
<li><span class="version">1.3</span><span>Type-safe routing between review and implementation requests.</span></li>
|
||||||
|
<li><span class="version">2.0</span><span>Implement requested changes from issues or existing PRs, then create or update a PR automatically.</span></li>
|
||||||
|
<li><span class="version">2.1</span><span>Project instructions from <code>AGENTS.md</code> and separate review/implementation models.</span></li>
|
||||||
|
<li><span class="version">3.0</span><span>Automatically investigate and fix project errors reported by Sentry.</span></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section setup-section" id="setup" aria-labelledby="setup-title">
|
||||||
|
<div class="container">
|
||||||
|
<div class="section-heading">
|
||||||
|
<p class="eyebrow">A few minutes to get steeping</p>
|
||||||
|
<h2 id="setup-title">Bring Steep to your Gitea.</h2>
|
||||||
|
<p>Follow these steps in order. You will need a <a href="https://rustup.rs/">Rust toolchain</a>, a reachable Docker or Podman daemon, an OpenRouter API key, and a Gitea account for Steep.</p>
|
||||||
|
</div>
|
||||||
|
<div class="setup-layout">
|
||||||
|
<article class="setup-card">
|
||||||
|
<h3>1. Install and configure</h3>
|
||||||
|
<ol class="setup-steps">
|
||||||
|
<li>
|
||||||
|
<strong>Clone the source</strong>
|
||||||
|
<pre>git clone https://gitea.qpismont.fr/qpismont/herald.git
|
||||||
|
cd herald</pre>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Create a Gitea token</strong>
|
||||||
|
<p>Create a token for the account Steep should act as. Give it access to the repositories it will review, including permission to read pull requests and publish comments/reviews. Steep uses this token to identify the account; you will use that account's username when asking for a review.</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Configure every required setting</strong>
|
||||||
|
<pre>cp .env.example .env
|
||||||
|
$EDITOR .env</pre>
|
||||||
|
<p>Set all nine required variables:</p>
|
||||||
|
qpismont marked this conversation as resolved
Herald
commented
[bug] Le guide demande de configurer neuf variables, mais omet **[bug]** Le guide demande de configurer neuf variables, mais omet `DATABASE_URL`, désormais obligatoire, puis passe directement au lancement sans expliquer comment appliquer les migrations. Un nouvel utilisateur suivant ces étapes ne peut pas démarrer le service. Ajoutez la configuration de PostgreSQL et l’étape `migration apply`.
|
|||||||
|
<ul class="required-vars">
|
||||||
|
<li><code>GITEA_URL</code> — base URL, e.g. <code>https://gitea.example.com</code> (no <code>/api/v1</code>)</li>
|
||||||
|
<li><code>GITEA_TOKEN</code> — the token created above</li>
|
||||||
|
<li><code>GITEA_TIMEOUT</code> — API timeout in seconds</li>
|
||||||
|
<li><code>OPEN_ROUTER_API_KEY</code> — your OpenRouter key</li>
|
||||||
|
<li><code>OPEN_ROUTER_MODEL</code> — an available OpenRouter model ID</li>
|
||||||
|
<li><code>OPEN_ROUTER_TIMEOUT</code> — request timeout in seconds</li>
|
||||||
|
<li><code>BOT_MAX_CONCURRENT</code> — maximum simultaneous reviews</li>
|
||||||
|
<li><code>HTTP_PORT</code> — web server port; use <code>3001</code> for the commands below</li>
|
||||||
|
<li><code>WEBHOOK_SIG_HEADER_SECRET</code> — generate this in the next step</li>
|
||||||
|
</ul>
|
||||||
|
<p class="subtle">Keep the example's numeric values for the timeouts and concurrency, or replace them with positive integers. Do not commit your completed <code>.env</code>.</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Generate and configure the webhook secret</strong>
|
||||||
|
<pre>openssl rand -hex 32</pre>
|
||||||
|
<p>Copy the output into <code>WEBHOOK_SIG_HEADER_SECRET</code> in <code>.env</code>. You will enter the exact same value in Gitea in the next step.</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Start the daemon, build, and launch Steep</strong>
|
||||||
|
<pre>podman system service --time=0 &
|
||||||
|
export DOCKER_HOST=unix:///run/user/$(id -u)/podman/podman.sock
|
||||||
|
cargo build --release -p steep-server
|
||||||
|
./target/release/steep-server</pre>
|
||||||
|
<p class="subtle">Keep this process running. With Docker, omit the Podman command and use its socket; the default is <code>unix:///var/run/docker.sock</code>. Optional settings such as metrics and Sentry can be added later.</p>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
<a class="source-link" href="https://gitea.qpismont.fr/qpismont/herald">Explore the source on Gitea <span aria-hidden="true">↗</span></a>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="setup-card webhook-card">
|
||||||
|
<h3>2. Add the Gitea webhook</h3>
|
||||||
|
<ol class="setup-steps">
|
||||||
|
<li>
|
||||||
|
<strong>Choose an address Gitea can reach</strong>
|
||||||
|
<p>In the repository's <strong>Settings → Webhooks</strong>, add a Gitea webhook. Set the target to Steep's public/reachable address and port:</p>
|
||||||
|
<div class="callout"><code>http://steep.example.com:3001/webhook</code><br>Replace the host and scheme with yours. Do not use <code>localhost</code> unless Gitea and Steep share that network.</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Select the event and set the secret</strong>
|
||||||
|
<p>Enable at least <strong>Pull Request Comment</strong>. Paste the generated value into Gitea's <strong>Secret</strong> field; it must exactly match <code>WEBHOOK_SIG_HEADER_SECRET</code>.</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Allow the connection</strong>
|
||||||
|
<p>Make sure Gitea can connect to Steep on port <code>3001</code> (or your configured <code>HTTP_PORT</code>), including through any firewall or reverse proxy. Steep must also be able to reach Gitea's API, OpenRouter, and the container daemon.</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Trigger a review</strong>
|
||||||
|
<p>Open a pull request and add a comment that starts with <code>@username</code>, using the username of the Gitea account behind <code>GITEA_TOKEN</code>. For example:</p>
|
||||||
|
<pre>@steep review this PR</pre>
|
||||||
|
<p class="subtle">The mention must be at the very start of the comment. If nothing happens, check the webhook's delivery history in Gitea and Steep's logs.</p>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<div class="container footer-inner">
|
||||||
|
<span><span class="footer-brand">STEEP</span> · Let your code steep.</span>
|
||||||
|
<span>Built with Rust · <a href="https://gitea.qpismont.fr/qpismont/herald">Source on Gitea</a></span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
<script>
|
||||||
|
const themeToggle = document.getElementById("theme-toggle");
|
||||||
|
const themeIcon = document.getElementById("theme-icon");
|
||||||
|
const themeColor = document.querySelector('meta[name="theme-color"]');
|
||||||
|
|
||||||
|
function setTheme(theme, persist = true) {
|
||||||
|
document.documentElement.dataset.theme = theme;
|
||||||
|
themeToggle.setAttribute("aria-checked", String(theme === "dark"));
|
||||||
|
themeIcon.textContent = theme === "dark" ? "☾" : "☀";
|
||||||
|
themeColor.content = theme === "dark" ? "#0b1520" : "#f7f8f5";
|
||||||
|
|
||||||
|
if (persist) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem("steep-theme", theme);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setTheme(document.documentElement.dataset.theme || "dark", false);
|
||||||
|
themeToggle.addEventListener("click", () => {
|
||||||
|
setTheme(document.documentElement.dataset.theme === "dark" ? "light" : "dark");
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
IMAGE="docker.io/tintounn/steep-website"
|
||||||
|
|
||||||
|
if [ -z "${CI_COMMIT_TAG:-}" ]; then
|
||||||
|
echo "Error: CI_COMMIT_TAG is not set" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
TAG="${CI_COMMIT_TAG}"
|
||||||
|
IMAGE_REF="${IMAGE}:${TAG}"
|
||||||
|
|
||||||
|
echo "Building ${IMAGE_REF}..."
|
||||||
|
buildah build \
|
||||||
|
--file public/Containerfile \
|
||||||
|
--tag "${IMAGE_REF}" \
|
||||||
|
public
|
||||||
|
|
||||||
|
echo "Pushing ${IMAGE_REF}..."
|
||||||
|
buildah push "${IMAGE_REF}"
|
||||||
|
|
||||||
|
echo "Done: ${IMAGE_REF}"
|
||||||
[bug] L’attente d’un permis n’écoute pas
shutdown. Si tous les slots sont occupés au moment de l’arrêt, le bot attend qu’un slot se libère puis lance quand même le job en attente, et peut continuer ainsi à vider la file. Cela contredit le comportement annoncé où seuls les jobs déjà en cours terminent ; rendez aussi cette attente interruptible.