From 4b076997ed602296608f3a7c8898d6a91a77d72e Mon Sep 17 00:00:00 2001 From: Sean JA Date: Mon, 2 Mar 2026 22:42:41 +0800 Subject: [PATCH] feat(github): add webhook integration with octocrab models - Introduced octocrab models for type-safe GitHub webhook handling - Replaced json!() macros with proper struct serialization refactor: introduced octocrab models for Github webhook --- Cargo.lock | 336 ++++++++++++++++ Cargo.toml | 1 + src/config.rs | 121 +++++- src/debounce_do.rs | 61 ++- src/dispatch.rs | 2 +- src/event.rs | 82 +++- src/lib.rs | 9 + src/main.rs | 4 + src/sinks/lark/bot.rs | 136 +++++-- src/sinks/lark/cards.rs | 558 +++++++++++++++++++-------- src/sinks/lark/mod.rs | 39 +- src/sources/github/handler.rs | 484 +++++++++++++++++++++++ src/sources/github/mod.rs | 7 + src/sources/github/utils.rs | 18 + src/sources/linear/utils.rs | 10 +- src/sources/mod.rs | 1 + src/utils.rs | 14 + tests/fixtures/github_pr_opened.json | 312 +++++++++++++++ 18 files changed, 1953 insertions(+), 242 deletions(-) create mode 100644 src/sources/github/handler.rs create mode 100644 src/sources/github/mod.rs create mode 100644 src/sources/github/utils.rs create mode 100644 tests/fixtures/github_pr_opened.json 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..9a74740 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,6 +44,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 } 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..0df5f61 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(ref email), Some(ref 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..f7b2eb2 --- /dev/null +++ b/src/sources/github/handler.rs @@ -0,0 +1,484 @@ +//! Axum handler for `POST /github/webhook` — receives GitHub webhook payloads, +//! converts them to [`Event`]s, and dispatches immediately (no debounce). + +use std::sync::Arc; + +use axum::{ + body::Bytes, + extract::State, + http::{HeaderMap, StatusCode}, +}; +use octocrab::models::webhook_events::{ + WebhookEvent, WebhookEventPayload, + payload::{ + DependabotAlertWebhookEventAction, IssuesWebhookEventAction, PullRequestWebhookEventAction, + SecretScanningAlertWebhookEventAction, WorkflowRunWebhookEventAction, + }, +}; +use tracing::{info, warn}; + +use crate::{ + config::{AppState, GitHubConfig}, + dispatch, + event::{CommitSummary, Event}, +}; + +use super::utils::{branch_from_ref, verify_github_signature}; + +const MAX_COMMITS: usize = 5; + +/// Minimal struct for lightweight repo-name extraction before full deserialization. +#[derive(serde::Deserialize)] +struct RepoProbe { + repository: RepoName, +} + +#[derive(serde::Deserialize)] +struct RepoName { + name: String, +} + +// --------------------------------------------------------------------------- +// Thin helper structs for octocrab payloads stored as serde_json::Value +// --------------------------------------------------------------------------- + +#[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, +} + +/// Handles incoming GitHub webhook requests. +/// +/// 1. Verifies the `X-Hub-Signature-256` HMAC header. +/// 2. Routes by the `X-GitHub-Event` header via octocrab's `WebhookEvent`. +/// 3. Converts to an [`Event`] and dispatches immediately. +pub async fn webhook_handler( + State(state): State>, + headers: HeaderMap, + body: Bytes, +) -> StatusCode { + let github = match &state.github { + Some(cfg) => cfg, + None => { + warn!("received GitHub webhook but GITHUB_WEBHOOK_SECRET not configured"); + return StatusCode::NOT_FOUND; + } + }; + + let signature = match headers + .get("x-hub-signature-256") + .and_then(|v| v.to_str().ok()) + { + Some(s) => s, + None => { + warn!("missing x-hub-signature-256 header"); + return StatusCode::UNAUTHORIZED; + } + }; + + if !verify_github_signature(&github.webhook_secret, &body, signature) { + warn!("invalid GitHub webhook signature"); + return StatusCode::UNAUTHORIZED; + } + + // Repo whitelist filter — skip events from repos not on the list. + if !github.repo_whitelist.is_empty() { + match serde_json::from_slice::(&body) { + Ok(probe) => { + if !github.repo_whitelist.contains(&probe.repository.name) { + info!( + "ignoring event from non-whitelisted repo: {}", + probe.repository.name + ); + return StatusCode::OK; + } + } + Err(_) => { + warn!("could not extract repository name for whitelist check"); + } + } + } + + let event_type = headers + .get("x-github-event") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + + let webhook = match WebhookEvent::try_from_header_and_body(event_type, &body) { + Ok(ev) => ev, + Err(e) => { + warn!("failed to parse GitHub webhook event: {e}"); + return StatusCode::BAD_REQUEST; + } + }; + + // Extract repo full_name from the top-level repository field. + let repo = webhook + .repository + .as_ref() + .and_then(|r| r.full_name.as_ref()) + .cloned() + .unwrap_or_default(); + + match webhook.specific { + WebhookEventPayload::PullRequest(payload) => { + handle_pull_request(&state, github, &repo, *payload).await + } + WebhookEventPayload::Issues(payload) => { + handle_issues(&state, github, &repo, *payload).await + } + WebhookEventPayload::Push(payload) => handle_push(&state, &repo, *payload).await, + WebhookEventPayload::WorkflowRun(payload) => { + handle_workflow_run(&state, &repo, *payload).await + } + WebhookEventPayload::SecretScanningAlert(payload) => { + handle_secret_scanning(&state, &repo, *payload).await + } + WebhookEventPayload::DependabotAlert(payload) => { + handle_dependabot(&state, &repo, *payload).await + } + _ => { + info!("ignoring GitHub event type: {event_type}"); + StatusCode::OK + } + } +} + +async fn handle_pull_request( + state: &Arc, + github: &GitHubConfig, + repo: &str, + payload: octocrab::models::webhook_events::payload::PullRequestWebhookEventPayload, +) -> StatusCode { + let pr = &payload.pull_request; + let number = payload.number; + let title = pr.title.clone().unwrap_or_default(); + let author = pr + .user + .as_ref() + .map(|u| u.login.clone()) + .unwrap_or_default(); + let html_url = pr + .html_url + .as_ref() + .map(|u| u.to_string()) + .unwrap_or_default(); + + match payload.action { + PullRequestWebhookEventAction::Opened => { + info!("GitHub PR opened: {repo}#{number}"); + let event = Event::PrOpened { + repo: repo.to_string(), + number, + title, + author, + head_branch: pr.head.ref_field.clone(), + base_branch: pr.base.ref_field.clone(), + additions: pr.additions.unwrap_or(0), + deletions: pr.deletions.unwrap_or(0), + url: html_url, + }; + dispatch::dispatch(&event, state, None).await; + StatusCode::OK + } + PullRequestWebhookEventAction::ReviewRequested => { + let reviewer = match &payload.requested_reviewer { + Some(u) => u.login.clone(), + None => { + info!("review_requested without requested_reviewer, ignoring"); + return StatusCode::OK; + } + }; + + info!("GitHub review requested: {repo}#{number} reviewer={reviewer}"); + + let reviewer_lark_id = github.user_map.get(&reviewer).cloned(); + let dm_email = reviewer_lark_id.clone(); + + let event = Event::PrReviewRequested { + repo: repo.to_string(), + number, + title, + author, + reviewer, + reviewer_lark_id, + url: html_url, + }; + dispatch::dispatch(&event, state, dm_email.as_deref()).await; + StatusCode::OK + } + PullRequestWebhookEventAction::Closed if pr.merged_at.is_some() => { + let merged_by = pr + .merged_by + .as_ref() + .map(|u| u.login.clone()) + .unwrap_or_else(|| author.clone()); + + info!("GitHub PR merged: {repo}#{number} by {merged_by}"); + + let event = Event::PrMerged { + repo: repo.to_string(), + number, + title, + author, + merged_by, + url: html_url, + }; + dispatch::dispatch(&event, state, None).await; + StatusCode::OK + } + _ => { + info!("ignoring pull_request action for {repo}#{number}"); + StatusCode::OK + } + } +} + +async fn handle_issues( + state: &Arc, + github: &GitHubConfig, + repo: &str, + payload: octocrab::models::webhook_events::payload::IssuesWebhookEventPayload, +) -> StatusCode { + if payload.action != IssuesWebhookEventAction::Labeled { + info!("ignoring issues action"); + return StatusCode::OK; + } + + let label = match &payload.label { + Some(l) => l.name.clone(), + None => return StatusCode::OK, + }; + + if !github.alert_labels.contains(&label.to_lowercase()) { + info!("ignoring non-alert label: {label}"); + return StatusCode::OK; + } + + let issue = &payload.issue; + let number = issue.number; + let title = issue.title.clone(); + let author = issue.user.login.clone(); + let html_url = issue.html_url.to_string(); + + info!("GitHub issue labeled alert: {repo}#{number} label={label}"); + + let event = Event::IssueLabeledAlert { + repo: repo.to_string(), + number, + title, + label, + author, + url: html_url, + }; + dispatch::dispatch(&event, state, None).await; + StatusCode::OK +} + +async fn handle_push( + state: &Arc, + repo: &str, + payload: octocrab::models::webhook_events::payload::PushWebhookEventPayload, +) -> StatusCode { + let branch = branch_from_ref(&payload.r#ref); + + if !is_protected_branch(branch) { + info!("ignoring push to non-protected branch: {branch}"); + return StatusCode::OK; + } + + info!( + "GitHub push to {repo}@{branch}: {} commit(s)", + payload.commits.len() + ); + + let commits: Vec = payload + .commits + .iter() + .take(MAX_COMMITS) + .map(|c| CommitSummary { + sha_short: c.id.chars().take(7).collect(), + message_line: c.message.lines().next().unwrap_or("").to_string(), + author: c.author.user.name.clone(), + }) + .collect(); + + let event = Event::BranchPush { + repo: repo.to_string(), + branch: branch.to_string(), + pusher: payload.pusher.user.name.clone(), + commits, + compare_url: payload.compare.to_string(), + }; + dispatch::dispatch(&event, state, None).await; + StatusCode::OK +} + +fn is_protected_branch(branch: &str) -> bool { + matches!(branch, "main" | "master") || branch.starts_with("release") +} + +async fn handle_workflow_run( + state: &Arc, + repo: &str, + payload: octocrab::models::webhook_events::payload::WorkflowRunWebhookEventPayload, +) -> StatusCode { + if payload.action != WorkflowRunWebhookEventAction::Completed { + info!("ignoring workflow_run action"); + return StatusCode::OK; + } + + let run: WorkflowRunData = match serde_json::from_value(payload.workflow_run) { + Ok(r) => r, + Err(e) => { + warn!("failed to parse workflow_run data: {e}"); + return StatusCode::BAD_REQUEST; + } + }; + + let conclusion = run.conclusion.unwrap_or_else(|| "unknown".to_string()); + + if conclusion != "failure" { + info!("ignoring workflow_run with conclusion: {conclusion}"); + return StatusCode::OK; + } + + info!( + "GitHub workflow_run failed: {repo} workflow={} branch={}", + run.name, run.head_branch + ); + + let event = Event::WorkflowRunFailed { + repo: repo.to_string(), + workflow_name: run.name, + branch: run.head_branch, + actor: run.actor.login, + conclusion, + url: run.html_url, + }; + dispatch::dispatch(&event, state, None).await; + StatusCode::OK +} + +async fn handle_secret_scanning( + state: &Arc, + repo: &str, + payload: octocrab::models::webhook_events::payload::SecretScanningAlertWebhookEventPayload, +) -> StatusCode { + if payload.action != SecretScanningAlertWebhookEventAction::Created { + info!("ignoring secret_scanning_alert action"); + return StatusCode::OK; + } + + let alert: SecretScanningAlertData = match serde_json::from_value(payload.alert) { + Ok(a) => a, + Err(e) => { + warn!("failed to parse secret_scanning_alert data: {e}"); + return StatusCode::BAD_REQUEST; + } + }; + + let secret_type = alert + .secret_type_display_name + .as_deref() + .unwrap_or(&alert.secret_type); + + info!("GitHub secret scanning alert: {repo} type={secret_type}"); + + let event = Event::SecretScanningAlert { + repo: repo.to_string(), + secret_type: secret_type.to_string(), + url: alert.html_url, + }; + dispatch::dispatch(&event, state, None).await; + StatusCode::OK +} + +async fn handle_dependabot( + state: &Arc, + repo: &str, + payload: octocrab::models::webhook_events::payload::DependabotAlertWebhookEventPayload, +) -> StatusCode { + if payload.action != DependabotAlertWebhookEventAction::Created { + info!("ignoring dependabot_alert action"); + return StatusCode::OK; + } + + let alert: DependabotAlertData = match serde_json::from_value(payload.alert) { + Ok(a) => a, + Err(e) => { + warn!("failed to parse dependabot_alert data: {e}"); + return StatusCode::BAD_REQUEST; + } + }; + + let severity = alert.severity.to_lowercase(); + + if severity != "critical" && severity != "high" { + info!("ignoring dependabot_alert with severity: {severity}"); + return StatusCode::OK; + } + + let package = alert + .dependency + .as_ref() + .and_then(|d| d.package.as_ref()) + .map(|p| p.name.as_str()) + .unwrap_or("unknown"); + let summary = alert + .security_advisory + .as_ref() + .map(|a| a.summary.as_str()) + .unwrap_or("No summary available"); + + info!("GitHub dependabot alert: {repo} pkg={package} severity={severity}"); + + let event = Event::DependabotAlert { + repo: repo.to_string(), + package: package.to_string(), + severity, + summary: summary.to_string(), + url: alert.html_url, + }; + dispatch::dispatch(&event, state, None).await; + StatusCode::OK +} diff --git a/src/sources/github/mod.rs b/src/sources/github/mod.rs new file mode 100644 index 0000000..12296a1 --- /dev/null +++ b/src/sources/github/mod.rs @@ -0,0 +1,7 @@ +//! GitHub webhook source — receives PR, push, and issue events and converts +//! them to the unified [`Event`](crate::event::Event) model. + +mod handler; +mod utils; + +pub use handler::webhook_handler; diff --git a/src/sources/github/utils.rs b/src/sources/github/utils.rs new file mode 100644 index 0000000..4383c51 --- /dev/null +++ b/src/sources/github/utils.rs @@ -0,0 +1,18 @@ +//! GitHub-specific helpers: signature verification and branch extraction. + +/// Verifies the `X-Hub-Signature-256` header. +/// +/// GitHub sends the signature as `sha256=`. This strips the prefix +/// and delegates to the shared HMAC verifier. +pub fn verify_github_signature(secret: &str, body: &[u8], header_value: &str) -> bool { + let Some(hex_sig) = header_value.strip_prefix("sha256=") else { + return false; + }; + crate::utils::verify_hmac_sha256(secret, body, hex_sig) +} + +/// Extracts the short branch name from a full git ref +/// (e.g. `"refs/heads/main"` → `"main"`). +pub fn branch_from_ref(git_ref: &str) -> &str { + git_ref.strip_prefix("refs/heads/").unwrap_or(git_ref) +} diff --git a/src/sources/linear/utils.rs b/src/sources/linear/utils.rs index e728a09..9fbbe84 100644 --- a/src/sources/linear/utils.rs +++ b/src/sources/linear/utils.rs @@ -1,20 +1,12 @@ //! Linear-specific helpers: signature verification and change detection. -use hmac::{Hmac, Mac}; -use sha2::Sha256; - use crate::event::Priority; use super::models::{Issue, UpdatedFrom}; /// Verifies the `linear-signature` header using HMAC-SHA256. pub fn verify_signature(secret: &str, body: &[u8], signature: &str) -> bool { - let Ok(mut mac) = Hmac::::new_from_slice(secret.as_bytes()) else { - return false; - }; - mac.update(body); - let expected = hex::encode(mac.finalize().into_bytes()); - expected == signature + crate::utils::verify_hmac_sha256(secret, body, signature) } /// Compares the current [`Issue`] state against `updated_from` and returns diff --git a/src/sources/mod.rs b/src/sources/mod.rs index 81e0f9b..0f55a99 100644 --- a/src/sources/mod.rs +++ b/src/sources/mod.rs @@ -1,3 +1,4 @@ //! Webhook receivers that normalize platform payloads into [`Event`](crate::event::Event)s. +pub mod github; pub mod linear; diff --git a/src/utils.rs b/src/utils.rs index a3d128d..28bf132 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,3 +1,17 @@ +use hmac::{Hmac, Mac}; +use sha2::Sha256; + +/// Computes HMAC-SHA256 over `body` using `secret` and compares the +/// hex-encoded result against `expected_hex`. +pub fn verify_hmac_sha256(secret: &str, body: &[u8], expected_hex: &str) -> bool { + let Ok(mut mac) = Hmac::::new_from_slice(secret.as_bytes()) else { + return false; + }; + mac.update(body); + let computed = hex::encode(mac.finalize().into_bytes()); + computed == expected_hex +} + /// Truncates `s` to at most `max_chars` characters, appending `"…"` when /// truncation occurs. pub fn truncate(s: &str, max_chars: usize) -> String { diff --git a/tests/fixtures/github_pr_opened.json b/tests/fixtures/github_pr_opened.json new file mode 100644 index 0000000..d624c8c --- /dev/null +++ b/tests/fixtures/github_pr_opened.json @@ -0,0 +1,312 @@ +{ + "action": "opened", + "number": 2, + "pull_request": { + "url": "https://api.github.com/repos/Codertocat/Hello-World/pulls/2", + "id": 279147437, + "node_id": "MDExOlB1bGxSZXF1ZXN0Mjc5MTQ3NDM3", + "html_url": "https://github.com/Codertocat/Hello-World/pull/2", + "diff_url": "https://github.com/Codertocat/Hello-World/pull/2.diff", + "patch_url": "https://github.com/Codertocat/Hello-World/pull/2.patch", + "issue_url": "https://api.github.com/repos/Codertocat/Hello-World/issues/2", + "number": 2, + "state": "open", + "locked": false, + "title": "Update the README with new information.", + "user": { + "login": "Codertocat", + "id": 21031067, + "node_id": "MDQ6VXNlcjIxMDMxMDY3", + "avatar_url": "https://avatars1.githubusercontent.com/u/21031067?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/Codertocat", + "html_url": "https://github.com/Codertocat", + "followers_url": "https://api.github.com/users/Codertocat/followers", + "following_url": "https://api.github.com/users/Codertocat/following{/other_user}", + "gists_url": "https://api.github.com/users/Codertocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/Codertocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/Codertocat/subscriptions", + "organizations_url": "https://api.github.com/users/Codertocat/orgs", + "repos_url": "https://api.github.com/users/Codertocat/repos", + "events_url": "https://api.github.com/users/Codertocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/Codertocat/received_events", + "type": "User", + "site_admin": false + }, + "body": "This is a pretty simple change that we need to pull into master.", + "created_at": "2019-05-15T15:20:33Z", + "updated_at": "2019-05-15T15:20:33Z", + "closed_at": null, + "merged_at": null, + "merge_commit_sha": null, + "assignee": null, + "assignees": [], + "requested_reviewers": [], + "requested_teams": [], + "labels": [], + "milestone": null, + "commits_url": "https://api.github.com/repos/Codertocat/Hello-World/pulls/2/commits", + "review_comments_url": "https://api.github.com/repos/Codertocat/Hello-World/pulls/2/comments", + "review_comment_url": "https://api.github.com/repos/Codertocat/Hello-World/pulls/comments{/number}", + "comments_url": "https://api.github.com/repos/Codertocat/Hello-World/issues/2/comments", + "statuses_url": "https://api.github.com/repos/Codertocat/Hello-World/statuses/ec26c3e57ca3a959ca5aad62de7213c562f8c821", + "head": { + "label": "Codertocat:changes", + "ref": "changes", + "sha": "ec26c3e57ca3a959ca5aad62de7213c562f8c821", + "user": { + "login": "Codertocat", + "id": 21031067, + "node_id": "MDQ6VXNlcjIxMDMxMDY3", + "avatar_url": "https://avatars1.githubusercontent.com/u/21031067?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/Codertocat", + "html_url": "https://github.com/Codertocat", + "followers_url": "https://api.github.com/users/Codertocat/followers", + "following_url": "https://api.github.com/users/Codertocat/following{/other_user}", + "gists_url": "https://api.github.com/users/Codertocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/Codertocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/Codertocat/subscriptions", + "organizations_url": "https://api.github.com/users/Codertocat/orgs", + "repos_url": "https://api.github.com/users/Codertocat/repos", + "events_url": "https://api.github.com/users/Codertocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/Codertocat/received_events", + "type": "User", + "site_admin": false + }, + "repo": { + "id": 186853002, + "node_id": "MDEwOlJlcG9zaXRvcnkxODY4NTMwMDI=", + "name": "Hello-World", + "full_name": "Codertocat/Hello-World", + "private": false, + "owner": { + "login": "Codertocat", + "id": 21031067, + "node_id": "MDQ6VXNlcjIxMDMxMDY3", + "avatar_url": "https://avatars1.githubusercontent.com/u/21031067?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/Codertocat", + "html_url": "https://github.com/Codertocat", + "followers_url": "https://api.github.com/users/Codertocat/followers", + "following_url": "https://api.github.com/users/Codertocat/following{/other_user}", + "gists_url": "https://api.github.com/users/Codertocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/Codertocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/Codertocat/subscriptions", + "organizations_url": "https://api.github.com/users/Codertocat/orgs", + "repos_url": "https://api.github.com/users/Codertocat/repos", + "events_url": "https://api.github.com/users/Codertocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/Codertocat/received_events", + "type": "User", + "site_admin": false + }, + "html_url": "https://github.com/Codertocat/Hello-World", + "description": null, + "fork": false, + "url": "https://api.github.com/repos/Codertocat/Hello-World", + "created_at": "2019-05-15T15:19:25Z", + "updated_at": "2019-05-15T15:19:27Z", + "pushed_at": "2019-05-15T15:20:32Z", + "homepage": null, + "size": 0, + "stargazers_count": 0, + "watchers_count": 0, + "language": null, + "has_issues": true, + "has_projects": true, + "has_downloads": true, + "has_wiki": true, + "has_pages": true, + "forks_count": 0, + "mirror_url": null, + "archived": false, + "disabled": false, + "open_issues_count": 2, + "license": null, + "forks": 0, + "open_issues": 2, + "watchers": 0, + "default_branch": "master" + } + }, + "base": { + "label": "Codertocat:master", + "ref": "master", + "sha": "f95f852bd8fca8fcc58a9a2d6c842781e32a215e", + "user": { + "login": "Codertocat", + "id": 21031067, + "node_id": "MDQ6VXNlcjIxMDMxMDY3", + "avatar_url": "https://avatars1.githubusercontent.com/u/21031067?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/Codertocat", + "html_url": "https://github.com/Codertocat", + "followers_url": "https://api.github.com/users/Codertocat/followers", + "following_url": "https://api.github.com/users/Codertocat/following{/other_user}", + "gists_url": "https://api.github.com/users/Codertocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/Codertocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/Codertocat/subscriptions", + "organizations_url": "https://api.github.com/users/Codertocat/orgs", + "repos_url": "https://api.github.com/users/Codertocat/repos", + "events_url": "https://api.github.com/users/Codertocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/Codertocat/received_events", + "type": "User", + "site_admin": false + }, + "repo": { + "id": 186853002, + "node_id": "MDEwOlJlcG9zaXRvcnkxODY4NTMwMDI=", + "name": "Hello-World", + "full_name": "Codertocat/Hello-World", + "private": false, + "owner": { + "login": "Codertocat", + "id": 21031067, + "node_id": "MDQ6VXNlcjIxMDMxMDY3", + "avatar_url": "https://avatars1.githubusercontent.com/u/21031067?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/Codertocat", + "html_url": "https://github.com/Codertocat", + "followers_url": "https://api.github.com/users/Codertocat/followers", + "following_url": "https://api.github.com/users/Codertocat/following{/other_user}", + "gists_url": "https://api.github.com/users/Codertocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/Codertocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/Codertocat/subscriptions", + "organizations_url": "https://api.github.com/users/Codertocat/orgs", + "repos_url": "https://api.github.com/users/Codertocat/repos", + "events_url": "https://api.github.com/users/Codertocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/Codertocat/received_events", + "type": "User", + "site_admin": false + }, + "html_url": "https://github.com/Codertocat/Hello-World", + "description": null, + "fork": false, + "url": "https://api.github.com/repos/Codertocat/Hello-World", + "created_at": "2019-05-15T15:19:25Z", + "updated_at": "2019-05-15T15:19:27Z", + "pushed_at": "2019-05-15T15:20:32Z", + "homepage": null, + "size": 0, + "stargazers_count": 0, + "watchers_count": 0, + "language": null, + "has_issues": true, + "has_projects": true, + "has_downloads": true, + "has_wiki": true, + "has_pages": true, + "forks_count": 0, + "mirror_url": null, + "archived": false, + "disabled": false, + "open_issues_count": 2, + "license": null, + "forks": 0, + "open_issues": 2, + "watchers": 0, + "default_branch": "master" + } + }, + "_links": { + "self": { "href": "https://api.github.com/repos/Codertocat/Hello-World/pulls/2" }, + "html": { "href": "https://github.com/Codertocat/Hello-World/pull/2" }, + "issue": { "href": "https://api.github.com/repos/Codertocat/Hello-World/issues/2" }, + "comments": { "href": "https://api.github.com/repos/Codertocat/Hello-World/issues/2/comments" }, + "review_comments": { "href": "https://api.github.com/repos/Codertocat/Hello-World/pulls/2/comments" }, + "review_comment": { "href": "https://api.github.com/repos/Codertocat/Hello-World/pulls/comments{/number}" }, + "commits": { "href": "https://api.github.com/repos/Codertocat/Hello-World/pulls/2/commits" }, + "statuses": { "href": "https://api.github.com/repos/Codertocat/Hello-World/statuses/ec26c3e57ca3a959ca5aad62de7213c562f8c821" } + }, + "author_association": "OWNER", + "auto_merge": null, + "active_lock_reason": null, + "draft": false, + "merged": false, + "mergeable": null, + "rebaseable": null, + "mergeable_state": "unknown", + "merged_by": null, + "comments": 0, + "review_comments": 0, + "maintainer_can_modify": false, + "commits": 1, + "additions": 1, + "deletions": 1, + "changed_files": 1 + }, + "repository": { + "id": 186853002, + "node_id": "MDEwOlJlcG9zaXRvcnkxODY4NTMwMDI=", + "name": "Hello-World", + "full_name": "Codertocat/Hello-World", + "private": false, + "owner": { + "login": "Codertocat", + "id": 21031067, + "node_id": "MDQ6VXNlcjIxMDMxMDY3", + "avatar_url": "https://avatars1.githubusercontent.com/u/21031067?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/Codertocat", + "html_url": "https://github.com/Codertocat", + "followers_url": "https://api.github.com/users/Codertocat/followers", + "following_url": "https://api.github.com/users/Codertocat/following{/other_user}", + "gists_url": "https://api.github.com/users/Codertocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/Codertocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/Codertocat/subscriptions", + "organizations_url": "https://api.github.com/users/Codertocat/orgs", + "repos_url": "https://api.github.com/users/Codertocat/repos", + "events_url": "https://api.github.com/users/Codertocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/Codertocat/received_events", + "type": "User", + "site_admin": false + }, + "html_url": "https://github.com/Codertocat/Hello-World", + "description": null, + "fork": false, + "url": "https://api.github.com/repos/Codertocat/Hello-World", + "created_at": "2019-05-15T15:19:25Z", + "updated_at": "2019-05-15T15:19:27Z", + "pushed_at": "2019-05-15T15:20:32Z", + "homepage": null, + "size": 0, + "stargazers_count": 0, + "watchers_count": 0, + "language": null, + "has_issues": true, + "has_projects": true, + "has_downloads": true, + "has_wiki": true, + "has_pages": true, + "forks_count": 0, + "mirror_url": null, + "archived": false, + "disabled": false, + "open_issues_count": 2, + "license": null, + "forks": 0, + "open_issues": 2, + "watchers": 0, + "default_branch": "master" + }, + "sender": { + "login": "Codertocat", + "id": 21031067, + "node_id": "MDQ6VXNlcjIxMDMxMDY3", + "avatar_url": "https://avatars1.githubusercontent.com/u/21031067?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/Codertocat", + "html_url": "https://github.com/Codertocat", + "followers_url": "https://api.github.com/users/Codertocat/followers", + "following_url": "https://api.github.com/users/Codertocat/following{/other_user}", + "gists_url": "https://api.github.com/users/Codertocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/Codertocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/Codertocat/subscriptions", + "organizations_url": "https://api.github.com/users/Codertocat/orgs", + "repos_url": "https://api.github.com/users/Codertocat/repos", + "events_url": "https://api.github.com/users/Codertocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/Codertocat/received_events", + "type": "User", + "site_admin": false + } +}