use axum::{extract::State, routing::get, Json, Router};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use crate::error::ServerError;
use crate::state::AppState;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PollingConfig {
pub enabled: bool,
pub interval_seconds: u32,
pub last_event_ids: HashMap<String, String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_checked_at: Option<String>,
pub is_running: bool,
}
impl Default for PollingConfig {
fn default() -> Self {
let enabled = std::env::var("GITHUB_POLLING_ENABLED")
.unwrap_or_else(|_| "false".to_string())
.parse()
.unwrap_or(false);
let interval_seconds = std::env::var("GITHUB_POLLING_INTERVAL")
.unwrap_or_else(|_| "30".to_string())
.parse()
.unwrap_or(30)
.max(10);
Self {
enabled,
interval_seconds,
last_event_ids: HashMap::new(),
last_checked_at: None,
is_running: false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GitHubEvent {
pub id: String,
#[serde(rename = "type")]
pub event_type: String,
pub actor: GitHubActor,
pub repo: GitHubRepo,
pub payload: serde_json::Value,
pub created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitHubActor {
pub login: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub avatar_url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitHubRepo {
pub name: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PollResult {
pub repo: String,
pub events_found: u32,
pub events_processed: u32,
pub events_skipped: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub new_last_event_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PollSummary {
pub repos_checked: u32,
pub total_events_found: u32,
pub total_events_processed: u32,
pub total_events_skipped: u32,
}
lazy_static::lazy_static! {
static ref POLLING_CONFIG: Mutex<PollingConfig> = Mutex::new(PollingConfig::default());
static ref POLLING_HANDLE: Mutex<Option<Arc<tokio::task::JoinHandle<()>>>> = Mutex::new(None);
}
pub fn start_polling_if_enabled() {
let config = POLLING_CONFIG.lock().unwrap().clone();
if config.enabled {
tracing::info!(
"[Polling] Auto-starting from env: interval={}s",
config.interval_seconds
);
start_polling_task();
}
}
fn start_polling_task() {
let mut handle_guard = POLLING_HANDLE.lock().unwrap();
if handle_guard.is_some() {
return;
}
let handle = tokio::spawn(async {
loop {
let interval = {
let config = POLLING_CONFIG.lock().unwrap();
if !config.enabled {
break;
}
config.interval_seconds
};
tokio::time::sleep(Duration::from_secs(interval as u64)).await;
if let Err(e) = poll_all_repos().await {
tracing::error!("[Polling] Error during poll: {}", e);
}
}
tracing::info!("[Polling] Background task stopped");
});
*handle_guard = Some(Arc::new(handle));
}
fn stop_polling_task() {
let mut handle_guard = POLLING_HANDLE.lock().unwrap();
if let Some(handle) = handle_guard.take() {
handle.abort();
tracing::info!("[Polling] Background task aborted");
}
}
async fn poll_all_repos() -> Result<Vec<PollResult>, String> {
let results: Vec<PollResult> = vec![];
let checked_at = chrono::Utc::now().to_rfc3339();
{
let mut config = POLLING_CONFIG.lock().unwrap();
config.last_checked_at = Some(checked_at);
}
Ok(results)
}
pub fn router() -> Router<AppState> {
Router::new()
.route("/config", get(get_config).post(update_config))
.route("/check", get(get_status).post(check_now))
}
async fn get_config() -> Result<Json<serde_json::Value>, ServerError> {
let config = POLLING_CONFIG.lock().unwrap().clone();
Ok(Json(serde_json::json!({
"ok": true,
"config": config,
})))
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct UpdateConfigRequest {
enabled: Option<bool>,
interval_seconds: Option<u32>,
}
async fn update_config(
Json(body): Json<UpdateConfigRequest>,
) -> Result<Json<serde_json::Value>, ServerError> {
let mut config = POLLING_CONFIG.lock().unwrap();
if let Some(enabled) = body.enabled {
config.enabled = enabled;
config.is_running = enabled;
drop(config);
if enabled {
start_polling_task();
send_notification(
"Routa Polling Enabled",
"GitHub event polling is now active",
);
} else {
stop_polling_task();
send_notification(
"Routa Polling Disabled",
"GitHub event polling has been stopped",
);
}
config = POLLING_CONFIG.lock().unwrap();
}
if let Some(interval) = body.interval_seconds {
if interval >= 10 {
config.interval_seconds = interval;
if config.enabled {
drop(config);
stop_polling_task();
start_polling_task();
config = POLLING_CONFIG.lock().unwrap();
}
}
}
let config_clone = config.clone();
drop(config);
Ok(Json(serde_json::json!({
"ok": true,
"config": config_clone,
})))
}
async fn get_status() -> Result<Json<serde_json::Value>, ServerError> {
let config = POLLING_CONFIG.lock().unwrap();
Ok(Json(serde_json::json!({
"ok": true,
"isRunning": config.is_running,
"lastCheckedAt": config.last_checked_at,
"intervalSeconds": config.interval_seconds,
"enabled": config.enabled,
})))
}
async fn check_now(State(_state): State<AppState>) -> Result<Json<serde_json::Value>, ServerError> {
let results = poll_all_repos()
.await
.map_err(|e| ServerError::Internal(format!("Polling failed: {e}")))?;
let summary = PollSummary {
repos_checked: results.len() as u32,
total_events_found: results.iter().map(|r| r.events_found).sum(),
total_events_processed: results.iter().map(|r| r.events_processed).sum(),
total_events_skipped: results.iter().map(|r| r.events_skipped).sum(),
};
send_notification(
"Routa Poll Check",
&format!(
"Checked {} repos, {} events found",
summary.repos_checked, summary.total_events_found
),
);
let checked_at = POLLING_CONFIG
.lock()
.unwrap()
.last_checked_at
.clone()
.unwrap_or_else(|| chrono::Utc::now().to_rfc3339());
tracing::info!(
"[Polling] Check completed: {} repos, {} events",
summary.repos_checked,
summary.total_events_found
);
Ok(Json(serde_json::json!({
"ok": true,
"checkedAt": checked_at,
"summary": summary,
"results": results,
})))
}
fn send_notification(title: &str, body: &str) {
#[cfg(target_os = "macos")]
{
use notify_rust::Notification;
let _ = Notification::new()
.summary(title)
.body(body)
.appname("Routa")
.sound_name("default")
.show();
}
#[cfg(target_os = "windows")]
{
use notify_rust::Notification;
let _ = Notification::new()
.summary(title)
.body(body)
.appname("Routa")
.show();
}
#[cfg(target_os = "linux")]
{
use notify_rust::Notification;
let _ = Notification::new()
.summary(title)
.body(body)
.appname("Routa")
.show();
}
tracing::debug!("[Notification] {}: {}", title, body);
}