diff --git a/.wrangler/cache/wrangler-account.json b/.wrangler/cache/wrangler-account.json new file mode 100644 index 0000000..cefbeb5 --- /dev/null +++ b/.wrangler/cache/wrangler-account.json @@ -0,0 +1,6 @@ +{ + "account": { + "id": "554e131b095636ce2be18cf4fb288502", + "name": "S3anjia@gmail.com's Account" + } +} \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index ec40ab8..53bf37f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,24 @@ dependencies = [ "memchr", ] +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "arc-swap" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f3647c145568cec02c42054e07bdf9a5a698e15b466fb2341bfc393cd24aa5" +dependencies = [ + "rustversion", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -170,11 +188,20 @@ version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ + "iana-time-zone", "js-sys", "num-traits", + "serde", "wasm-bindgen", + "windows-link", ] +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -194,6 +221,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + [[package]] name = "digest" version = "0.10.7" @@ -216,6 +252,12 @@ dependencies = [ "syn", ] +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "figment" version = "0.10.19" @@ -244,6 +286,21 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.31" @@ -251,6 +308,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -259,6 +317,17 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + [[package]] name = "futures-io" version = "0.3.32" @@ -294,6 +363,7 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -342,6 +412,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hex" version = "0.4.3" @@ -464,6 +540,30 @@ dependencies = [ "tracing", ] +[[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]] name = "icu_collections" version = "2.1.1" @@ -604,6 +704,21 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + [[package]] name = "larkstack" version = "0.1.0" @@ -614,6 +729,7 @@ dependencies = [ "hex", "hmac", "http-body-util", + "octocrab", "reqwest", "serde", "serde_json", @@ -710,6 +826,31 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -719,6 +860,41 @@ dependencies = [ "autocfg", ] +[[package]] +name = "octocrab" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b97f949a7cb04608441c2ddb28e15a377e8b5142c2d1835ad2686d434de8558" +dependencies = [ + "arc-swap", + "async-trait", + "base64", + "bytes", + "cfg-if", + "chrono", + "either", + "futures", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "jsonwebtoken", + "once_cell", + "percent-encoding", + "pin-project", + "secrecy", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "snafu", + "tower", + "tower-http", + "url", + "web-time", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -748,6 +924,16 @@ dependencies = [ "syn", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -795,6 +981,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1047,6 +1239,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "zeroize", +] + [[package]] name = "serde" version = "1.0.228" @@ -1150,6 +1351,18 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror", + "time", +] + [[package]] name = "slab" version = "0.4.12" @@ -1162,6 +1375,27 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "snafu" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e84b3f4eacbf3a1ce05eac6763b4d629d60cbc94d632e4092c54ade71f1e1a2" +dependencies = [ + "snafu-derive", +] + +[[package]] +name = "snafu-derive" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "socket2" version = "0.6.2" @@ -1244,6 +1478,37 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.2" @@ -1305,6 +1570,19 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "tower" version = "0.5.3" @@ -1316,8 +1594,10 @@ dependencies = [ "pin-project-lite", "sync_wrapper", "tokio", + "tokio-util", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -1336,6 +1616,7 @@ dependencies = [ "tower", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -1454,6 +1735,7 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] @@ -1587,6 +1869,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" dependencies = [ "js-sys", + "serde", "wasm-bindgen", ] @@ -1599,12 +1882,65 @@ dependencies = [ "rustls-pki-types", ] +[[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", +] + +[[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", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "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]] name = "windows-sys" version = "0.52.0" diff --git a/Cargo.toml b/Cargo.toml index 458a83d..2d70f6b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ native = [ "reqwest/rustls-tls", "dep:figment", "dep:tracing-subscriber", + "dep:octocrab", ] cf-worker = [ "dep:worker", @@ -44,6 +45,7 @@ axum = { version = "0.8", default-features = false, features = [ ] } tokio = { version = "1", default-features = false, features = ["sync"] } reqwest = { version = "0.12", default-features = false, features = ["json"] } +octocrab = { version = "0.42", default-features = false, optional = true } serde = { version = "1", features = ["derive"] } serde_json = "1" hmac = "0.12" diff --git a/src/config.rs b/src/config.rs index 1190389..ffe7112 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + #[cfg(not(feature = "cf-worker"))] use figment::{Figment, providers::Env}; use reqwest::Client; @@ -51,6 +53,7 @@ impl LinearConfig { pub struct LarkConfig { #[serde(default)] pub webhook_url: String, + pub target_chat_id: Option, pub app_id: Option, pub app_secret: Option, pub verification_token: Option, @@ -75,6 +78,7 @@ impl LarkConfig { .var("LARK_WEBHOOK_URL") .map(|v| v.to_string()) .unwrap_or_default(), + target_chat_id: env.var("LARK_TARGET_CHAT_ID").ok().map(|v| v.to_string()), app_id: env.var("LARK_APP_ID").ok().map(|v| v.to_string()), app_secret: env.secret("LARK_APP_SECRET").ok().map(|s| s.to_string()), verification_token: env @@ -89,17 +93,113 @@ impl LarkConfig { pub fn bot_client(&self, http: &Client) -> Option { match (&self.app_id, &self.app_secret) { (Some(id), Some(secret)) => { - info!("lark bot configured – DM notifications enabled"); + info!("lark bot configured – Bot API notifications enabled"); Some(LarkBotClient::new(id.clone(), secret.clone(), http.clone())) } _ => { - info!("LARK_APP_ID/LARK_APP_SECRET not set – DM notifications disabled"); + info!("LARK_APP_ID/LARK_APP_SECRET not set – Bot API notifications disabled"); None } } } } +fn default_alert_labels() -> Vec { + vec!["bug".into(), "urgent".into(), "p0".into()] +} + +#[derive(Debug)] +pub struct GitHubConfig { + pub webhook_secret: String, + pub user_map: HashMap, + pub alert_labels: Vec, + pub repo_whitelist: Vec, + pub pat: Option, +} + +#[cfg(not(feature = "cf-worker"))] +impl GitHubConfig { + pub fn from_env() -> Option { + let secret = std::env::var("GITHUB_WEBHOOK_SECRET").ok()?; + + let user_map: HashMap = std::env::var("GITHUB_USER_MAP") + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default(); + + let alert_labels: Vec = std::env::var("GITHUB_ALERT_LABELS") + .ok() + .map(|s| s.split(',').map(|l| l.trim().to_lowercase()).collect()) + .unwrap_or_else(default_alert_labels); + + let repo_whitelist: Vec = std::env::var("GITHUB_REPO_WHITELIST") + .ok() + .map(|s| { + s.split(',') + .map(|r| r.trim().to_string()) + .filter(|r| !r.is_empty()) + .collect() + }) + .unwrap_or_default(); + + let pat = std::env::var("GITHUB_PAT").ok(); + + Some(Self { + webhook_secret: secret, + user_map, + alert_labels, + repo_whitelist, + pat, + }) + } +} + +#[cfg(feature = "cf-worker")] +impl GitHubConfig { + pub fn from_worker_env(env: &worker::Env) -> Option { + let secret = env.secret("GITHUB_WEBHOOK_SECRET").ok()?.to_string(); + + let user_map: HashMap = env + .var("GITHUB_USER_MAP") + .ok() + .and_then(|v| serde_json::from_str(&v.to_string()).ok()) + .unwrap_or_default(); + + let alert_labels: Vec = env + .var("GITHUB_ALERT_LABELS") + .ok() + .map(|v| { + v.to_string() + .split(',') + .map(|l| l.trim().to_lowercase()) + .collect() + }) + .unwrap_or_else(default_alert_labels); + + let repo_whitelist: Vec = env + .var("GITHUB_REPO_WHITELIST") + .ok() + .map(|v| { + v.to_string() + .split(',') + .map(|r| r.trim().to_string()) + .filter(|r| !r.is_empty()) + .collect() + }) + .unwrap_or_default(); + + let pat = env.secret("GITHUB_PAT").ok().map(|s| s.to_string()); + + Some(Self { + webhook_secret: secret, + user_map, + alert_labels, + repo_whitelist, + pat, + }) + } +} + fn default_port() -> u16 { 3000 } @@ -159,6 +259,7 @@ pub struct AppState { pub linear: LinearConfig, pub lark: LarkConfig, pub server: ServerConfig, + pub github: Option, pub http: Client, pub lark_bot: Option, pub linear_client: Option, @@ -174,6 +275,7 @@ impl AppState { let linear = LinearConfig::from_env().expect("invalid linear config"); let lark = LarkConfig::from_env().expect("invalid lark config"); let server = ServerConfig::from_env().expect("invalid server config"); + let github = GitHubConfig::from_env(); let http = Client::new(); let lark_bot = lark.bot_client(&http); @@ -182,12 +284,25 @@ impl AppState { if lark.verification_token.is_some() { info!("LARK_VERIFICATION_TOKEN set – event verification enabled"); } + if lark.target_chat_id.is_some() { + info!("LARK_TARGET_CHAT_ID set – Bot API group chat enabled"); + } + if let Some(gh) = &github { + info!("GITHUB_WEBHOOK_SECRET set – GitHub webhook source enabled"); + if !gh.repo_whitelist.is_empty() { + info!("GitHub repo whitelist: {:?}", gh.repo_whitelist); + } + if gh.pat.is_some() { + info!("GITHUB_PAT set – outbound GitHub API enabled"); + } + } info!("debounce delay: {}ms", server.debounce_delay_ms); Self { linear, lark, server, + github, http, lark_bot, linear_client, @@ -202,6 +317,7 @@ impl AppState { let linear = LinearConfig::from_worker_env(&env).expect("invalid linear config"); let lark = LarkConfig::from_worker_env(&env).expect("invalid lark config"); let server = ServerConfig::from_worker_env(&env).expect("invalid server config"); + let github = GitHubConfig::from_worker_env(&env); let http = Client::new(); let lark_bot = lark.bot_client(&http); @@ -211,6 +327,7 @@ impl AppState { linear, lark, server, + github, http, lark_bot, linear_client, diff --git a/src/debounce_do.rs b/src/debounce_do.rs index 798bf1e..b648b94 100644 --- a/src/debounce_do.rs +++ b/src/debounce_do.rs @@ -84,26 +84,51 @@ impl DurableObject for DebounceObject { storage.delete_all().await?; let http = reqwest::Client::new(); - let webhook_url = self + + // Build the card once, then deliver via Bot API or webhook fallback. + let card = crate::sinks::lark::cards::build_lark_card(&event); + + let app_id = self.env.var("LARK_APP_ID").ok().map(|v| v.to_string()); + let app_secret = self .env - .var("LARK_WEBHOOK_URL") - .map(|v| v.to_string()) - .unwrap_or_default(); - - crate::sinks::lark::notify(&event, &http, &webhook_url).await; - - if let Some(ref email) = dm_email { - let app_id = self.env.var("LARK_APP_ID").ok().map(|v| v.to_string()); - let app_secret = self - .env - .secret("LARK_APP_SECRET") - .ok() - .map(|s| s.to_string()); - - if let (Some(id), Some(secret)) = (app_id, app_secret) { - let bot = crate::sinks::lark::LarkBotClient::new(id, secret, http); - crate::sinks::lark::try_dm(&event, &bot, email).await; + .secret("LARK_APP_SECRET") + .ok() + .map(|s| s.to_string()); + let target_chat_id = self + .env + .var("LARK_TARGET_CHAT_ID") + .ok() + .map(|v| v.to_string()); + + let bot = match (app_id, app_secret) { + (Some(id), Some(secret)) => Some(crate::sinks::lark::LarkBotClient::new( + id, + secret, + http.clone(), + )), + _ => None, + }; + + match (&bot, &target_chat_id) { + (Some(b), Some(chat_id)) => { + if let Err(e) = b.send_to_chat(chat_id, &card.card).await { + worker::console_error!("failed to send card to chat: {e}"); + } } + _ => { + let webhook_url = self + .env + .var("LARK_WEBHOOK_URL") + .map(|v| v.to_string()) + .unwrap_or_default(); + if !webhook_url.is_empty() { + crate::sinks::lark::webhook::send_lark_card(&http, &webhook_url, &card).await; + } + } + } + + if let (Some(email), Some(b)) = (&dm_email, &bot) { + crate::sinks::lark::try_dm(&event, b, email).await; } Response::ok("dispatched") diff --git a/src/dispatch.rs b/src/dispatch.rs index e0dfa02..65af8c4 100644 --- a/src/dispatch.rs +++ b/src/dispatch.rs @@ -5,7 +5,7 @@ use crate::{config::AppState, event::Event, sinks}; /// Sends `event` to all sinks. If `dm_email` is provided, a direct message /// is also sent to that address. pub async fn dispatch(event: &Event, state: &AppState, dm_email: Option<&str>) { - sinks::lark::notify(event, &state.http, &state.lark.webhook_url).await; + sinks::lark::notify(event, state).await; if let (Some(email), Some(bot)) = (dm_email, &state.lark_bot) { sinks::lark::try_dm(event, bot, email).await; diff --git a/src/event.rs b/src/event.rs index c78eb52..5af19b5 100644 --- a/src/event.rs +++ b/src/event.rs @@ -55,9 +55,18 @@ impl Priority { } } +/// Abbreviated commit info for push events. +#[derive(Serialize, Deserialize, Clone)] +pub struct CommitSummary { + pub sha_short: String, + pub message_line: String, + pub author: String, +} + /// A normalized event produced by a source and consumed by sinks. #[derive(Serialize, Deserialize)] pub enum Event { + // --- Linear events --- IssueCreated { #[allow(dead_code)] source: String, @@ -95,24 +104,89 @@ pub enum Event { body: String, url: String, }, + + // --- GitHub events --- + PrOpened { + repo: String, + number: u64, + title: String, + author: String, + head_branch: String, + base_branch: String, + additions: u64, + deletions: u64, + url: String, + }, + PrReviewRequested { + repo: String, + number: u64, + title: String, + author: String, + reviewer: String, + reviewer_lark_id: Option, + url: String, + }, + PrMerged { + repo: String, + number: u64, + title: String, + author: String, + merged_by: String, + url: String, + }, + IssueLabeledAlert { + repo: String, + number: u64, + title: String, + label: String, + author: String, + url: String, + }, + BranchPush { + repo: String, + branch: String, + pusher: String, + commits: Vec, + compare_url: String, + }, + WorkflowRunFailed { + repo: String, + workflow_name: String, + branch: String, + actor: String, + conclusion: String, + url: String, + }, + SecretScanningAlert { + repo: String, + secret_type: String, + url: String, + }, + DependabotAlert { + repo: String, + package: String, + severity: String, + summary: String, + url: String, + }, } impl Event { - /// Returns the accumulated change descriptions (empty for comments). + /// Returns the accumulated change descriptions (empty for non-issue events). pub fn changes(&self) -> &[String] { match self { Event::IssueCreated { changes, .. } | Event::IssueUpdated { changes, .. } => changes, - Event::CommentCreated { .. } => &[], + _ => &[], } } - /// Replaces the change descriptions (no-op for comments). + /// Replaces the change descriptions (no-op for non-issue events). pub fn set_changes(&mut self, new_changes: Vec) { match self { Event::IssueCreated { changes, .. } | Event::IssueUpdated { changes, .. } => { *changes = new_changes; } - Event::CommentCreated { .. } => {} + _ => {} } } diff --git a/src/lib.rs b/src/lib.rs index a8374f3..5caad0a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -40,6 +40,15 @@ mod cf_entry { .await; text_response(status, "") } + ("POST", "/github/webhook") => { + let status = crate::sources::github::webhook_handler( + axum::extract::State(state), + parts.headers, + body_bytes, + ) + .await; + text_response(status, "") + } ("POST", "/lark/event") => { let (status, axum::Json(json)) = crate::sinks::lark::lark_event_handler(axum::extract::State(state), body_bytes) diff --git a/src/main.rs b/src/main.rs index 2038961..b15f8d6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -28,6 +28,10 @@ async fn main() { "/webhook", post(larkstack::sources::linear::webhook_handler), ) + .route( + "/github/webhook", + post(larkstack::sources::github::webhook_handler), + ) .route( "/lark/event", post(larkstack::sinks::lark::lark_event_handler), diff --git a/src/sinks/lark/bot.rs b/src/sinks/lark/bot.rs index 890061b..8c8062d 100644 --- a/src/sinks/lark/bot.rs +++ b/src/sinks/lark/bot.rs @@ -1,7 +1,7 @@ //! Lark Bot API client for sending direct messages via tenant access token. use reqwest::Client; -use serde_json::json; +use serde::{Deserialize, Serialize}; use tokio::sync::Mutex; use tracing::info; @@ -12,6 +12,44 @@ use web_time::Instant; use super::models::LarkCard; +// --------------------------------------------------------------------------- +// Request / response types +// --------------------------------------------------------------------------- + +#[derive(Serialize)] +struct TokenRequest<'a> { + app_id: &'a str, + app_secret: &'a str, +} + +#[derive(Deserialize)] +struct TokenResponse { + code: i64, + tenant_access_token: Option, + #[serde(default = "default_expire")] + expire: u64, +} + +fn default_expire() -> u64 { + 7200 +} + +#[derive(Serialize)] +struct SendMessagePayload { + receive_id: String, + msg_type: &'static str, + content: String, +} + +#[derive(Deserialize)] +struct LarkApiResponse { + code: i64, +} + +// --------------------------------------------------------------------------- +// Client +// --------------------------------------------------------------------------- + /// Authenticated Lark bot that can send interactive-card DMs. pub struct LarkBotClient { app_id: String, @@ -48,75 +86,97 @@ impl LarkBotClient { return Ok(cached.value.clone()); } + let req = TokenRequest { + app_id: &self.app_id, + app_secret: &self.app_secret, + }; + let resp = self .http .post("https://open.larksuite.com/open-apis/auth/v3/tenant_access_token/internal") - .json(&json!({ - "app_id": self.app_id, - "app_secret": self.app_secret, - })) + .json(&req) .send() .await .map_err(|e| format!("token request failed: {e}"))?; - let body: serde_json::Value = resp + let token_resp: TokenResponse = resp .json() .await .map_err(|e| format!("token response parse failed: {e}"))?; - let code = body.get("code").and_then(|v| v.as_i64()).unwrap_or(-1); - if code != 0 { - return Err(format!("token API error: {body}")); + if token_resp.code != 0 { + return Err(format!("token API error code {}", token_resp.code)); } - let token = body - .get("tenant_access_token") - .and_then(|v| v.as_str()) - .ok_or_else(|| "missing tenant_access_token in response".to_string())? - .to_string(); - - let expire = body.get("expire").and_then(|v| v.as_u64()).unwrap_or(7200); + let token = token_resp + .tenant_access_token + .ok_or_else(|| "missing tenant_access_token in response".to_string())?; cached.value = token.clone(); - cached.expires_at = Instant::now() + std::time::Duration::from_secs(expire); + cached.expires_at = Instant::now() + std::time::Duration::from_secs(token_resp.expire); - info!("refreshed lark bot tenant access token (expires in {expire}s)"); + info!( + "refreshed lark bot tenant access token (expires in {}s)", + token_resp.expire + ); Ok(token) } - /// Sends an interactive card to a user identified by `email`. - pub async fn send_dm(&self, email: &str, card: &LarkCard) -> Result<(), String> { + /// Sends an interactive card message and checks the response code. + async fn send_card(&self, url: &str, receive_id: &str, card: &LarkCard) -> Result<(), String> { let token = self.get_token().await?; - let payload = json!({ - "receive_id": email, - "msg_type": "interactive", - "content": serde_json::to_string(card).unwrap_or_default(), - }); + let payload = SendMessagePayload { + receive_id: receive_id.to_string(), + msg_type: "interactive", + content: serde_json::to_string(card).unwrap_or_default(), + }; let resp = self .http - .post("https://open.larksuite.com/open-apis/im/v1/messages?receive_id_type=email") + .post(url) .header("Authorization", format!("Bearer {token}")) .json(&payload) .send() .await - .map_err(|e| format!("DM request failed: {e}"))?; + .map_err(|e| format!("send_card request failed: {e}"))?; let status = resp.status(); let body = resp.text().await.unwrap_or_default(); - if status.is_success() { - let parsed: serde_json::Value = - serde_json::from_str(&body).unwrap_or(serde_json::Value::Null); - let code = parsed.get("code").and_then(|v| v.as_i64()).unwrap_or(-1); - if code != 0 { - return Err(format!("DM API returned code {code}: {body}")); - } - info!("DM sent to {email}"); - Ok(()) - } else { - Err(format!("DM request returned {status}: {body}")) + if !status.is_success() { + return Err(format!("send_card returned {status}: {body}")); } + + let api_resp: LarkApiResponse = + serde_json::from_str(&body).map_err(|e| format!("response parse failed: {e}"))?; + + if api_resp.code != 0 { + return Err(format!("Lark API returned code {}: {body}", api_resp.code)); + } + + Ok(()) + } + + /// Sends an interactive card to a user identified by `email`. + pub async fn send_dm(&self, email: &str, card: &LarkCard) -> Result<(), String> { + self.send_card( + "https://open.larksuite.com/open-apis/im/v1/messages?receive_id_type=email", + email, + card, + ) + .await + .inspect(|()| info!("DM sent to {email}")) + } + + /// Sends an interactive card to a group chat identified by `chat_id`. + pub async fn send_to_chat(&self, chat_id: &str, card: &LarkCard) -> Result<(), String> { + self.send_card( + "https://open.larksuite.com/open-apis/im/v1/messages?receive_id_type=chat_id", + chat_id, + card, + ) + .await + .inspect(|()| info!("card sent to chat {chat_id}")) } } diff --git a/src/sinks/lark/cards.rs b/src/sinks/lark/cards.rs index 7af4d8f..272c7e7 100644 --- a/src/sinks/lark/cards.rs +++ b/src/sinks/lark/cards.rs @@ -51,17 +51,51 @@ fn build_fields(status: &str, priority: &str, assignee: Option<&str>) -> Value { /// Builds a "View in Linear" action button element. fn build_action_button(url: &str) -> Value { + build_link_button(url, "View in Linear") +} + +fn build_link_button(url: &str, label: &str) -> Value { json!({ "tag": "action", "actions": [{ "tag": "button", - "text": { "tag": "plain_text", "content": "View in Linear" }, + "text": { "tag": "plain_text", "content": label }, "type": "primary", "url": url, }] }) } +fn md_div(content: &str) -> Value { + json!({ + "tag": "div", + "text": { + "tag": "lark_md", + "content": content, + } + }) +} + +fn build_card(color: &str, header_text: String, elements: Vec) -> LarkMessage { + LarkMessage { + msg_type: "interactive", + card: LarkCard { + header: LarkHeader { + template: color.to_string(), + title: LarkTitle { + content: header_text, + tag: "plain_text", + }, + }, + elements, + }, + } +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + /// Formats an [`Event`] as a [`LarkMessage`] for group webhook delivery. pub fn build_lark_card(event: &Event) -> LarkMessage { match event { @@ -114,9 +148,208 @@ pub fn build_lark_card(event: &Event) -> LarkMessage { url, .. } => build_comment_card(identifier, issue_title, author, body, url), + + // --- GitHub events --- + Event::PrOpened { + repo, + number, + title, + author, + head_branch, + base_branch, + additions, + deletions, + url, + } => build_pr_opened_card( + repo, + *number, + title, + author, + head_branch, + base_branch, + *additions, + *deletions, + url, + ), + Event::PrReviewRequested { + repo, + number, + title, + author, + reviewer, + reviewer_lark_id, + url, + } => build_pr_review_requested_card( + repo, + *number, + title, + author, + reviewer, + reviewer_lark_id.as_deref(), + url, + ), + Event::PrMerged { + repo, + number, + title, + author, + merged_by, + url, + } => build_pr_merged_card(repo, *number, title, author, merged_by, url), + Event::IssueLabeledAlert { + repo, + number, + title, + label, + author, + url, + } => build_issue_labeled_card(repo, *number, title, label, author, url), + Event::BranchPush { + repo, + branch, + pusher, + commits, + compare_url, + } => build_branch_push_card(repo, branch, pusher, commits, compare_url), + Event::WorkflowRunFailed { + repo, + workflow_name, + branch, + actor, + url, + .. + } => build_workflow_failed_card(repo, workflow_name, branch, actor, url), + Event::SecretScanningAlert { + repo, + secret_type, + url, + } => build_secret_scanning_card(repo, secret_type, url), + Event::DependabotAlert { + repo, + package, + severity, + summary, + url, + } => build_dependabot_card(repo, package, severity, summary, url), + } +} + +/// Builds a DM card for assignment or review-request notifications. +/// +/// Returns `None` for event types that do not support DM notifications. +pub fn build_assign_dm_card(event: &Event) -> Option { + match event { + Event::IssueCreated { + identifier, + title, + status, + priority, + url, + .. + } + | Event::IssueUpdated { + identifier, + title, + status, + priority, + url, + .. + } => { + let mut elements = vec![]; + elements.push(md_div(&format!( + "You've been assigned to **{}**\n{}", + identifier, title + ))); + elements.push(build_fields(status, &priority.display(), None)); + elements.push(build_action_button(url)); + + Some(LarkCard { + header: LarkHeader { + template: priority_color(priority).to_string(), + title: LarkTitle { + content: format!("[Linear] Assigned: {identifier}"), + tag: "plain_text", + }, + }, + elements, + }) + } + Event::PrReviewRequested { + repo, + number, + title, + author, + url, + .. + } => { + let mut elements = vec![]; + elements.push(md_div(&format!( + "**{author}** requested your review on **#{number}**\n{title}" + ))); + elements.push(md_div(&format!("**Repository:** {repo}"))); + elements.push(build_link_button(url, "View on GitHub")); + + Some(LarkCard { + header: LarkHeader { + template: "yellow".to_string(), + title: LarkTitle { + content: format!("[{repo}] Review Requested #{number}"), + tag: "plain_text", + }, + }, + elements, + }) + } + _ => None, } } +/// Builds an inline preview card from GraphQL-fetched issue data. +/// +/// This is used for Lark link unfurling and does **not** go through [`Event`]. +pub fn build_preview_card(issue: &LinearIssueData) -> LarkCard { + let priority = Priority::from_linear(issue.priority); + let color = priority_color(&priority); + let assignee = issue + .assignee + .as_ref() + .map(|a| a.name.as_str()) + .unwrap_or("Unassigned"); + + let mut elements = vec![]; + + elements.push(md_div(&format!("**{}**", issue.title))); + + if let Some(desc) = &issue.description { + let trimmed = desc.trim(); + if !trimmed.is_empty() { + elements.push(md_div(&truncate(trimmed, 200))); + } + } + + elements.push(build_fields( + &issue.state.name, + &priority.display(), + Some(assignee), + )); + elements.push(build_action_button(&issue.url)); + + LarkCard { + header: LarkHeader { + template: color.to_string(), + title: LarkTitle { + content: format!("[Linear] {}", issue.identifier), + tag: "plain_text", + }, + }, + elements, + } +} + +// --------------------------------------------------------------------------- +// Linear card builders (private) +// --------------------------------------------------------------------------- + #[allow(clippy::too_many_arguments)] fn build_issue_card( action: &str, @@ -134,36 +367,17 @@ fn build_issue_card( let mut elements = vec![]; - elements.push(json!({ - "tag": "div", - "text": { - "tag": "lark_md", - "content": format!("**{title}**"), - } - })); + elements.push(md_div(&format!("**{title}**"))); if let Some(desc) = description { let trimmed = desc.trim(); if !trimmed.is_empty() { - elements.push(json!({ - "tag": "div", - "text": { - "tag": "lark_md", - "content": truncate(trimmed, 200), - } - })); + elements.push(md_div(&truncate(trimmed, 200))); } } if !changes.is_empty() { - let change_text = changes.join("\n"); - elements.push(json!({ - "tag": "div", - "text": { - "tag": "lark_md", - "content": change_text, - } - })); + elements.push(md_div(&changes.join("\n"))); } elements.push(build_fields( @@ -173,19 +387,7 @@ fn build_issue_card( )); elements.push(build_action_button(url)); - LarkMessage { - msg_type: "interactive", - card: LarkCard { - header: LarkHeader { - template: color.to_string(), - title: LarkTitle { - content: format!("[Linear] {action}: {identifier}"), - tag: "plain_text", - }, - }, - elements, - }, - } + build_card(color, format!("[Linear] {action}: {identifier}"), elements) } fn build_comment_card( @@ -203,152 +405,188 @@ fn build_comment_card( let mut elements = vec![]; - elements.push(json!({ - "tag": "div", - "text": { - "tag": "lark_md", - "content": format!("**{author}** commented on **{issue_ref}**"), - } - })); + elements.push(md_div(&format!( + "**{author}** commented on **{issue_ref}**" + ))); let body = truncate(body.trim(), 200); if !body.is_empty() { - elements.push(json!({ - "tag": "div", - "text": { - "tag": "lark_md", - "content": body, - } - })); + elements.push(md_div(&body)); } elements.push(build_action_button(url)); - LarkMessage { - msg_type: "interactive", - card: LarkCard { - header: LarkHeader { - template: "blue".to_string(), - title: LarkTitle { - content: format!("[Linear] Comment: {identifier}"), - tag: "plain_text", - }, - }, - elements, - }, - } + build_card("blue", format!("[Linear] Comment: {identifier}"), elements) } -/// Builds a DM card notifying the assignee about an issue event. -/// -/// # Panics -/// -/// Panics if called with [`Event::CommentCreated`]. -pub fn build_assign_dm_card(event: &Event) -> LarkCard { - let (identifier, title, status, priority, url) = match event { - Event::IssueCreated { - identifier, - title, - status, - priority, - url, - .. - } - | Event::IssueUpdated { - identifier, - title, - status, - priority, - url, - .. - } => ( - identifier.as_str(), - title.as_str(), - status.as_str(), - priority, - url.as_str(), - ), - Event::CommentCreated { .. } => unreachable!("build_assign_dm_card called with comment"), - }; +// --------------------------------------------------------------------------- +// GitHub card builders (private) +// --------------------------------------------------------------------------- +#[allow(clippy::too_many_arguments)] +fn build_pr_opened_card( + repo: &str, + number: u64, + title: &str, + author: &str, + head_branch: &str, + base_branch: &str, + additions: u64, + deletions: u64, + url: &str, +) -> LarkMessage { let mut elements = vec![]; - elements.push(json!({ - "tag": "div", - "text": { - "tag": "lark_md", - "content": format!( - "You've been assigned to **{}**\n{}", - identifier, title - ), - } - })); + elements.push(md_div(&format!("**{title}**"))); + elements.push(md_div(&format!( + "**Branch:** `{head_branch}` → `{base_branch}`\n**Changes:** +{additions} / -{deletions}" + ))); + elements.push(md_div(&format!("**Author:** {author}"))); + elements.push(build_link_button(url, "View on GitHub")); - elements.push(build_fields(status, &priority.display(), None)); - elements.push(build_action_button(url)); + build_card("purple", format!("[{repo}] PR Opened #{number}"), elements) +} - LarkCard { - header: LarkHeader { - template: priority_color(priority).to_string(), - title: LarkTitle { - content: format!("[Linear] Assigned: {identifier}"), - tag: "plain_text", - }, - }, +fn build_pr_review_requested_card( + repo: &str, + number: u64, + title: &str, + author: &str, + reviewer: &str, + reviewer_lark_id: Option<&str>, + url: &str, +) -> LarkMessage { + let mut elements = vec![]; + + elements.push(md_div(&format!("**{title}**"))); + + let reviewer_display = match reviewer_lark_id { + Some(email) => format!(""), + None => reviewer.to_string(), + }; + elements.push(md_div(&format!( + "**Reviewer:** {reviewer_display}\n**Author:** {author}" + ))); + elements.push(build_link_button(url, "View on GitHub")); + + build_card( + "yellow", + format!("[{repo}] Review Requested #{number}"), elements, - } + ) } -/// Builds an inline preview card from GraphQL-fetched issue data. -/// -/// This is used for Lark link unfurling and does **not** go through [`Event`]. -pub fn build_preview_card(issue: &LinearIssueData) -> LarkCard { - let priority = Priority::from_linear(issue.priority); - let color = priority_color(&priority); - let assignee = issue - .assignee - .as_ref() - .map(|a| a.name.as_str()) - .unwrap_or("Unassigned"); +fn build_pr_merged_card( + repo: &str, + number: u64, + title: &str, + author: &str, + merged_by: &str, + url: &str, +) -> LarkMessage { + let mut elements = vec![]; + elements.push(md_div(&format!("**{title}**"))); + elements.push(md_div(&format!( + "**Merged by:** {merged_by}\n**Author:** {author}" + ))); + elements.push(build_link_button(url, "View on GitHub")); + + build_card("green", format!("[{repo}] PR Merged #{number}"), elements) +} + +fn build_issue_labeled_card( + repo: &str, + number: u64, + title: &str, + label: &str, + author: &str, + url: &str, +) -> LarkMessage { let mut elements = vec![]; - elements.push(json!({ - "tag": "div", - "text": { - "tag": "lark_md", - "content": format!("**{}**", issue.title), - } - })); + elements.push(md_div(&format!("**{title}**"))); + elements.push(md_div(&format!( + "**Label:** `{label}`\n**Author:** {author}" + ))); + elements.push(build_link_button(url, "View on GitHub")); - if let Some(desc) = &issue.description { - let trimmed = desc.trim(); - if !trimmed.is_empty() { - elements.push(json!({ - "tag": "div", - "text": { - "tag": "lark_md", - "content": truncate(trimmed, 200), - } - })); - } - } + build_card("red", format!("[{repo}] Issue Alert #{number}"), elements) +} - elements.push(build_fields( - &issue.state.name, - &priority.display(), - Some(assignee), - )); - elements.push(build_action_button(&issue.url)); +fn build_branch_push_card( + repo: &str, + branch: &str, + pusher: &str, + commits: &[crate::event::CommitSummary], + compare_url: &str, +) -> LarkMessage { + let mut elements = vec![]; - LarkCard { - header: LarkHeader { - template: color.to_string(), - title: LarkTitle { - content: format!("[Linear] {}", issue.identifier), - tag: "plain_text", - }, - }, - elements, + elements.push(md_div(&format!("**Pushed by:** {pusher}"))); + + if !commits.is_empty() { + let commit_lines: Vec = commits + .iter() + .map(|c| format!("`{}` {} — {}", c.sha_short, c.message_line, c.author)) + .collect(); + elements.push(md_div(&commit_lines.join("\n"))); } + + elements.push(build_link_button(compare_url, "Compare Changes")); + + build_card("blue", format!("[{repo}] Push to {branch}"), elements) +} + +fn build_workflow_failed_card( + repo: &str, + workflow_name: &str, + branch: &str, + actor: &str, + url: &str, +) -> LarkMessage { + let mut elements = vec![]; + + elements.push(md_div(&format!("**Workflow:** {workflow_name}"))); + elements.push(md_div(&format!( + "**Branch:** `{branch}`\n**Triggered by:** {actor}" + ))); + elements.push(build_link_button(url, "View Workflow Run")); + + build_card("red", format!("[{repo}] CI Failed"), elements) +} + +fn build_secret_scanning_card(repo: &str, secret_type: &str, url: &str) -> LarkMessage { + let mut elements = vec![]; + + elements.push(md_div(&format!( + "**Secret type:** {secret_type}\n\nA leaked credential was detected in the repository. Rotate this secret immediately." + ))); + elements.push(build_link_button(url, "View Alert")); + + build_card("red", format!("[{repo}] Secret Leaked"), elements) +} + +fn build_dependabot_card( + repo: &str, + package: &str, + severity: &str, + summary: &str, + url: &str, +) -> LarkMessage { + let color = if severity == "critical" { + "red" + } else { + "orange" + }; + + let mut elements = vec![]; + + elements.push(md_div(&format!( + "**Package:** `{package}`\n**Severity:** {severity}" + ))); + elements.push(md_div(summary)); + elements.push(build_link_button(url, "View Alert")); + + build_card(color, format!("[{repo}] Dependabot Alert"), elements) } diff --git a/src/sinks/lark/mod.rs b/src/sinks/lark/mod.rs index 108fea5..b8450ac 100644 --- a/src/sinks/lark/mod.rs +++ b/src/sinks/lark/mod.rs @@ -4,26 +4,45 @@ mod bot; pub mod cards; pub mod event_handler; pub mod models; -mod webhook; +pub(crate) mod webhook; pub use bot::LarkBotClient; pub use event_handler::lark_event_handler; -use reqwest::Client; use tracing::error; -use crate::event::Event; +use crate::{config::AppState, event::Event}; -/// Sends a card notification for `event` to the given Lark group webhook. -pub async fn notify(event: &Event, http: &Client, webhook_url: &str) { +/// Sends a card notification for `event` to the Lark group. +/// +/// Prefers Bot API (`target_chat_id`) when available, falls back to the +/// simple webhook (`webhook_url`). +pub async fn notify(event: &Event, state: &AppState) { let card = cards::build_lark_card(event); - webhook::send_lark_card(http, webhook_url, &card).await; + + match (&state.lark_bot, &state.lark.target_chat_id) { + (Some(bot), Some(chat_id)) => { + if let Err(e) = bot.send_to_chat(chat_id, &card.card).await { + error!("failed to send card to chat {chat_id}: {e}"); + } + } + _ if !state.lark.webhook_url.is_empty() => { + webhook::send_lark_card(&state.http, &state.lark.webhook_url, &card).await; + } + _ => { + error!( + "no Lark delivery method configured (need LARK_TARGET_CHAT_ID + bot, or LARK_WEBHOOK_URL)" + ); + } + } } -/// DMs the assignee about `event` (no-op when `bot` is `None`). +/// DMs the assignee about `event` (no-op when `bot` is `None` or event +/// does not support DM notifications). pub async fn try_dm(event: &Event, bot: &LarkBotClient, email: &str) { - let card = cards::build_assign_dm_card(event); - if let Err(e) = bot.send_dm(email, &card).await { - error!("failed to DM assignee {email}: {e}"); + if let Some(card) = cards::build_assign_dm_card(event) + && let Err(e) = bot.send_dm(email, &card).await + { + error!("failed to DM {email}: {e}"); } } diff --git a/src/sources/github/handler.rs b/src/sources/github/handler.rs new file mode 100644 index 0000000..c57cdee --- /dev/null +++ b/src/sources/github/handler.rs @@ -0,0 +1,868 @@ +//! Axum handler for `POST /github/webhook` — receives GitHub webhook payloads, +//! converts them to [`Event`]s, and dispatches immediately (no debounce). +//! +//! **Native** build: uses octocrab's strongly-typed `WebhookEvent` models. +//! **CF Worker** build: uses minimal hand-rolled thin structs (octocrab's +//! unconditional `hyper` dep pulls in `mio`, which does not compile to WASM). + +use std::sync::Arc; + +use axum::{ + body::Bytes, + extract::State, + http::{HeaderMap, StatusCode}, +}; +use tracing::{info, warn}; + +#[cfg(feature = "native")] +use octocrab::models::webhook_events::{ + WebhookEvent, WebhookEventPayload, + payload::{ + DependabotAlertWebhookEventAction, IssuesWebhookEventAction, PullRequestWebhookEventAction, + SecretScanningAlertWebhookEventAction, WorkflowRunWebhookEventAction, + }, +}; + +use crate::{ + config::{AppState, GitHubConfig}, + dispatch, + event::{CommitSummary, Event}, +}; + +use super::utils::{branch_from_ref, verify_github_signature}; + +const MAX_COMMITS: usize = 5; + +// --------------------------------------------------------------------------- +// Shared thin structs (used by both native and cf-worker) +// --------------------------------------------------------------------------- + +/// Pre-parse: extracts `repository.name` (whitelist) and `full_name` (display) +/// without deserializing the full payload. +#[derive(serde::Deserialize)] +struct RepoProbe { + repository: RepoName, +} + +#[derive(serde::Deserialize)] +struct RepoName { + name: String, + full_name: Option, +} + +/// Inner data for `workflow_run` events — stored as `serde_json::Value` in +/// octocrab, so we deserialize manually in both build targets. +#[derive(serde::Deserialize)] +struct WorkflowRunData { + conclusion: Option, + name: String, + head_branch: String, + actor: WorkflowRunActor, + html_url: String, +} + +#[derive(serde::Deserialize)] +struct WorkflowRunActor { + login: String, +} + +#[derive(serde::Deserialize)] +struct SecretScanningAlertData { + secret_type_display_name: Option, + secret_type: String, + html_url: String, +} + +#[derive(serde::Deserialize)] +struct DependabotAlertData { + severity: String, + dependency: Option, + security_advisory: Option, + html_url: String, +} + +#[derive(serde::Deserialize)] +struct DependabotDependency { + package: Option, +} + +#[derive(serde::Deserialize)] +struct DependabotPackage { + name: String, +} + +#[derive(serde::Deserialize)] +struct DependabotAdvisory { + summary: String, +} + +// --------------------------------------------------------------------------- +// CF Worker thin structs (replacing octocrab types that can't compile to WASM) +// --------------------------------------------------------------------------- + +#[cfg(feature = "cf-worker")] +mod thin { + use serde::Deserialize; + + #[derive(Deserialize)] + pub struct PrPayload { + pub action: String, + pub number: u64, + pub pull_request: PullRequest, + pub requested_reviewer: Option, + } + + #[derive(Deserialize)] + pub struct PullRequest { + pub title: Option, + pub user: Option, + pub html_url: Option, + pub head: GitRef, + pub base: GitRef, + pub additions: Option, + pub deletions: Option, + pub merged_at: Option, + pub merged_by: Option, + } + + #[derive(Deserialize)] + pub struct User { + pub login: String, + } + + #[derive(Deserialize)] + pub struct GitRef { + pub r#ref: String, + } + + #[derive(Deserialize)] + pub struct IssuesPayload { + pub action: String, + pub label: Option