From 7455e0b1e1a95f8056f0d1bd6778c34602c15ad2 Mon Sep 17 00:00:00 2001 From: Sean JA Date: Mon, 2 Mar 2026 22:42:41 +0800 Subject: [PATCH 1/5] feat: GitHub integration --- src/config.rs | 111 ++++++- 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 | 36 +++ src/sinks/lark/cards.rs | 558 ++++++++++++++++++++++++---------- src/sinks/lark/mod.rs | 39 ++- src/sources/github/handler.rs | 413 +++++++++++++++++++++++++ src/sources/github/mod.rs | 8 + src/sources/github/models.rs | 160 ++++++++++ src/sources/github/utils.rs | 18 ++ src/sources/linear/utils.rs | 10 +- src/sources/mod.rs | 1 + src/utils.rs | 14 + 16 files changed, 1322 insertions(+), 204 deletions(-) create mode 100644 src/sources/github/handler.rs create mode 100644 src/sources/github/mod.rs create mode 100644 src/sources/github/models.rs create mode 100644 src/sources/github/utils.rs diff --git a/src/config.rs b/src/config.rs index 1190389..b817dc0 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,106 @@ 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, +} + +#[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(); + + Some(Self { + webhook_secret: secret, + user_map, + alert_labels, + repo_whitelist, + }) + } +} + +#[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(); + + Some(Self { + webhook_secret: secret, + user_map, + alert_labels, + repo_whitelist, + }) + } +} + fn default_port() -> u16 { 3000 } @@ -159,6 +252,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 +268,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 +277,22 @@ 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); + } + } info!("debounce delay: {}ms", server.debounce_delay_ms); Self { linear, lark, server, + github, http, lark_bot, linear_client, @@ -202,6 +307,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 +317,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..bcc771a 100644 --- a/src/sinks/lark/bot.rs +++ b/src/sinks/lark/bot.rs @@ -119,4 +119,40 @@ impl LarkBotClient { Err(format!("DM request returned {status}: {body}")) } } + + /// 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> { + let token = self.get_token().await?; + + let payload = json!({ + "receive_id": chat_id, + "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=chat_id") + .header("Authorization", format!("Bearer {token}")) + .json(&payload) + .send() + .await + .map_err(|e| format!("chat message 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!("chat API returned code {code}: {body}")); + } + info!("card sent to chat {chat_id}"); + Ok(()) + } else { + Err(format!("chat message request returned {status}: {body}")) + } + } } 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..678cd58 --- /dev/null +++ b/src/sources/github/handler.rs @@ -0,0 +1,413 @@ +//! 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 tracing::{info, warn}; + +use crate::{ + config::{AppState, GitHubConfig}, + dispatch, + event::{CommitSummary, Event}, +}; + +use super::{ + models::{ + DependabotPayload, IssuesPayload, PullRequestPayload, PushPayload, SecretScanningPayload, + WorkflowRunPayload, + }, + 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, +} + +/// Handles incoming GitHub webhook requests. +/// +/// 1. Verifies the `X-Hub-Signature-256` HMAC header. +/// 2. Routes by the `X-GitHub-Event` header. +/// 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(""); + + match event_type { + "pull_request" => handle_pull_request(&state, &body, github).await, + "issues" => handle_issues(&state, &body, github).await, + "push" => handle_push(&state, &body).await, + "workflow_run" => handle_workflow_run(&state, &body).await, + "secret_scanning_alert" => handle_secret_scanning(&state, &body).await, + "dependabot_alert" => handle_dependabot(&state, &body).await, + _ => { + info!("ignoring GitHub event type: {event_type}"); + StatusCode::OK + } + } +} + +async fn handle_pull_request( + state: &Arc, + body: &[u8], + github: &GitHubConfig, +) -> StatusCode { + let payload: PullRequestPayload = match serde_json::from_slice(body) { + Ok(p) => p, + Err(e) => { + warn!("failed to parse pull_request payload: {e}"); + return StatusCode::BAD_REQUEST; + } + }; + + let pr = &payload.pull_request; + let repo = &payload.repository.full_name; + + match payload.action.as_str() { + "opened" => { + info!("GitHub PR opened: {repo}#{}", pr.number); + let event = Event::PrOpened { + repo: repo.clone(), + number: pr.number, + title: pr.title.clone(), + author: pr.user.login.clone(), + head_branch: pr.head.ref_name.clone(), + base_branch: pr.base.ref_name.clone(), + additions: pr.additions, + deletions: pr.deletions, + url: pr.html_url.clone(), + }; + dispatch::dispatch(&event, state, None).await; + StatusCode::OK + } + "review_requested" => { + let reviewer = match &payload.requested_reviewer { + Some(u) => &u.login, + None => { + info!("review_requested without requested_reviewer, ignoring"); + return StatusCode::OK; + } + }; + + info!( + "GitHub review requested: {repo}#{} reviewer={reviewer}", + pr.number + ); + + let reviewer_lark_id = github.user_map.get(reviewer).cloned(); + let dm_email = reviewer_lark_id.clone(); + + let event = Event::PrReviewRequested { + repo: repo.clone(), + number: pr.number, + title: pr.title.clone(), + author: pr.user.login.clone(), + reviewer: reviewer.clone(), + reviewer_lark_id, + url: pr.html_url.clone(), + }; + dispatch::dispatch(&event, state, dm_email.as_deref()).await; + StatusCode::OK + } + "closed" if pr.merged => { + let merged_by = pr + .merged_by + .as_ref() + .map(|u| u.login.clone()) + .unwrap_or_else(|| pr.user.login.clone()); + + info!("GitHub PR merged: {repo}#{} by {merged_by}", pr.number); + + let event = Event::PrMerged { + repo: repo.clone(), + number: pr.number, + title: pr.title.clone(), + author: pr.user.login.clone(), + merged_by, + url: pr.html_url.clone(), + }; + dispatch::dispatch(&event, state, None).await; + StatusCode::OK + } + _ => { + info!( + "ignoring pull_request action: {} for {repo}#{}", + payload.action, pr.number + ); + StatusCode::OK + } + } +} + +async fn handle_issues(state: &Arc, body: &[u8], github: &GitHubConfig) -> StatusCode { + let payload: IssuesPayload = match serde_json::from_slice(body) { + Ok(p) => p, + Err(e) => { + warn!("failed to parse issues payload: {e}"); + return StatusCode::BAD_REQUEST; + } + }; + + if payload.action != "labeled" { + info!("ignoring issues action: {}", payload.action); + return StatusCode::OK; + } + + let label = match &payload.label { + Some(l) => &l.name, + None => return StatusCode::OK, + }; + + if !github.alert_labels.contains(&label.to_lowercase()) { + info!("ignoring non-alert label: {label}"); + return StatusCode::OK; + } + + let repo = &payload.repository.full_name; + let issue = &payload.issue; + + info!( + "GitHub issue labeled alert: {repo}#{} label={label}", + issue.number + ); + + let event = Event::IssueLabeledAlert { + repo: repo.clone(), + number: issue.number, + title: issue.title.clone(), + label: label.clone(), + author: issue.user.login.clone(), + url: issue.html_url.clone(), + }; + dispatch::dispatch(&event, state, None).await; + StatusCode::OK +} + +async fn handle_push(state: &Arc, body: &[u8]) -> StatusCode { + let payload: PushPayload = match serde_json::from_slice(body) { + Ok(p) => p, + Err(e) => { + warn!("failed to parse push payload: {e}"); + return StatusCode::BAD_REQUEST; + } + }; + + let branch = branch_from_ref(&payload.ref_name); + + if !is_protected_branch(branch) { + info!("ignoring push to non-protected branch: {branch}"); + return StatusCode::OK; + } + + let repo = &payload.repository.full_name; + 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.name.clone(), + }) + .collect(); + + let event = Event::BranchPush { + repo: repo.clone(), + branch: branch.to_string(), + pusher: payload.pusher.name.clone(), + commits, + compare_url: payload.compare.clone(), + }; + 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, body: &[u8]) -> StatusCode { + let payload: WorkflowRunPayload = match serde_json::from_slice(body) { + Ok(p) => p, + Err(e) => { + warn!("failed to parse workflow_run payload: {e}"); + return StatusCode::BAD_REQUEST; + } + }; + + if payload.action != "completed" { + info!("ignoring workflow_run action: {}", payload.action); + return StatusCode::OK; + } + + let run = &payload.workflow_run; + let conclusion = run.conclusion.as_deref().unwrap_or("unknown"); + + if conclusion != "failure" { + info!("ignoring workflow_run with conclusion: {conclusion}"); + return StatusCode::OK; + } + + let repo = &payload.repository.full_name; + info!( + "GitHub workflow_run failed: {repo} workflow={} branch={}", + run.name, run.head_branch + ); + + let event = Event::WorkflowRunFailed { + repo: repo.clone(), + workflow_name: run.name.clone(), + branch: run.head_branch.clone(), + actor: run.actor.login.clone(), + conclusion: conclusion.to_string(), + url: run.html_url.clone(), + }; + dispatch::dispatch(&event, state, None).await; + StatusCode::OK +} + +async fn handle_secret_scanning(state: &Arc, body: &[u8]) -> StatusCode { + let payload: SecretScanningPayload = match serde_json::from_slice(body) { + Ok(p) => p, + Err(e) => { + warn!("failed to parse secret_scanning_alert payload: {e}"); + return StatusCode::BAD_REQUEST; + } + }; + + if payload.action != "created" { + info!("ignoring secret_scanning_alert action: {}", payload.action); + return StatusCode::OK; + } + + let repo = &payload.repository.full_name; + let alert = &payload.alert; + let display_type = alert + .secret_type_display_name + .as_deref() + .unwrap_or(&alert.secret_type); + + info!("GitHub secret scanning alert: {repo} type={display_type}"); + + let event = Event::SecretScanningAlert { + repo: repo.clone(), + secret_type: display_type.to_string(), + url: alert.html_url.clone(), + }; + dispatch::dispatch(&event, state, None).await; + StatusCode::OK +} + +async fn handle_dependabot(state: &Arc, body: &[u8]) -> StatusCode { + let payload: DependabotPayload = match serde_json::from_slice(body) { + Ok(p) => p, + Err(e) => { + warn!("failed to parse dependabot_alert payload: {e}"); + return StatusCode::BAD_REQUEST; + } + }; + + if payload.action != "created" { + info!("ignoring dependabot_alert action: {}", payload.action); + return StatusCode::OK; + } + + let alert = &payload.alert; + let severity = alert.severity.to_lowercase(); + + if severity != "critical" && severity != "high" { + info!("ignoring dependabot_alert with severity: {severity}"); + return StatusCode::OK; + } + + let repo = &payload.repository.full_name; + 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.clone(), + package: package.to_string(), + severity, + summary: summary.to_string(), + url: alert.html_url.clone(), + }; + 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..bbcb340 --- /dev/null +++ b/src/sources/github/mod.rs @@ -0,0 +1,8 @@ +//! GitHub webhook source — receives PR, push, and issue events and converts +//! them to the unified [`Event`](crate::event::Event) model. + +mod handler; +pub mod models; +mod utils; + +pub use handler::webhook_handler; diff --git a/src/sources/github/models.rs b/src/sources/github/models.rs new file mode 100644 index 0000000..4a71b5c --- /dev/null +++ b/src/sources/github/models.rs @@ -0,0 +1,160 @@ +//! Deserialization types for GitHub webhook payloads (subset). + +use serde::Deserialize; + +/// Top-level `pull_request` webhook payload. +#[derive(Debug, Deserialize)] +pub struct PullRequestPayload { + pub action: String, + pub pull_request: PullRequest, + pub requested_reviewer: Option, + pub repository: Repository, +} + +#[derive(Debug, Deserialize)] +pub struct PullRequest { + pub number: u64, + pub title: String, + pub html_url: String, + pub user: GitHubUser, + pub head: GitRef, + pub base: GitRef, + #[serde(default)] + pub additions: u64, + #[serde(default)] + pub deletions: u64, + #[serde(default)] + pub merged: bool, + pub merged_by: Option, +} + +#[derive(Debug, Deserialize)] +pub struct GitRef { + #[serde(rename = "ref")] + pub ref_name: String, +} + +#[derive(Debug, Deserialize)] +pub struct GitHubUser { + pub login: String, +} + +/// Top-level `issues` webhook payload. +#[derive(Debug, Deserialize)] +pub struct IssuesPayload { + pub action: String, + pub issue: GitHubIssue, + pub label: Option