Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
336 changes: 336 additions & 0 deletions Cargo.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
121 changes: 119 additions & 2 deletions src/config.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
use std::collections::HashMap;

#[cfg(not(feature = "cf-worker"))]
use figment::{Figment, providers::Env};
use reqwest::Client;
Expand Down Expand Up @@ -51,6 +53,7 @@ impl LinearConfig {
pub struct LarkConfig {
#[serde(default)]
pub webhook_url: String,
pub target_chat_id: Option<String>,
pub app_id: Option<String>,
pub app_secret: Option<String>,
pub verification_token: Option<String>,
Expand All @@ -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
Expand All @@ -89,17 +93,113 @@ impl LarkConfig {
pub fn bot_client(&self, http: &Client) -> Option<LarkBotClient> {
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<String> {
vec!["bug".into(), "urgent".into(), "p0".into()]
}

#[derive(Debug)]
pub struct GitHubConfig {
pub webhook_secret: String,
pub user_map: HashMap<String, String>,
pub alert_labels: Vec<String>,
pub repo_whitelist: Vec<String>,
pub pat: Option<String>,
}

#[cfg(not(feature = "cf-worker"))]
impl GitHubConfig {
pub fn from_env() -> Option<Self> {
let secret = std::env::var("GITHUB_WEBHOOK_SECRET").ok()?;

let user_map: HashMap<String, String> = std::env::var("GITHUB_USER_MAP")
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();

let alert_labels: Vec<String> = 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<String> = 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<Self> {
let secret = env.secret("GITHUB_WEBHOOK_SECRET").ok()?.to_string();

let user_map: HashMap<String, String> = env
.var("GITHUB_USER_MAP")
.ok()
.and_then(|v| serde_json::from_str(&v.to_string()).ok())
.unwrap_or_default();

let alert_labels: Vec<String> = 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<String> = 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
}
Expand Down Expand Up @@ -159,6 +259,7 @@ pub struct AppState {
pub linear: LinearConfig,
pub lark: LarkConfig,
pub server: ServerConfig,
pub github: Option<GitHubConfig>,
pub http: Client,
pub lark_bot: Option<LarkBotClient>,
pub linear_client: Option<LinearClient>,
Expand All @@ -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);
Expand All @@ -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,
Expand All @@ -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);
Expand All @@ -211,6 +327,7 @@ impl AppState {
linear,
lark,
server,
github,
http,
lark_bot,
linear_client,
Expand Down
61 changes: 43 additions & 18 deletions src/debounce_do.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
2 changes: 1 addition & 1 deletion src/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
82 changes: 78 additions & 4 deletions src/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<String>,
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<CommitSummary>,
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<String>) {
match self {
Event::IssueCreated { changes, .. } | Event::IssueUpdated { changes, .. } => {
*changes = new_changes;
}
Event::CommentCreated { .. } => {}
_ => {}
}
}

Expand Down
Loading
Loading