diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 1b89658..ba2efc9 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,4 +1,4 @@ -FROM debian:trixie +FROM rust:1.97-trixie ARG USERNAME=dev ARG USER_UID=1000 @@ -18,11 +18,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && rm -rf /var/lib/apt/lists/* RUN groupadd --gid ${USER_GID:-1000} $USERNAME \ - && useradd --uid ${USER_UID:-1000} --gid ${USER_GID:-1000} -m $USERNAME + && useradd --uid ${USER_UID:-1000} --gid ${USER_GID:-1000} -m $USERNAME \ + && rustup component add clippy \ + && rustup component add rustfmt + + USER $USERNAME WORKDIR /home/$USERNAME - -ENV PATH="/home/${USERNAME}/.cargo/bin:${PATH}" - -RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index a0fc52a..b9310d6 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -19,5 +19,6 @@ }, "workspaceMount": "source=${localWorkspaceFolder},target=/workspaces/herald,type=bind", "workspaceFolder": "/workspaces/herald", + "runArgs": ["--userns=keep-id", "--security-opt", "label=disable"], "appPort": [3000] } diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 89343aa..0000000 --- a/.dockerignore +++ /dev/null @@ -1,4 +0,0 @@ -target/ -.env -.devcontainer/ -docs/ diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 660eb93..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "rust-analyzer.check.command": "clippy" -} \ No newline at end of file diff --git a/.woodpecker/tests.yml b/.woodpecker/tests.yml index b151ff1..7d128af 100644 --- a/.woodpecker/tests.yml +++ b/.woodpecker/tests.yml @@ -3,13 +3,27 @@ when: - push steps: + - name: fmt + image: rust:1.97 + commands: + - rustup component add rustfmt + - cargo fmt --all -- --check + - name: clippy - image: rust:1.96 + image: rust:1.97 commands: - rustup component add clippy - - cargo clippy + - cargo clippy --workspace --all-targets --all-features -- -D warnings - name: test - image: rust:1.96 + image: rust:1.97 commands: - - cargo test + - cargo test --workspace --all-targets + + - name: container-build + image: quay.io/buildah/stable + privileged: true + volumes: + - /data/woodpecker-builds:/data + commands: + - buildah bud -f Containerfile -t herald-ci . diff --git a/.zed/settings.json b/.zed/settings.json new file mode 100644 index 0000000..c873cae --- /dev/null +++ b/.zed/settings.json @@ -0,0 +1,22 @@ +{ + "languages": { + "Rust": { + "format_on_save": "on", + "formatter": "language_server" + } + }, + "lsp": { + "rust-analyzer": { + "initialization_options": { + "check": { + "command": "clippy", + "extraArgs": [ + "--", + "-D", + "warnings" + ] + } + } + } + } +} diff --git a/Cargo.lock b/Cargo.lock index 7f4d25b..3def019 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -490,6 +490,18 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "devcontainer-rs" +version = "0.1.0" +dependencies = [ + "anyhow", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.18", + "tokio", +] + [[package]] name = "dispatch2" version = "0.3.1" @@ -787,12 +799,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] -name = "herald" -version = "1.1.0" +name = "herald-server" +version = "1.2.0" dependencies = [ "anyhow", "axum", "bytes", + "devcontainer-rs", "dotenvy", "futures-util", "hex", diff --git a/Cargo.toml b/Cargo.toml index 3340e25..0365198 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,11 @@ -[package] -name = "herald" -version = "1.1.0" -edition = "2024" +[workspace] +members = [ + "crates/herald-server", + "crates/devcontainer-rs", +] +resolver = "3" -[profile.release] -debug = 1 - -[dependencies] +[workspace.dependencies] reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } tokio = { version = "1.53", features = ["full"] } tokio-stream = "0.1" @@ -19,9 +18,9 @@ sentry-anyhow = { version = "0.48", features = ["backtrace"] } openrouter-rs = "0.12" dotenvy = "0.15" tower = "0.5" -tower-http = {version = "0.6", features = ["trace"] } +tower-http = { version = "0.6", features = ["trace"] } tracing = "0.1" -tracing-subscriber = { version = "0.3", features=["env-filter"] } +tracing-subscriber = { version = "0.3", features = ["env-filter"] } axum = "0.8" anyhow = { version = "1.0", features = ["backtrace"] } thiserror = "2.0" @@ -30,3 +29,6 @@ hex = "0.4" bytes = "1.1" metrics = "0.24" metrics-exporter-prometheus = { version = "0.18", default-features = false, features = ["http-listener"] } + +[profile.release] +debug = 1 diff --git a/Containerfile b/Containerfile index 6508b0c..7150f27 100644 --- a/Containerfile +++ b/Containerfile @@ -1,12 +1,15 @@ -FROM rust:1.96 as builder +FROM rust:1.97-trixie as builder WORKDIR /app -COPY . . -RUN cargo build --release + +COPY Cargo.toml Cargo.lock ./ +COPY crates/ crates/ + +RUN cargo build --release --package herald-server FROM debian:trixie-slim WORKDIR /app -COPY --from=builder /app/target/release/herald . -CMD [ "./herald" ] +COPY --from=builder /app/target/release/herald-server . +CMD [ "./herald-server" ] diff --git a/crates/devcontainer-rs/Cargo.toml b/crates/devcontainer-rs/Cargo.toml new file mode 100644 index 0000000..1dd07fb --- /dev/null +++ b/crates/devcontainer-rs/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "devcontainer-rs" +version = "0.1.0" +edition = "2024" + +[dependencies] +tokio = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +anyhow = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +tempfile = "3" diff --git a/crates/devcontainer-rs/src/lib.rs b/crates/devcontainer-rs/src/lib.rs new file mode 100644 index 0000000..81960de --- /dev/null +++ b/crates/devcontainer-rs/src/lib.rs @@ -0,0 +1,152 @@ +use std::{ + collections::HashMap, + path::{Path, PathBuf}, +}; + +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +pub struct DevContainerBuildSchema { + #[serde(default)] + pub dockerfile: String, + #[serde(default)] + pub args: HashMap, +} + +#[derive(Debug, Deserialize)] +pub struct DevContainerSchema { + #[serde(default)] + pub name: Option, + pub build: DevContainerBuildSchema, + + #[serde(rename = "workspaceFolder", default)] + pub workspace_folder: Option, + + #[serde(rename = "containerEnv", default)] + pub container_env: HashMap, + + #[serde(rename = "postCreateCommand", default)] + pub post_create_command: Option, + + #[serde(rename = "postStartCommand", default)] + pub post_start_command: Option, +} + +#[derive(Debug)] +pub struct DevContainer { + pub container_file_path: PathBuf, + pub name: Option, + pub build_args: HashMap, + pub container_env: HashMap, + pub workspace_folder: Option, + pub post_create_command: Option, + pub post_start_command: Option, +} + +#[derive(Debug, thiserror::Error)] +pub enum ParseError { + #[error("failed to read devcontainer file `{path}`: {source}")] + Read { + path: PathBuf, + source: std::io::Error, + }, + + #[error("invalid devcontainer JSON in `{path}`: {source}")] + Json { + path: PathBuf, + source: serde_json::Error, + }, + + #[error("container file `{0}` does not exist or is not a regular file")] + ContainerFileNotFound(PathBuf), + + #[error("the devcontainer file path has no parent directory: `{0}`")] + InvalidDevContainerPath(PathBuf), +} + +impl TryFrom<(DevContainerSchema, PathBuf)> for DevContainer { + type Error = ParseError; + + fn try_from( + (schema, devcontainer_path): (DevContainerSchema, PathBuf), + ) -> Result { + let base_dir = devcontainer_path + .parent() + .ok_or_else(|| ParseError::InvalidDevContainerPath(devcontainer_path.clone()))?; + + let container_file_path = base_dir.join(schema.build.dockerfile); + + if !container_file_path.is_file() { + return Err(ParseError::ContainerFileNotFound(container_file_path)); + } + + Ok(Self { + container_file_path, + name: schema.name, + build_args: schema.build.args, + container_env: schema.container_env, + workspace_folder: schema.workspace_folder, + post_create_command: schema.post_create_command, + post_start_command: schema.post_start_command, + }) + } +} + +pub async fn parse(path: impl AsRef) -> Result { + let path = path.as_ref().to_path_buf(); + let contents = tokio::fs::read_to_string(&path) + .await + .map_err(|source| ParseError::Read { + path: path.clone(), + source, + })?; + + let schema = serde_json::from_str::(&contents).map_err(|source| { + ParseError::Json { + path: path.clone(), + source, + } + })?; + + DevContainer::try_from((schema, path)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[tokio::test] + async fn parses_devcontainer_file() { + let dir = tempfile::tempdir().unwrap(); + let devcontainer_path = dir.path().join("devcontainer.json"); + let dockerfile_path = dir.path().join("Dockerfile"); + + fs::write(&dockerfile_path, "FROM alpine\n").unwrap(); + fs::write( + &devcontainer_path, + r#"{ + "name": "test", + "build": { + "dockerfile": "Dockerfile", + "args": { + "VERSION": "1" + } + }, + "workspaceFolder": "/workspace", + "containerEnv": { + "RUST_LOG": "debug" + } + }"#, + ) + .unwrap(); + + let config = parse(&devcontainer_path).await.unwrap(); + + assert_eq!(config.name.as_deref(), Some("test")); + assert_eq!(config.container_file_path, dockerfile_path); + assert_eq!(config.build_args.get("VERSION").unwrap(), "1"); + assert_eq!(config.container_env.get("RUST_LOG").unwrap(), "debug"); + assert_eq!(config.workspace_folder.as_deref(), Some("/workspace")); + } +} diff --git a/crates/herald-server/Cargo.toml b/crates/herald-server/Cargo.toml new file mode 100644 index 0000000..8e015d9 --- /dev/null +++ b/crates/herald-server/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "herald-server" +version = "1.2.0" +edition = "2024" + +[dependencies] +reqwest = { workspace = true } +tokio = { workspace = true } +tokio-stream = { workspace = true } +tokio-util = { workspace = true } +futures-util = { workspace = true } +serde_json = { workspace = true } +serde = { workspace = true } +sentry = { workspace = true } +sentry-anyhow = { workspace = true } +openrouter-rs = { workspace = true } +dotenvy = { workspace = true } +tower = { workspace = true } +tower-http = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +axum = { workspace = true } +anyhow = { workspace = true } +thiserror = { workspace = true } +ring = { workspace = true } +hex = { workspace = true } +bytes = { workspace = true } +metrics = { workspace = true } +metrics-exporter-prometheus = { workspace = true } +devcontainer-rs = { path = "../devcontainer-rs" } \ No newline at end of file diff --git a/src/api.rs b/crates/herald-server/src/api.rs similarity index 100% rename from src/api.rs rename to crates/herald-server/src/api.rs diff --git a/src/bot.rs b/crates/herald-server/src/bot.rs similarity index 98% rename from src/bot.rs rename to crates/herald-server/src/bot.rs index 470990f..0a4482c 100644 --- a/src/bot.rs +++ b/crates/herald-server/src/bot.rs @@ -1,5 +1,7 @@ use crate::{ - gitea::{GiteaAPI, WebhookType}, metrics, open_router::OpenRouterClient, + gitea::{GiteaAPI, WebhookType}, + metrics, + open_router::OpenRouterClient, }; use serde::Deserialize; use std::{collections::HashSet, sync::Arc}; @@ -18,7 +20,6 @@ pub struct ReviewResult { pub struct ReviewItem { pub filename: String, pub line: Option, - pub code: String, pub message: String, } diff --git a/src/bot_actions/mod.rs b/crates/herald-server/src/bot_actions/mod.rs similarity index 100% rename from src/bot_actions/mod.rs rename to crates/herald-server/src/bot_actions/mod.rs diff --git a/src/bot_actions/review.rs b/crates/herald-server/src/bot_actions/review.rs similarity index 97% rename from src/bot_actions/review.rs rename to crates/herald-server/src/bot_actions/review.rs index c156aa2..8ca234a 100644 --- a/src/bot_actions/review.rs +++ b/crates/herald-server/src/bot_actions/review.rs @@ -4,7 +4,11 @@ use tokio_util::io::StreamReader; use tracing::instrument; use crate::{ - bot::ReviewResult, consts::{BOT_PROCESS_MSG, MAX_DIFF_SIZE, REVIEW_PROMPT}, gitea::{GiteaAPI, ReviewPayload}, metrics, open_router::OpenRouterClient, + bot::ReviewResult, + consts::{BOT_PROCESS_MSG, MAX_DIFF_SIZE, REVIEW_PROMPT}, + gitea::{GiteaAPI, ReviewPayload}, + metrics, + open_router::OpenRouterClient, }; #[instrument(skip(gitea_api, open_router_client, http_client, review_payload))] diff --git a/src/consts.rs b/crates/herald-server/src/consts.rs similarity index 100% rename from src/consts.rs rename to crates/herald-server/src/consts.rs diff --git a/src/env.rs b/crates/herald-server/src/env.rs similarity index 100% rename from src/env.rs rename to crates/herald-server/src/env.rs diff --git a/src/errors.rs b/crates/herald-server/src/errors.rs similarity index 100% rename from src/errors.rs rename to crates/herald-server/src/errors.rs diff --git a/src/gitea.rs b/crates/herald-server/src/gitea.rs similarity index 97% rename from src/gitea.rs rename to crates/herald-server/src/gitea.rs index 8477b4b..b6b48fb 100644 --- a/src/gitea.rs +++ b/crates/herald-server/src/gitea.rs @@ -197,7 +197,6 @@ pub struct ReviewPayload { #[derive(Deserialize, Debug)] pub struct PullRequest { - pub id: u64, pub diff_url: String, pub number: u64, pub title: String, @@ -207,12 +206,10 @@ pub struct PullRequest { pub struct Comment { pub id: u64, pub body: String, - pub user: User, } #[derive(Deserialize, Debug)] pub struct User { - pub id: u64, pub login: String, } @@ -282,10 +279,8 @@ mod tests { match result.unwrap() { WebhookType::Review(payload) => { assert_eq!(payload.action, "created"); - assert_eq!(payload.pull_request.id, 42); assert_eq!(payload.comment.id, 7); assert_eq!(payload.comment.body, "@test_bot LGTM"); - assert_eq!(payload.comment.user.id, 100); } } } @@ -375,10 +370,8 @@ mod tests { let payload: ReviewPayload = serde_json::from_value(json).unwrap(); assert_eq!(payload.action, "created"); - assert_eq!(payload.pull_request.id, 99); assert_eq!(payload.comment.id, 12); assert_eq!(payload.comment.body, "Needs work"); - assert_eq!(payload.comment.user.id, 200); } #[test] diff --git a/src/main.rs b/crates/herald-server/src/main.rs similarity index 95% rename from src/main.rs rename to crates/herald-server/src/main.rs index 27d0554..3a5ab03 100644 --- a/src/main.rs +++ b/crates/herald-server/src/main.rs @@ -1,4 +1,9 @@ -use crate::{bot::Bot, gitea::{GiteaAPI, WebhookType}, open_router::OpenRouterClient, state::AppState}; +use crate::{ + bot::Bot, + gitea::{GiteaAPI, WebhookType}, + open_router::OpenRouterClient, + state::AppState, +}; use dotenvy::dotenv; use tokio::signal::unix::{SignalKind, signal}; diff --git a/src/metrics.rs b/crates/herald-server/src/metrics.rs similarity index 56% rename from src/metrics.rs rename to crates/herald-server/src/metrics.rs index 23698d8..44d3347 100644 --- a/src/metrics.rs +++ b/crates/herald-server/src/metrics.rs @@ -1,10 +1,9 @@ use std::{net::SocketAddr, str::FromStr}; -use metrics::{Unit, describe_counter, describe_gauge, counter, gauge}; +use metrics::{Unit, counter, describe_counter, describe_gauge, gauge}; pub fn webhook_received(event_type: &str) { - counter!("herald_webhooks_received_total", "event_type" => event_type.to_string()) - .increment(1); + counter!("herald_webhooks_received_total", "event_type" => event_type.to_string()).increment(1); } pub fn webhook_duplicate(event_type: &str) { @@ -31,8 +30,7 @@ pub fn task_completed(event_type: &str) { } pub fn task_failed(event_type: &str) { - counter!("herald_bot_tasks_failed_total", "event_type" => event_type.to_string()) - .increment(1); + counter!("herald_bot_tasks_failed_total", "event_type" => event_type.to_string()).increment(1); } pub fn openrouter_cost_usd(cost: f64) { @@ -40,13 +38,41 @@ pub fn openrouter_cost_usd(cost: f64) { } pub fn describe() { - describe_counter!("herald_webhooks_received_total", Unit::Count, "Total webhooks received"); - describe_counter!("herald_webhooks_duplicate_total", Unit::Count, "Webhooks rejected as duplicates"); - describe_counter!("herald_webhooks_channel_full_total", Unit::Count, "Webhooks dropped because the bot channel was full"); - describe_gauge!("herald_bot_tasks_active", Unit::Count, "Bot tasks currently in progress"); - describe_counter!("herald_bot_tasks_completed_total", Unit::Count, "Bot tasks completed successfully"); - describe_counter!("herald_bot_tasks_failed_total", Unit::Count, "Bot tasks that failed"); - describe_counter!("herald_openrouter_cost_cents_total", Unit::Count, "Total OpenRouter cost in cents (divide by 100 for USD)"); + describe_counter!( + "herald_webhooks_received_total", + Unit::Count, + "Total webhooks received" + ); + describe_counter!( + "herald_webhooks_duplicate_total", + Unit::Count, + "Webhooks rejected as duplicates" + ); + describe_counter!( + "herald_webhooks_channel_full_total", + Unit::Count, + "Webhooks dropped because the bot channel was full" + ); + describe_gauge!( + "herald_bot_tasks_active", + Unit::Count, + "Bot tasks currently in progress" + ); + describe_counter!( + "herald_bot_tasks_completed_total", + Unit::Count, + "Bot tasks completed successfully" + ); + describe_counter!( + "herald_bot_tasks_failed_total", + Unit::Count, + "Bot tasks that failed" + ); + describe_counter!( + "herald_openrouter_cost_cents_total", + Unit::Count, + "Total OpenRouter cost in cents (divide by 100 for USD)" + ); } pub fn install(bind_addr: &str) -> anyhow::Result<()> { diff --git a/src/open_router.rs b/crates/herald-server/src/open_router.rs similarity index 100% rename from src/open_router.rs rename to crates/herald-server/src/open_router.rs diff --git a/src/state.rs b/crates/herald-server/src/state.rs similarity index 100% rename from src/state.rs rename to crates/herald-server/src/state.rs