1use crate::api::jira::{Jira, SessionMode};
19use crate::db::jira_inbox::{ChangedIssue, Include, JiraInbox, JiraInboxItem, JiraInboxUpsert, LeftReason, UpsertBatchResult};
20use crate::db::jira_statuses::JiraStatuses;
21use crate::libs::config::{Config, JiraInboxConfig};
22use crate::libs::inbox_rules::{self, RuleBook, RuleProblem, Ruled};
23use crate::libs::messages::Message;
24use crate::libs::toast_action::{Mailbox, ToastAction};
25use crate::libs::toast_apply;
26use crate::{msg_info, msg_warning};
27use anyhow::Result;
28use chrono::{Duration, Local, NaiveDateTime};
29use std::collections::{HashMap, VecDeque};
30use std::process::Command;
31use tracing::{debug, warn};
32
33pub const TOAST_STORM_THRESHOLD: usize = 5;
43
44pub fn toasts_collapse(count: usize) -> bool {
46 count > TOAST_STORM_THRESHOLD
47}
48
49pub const TOAST_WINDOW_MINUTES: i64 = 60;
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum ToastPlan {
55 Each,
57 Summary,
59 Quiet,
61}
62
63#[derive(Debug, Default)]
82pub struct ToastBudget {
83 ordinary: Lane,
84 urgent: Lane,
85}
86
87impl ToastBudget {
88 pub fn new() -> Self {
89 Self::default()
90 }
91
92 pub fn admit(&mut self, now: NaiveDateTime, count: usize) -> ToastPlan {
94 self.ordinary.admit(now, count)
95 }
96
97 pub fn admit_urgent(&mut self, now: NaiveDateTime, count: usize) -> ToastPlan {
99 self.urgent.admit(now, count)
100 }
101}
102
103#[derive(Debug, Default)]
105struct Lane {
106 shown: VecDeque<NaiveDateTime>,
107 summary_at: Option<NaiveDateTime>,
108}
109
110impl Lane {
111 fn admit(&mut self, now: NaiveDateTime, count: usize) -> ToastPlan {
112 let since = now - Duration::minutes(TOAST_WINDOW_MINUTES);
113 while self.shown.front().is_some_and(|t| *t <= since) {
114 self.shown.pop_front();
115 }
116 if self.summary_at.is_some_and(|t| t <= since) {
117 self.summary_at = None;
118 }
119
120 if count == 0 {
121 return ToastPlan::Each;
122 }
123 if self.shown.len() + count <= TOAST_STORM_THRESHOLD {
124 self.shown.extend(std::iter::repeat_n(now, count));
125 return ToastPlan::Each;
126 }
127 if self.summary_at.is_none() {
128 self.summary_at = Some(now);
129 return ToastPlan::Summary;
130 }
131 ToastPlan::Quiet
132 }
133}
134
135enum Pending {
137 New(JiraInboxItem),
138 Changed(JiraInboxItem, String),
139 Gone(JiraInboxItem),
140 Resolved(JiraInboxItem),
142 Urgent(JiraInboxItem, String),
144}
145
146#[derive(Debug, Clone, PartialEq, Eq)]
148pub enum Notice {
149 New,
151 Changed(String),
153 Urgent(String),
155}
156
157pub fn notices(fresh: &[String], changed: &[(String, String)], urgent: &[(String, String)]) -> Vec<(String, Notice)> {
166 let urgent_by = |key: &str| urgent.iter().find(|(k, _)| k == key).map(|(_, rule)| rule.clone());
167 let mut out: Vec<(String, Notice)> = Vec::new();
168 for key in fresh {
169 let notice = urgent_by(key).map(Notice::Urgent).unwrap_or(Notice::New);
170 out.push((key.clone(), notice));
171 }
172 for (key, change) in changed {
173 if !fresh.contains(key) && urgent_by(key).is_none() {
174 out.push((key.clone(), Notice::Changed(change.clone())));
175 }
176 }
177 for (key, rule) in urgent {
178 if !fresh.contains(key) {
179 out.push((key.clone(), Notice::Urgent(rule.clone())));
180 }
181 }
182 out
183}
184
185fn summary_line(pending: &[Pending]) -> String {
187 let count = |f: fn(&Pending) -> bool| pending.iter().filter(|p| f(p)).count();
188 let parts = [
189 (count(|p| matches!(p, Pending::New(_))), "new"),
190 (count(|p| matches!(p, Pending::Changed(..))), "changed"),
191 (count(|p| matches!(p, Pending::Gone(_))), "left the inbox"),
192 (count(|p| matches!(p, Pending::Resolved(_))), "resolved with your task open"),
193 (count(|p| matches!(p, Pending::Urgent(..))), "urgent"),
194 ];
195 let said: Vec<String> = parts.iter().filter(|(n, _)| *n > 0).map(|(n, what)| format!("{n} {what}")).collect();
196 format!("Issues: {}", said.join(", "))
197}
198
199#[derive(Debug, Default)]
201pub struct SyncOutcome {
202 pub fetched: usize,
203 pub new_keys: Vec<String>,
204 pub updated: usize,
205 pub changed: Vec<ChangedIssue>,
207 pub gone_keys: Vec<String>,
209 pub resolved: Vec<ResolvedIssue>,
214 pub ruled: Vec<Ruled>,
216 pub rule_problems: Vec<RuleProblem>,
218 pub notified: usize,
219 pub skipped: bool,
221}
222
223#[derive(Debug, Clone, PartialEq, Eq)]
225pub struct ResolvedIssue {
226 pub issue_key: String,
227 pub summary: String,
228 pub resolution: Option<String>,
230}
231
232const DEPARTURES_PER_POLL: usize = 20;
238
239async fn explain_departures(jira: &mut Jira, mode: SessionMode) -> Result<Vec<ResolvedIssue>> {
246 let db = JiraInbox::new()?;
247 let mut resolved = Vec::new();
248 for key in db.unexplained_departures(DEPARTURES_PER_POLL)? {
249 let state = match jira.issue_state(&key, mode).await {
250 Ok(state) => state,
251 Err(e) => {
252 warn!("Jira inbox: could not learn why {} left: {}", key, e);
253 continue;
254 }
255 };
256 match state.and_then(|s| s.resolution) {
257 Some(resolution) => {
258 db.set_left(&key, LeftReason::Resolved, Some(&resolution))?;
259 if toast_apply::task_open(&key)?
260 && let Some(item) = db.get_by_key(&key)?
261 {
262 resolved.push(ResolvedIssue {
263 issue_key: key.clone(),
264 summary: item.summary,
265 resolution: Some(resolution),
266 });
267 }
268 }
269 None => {
270 db.set_left(&key, LeftReason::Moved, None)?;
271 }
272 }
273 }
274 Ok(resolved)
275}
276
277pub async fn sync_interactive(notify: bool) -> Result<SyncOutcome> {
283 let config = Config::read()?;
284 let Some(jira_config) = config.jira.clone() else {
285 msg_warning!(Message::JiraInboxRequiresJiraConfig);
286 return Ok(SyncOutcome {
287 skipped: true,
288 ..Default::default()
289 });
290 };
291
292 let inbox_cfg = config.jira_inbox.clone().unwrap_or_default();
293 let allow_toast = notify && inbox_cfg.notify;
294
295 let mut jira = Jira::new(&jira_config);
296 let issues = jira.get_assigned_open_issues(&inbox_cfg.extra_field_ids()).await?;
297 let poll = Poll {
299 mode: SessionMode::Interactive,
300 notify: allow_toast,
301 offer_by_toast: false,
302 };
303 apply_issues(&mut jira, &issues, &inbox_cfg, poll, &mut ToastBudget::new()).await
304}
305
306#[derive(Debug, Clone, Copy)]
308struct Poll {
309 mode: SessionMode,
310 notify: bool,
312 offer_by_toast: bool,
314}
315
316pub async fn sync_noninteractive(inbox_cfg: &JiraInboxConfig, budget: &mut ToastBudget) -> Result<SyncOutcome> {
321 if !inbox_cfg.enabled {
322 return Ok(SyncOutcome {
323 skipped: true,
324 ..Default::default()
325 });
326 }
327
328 let config = Config::read()?;
329 let Some(jira_config) = config.jira.clone() else {
330 return Ok(SyncOutcome {
331 skipped: true,
332 ..Default::default()
333 });
334 };
335
336 let mut jira = Jira::new(&jira_config);
337 let Some(issues) = jira.get_assigned_open_issues_noninteractive(&inbox_cfg.extra_field_ids()).await? else {
338 warn!("Jira inbox poll skipped: no cached session or secret");
339 return Ok(SyncOutcome {
340 skipped: true,
341 ..Default::default()
342 });
343 };
344
345 let poll = Poll {
346 mode: SessionMode::Unattended,
347 notify: inbox_cfg.notify,
348 offer_by_toast: true,
349 };
350 apply_issues(&mut jira, &issues, inbox_cfg, poll, budget).await
351}
352
353async fn apply_issues(
354 jira: &mut Jira,
355 issues: &[crate::api::jira::JiraIssue],
356 inbox_cfg: &JiraInboxConfig,
357 poll: Poll,
358 budget: &mut ToastBudget,
359) -> Result<SyncOutcome> {
360 let notify = poll.notify;
361 let statuses = JiraStatuses::new()?;
362 let sort_field = inbox_cfg.sort_by_field.as_deref().map(str::trim).filter(|s| !s.is_empty());
363
364 let mut upserts = Vec::with_capacity(issues.len());
365 for issue in issues {
366 if !issue.fields.status.id.is_empty() {
367 statuses.upsert(&issue.fields.status.id, &issue.fields.status.name)?;
368 }
369
370 let status_id = if issue.fields.status.id.is_empty() {
371 None
372 } else {
373 Some(issue.fields.status.id.clone())
374 };
375
376 let sort_value = sort_field.and_then(|id| Jira::sort_value_from_issue(issue, id));
377
378 upserts.push(JiraInboxUpsert {
379 issue_key: issue.key.clone(),
380 issue_id: issue.id.clone(),
381 summary: issue.fields.summary.clone(),
382 status_id,
383 status_name: issue.fields.status.name.clone(),
384 priority: issue.fields.priority.as_ref().map(|p| p.name.clone()),
385 priority_rank: Jira::priority_rank(&issue.fields.priority),
386 sort_value,
387 url: jira.issue_browse_url(&issue.key),
388 raw_updated: issue.fields.updated.clone(),
389 issue_type: issue.fields.issuetype.as_ref().map(|t| t.name.clone()),
390 project: issue
393 .fields
394 .project
395 .as_ref()
396 .map(|p| p.key.clone())
397 .or_else(|| issue.key.split_once('-').map(|(project, _)| project.to_string())),
398 });
399 }
400
401 let db = JiraInbox::new()?;
402 let before: HashMap<String, JiraInboxItem> = if inbox_cfg.rules.is_empty() {
405 HashMap::new()
406 } else {
407 db.list(Include::EVERYTHING)?.into_iter().map(|item| (item.issue_key.clone(), item)).collect()
408 };
409 let UpsertBatchResult { new_keys, updated, changed } = db.upsert_batch(&upserts)?;
410
411 let present_keys: Vec<String> = upserts.iter().map(|u| u.issue_key.clone()).collect();
414 let gone_keys = db.mark_gone(&present_keys)?;
415
416 let book = RuleBook::of_inbox(inbox_cfg, &db)?;
419 for problem in &book.problems {
420 debug!("Jira inbox: rule {} is skipped: {}", problem.number, problem.reason);
421 }
422 let ruled = inbox_rules::apply_entering(&db, &book, &before, &present_keys)?;
423
424 let resolved = match explain_departures(jira, poll.mode).await {
427 Ok(resolved) => resolved,
428 Err(e) => {
429 warn!("Jira inbox: could not explain departures: {}", e);
430 Vec::new()
431 }
432 };
433
434 let mut pending = Vec::new();
437 if notify {
438 let to_notify = db.list_unnotified_new(&new_keys)?;
441 let fresh: Vec<String> = to_notify.iter().map(|i| i.issue_key.clone()).collect();
442 let moved: Vec<(String, String)> = if inbox_cfg.notify_changes {
443 changed
444 .iter()
445 .filter(|c| c.notable && !c.dismissed)
446 .map(|c| (c.issue_key.clone(), c.change.clone()))
447 .collect()
448 } else {
449 Vec::new()
450 };
451 let urgent: Vec<(String, String)> = ruled.iter().filter_map(|r| r.urgent.clone().map(|rule| (r.issue_key.clone(), rule))).collect();
452 for (key, notice) in notices(&fresh, &moved, &urgent) {
453 if let Ok(Some(item)) = db.get_by_key(&key)
455 && !item.dismissed
456 {
457 pending.push(match notice {
458 Notice::New => Pending::New(item),
459 Notice::Changed(change) => Pending::Changed(item, change),
460 Notice::Urgent(rule) => Pending::Urgent(item, rule),
461 });
462 }
463 }
464 db.mark_notified(&fresh)?;
468 }
469 if notify && inbox_cfg.notify_gone {
470 let offered: Vec<&str> = resolved.iter().map(|r| r.issue_key.as_str()).collect();
473 for key in gone_keys.iter().filter(|k| !(poll.offer_by_toast && offered.contains(&k.as_str()))) {
474 if let Ok(Some(item)) = db.get_by_key(key) {
475 pending.push(Pending::Gone(item));
476 }
477 }
478 }
479 if notify && poll.offer_by_toast {
482 for issue in &resolved {
483 if let Ok(Some(item)) = db.get_by_key(&issue.issue_key) {
484 pending.push(Pending::Resolved(item));
485 }
486 }
487 }
488
489 let (urgent_pending, ordinary): (Vec<Pending>, Vec<Pending>) = pending.into_iter().partition(|p| matches!(p, Pending::Urgent(..)));
490 let now = Local::now().naive_local();
491 let mut notified = 0;
492 notified += show_pending(jira, &urgent_pending, budget.admit_urgent(now, urgent_pending.len()));
493 notified += show_pending(jira, &ordinary, budget.admit(now, ordinary.len()));
494
495 Ok(SyncOutcome {
496 fetched: issues.len(),
497 new_keys,
498 updated,
499 changed,
500 gone_keys,
501 resolved,
502 ruled,
503 rule_problems: book.problems,
504 notified,
505 skipped: false,
506 })
507}
508
509fn show_pending(jira: &Jira, pending: &[Pending], plan: ToastPlan) -> usize {
511 match plan {
512 ToastPlan::Each => pending
513 .iter()
514 .filter(|toast| match toast {
515 Pending::New(item) => show_toast(item),
516 Pending::Changed(item, change) => show_change_toast(item, change),
517 Pending::Gone(item) => show_gone_toast(item),
518 Pending::Resolved(item) => show_resolved_toast(item),
519 Pending::Urgent(item, rule) => show_urgent_toast(item, rule),
520 })
521 .count(),
522 ToastPlan::Summary => usize::from(show_summary_toast(jira, &summary_line(pending))),
523 ToastPlan::Quiet => {
524 debug!("Jira inbox: held back {} toast(s); the hour's budget is spent", pending.len());
525 0
526 }
527 }
528}
529
530pub fn wake_snoozed(notify: bool) -> Result<usize> {
536 let woken = JiraInbox::new()?.wake_due()?;
537 if woken.is_empty() {
538 return Ok(0);
539 }
540 if notify {
541 if toasts_collapse(woken.len()) {
542 let list_url = open_issues_url_from(&woken[0].url);
546 show_raw_toast(
547 "Jira inbox",
548 &format!("{} snoozed issues are back - see `kasl inbox`", woken.len()),
549 &list_url,
550 "inbox",
551 &[],
552 );
553 } else {
554 for item in &woken {
555 show_snoozed_toast(item);
556 }
557 }
558 }
559 Ok(woken.len())
560}
561
562fn open_issues_url_from(issue_url: &str) -> String {
569 match issue_url.rsplit_once("/browse/") {
570 Some((base, _)) => format!("{base}/issues/?jql=assignee%20%3D%20currentUser()%20AND%20resolution%20is%20EMPTY"),
571 None => issue_url.to_string(),
572 }
573}
574
575pub fn show_snoozed_toast(item: &JiraInboxItem) -> bool {
577 let body = format!("Back from snooze - {}", item.summary);
578 show_issue_toast(item, &body)
579}
580
581pub fn show_toast(item: &JiraInboxItem) -> bool {
586 show_issue_toast(item, &toast_body(item))
587}
588
589pub fn show_urgent_toast(item: &JiraInboxItem, rule: &str) -> bool {
596 let body = format!("Urgent by the rule '{rule}' - {}", toast_body(item));
597 show_toast_as(
598 &format!("Jira {} - urgent", item.issue_key),
599 &body,
600 &item.url,
601 &item.issue_key,
602 &ToastAction::ALL,
603 Urgency::Urgent,
604 )
605}
606
607pub fn show_change_toast(item: &JiraInboxItem, change: &str) -> bool {
609 let body = format!("{change} — {}", item.summary);
610 show_issue_toast(item, &body)
611}
612
613pub fn show_summary_toast(jira: &Jira, what: &str) -> bool {
618 show_raw_toast("Jira inbox", &format!("{what} - see `kasl inbox`"), &jira.open_issues_url(), "inbox", &[])
619}
620
621pub fn show_gone_toast(item: &JiraInboxItem) -> bool {
626 let body = format!("Left the inbox — {}", item.summary);
627 show_raw_toast(&format!("Jira {}", item.issue_key), &body, &item.url, &item.issue_key, &[])
628}
629
630pub fn show_resolved_toast(item: &JiraInboxItem) -> bool {
635 let resolution = item.resolution.as_deref().map(|r| format!(" ({r})")).unwrap_or_default();
636 let body = format!("Resolved in Jira{resolution} - your task is still open. {}", item.summary);
637 show_raw_toast(&format!("Jira {}", item.issue_key), &body, &item.url, &item.issue_key, &[ToastAction::Done])
638}
639
640fn show_issue_toast(item: &JiraInboxItem, body: &str) -> bool {
642 show_raw_toast(&format!("Jira {}", item.issue_key), body, &item.url, &item.issue_key, &ToastAction::ALL)
643}
644
645#[derive(Debug, Clone, Copy, PartialEq, Eq)]
647enum Urgency {
648 Normal,
650 Urgent,
652}
653
654fn show_raw_toast(title: &str, body: &str, url: &str, key: &str, actions: &[ToastAction]) -> bool {
656 show_toast_as(title, body, url, key, actions, Urgency::Normal)
657}
658
659fn show_toast_as(title: &str, body: &str, url: &str, key: &str, actions: &[ToastAction], urgency: Urgency) -> bool {
660 #[cfg(windows)]
661 {
662 show_toast_windows(title, body, url, key, actions, urgency)
663 }
664 #[cfg(not(windows))]
665 {
666 show_toast_other(title, body, url, key, actions, urgency)
667 }
668}
669
670fn toast_body(item: &JiraInboxItem) -> String {
671 let priority = item.priority.as_deref().unwrap_or("—");
672 match item.sort_value {
673 Some(score) => format!("[score {score}] [{priority}] {}", item.summary),
674 None => format!("[{priority}] {}", item.summary),
675 }
676}
677
678#[cfg(windows)]
684fn toast_logo_path() -> Option<std::path::PathBuf> {
685 const LOGO: &[u8] = include_bytes!("../../assets/toast-96.png");
686 let path = crate::libs::data_storage::DataStorage::new().get_path("toast-logo.png").ok()?;
687 if std::fs::metadata(&path).map(|m| m.len() != LOGO.len() as u64).unwrap_or(true) {
689 std::fs::write(&path, LOGO).ok()?;
690 }
691 Some(path)
692}
693
694#[cfg(windows)]
700fn windows_buttons(key: &str, actions: &[ToastAction]) -> Vec<win_toast_notify::Action> {
701 actions
702 .iter()
703 .filter_map(|action| match crate::libs::toast_shortcut::ensure(*action, key) {
704 Ok(uri) => Some(win_toast_notify::Action {
705 activation_type: win_toast_notify::ActivationType::Protocol,
706 action_content: action.label().to_string(),
707 arguments: uri,
708 image_url: None,
709 }),
710 Err(e) => {
711 warn!("No {} button on the {} toast: {}", action.as_str(), key, e);
712 None
713 }
714 })
715 .collect()
716}
717
718#[cfg(windows)]
719fn show_toast_windows(title: &str, body: &str, url: &str, key: &str, actions: &[ToastAction], urgency: Urgency) -> bool {
720 let mut toast = win_toast_notify::WinToastNotify::new().set_title(title).set_messages(vec![body]).set_open(url);
721 if let Some(logo) = toast_logo_path() {
722 toast = toast.set_logo(&logo.to_string_lossy(), win_toast_notify::CropCircle::False);
723 }
724 if urgency == Urgency::Urgent {
728 toast = toast.set_scenario(win_toast_notify::Scenario::Reminder);
729 }
730 if !actions.is_empty() {
731 let buttons = windows_buttons(key, actions);
732 if !buttons.is_empty() {
733 toast = toast.set_actions(buttons);
734 }
735 }
736
737 match toast.show() {
738 Ok(()) => {
739 debug!("Showed toast for {}", key);
740 true
741 }
742 Err(e) => {
743 warn!("Failed to show toast for {}: {}", key, e);
744 false
745 }
746 }
747}
748
749#[cfg(all(not(windows), not(target_os = "macos")))]
756fn show_toast_other(title: &str, body: &str, url: &str, key: &str, actions: &[ToastAction], urgency: Urgency) -> bool {
757 let url = url.to_string();
758 let owned_key = key.to_string();
759
760 let mut notification = notify_rust::Notification::new();
761 notification.summary(title).body(body).action("default", "Open");
762 if urgency == Urgency::Urgent {
764 notification.urgency(notify_rust::Urgency::Critical);
765 }
766 for action in actions {
767 notification.action(action.as_str(), action.label());
768 }
769
770 match notification.show() {
771 Ok(handle) => {
772 std::thread::spawn(move || {
774 handle.wait_for_action(|action| {
775 if action == "default" {
776 if let Err(e) = open_url(&url) {
777 warn!("Failed to open {} from toast: {}", owned_key, e);
778 }
779 } else if let Some(chosen) = ToastAction::parse(action) {
780 let request = crate::libs::toast_action::ToastRequest::new(chosen, &owned_key);
781 match Mailbox::open().and_then(|m| m.post(&request)) {
784 Ok(()) => debug!("Posted {} {} from a toast button", action, owned_key),
785 Err(e) => warn!("Failed to post {} {} from a toast button: {}", action, owned_key, e),
786 }
787 }
788 });
789 });
790 debug!("Showed toast for {}", key);
791 true
792 }
793 Err(e) => {
794 warn!("Failed to show toast for {}: {}", key, e);
795 false
796 }
797 }
798}
799
800#[cfg(target_os = "macos")]
805fn show_toast_other(title: &str, body: &str, _url: &str, key: &str, _actions: &[ToastAction], _urgency: Urgency) -> bool {
806 match notify_rust::Notification::new().summary(title).body(body).show() {
807 Ok(_) => {
808 debug!("Showed toast for {}", key);
809 true
810 }
811 Err(e) => {
812 warn!("Failed to show toast for {}: {}", key, e);
813 false
814 }
815 }
816}
817
818pub fn open_url(url: &str) -> Result<()> {
820 #[cfg(windows)]
821 {
822 Command::new("cmd").args(["/C", "start", "", url]).spawn()?;
823 }
824 #[cfg(target_os = "macos")]
825 {
826 Command::new("open").arg(url).spawn()?;
827 }
828 #[cfg(all(unix, not(target_os = "macos")))]
829 {
830 Command::new("xdg-open").arg(url).spawn()?;
831 }
832 Ok(())
833}
834
835pub async fn drain_toast_actions() -> Result<usize> {
846 let requests = Mailbox::open()?.collect()?;
847 if requests.is_empty() {
848 return Ok(0);
849 }
850
851 let db = JiraInbox::new()?;
852 let mut done = 0;
853 for request in &requests {
854 let url = db.get_by_key(&request.issue_key).ok().flatten().map(|item| item.url).unwrap_or_default();
858 match toast_apply::apply(request).await {
859 Ok(outcome) => {
860 debug!("Toast action {} {}: {:?}", request.action.as_str(), request.issue_key, outcome);
861 if outcome.settled() {
862 #[cfg(windows)]
865 crate::libs::toast_shortcut::forget(&request.issue_key);
866 }
867 msg_info!(Message::ToastActionApplied(outcome.summary(&request.issue_key)));
868 show_raw_toast("Jira inbox", &outcome.summary(&request.issue_key), &url, &request.issue_key, &[]);
872 done += 1;
873 }
874 Err(e) => {
875 warn!("Toast action {} {} failed: {}", request.action.as_str(), request.issue_key, e);
876 msg_warning!(Message::ToastActionFailed(request.issue_key.clone(), e.to_string()));
877 }
878 }
879 }
880 Ok(done)
881}
882
883const MAILBOX_INTERVAL_SECS: u64 = 2;
891
892pub async fn run_mailbox_watcher() {
899 loop {
900 match drain_toast_actions().await {
901 Ok(0) => {}
902 Ok(count) => debug!("Carried out {} toast action(s)", count),
903 Err(e) => warn!("Jira inbox: failed to drain toast actions: {}", e),
904 }
905 tokio::time::sleep(std::time::Duration::from_secs(MAILBOX_INTERVAL_SECS)).await;
906 }
907}
908
909pub async fn run_poller() {
915 let mut budget = ToastBudget::new();
916 loop {
917 let config = match Config::read() {
918 Ok(c) => c,
919 Err(e) => {
920 warn!("Jira inbox: failed to read config: {}", e);
921 tokio::time::sleep(std::time::Duration::from_secs(60)).await;
922 continue;
923 }
924 };
925
926 let Some(inbox_cfg) = config.jira_inbox.clone() else {
927 tokio::time::sleep(std::time::Duration::from_secs(60)).await;
929 continue;
930 };
931
932 if !inbox_cfg.enabled || config.jira.is_none() {
933 tokio::time::sleep(std::time::Duration::from_secs(inbox_cfg.poll_interval_secs.max(60))).await;
934 continue;
935 }
936
937 match wake_snoozed(inbox_cfg.notify) {
942 Ok(count) if count > 0 => msg_info!(Message::JiraInboxWoke(count)),
943 Ok(_) => {}
944 Err(e) => warn!("Jira inbox wake error: {}", e),
945 }
946
947 match sync_noninteractive(&inbox_cfg, &mut budget).await {
948 Ok(outcome) if !outcome.skipped => {
949 if !outcome.new_keys.is_empty() {
950 msg_info!(Message::JiraInboxNewIssues(outcome.new_keys.len()));
951 }
952 debug!(
953 "Jira inbox sync: fetched={}, new={}, updated={}, changed={}, gone={}, notified={}",
954 outcome.fetched,
955 outcome.new_keys.len(),
956 outcome.updated,
957 outcome.changed.len(),
958 outcome.gone_keys.len(),
959 outcome.notified
960 );
961 }
962 Ok(_) => {}
963 Err(e) => warn!("Jira inbox sync error: {}", e),
964 }
965
966 let secs = inbox_cfg.poll_interval_secs.max(30);
967 tokio::time::sleep(std::time::Duration::from_secs(secs)).await;
968 }
969}