2026-04-13 12:41:02 +00:00
|
|
|
use crate::models::credential::TuleapCredentials;
|
|
|
|
|
use crate::models::ticket::ProcessedTicket;
|
|
|
|
|
use crate::models::tracker::WatchedTracker;
|
|
|
|
|
use crate::services::tuleap_client::TuleapClient;
|
2026-04-15 16:03:48 +00:00
|
|
|
use crate::services::{crypto, filter_engine, notifier};
|
2026-04-13 12:41:02 +00:00
|
|
|
use rusqlite::Connection;
|
|
|
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
|
use tauri::{AppHandle, Emitter};
|
|
|
|
|
use tokio::time::{interval, Duration};
|
|
|
|
|
|
|
|
|
|
pub fn start(
|
|
|
|
|
db: Arc<Mutex<Connection>>,
|
|
|
|
|
encryption_key: [u8; 32],
|
|
|
|
|
http_client: reqwest::Client,
|
|
|
|
|
app_handle: AppHandle,
|
|
|
|
|
) {
|
2026-04-14 08:14:14 +00:00
|
|
|
tauri::async_runtime::spawn(async move {
|
2026-04-13 12:41:02 +00:00
|
|
|
let mut tick = interval(Duration::from_secs(60));
|
|
|
|
|
loop {
|
|
|
|
|
tick.tick().await;
|
|
|
|
|
poll_all_trackers(&db, &encryption_key, &http_client, &app_handle).await;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn poll_all_trackers(
|
|
|
|
|
db: &Arc<Mutex<Connection>>,
|
|
|
|
|
encryption_key: &[u8; 32],
|
|
|
|
|
http_client: &reqwest::Client,
|
|
|
|
|
app_handle: &AppHandle,
|
|
|
|
|
) {
|
|
|
|
|
// 1. Read all enabled trackers and credentials from DB
|
|
|
|
|
let (trackers, client) = {
|
|
|
|
|
let conn = match db.lock() {
|
|
|
|
|
Ok(c) => c,
|
|
|
|
|
Err(e) => {
|
|
|
|
|
eprintln!("poller: failed to lock db: {}", e);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let trackers = match WatchedTracker::list_all_enabled(&conn) {
|
|
|
|
|
Ok(t) => t,
|
|
|
|
|
Err(e) => {
|
|
|
|
|
eprintln!("poller: failed to list trackers: {}", e);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 2. Read credentials; bail silently if none
|
|
|
|
|
let creds = match TuleapCredentials::get(&conn) {
|
|
|
|
|
Ok(Some(c)) => c,
|
|
|
|
|
Ok(None) => return,
|
|
|
|
|
Err(e) => {
|
|
|
|
|
eprintln!("poller: failed to read credentials: {}", e);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let password = match crypto::decrypt(encryption_key, &creds.password_encrypted) {
|
|
|
|
|
Ok(p) => p,
|
|
|
|
|
Err(e) => {
|
|
|
|
|
eprintln!("poller: failed to decrypt password: {}", e);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let client = TuleapClient::new(http_client, &creds.tuleap_url, &creds.username, &password);
|
|
|
|
|
|
|
|
|
|
(trackers, client)
|
|
|
|
|
}; // lock released
|
|
|
|
|
|
|
|
|
|
// 3. For each tracker that should_poll, poll it
|
|
|
|
|
for tracker in &trackers {
|
|
|
|
|
if should_poll(tracker) {
|
|
|
|
|
poll_single_tracker(db, &client, tracker, app_handle).await;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn should_poll(tracker: &WatchedTracker) -> bool {
|
|
|
|
|
let last_polled_at = match &tracker.last_polled_at {
|
|
|
|
|
None => return true, // Never polled
|
|
|
|
|
Some(s) => s,
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-16 15:23:36 +00:00
|
|
|
let last = match parse_timestamp(last_polled_at) {
|
|
|
|
|
Some(dt) => dt,
|
|
|
|
|
None => {
|
2026-04-15 16:03:48 +00:00
|
|
|
eprintln!(
|
2026-04-16 15:23:36 +00:00
|
|
|
"poller: failed to parse last_polled_at '{}': unsupported format",
|
|
|
|
|
last_polled_at
|
2026-04-15 16:03:48 +00:00
|
|
|
);
|
2026-04-13 12:41:02 +00:00
|
|
|
return true; // Treat as never polled on parse error
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let elapsed = chrono::Utc::now().signed_duration_since(last).num_minutes();
|
|
|
|
|
elapsed >= tracker.polling_interval as i64
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-16 15:23:36 +00:00
|
|
|
fn parse_timestamp(value: &str) -> Option<chrono::DateTime<chrono::Utc>> {
|
|
|
|
|
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(value) {
|
|
|
|
|
return Some(dt.with_timezone(&chrono::Utc));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if let Ok(naive) = chrono::NaiveDateTime::parse_from_str(value, "%Y-%m-%d %H:%M:%S%.f") {
|
|
|
|
|
return Some(chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(
|
|
|
|
|
naive,
|
|
|
|
|
chrono::Utc,
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 12:41:02 +00:00
|
|
|
async fn poll_single_tracker(
|
|
|
|
|
db: &Arc<Mutex<Connection>>,
|
|
|
|
|
client: &TuleapClient,
|
|
|
|
|
tracker: &WatchedTracker,
|
|
|
|
|
app_handle: &AppHandle,
|
|
|
|
|
) {
|
2026-04-14 09:36:32 +00:00
|
|
|
let _ = app_handle.emit(
|
|
|
|
|
"polling-started",
|
|
|
|
|
serde_json::json!({
|
|
|
|
|
"project_id": &tracker.project_id,
|
|
|
|
|
"tracker_id": &tracker.id,
|
|
|
|
|
"tracker_label": &tracker.tracker_label,
|
|
|
|
|
"source": "scheduled",
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
2026-04-13 12:41:02 +00:00
|
|
|
// 1. Fetch artifacts
|
|
|
|
|
let artifacts = match client.get_artifacts(tracker.tracker_id).await {
|
|
|
|
|
Ok(a) => a,
|
|
|
|
|
Err(e) => {
|
2026-04-15 16:03:48 +00:00
|
|
|
eprintln!(
|
|
|
|
|
"poller: failed to fetch artifacts for tracker {}: {}",
|
|
|
|
|
tracker.id, e
|
|
|
|
|
);
|
2026-04-14 09:36:32 +00:00
|
|
|
let _ = app_handle.emit(
|
|
|
|
|
"polling-error",
|
|
|
|
|
serde_json::json!({
|
|
|
|
|
"project_id": &tracker.project_id,
|
|
|
|
|
"tracker_id": &tracker.id,
|
|
|
|
|
"tracker_label": &tracker.tracker_label,
|
|
|
|
|
"source": "scheduled",
|
|
|
|
|
"error": e,
|
|
|
|
|
}),
|
|
|
|
|
);
|
2026-04-13 12:41:02 +00:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 2. Apply filters
|
|
|
|
|
let filtered = filter_engine::apply_filters(&artifacts, &tracker.filters);
|
|
|
|
|
|
|
|
|
|
// 3. Insert new tickets and update last_polled_at
|
2026-04-14 08:09:19 +00:00
|
|
|
let new_tickets = {
|
2026-04-13 12:41:02 +00:00
|
|
|
let conn = match db.lock() {
|
|
|
|
|
Ok(c) => c,
|
|
|
|
|
Err(e) => {
|
|
|
|
|
eprintln!("poller: failed to lock db for insert: {}", e);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-14 08:09:19 +00:00
|
|
|
let mut inserted = Vec::new();
|
2026-04-13 12:41:02 +00:00
|
|
|
|
|
|
|
|
for artifact in &filtered {
|
2026-04-15 16:03:48 +00:00
|
|
|
let artifact_id = artifact.get("id").and_then(|v| v.as_i64()).unwrap_or(0) as i32;
|
2026-04-13 12:41:02 +00:00
|
|
|
|
|
|
|
|
let artifact_title = artifact
|
|
|
|
|
.get("title")
|
|
|
|
|
.and_then(|v| v.as_str())
|
|
|
|
|
.unwrap_or("")
|
|
|
|
|
.to_string();
|
|
|
|
|
|
2026-04-15 16:03:48 +00:00
|
|
|
let artifact_data =
|
|
|
|
|
serde_json::to_string(artifact).unwrap_or_else(|_| "{}".to_string());
|
2026-04-13 12:41:02 +00:00
|
|
|
|
|
|
|
|
match ProcessedTicket::insert_if_new(
|
|
|
|
|
&conn,
|
|
|
|
|
&tracker.id,
|
|
|
|
|
artifact_id,
|
|
|
|
|
&artifact_title,
|
|
|
|
|
&artifact_data,
|
|
|
|
|
) {
|
2026-04-14 08:09:19 +00:00
|
|
|
Ok(Some(ticket)) => inserted.push(ticket),
|
2026-04-13 12:41:02 +00:00
|
|
|
Ok(None) => {}
|
|
|
|
|
Err(e) => {
|
2026-04-15 16:03:48 +00:00
|
|
|
eprintln!(
|
|
|
|
|
"poller: failed to insert ticket (artifact {}): {}",
|
|
|
|
|
artifact_id, e
|
|
|
|
|
);
|
2026-04-13 12:41:02 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 4. Update last_polled_at
|
|
|
|
|
if let Err(e) = WatchedTracker::update_last_polled(&conn, &tracker.id) {
|
2026-04-15 16:03:48 +00:00
|
|
|
eprintln!(
|
|
|
|
|
"poller: failed to update last_polled_at for tracker {}: {}",
|
|
|
|
|
tracker.id, e
|
|
|
|
|
);
|
2026-04-13 12:41:02 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-14 08:09:19 +00:00
|
|
|
inserted
|
2026-04-13 12:41:02 +00:00
|
|
|
}; // lock released
|
|
|
|
|
|
|
|
|
|
// 5. Emit event if new tickets found
|
2026-04-14 08:09:19 +00:00
|
|
|
if !new_tickets.is_empty() {
|
2026-04-13 12:41:02 +00:00
|
|
|
if let Err(e) = app_handle.emit(
|
|
|
|
|
"new-tickets-detected",
|
|
|
|
|
serde_json::json!({
|
2026-04-14 09:36:32 +00:00
|
|
|
"project_id": &tracker.project_id,
|
|
|
|
|
"tracker_id": &tracker.id,
|
|
|
|
|
"tracker_label": &tracker.tracker_label,
|
2026-04-14 08:09:19 +00:00
|
|
|
"count": new_tickets.len(),
|
2026-04-13 12:41:02 +00:00
|
|
|
}),
|
|
|
|
|
) {
|
|
|
|
|
eprintln!("poller: failed to emit event: {}", e);
|
|
|
|
|
}
|
2026-04-14 08:09:19 +00:00
|
|
|
|
|
|
|
|
for ticket in &new_tickets {
|
|
|
|
|
notifier::notify_new_ticket(
|
|
|
|
|
db,
|
|
|
|
|
app_handle,
|
|
|
|
|
&tracker.project_id,
|
|
|
|
|
&ticket.id,
|
|
|
|
|
ticket.artifact_id,
|
|
|
|
|
&ticket.artifact_title,
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-04-13 12:41:02 +00:00
|
|
|
}
|
2026-04-14 09:36:32 +00:00
|
|
|
|
|
|
|
|
let _ = app_handle.emit(
|
|
|
|
|
"polling-finished",
|
|
|
|
|
serde_json::json!({
|
|
|
|
|
"project_id": &tracker.project_id,
|
|
|
|
|
"tracker_id": &tracker.id,
|
|
|
|
|
"tracker_label": &tracker.tracker_label,
|
|
|
|
|
"source": "scheduled",
|
|
|
|
|
"new_tickets_count": new_tickets.len(),
|
|
|
|
|
}),
|
|
|
|
|
);
|
2026-04-13 12:41:02 +00:00
|
|
|
}
|
2026-04-16 15:23:36 +00:00
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::parse_timestamp;
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn parse_timestamp_supports_rfc3339() {
|
|
|
|
|
let parsed = parse_timestamp("2026-04-16T10:15:30.123Z");
|
|
|
|
|
assert!(parsed.is_some());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn parse_timestamp_supports_legacy_sqlite_datetime() {
|
|
|
|
|
let parsed = parse_timestamp("2026-04-16 10:15:30");
|
|
|
|
|
assert!(parsed.is_some());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn parse_timestamp_rejects_invalid_values() {
|
|
|
|
|
let parsed = parse_timestamp("not-a-date");
|
|
|
|
|
assert!(parsed.is_none());
|
|
|
|
|
}
|
|
|
|
|
}
|