diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d0471ef..817e3f3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,7 +35,7 @@ jobs: with: components: clippy - uses: Swatinem/rust-cache@v2 - - run: cargo clippy --all-targets --all-features -- -D warnings + - run: cargo clippy --all-targets -- -D warnings test: name: Test diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..a3f0414 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,28 @@ +name: Deploy + +on: + push: + branches: [main] + paths: ["**.rs", "Cargo.toml", "Cargo.lock", "wrangler.toml", ".github/workflows/deploy.yml"] + +concurrency: + group: deploy + cancel-in-progress: true + +jobs: + deploy: + name: Deploy to Cloudflare Workers + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + + - uses: Swatinem/rust-cache@v2 + + - uses: cloudflare/wrangler-action@v3 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} diff --git a/.gitignore b/.gitignore index 636e0e1..3d0bf7f 100644 --- a/.gitignore +++ b/.gitignore @@ -29,4 +29,7 @@ Thumbs.db # 如果你的 test-webhook.sh 里包含了真实的 URL 或 Secret, # 请取消下面这行的注释(去掉 # 号) # ------------------------------------------------------ -# test-webhook.sh \ No newline at end of file +# test-webhook.sh +payload.json +.dev.vars +.wrangler/ \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index ec40ab8..0e7274f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,17 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -11,6 +22,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" @@ -124,6 +153,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.19.1" @@ -142,6 +180,15 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.56" @@ -170,11 +217,30 @@ 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 = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", ] +[[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 +260,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 +291,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 +325,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 +347,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -259,6 +356,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 +402,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 +451,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 +579,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" @@ -572,6 +711,16 @@ version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -604,16 +753,35 @@ 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" dependencies = [ + "aes", "axum", + "base64", + "cbc", "figment", "getrandom 0.2.17", "hex", "hmac", "http-body-util", + "octocrab", "reqwest", "serde", "serde_json", @@ -710,6 +878,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 +912,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 +976,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 +1033,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 +1291,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 +1403,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 +1427,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 +1530,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 +1622,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 +1646,10 @@ dependencies = [ "pin-project-lite", "sync_wrapper", "tokio", + "tokio-util", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -1336,6 +1668,7 @@ dependencies = [ "tower", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -1454,6 +1787,7 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] @@ -1587,6 +1921,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" dependencies = [ "js-sys", + "serde", "wasm-bindgen", ] @@ -1599,12 +1934,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..5fe54c7 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,11 +45,15 @@ 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" sha2 = "0.10" hex = "0.4" +aes = "0.8" +cbc = "0.1" +base64 = "0.22" tracing = "0.1" diff --git a/src/config.rs b/src/config.rs index 1190389..a6087e4 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,10 +1,15 @@ +use std::collections::HashMap; + #[cfg(not(feature = "cf-worker"))] use figment::{Figment, providers::Env}; use reqwest::Client; use serde::{Deserialize, Serialize}; use tracing::info; -use crate::{sinks::lark::LarkBotClient, sources::linear::client::LinearClient}; +use crate::{ + sinks::lark::LarkBotClient, + sources::{linear::client::LinearClient, x::XClient}, +}; #[cfg(not(feature = "cf-worker"))] use crate::debounce::DebounceMap; @@ -49,11 +54,27 @@ impl LinearConfig { #[derive(Debug, Default, Deserialize, Serialize)] pub struct LarkConfig { + /// Incoming webhook URL for Linear group chat notifications. #[serde(default)] pub webhook_url: String, + /// Incoming webhook URL for GitHub group chat notifications. + /// Falls back to `webhook_url` when empty. + #[serde(default)] + pub github_webhook_url: String, + /// Enterprise self-built app credentials — used only for Linear DMs. pub app_id: Option, pub app_secret: Option, + /// Enterprise self-built app credentials — used only for GitHub review-request DMs. + /// Falls back to `app_id`/`app_secret` when absent. + pub github_app_id: Option, + pub github_app_secret: Option, + /// Verification token for the Linear link-preview app. pub verification_token: Option, + /// Verification token for the X (Twitter) link-preview app (separate Lark app). + /// When set, requests carrying this token are also accepted. + pub x_verification_token: Option, + /// Encrypt key for the X (Twitter) link-preview app — used to decrypt AES-256-CBC payloads. + pub x_encrypt_key: Option, } #[cfg(not(feature = "cf-worker"))] @@ -72,40 +93,164 @@ impl LarkConfig { pub fn from_worker_env(env: &worker::Env) -> Result { Ok(Self { webhook_url: env - .var("LARK_WEBHOOK_URL") - .map(|v| v.to_string()) + .secret("LARK_WEBHOOK_URL") + .map(|s| s.to_string()) + .unwrap_or_default(), + github_webhook_url: env + .secret("LARK_GITHUB_WEBHOOK_URL") + .map(|s| s.to_string()) .unwrap_or_default(), - app_id: env.var("LARK_APP_ID").ok().map(|v| v.to_string()), + app_id: env.secret("LARK_APP_ID").ok().map(|s| s.to_string()), app_secret: env.secret("LARK_APP_SECRET").ok().map(|s| s.to_string()), + github_app_id: env.secret("LARK_GITHUB_APP_ID").ok().map(|s| s.to_string()), + github_app_secret: env + .secret("LARK_GITHUB_APP_SECRET") + .ok() + .map(|s| s.to_string()), verification_token: env .secret("LARK_VERIFICATION_TOKEN") .ok() .map(|s| s.to_string()), + x_verification_token: env + .secret("LARK_X_VERIFICATION_TOKEN") + .ok() + .map(|s| s.to_string()), + x_encrypt_key: env.secret("LARK_X_ENCRYPT_KEY").ok().map(|s| s.to_string()), }) } } impl LarkConfig { - pub fn bot_client(&self, http: &Client) -> Option { + /// Creates the Linear DM bot client (enterprise self-built app, DMs only). + pub fn linear_dm_bot(&self, http: &Client) -> Option { match (&self.app_id, &self.app_secret) { (Some(id), Some(secret)) => { - info!("lark bot configured – DM notifications enabled"); + info!("LARK_APP_ID set – Linear DM bot enabled"); Some(LarkBotClient::new(id.clone(), secret.clone(), http.clone())) } - _ => { - info!("LARK_APP_ID/LARK_APP_SECRET not set – DM notifications disabled"); - None + _ => None, + } + } + + /// Creates the GitHub DM bot client (enterprise self-built app, DMs only). + /// Falls back to the Linear DM bot when `LARK_GITHUB_APP_ID` is absent. + pub fn github_dm_bot(&self, http: &Client) -> Option { + match (&self.github_app_id, &self.github_app_secret) { + (Some(id), Some(secret)) => { + info!("LARK_GITHUB_APP_ID set – GitHub DM bot enabled"); + Some(LarkBotClient::new(id.clone(), secret.clone(), http.clone())) } + _ => 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 } fn default_debounce() -> u64 { - 5000 + 30_000 +} + +fn default_debounce_max_wait() -> u64 { + 120_000 } #[derive(Debug, Deserialize, Serialize)] @@ -114,6 +259,10 @@ pub struct ServerConfig { pub port: u16, #[serde(default = "default_debounce")] pub debounce_delay_ms: u64, + /// Hard cap: fire at most this many ms after the first event in a window, + /// even if new events keep arriving (prevents indefinite delay). + #[serde(default = "default_debounce_max_wait")] + pub debounce_max_wait_ms: u64, } impl Default for ServerConfig { @@ -121,6 +270,7 @@ impl Default for ServerConfig { Self { port: default_port(), debounce_delay_ms: default_debounce(), + debounce_max_wait_ms: default_debounce_max_wait(), } } } @@ -130,7 +280,7 @@ impl ServerConfig { pub fn from_env() -> Result> { Figment::new() .merge(figment::providers::Serialized::defaults(Self::default())) - .merge(Env::raw().only(&["PORT", "DEBOUNCE_DELAY_MS"])) + .merge(Env::raw().only(&["PORT", "DEBOUNCE_DELAY_MS", "DEBOUNCE_MAX_WAIT_MS"])) .extract() .map_err(Box::new) } @@ -150,6 +300,11 @@ impl ServerConfig { .ok() .and_then(|v| v.to_string().parse().ok()) .unwrap_or_else(default_debounce), + debounce_max_wait_ms: env + .var("DEBOUNCE_MAX_WAIT_MS") + .ok() + .and_then(|v| v.to_string().parse().ok()) + .unwrap_or_else(default_debounce_max_wait), }) } } @@ -159,9 +314,15 @@ pub struct AppState { pub linear: LinearConfig, pub lark: LarkConfig, pub server: ServerConfig, + pub github: Option, pub http: Client, + /// Bot client for the Linear notification app. pub lark_bot: Option, + /// Bot client for the GitHub notification app. + /// Falls back to `lark_bot` when `None`. + pub github_lark_bot: Option, pub linear_client: Option, + pub x_client: XClient, #[cfg(not(feature = "cf-worker"))] pub update_debounce: DebounceMap, #[cfg(feature = "cf-worker")] @@ -174,23 +335,39 @@ 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); + let lark_bot = lark.linear_dm_bot(&http); + let github_lark_bot = lark.github_dm_bot(&http); let linear_client = linear.graphql_client(&http); + let x_bearer = std::env::var("X_BEARER_TOKEN").ok(); + let x_client = XClient::new(x_bearer, http.clone()); if lark.verification_token.is_some() { info!("LARK_VERIFICATION_TOKEN set – event verification 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, + github_lark_bot, linear_client, + x_client, update_debounce: DebounceMap::new(), } } @@ -202,18 +379,25 @@ 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); + let lark_bot = lark.linear_dm_bot(&http); + let github_lark_bot = lark.github_dm_bot(&http); let linear_client = linear.graphql_client(&http); + let x_bearer = env.secret("X_BEARER_TOKEN").ok().map(|s| s.to_string()); + let x_client = XClient::new(x_bearer, http.clone()); Self { linear, lark, server, + github, http, lark_bot, + github_lark_bot, linear_client, + x_client, env, } } diff --git a/src/debounce.rs b/src/debounce.rs index 0856c23..7ca1029 100644 --- a/src/debounce.rs +++ b/src/debounce.rs @@ -1,6 +1,7 @@ //! Coalesces rapid-fire events on the same entity into a single notification. use std::collections::HashMap; +use std::time::Instant; use tokio::sync::{Mutex, oneshot}; @@ -14,6 +15,8 @@ pub struct PendingUpdate { pub dm_email: Option, /// Send on this to cancel the currently-scheduled timer task. cancel_tx: oneshot::Sender<()>, + /// When the first event in this debounce window arrived. + first_received_at: Instant, } /// Thread-safe map of entity keys to their pending debounced updates. @@ -37,39 +40,53 @@ impl DebounceMap { /// is replaced with the latest state. A create followed by updates stays /// a create. /// - /// Returns a [`oneshot::Receiver`] the caller should `select!` against a - /// sleep — if it fires, a newer update has taken over. + /// Returns `(cancel_rx, actual_delay_ms)`. The caller should `select!` + /// the cancel_rx against a sleep of `actual_delay_ms`. The actual delay + /// is `min(delay_ms, remaining_max_wait)` so the window never exceeds + /// `max_wait_ms` from the first event. pub async fn upsert( &self, key: String, event: Event, dm_email: Option, - ) -> oneshot::Receiver<()> { + delay_ms: u64, + max_wait_ms: u64, + ) -> (oneshot::Receiver<()>, u64) { let mut map = self.0.lock().await; - let (merged_event, merged_dm_email) = if let Some(existing) = map.remove(&key) { - let _ = existing.cancel_tx.send(()); + let (merged_event, merged_dm_email, first_received_at) = + if let Some(existing) = map.remove(&key) { + let _ = existing.cancel_tx.send(()); - // Accumulate change descriptions; skip exact duplicates. - let mut all: Vec = existing.event.changes().to_vec(); - for c in event.changes() { - if !all.contains(c) { - all.push(c.clone()); + // Accumulate change descriptions; skip exact duplicates. + let mut all: Vec = existing.event.changes().to_vec(); + for c in event.changes() { + if !all.contains(c) { + all.push(c.clone()); + } } - } - // A create followed by updates is still a "create". - let mut merged = if existing.event.is_issue_created() { - event.promote_to_created() + // A create followed by updates is still a "create". + let mut merged = if existing.event.is_issue_created() { + event.promote_to_created() + } else { + event + }; + merged.set_changes(all); + + ( + merged, + dm_email.or(existing.dm_email), + existing.first_received_at, + ) } else { - event + (event, dm_email, Instant::now()) }; - merged.set_changes(all); - (merged, dm_email.or(existing.dm_email)) - } else { - (event, dm_email) - }; + // Respect max_wait: never delay longer than max_wait_ms from the first event. + let elapsed_ms = first_received_at.elapsed().as_millis() as u64; + let remaining_max = max_wait_ms.saturating_sub(elapsed_ms); + let actual_delay = delay_ms.min(remaining_max).max(1); let (cancel_tx, cancel_rx) = oneshot::channel(); map.insert( @@ -78,9 +95,10 @@ impl DebounceMap { event: merged_event, dm_email: merged_dm_email, cancel_tx, + first_received_at, }, ); - cancel_rx + (cancel_rx, actual_delay) } /// Removes and returns the pending update for `key`, if any. diff --git a/src/debounce_do.rs b/src/debounce_do.rs index 798bf1e..c91d748 100644 --- a/src/debounce_do.rs +++ b/src/debounce_do.rs @@ -34,6 +34,7 @@ impl DurableObject for DebounceObject { serde_json::from_value(body["dm_email"].clone()).unwrap_or(None); let delay_ms: u64 = serde_json::from_value(body["delay_ms"].clone()) .map_err(|e| Error::RustError(format!("parse delay: {e}")))?; + let max_wait_ms: u64 = body["max_wait_ms"].as_u64().unwrap_or(120_000); let storage = self.state.storage(); @@ -66,8 +67,24 @@ impl DurableObject for DebounceObject { storage.put("dm_email", email).await?; } + // Track when the first event in this window arrived for max_wait enforcement. + let now_ms = js_sys::Date::now() as u64; + let first_received_ms: u64 = storage + .get::("first_received_ms") + .await + .unwrap_or(None) + .unwrap_or(now_ms); + storage.put("first_received_ms", &first_received_ms).await?; + + // Respect max_wait: never delay longer than max_wait_ms from the first event. + let elapsed_ms = now_ms.saturating_sub(first_received_ms); + let remaining_max = max_wait_ms.saturating_sub(elapsed_ms); + let actual_delay = delay_ms.min(remaining_max).max(1); + // Schedule (or reschedule) the alarm. - storage.set_alarm(Duration::from_millis(delay_ms)).await?; + storage + .set_alarm(Duration::from_millis(actual_delay)) + .await?; Response::ok("scheduled") } @@ -84,24 +101,30 @@ impl DurableObject for DebounceObject { storage.delete_all().await?; let http = reqwest::Client::new(); + + // Deliver to the Linear group chat via incoming webhook. + let card = crate::sinks::lark::cards::build_lark_card(&event); let webhook_url = self .env - .var("LARK_WEBHOOK_URL") - .map(|v| v.to_string()) + .secret("LARK_WEBHOOK_URL") + .map(|s| s.to_string()) .unwrap_or_default(); + if !webhook_url.is_empty() { + crate::sinks::lark::webhook::send_lark_card(&http, &webhook_url, &card).await; + } else { + worker::console_error!("LARK_WEBHOOK_URL not configured — group notification skipped"); + } - crate::sinks::lark::notify(&event, &http, &webhook_url).await; - - if let Some(ref email) = dm_email { + // Send DM via the Linear DM bot (enterprise self-built app). + if let Some(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); + let bot = crate::sinks::lark::LarkBotClient::new(id, secret, http.clone()); crate::sinks::lark::try_dm(&event, &bot, email).await; } } diff --git a/src/dispatch.rs b/src/dispatch.rs index e0dfa02..48dcbb1 100644 --- a/src/dispatch.rs +++ b/src/dispatch.rs @@ -2,12 +2,24 @@ 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. +/// Sends `event` to the Linear Lark group. If `dm_email` is provided, a +/// direct message is also sent using the Linear bot credentials. 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; } } + +/// Sends `event` to the GitHub Lark group. If `dm_email` is provided, a +/// direct message is sent using the GitHub bot credentials (falling back to +/// the Linear bot when no GitHub-specific bot is configured). +pub async fn dispatch_github(event: &Event, state: &AppState, dm_email: Option<&str>) { + sinks::lark::notify_github(event, state).await; + + let bot = state.github_lark_bot.as_ref().or(state.lark_bot.as_ref()); + if let (Some(email), Some(bot)) = (dm_email, 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..626f938 100644 --- a/src/sinks/lark/cards.rs +++ b/src/sinks/lark/cards.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; use crate::{ event::{Event, Priority}, - sources::linear::models::LinearIssueData, + sources::{linear::models::LinearIssueData, x::TweetData}, utils::truncate, }; @@ -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 { @@ -89,6 +123,7 @@ pub fn build_lark_card(event: &Event) -> LarkMessage { Event::IssueUpdated { identifier, title, + description, status, priority, assignee, @@ -99,7 +134,7 @@ pub fn build_lark_card(event: &Event) -> LarkMessage { "Updated", identifier, title, - None, + description.as_deref(), status, priority, assignee.as_deref(), @@ -114,9 +149,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 +368,25 @@ 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 has_desc = 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(&format!("*{}*", truncate(trimmed, 150)))); + true + } else { + false } - } + } else { + false + }; if !changes.is_empty() { - let change_text = changes.join("\n"); - elements.push(json!({ - "tag": "div", - "text": { - "tag": "lark_md", - "content": change_text, - } - })); + if has_desc { + elements.push(md_div("---")); + } + elements.push(md_div(&changes.join("\n"))); } elements.push(build_fields( @@ -173,19 +396,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 +414,256 @@ 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(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")); + + build_card("purple", format!("[{repo}] PR Opened #{number}"), elements) +} +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(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(build_fields(status, &priority.display(), None)); - elements.push(build_action_button(url)); + 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")); - LarkCard { - header: LarkHeader { - template: priority_color(priority).to_string(), - title: LarkTitle { - content: format!("[Linear] Assigned: {identifier}"), - tag: "plain_text", - }, - }, + build_card( + "yellow", + format!("[{repo}] Review Requested #{number}"), elements, + ) +} + +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(md_div(&format!("**{title}**"))); + elements.push(md_div(&format!( + "**Label:** `{label}`\n**Author:** {author}" + ))); + elements.push(build_link_button(url, "View on GitHub")); + + build_card("red", format!("[{repo}] Issue Alert #{number}"), elements) +} + +fn build_branch_push_card( + repo: &str, + branch: &str, + pusher: &str, + commits: &[crate::event::CommitSummary], + compare_url: &str, +) -> LarkMessage { + let mut elements = vec![]; + + 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) } -/// Builds an inline preview card from GraphQL-fetched issue data. +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) +} + +// --------------------------------------------------------------------------- +// X (Twitter) card builder +// --------------------------------------------------------------------------- + +/// Builds an inline preview card from fetched tweet 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"); +/// Returns `(card, inline_title)` where `inline_title` is the short text +/// shown inline in the chat before the card expands. +pub fn build_x_preview_card(tweet: &TweetData) -> (LarkCard, String) { + let author_at = if tweet.author_username.is_empty() { + tweet.author_name.clone() + } else { + format!("@{}", tweet.author_username) + }; let mut elements = vec![]; - elements.push(json!({ - "tag": "div", - "text": { - "tag": "lark_md", - "content": format!("**{}**", issue.title), - } - })); + if !tweet.text.is_empty() { + elements.push(json!({ + "tag": "markdown", + "content": truncate(&tweet.text, 200), + })); + } - 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), - } - })); - } + // Note: metrics + author combined, matching Python reference layout + let note_text = if tweet.like_count.is_some() || tweet.retweet_count.is_some() { + let likes = tweet.like_count.unwrap_or(0); + let retweets = tweet.retweet_count.unwrap_or(0); + format!("❤️ {likes} 🔁 {retweets} • {author_at} on X") + } else if !author_at.is_empty() { + format!("From {author_at} on X") + } else { + String::new() + }; + if !note_text.is_empty() { + elements.push(md_div(¬e_text)); } - elements.push(build_fields( - &issue.state.name, - &priority.display(), - Some(assignee), - )); - elements.push(build_action_button(&issue.url)); + elements.push(build_link_button(&tweet.url, "View on X")); - LarkCard { + let header_title = if tweet.author_name.is_empty() { + "X Post".to_string() + } else { + tweet.author_name.clone() + }; + + let card = LarkCard { header: LarkHeader { - template: color.to_string(), + template: "blue".to_string(), title: LarkTitle { - content: format!("[Linear] {}", issue.identifier), + content: header_title, tag: "plain_text", }, }, elements, - } + }; + + let inline_title = if !author_at.is_empty() && !tweet.text.is_empty() { + format!("{}: {}...", author_at, truncate(&tweet.text, 30)) + } else if !author_at.is_empty() { + format!("Post by {author_at}") + } else { + "X Post".to_string() + }; + + (card, inline_title) } diff --git a/src/sinks/lark/event_handler.rs b/src/sinks/lark/event_handler.rs index 371d8f3..137b35c 100644 --- a/src/sinks/lark/event_handler.rs +++ b/src/sinks/lark/event_handler.rs @@ -6,18 +6,62 @@ use std::sync::Arc; use axum::{Json, body::Bytes, extract::State, http::StatusCode}; use tracing::{error, info, warn}; -use crate::{config::AppState, sources::linear::client::extract_identifier_from_url}; +use crate::{ + config::AppState, + sources::{linear::client::extract_identifier_from_url, x::extract_tweet_id}, +}; -use super::cards::build_preview_card; +use super::cards::{build_preview_card, build_x_preview_card}; + +/// Decrypts a Lark AES-256-CBC encrypted payload. +/// +/// Lark sends `{"encrypt": ""}` when an Encrypt Key is configured. +/// Key = SHA256(encrypt_key), IV = first 16 bytes of decoded data. +fn decrypt_lark_payload( + encrypt_key: &str, + encrypted_data: &str, +) -> Result { + use aes::Aes256; + use base64::Engine as _; + use base64::engine::general_purpose::STANDARD as B64; + use cbc::cipher::{BlockDecryptMut, KeyIvInit, block_padding::Pkcs7}; + use sha2::{Digest, Sha256}; + + type Aes256CbcDec = cbc::Decryptor; + + let key = Sha256::digest(encrypt_key.as_bytes()); + + let encrypted_bytes = B64 + .decode(encrypted_data) + .map_err(|e| format!("base64 decode: {e}"))?; + + if encrypted_bytes.len() < 16 { + return Err("encrypted payload too short".into()); + } + + let (iv, ciphertext) = encrypted_bytes.split_at(16); + + let decryptor = + Aes256CbcDec::new_from_slices(&key, iv).map_err(|e| format!("cipher init: {e}"))?; + + let mut buf = ciphertext.to_vec(); + let plaintext = decryptor + .decrypt_padded_mut::(&mut buf) + .map_err(|e| format!("decrypt: {e}"))?; + + serde_json::from_slice(plaintext).map_err(|e| format!("json parse: {e}")) +} /// Handles incoming Lark event callbacks. /// /// Supports `url_verification` challenges and `url.preview.get` link previews. +/// When `LARK_X_ENCRYPT_KEY` is set, AES-256-CBC encrypted payloads are decrypted +/// before processing. pub async fn lark_event_handler( State(state): State>, body: Bytes, ) -> (StatusCode, Json) { - let body_value: serde_json::Value = match serde_json::from_slice(&body) { + let raw: serde_json::Value = match serde_json::from_slice(&body) { Ok(v) => v, Err(e) => { error!("failed to parse lark event body: {e}"); @@ -28,6 +72,32 @@ pub async fn lark_event_handler( } }; + // Decrypt if Lark sent an encrypted payload (Encrypt Key is configured in the app). + let body_value: serde_json::Value = + if let Some(encrypted) = raw.get("encrypt").and_then(|v| v.as_str()) { + match state.lark.x_encrypt_key.as_deref() { + Some(key) => match decrypt_lark_payload(key, encrypted) { + Ok(v) => v, + Err(e) => { + error!("failed to decrypt lark payload: {e}"); + return ( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({"error": "decryption failed"})), + ); + } + }, + None => { + warn!("received encrypted lark payload but LARK_X_ENCRYPT_KEY is not set"); + return ( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({"error": "encrypt key not configured"})), + ); + } + } + } else { + raw + }; + if body_value.get("type").and_then(|v| v.as_str()) == Some("url_verification") { let challenge = body_value .get("challenge") @@ -40,13 +110,28 @@ pub async fn lark_event_handler( ); } - if let Some(ref expected_token) = state.lark.verification_token { - let token = body_value - .get("header") - .and_then(|h| h.get("token")) - .and_then(|v| v.as_str()) - .unwrap_or(""); - if token != expected_token { + let incoming_token = body_value + .get("header") + .and_then(|h| h.get("token")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + + let token_required = + state.lark.verification_token.is_some() || state.lark.x_verification_token.is_some(); + + if token_required { + let valid = state + .lark + .verification_token + .as_deref() + .is_some_and(|t| t == incoming_token) + || state + .lark + .x_verification_token + .as_deref() + .is_some_and(|t| t == incoming_token); + + if !valid { warn!("lark event token mismatch"); return ( StatusCode::UNAUTHORIZED, @@ -71,28 +156,51 @@ pub async fn lark_event_handler( (StatusCode::OK, Json(serde_json::json!({}))) } -/// Fetches a Linear issue and returns an inline preview card. +/// Handles `url.preview.get` — routes to X or Linear preview based on URL. async fn handle_link_preview( state: &AppState, body: &serde_json::Value, ) -> (StatusCode, Json) { - let Some(ref linear) = state.linear_client else { - warn!("link preview requested but LINEAR_API_KEY not configured"); - return (StatusCode::OK, Json(serde_json::json!({}))); - }; - - let url = body - .get("event") - .and_then(|e| e.get("url")) + let event = body.get("event"); + let url = event + .and_then(|e| e.get("context")) + .and_then(|c| c.get("url")) .and_then(|v| v.as_str()) + .or_else(|| event.and_then(|e| e.get("url")).and_then(|v| v.as_str())) .or_else(|| { - body.get("event") + event .and_then(|e| e.get("body")) .and_then(|b| b.get("url")) .and_then(|v| v.as_str()) }) .unwrap_or(""); + // X / Twitter link + if let Some(tweet_id) = extract_tweet_id(url) { + info!("fetching tweet {tweet_id} for link preview"); + let tweet = state.x_client.fetch(tweet_id, url).await; + let (card, inline_title) = build_x_preview_card(&tweet); + info!("built X preview card: {inline_title}"); + return ( + StatusCode::OK, + Json(serde_json::json!({ + "inline": { + "i18n_title": { + "en_us": inline_title, + "zh_cn": inline_title, + } + }, + "card": { "type": "raw", "data": card } + })), + ); + } + + // Linear link + let Some(ref linear) = state.linear_client else { + info!("link preview requested but no handler matched URL: {url}"); + return (StatusCode::OK, Json(serde_json::json!({}))); + }; + let Some(identifier) = extract_identifier_from_url(url) else { info!("could not extract Linear identifier from URL: {url}"); return (StatusCode::OK, Json(serde_json::json!({}))); @@ -114,10 +222,7 @@ async fn handle_link_preview( "zh_cn": inline_title, } }, - "card": { - "type": "raw", - "data": card - } + "card": { "type": "raw", "data": card } })), ) } diff --git a/src/sinks/lark/mod.rs b/src/sinks/lark/mod.rs index 108fea5..3b7994f 100644 --- a/src/sinks/lark/mod.rs +++ b/src/sinks/lark/mod.rs @@ -4,26 +4,48 @@ 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 Linear group chat via webhook. +pub async fn notify(event: &Event, state: &AppState) { let card = cards::build_lark_card(event); - webhook::send_lark_card(http, webhook_url, &card).await; + if !state.lark.webhook_url.is_empty() { + webhook::send_lark_card(&state.http, &state.lark.webhook_url, &card).await; + } else { + error!("LARK_WEBHOOK_URL not configured — Linear group chat notification skipped"); + } +} + +/// Sends a card notification for `event` to the GitHub group chat via webhook. +/// +/// Uses `LARK_GITHUB_WEBHOOK_URL` when set, falls back to `LARK_WEBHOOK_URL`. +pub async fn notify_github(event: &Event, state: &AppState) { + let card = cards::build_lark_card(event); + let webhook = if !state.lark.github_webhook_url.is_empty() { + &state.lark.github_webhook_url + } else { + &state.lark.webhook_url + }; + if !webhook.is_empty() { + webhook::send_lark_card(&state.http, webhook, &card).await; + } else { + error!("no webhook URL configured — GitHub group chat notification skipped"); + } } -/// DMs the assignee about `event` (no-op when `bot` is `None`). +/// Sends a DM about `event` via the enterprise self-built app bot. +/// No-op when the 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..53ef74f --- /dev/null +++ b/src/sources/github/handler.rs @@ -0,0 +1,758 @@ +//! 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::Event, +}; + +use super::utils::verify_github_signature; + +// --------------------------------------------------------------------------- +// 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