Skip to main content

kasl/libs/
jira_inbox.rs

1//! Jira inbox sync and desktop toast helpers.
2//!
3//! Polls assigned open issues, upserts them into `jira_inbox`, and optionally
4//! shows desktop toast notifications for newly discovered keys. Toast click
5//! opens the issue browse URL (Windows: win-toast-notify protocol activation;
6//! other platforms: notify-rust action callback).
7//!
8//! A toast about an issue also carries the three decisions worth making about
9//! it - Take, Snooze, Dismiss - so the pile can be triaged without opening a
10//! terminal. How a button press gets back to the daemon is
11//! [`crate::libs::toast_action`]; what it then does is
12//! [`crate::libs::toast_apply`].
13//!
14//! Each poll also runs the inbox rules ([`crate::libs::inbox_rules`]) over
15//! the issues it brought: a rule may pin an issue, hide it, or turn its toast
16//! into an urgent one that stays on screen.
17
18use 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
33/// Above this many toasts, they collapse into one summary toast.
34///
35/// The poller counts against it over an hour ([`ToastBudget`]); waking
36/// snoozed issues, which the user scheduled, counts per wake.
37///
38/// A first sync of two hundred open issues, or a Jira-side re-scoring of all
39/// of them, is one event, not two hundred; two hundred toasts teach the user
40/// to switch notifications off, after which the one toast that matters is
41/// never seen.
42pub const TOAST_STORM_THRESHOLD: usize = 5;
43
44/// Whether `count` toasts of one kind should become a single summary toast.
45pub fn toasts_collapse(count: usize) -> bool {
46    count > TOAST_STORM_THRESHOLD
47}
48
49/// The span over which [`ToastBudget`] counts the toasts it let through.
50pub const TOAST_WINDOW_MINUTES: i64 = 60;
51
52/// What one poll's toasts turn into.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum ToastPlan {
55    /// Each issue gets its own toast.
56    Each,
57    /// One toast stands in for all of them.
58    Summary,
59    /// Nothing is shown; the list in `kasl inbox` carries the news.
60    Quiet,
61}
62
63/// How many issue toasts the poller may show, counted over time rather than per poll.
64///
65/// A limit per poll does not hold against a trickle. Jira-side automation
66/// that rescores two hundred issues a few at a time, or a list that keeps
67/// losing and regaining issues, arrives as three or four changes on every
68/// poll - under any per-poll threshold, and all of them toasted, poll after
69/// poll, for as long as it lasts. Counting over the last hour makes the
70/// total what is bounded: at most [`TOAST_STORM_THRESHOLD`] single toasts,
71/// then one summary, then quiet until the hour has room again.
72///
73/// Urgent toasts - the ones a rule asks for - count in a lane of their own,
74/// with the same bound: an afternoon of ordinary changes must not have spent
75/// the room an escalation needs, and an escalation of fifty issues at once is
76/// still a storm.
77///
78/// Held in memory by the poller. A restart forgets the count, which costs at
79/// most one more budget's worth, and keeps the budget from outliving the
80/// process that spends it.
81#[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    /// Decides how `count` ordinary toasts from one poll at `now` are shown, and spends the budget.
93    pub fn admit(&mut self, now: NaiveDateTime, count: usize) -> ToastPlan {
94        self.ordinary.admit(now, count)
95    }
96
97    /// The same decision for urgent toasts, from their own lane.
98    pub fn admit_urgent(&mut self, now: NaiveDateTime, count: usize) -> ToastPlan {
99        self.urgent.admit(now, count)
100    }
101}
102
103/// One kind of toast's spending over the last hour.
104#[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
135/// A toast one poll would like to show.
136enum Pending {
137    New(JiraInboxItem),
138    Changed(JiraInboxItem, String),
139    Gone(JiraInboxItem),
140    /// A taken issue Jira resolved while its task is still open.
141    Resolved(JiraInboxItem),
142    /// An issue a rule calls urgent, with the rule's name.
143    Urgent(JiraInboxItem, String),
144}
145
146/// What a poll has to say about one issue that is still in the inbox.
147#[derive(Debug, Clone, PartialEq, Eq)]
148pub enum Notice {
149    /// It arrived.
150    New,
151    /// A person moved it: the change in words.
152    Changed(String),
153    /// A rule calls it urgent: the rule's name.
154    Urgent(String),
155}
156
157/// Which toast each issue of a poll gets: one at most.
158///
159/// `fresh` are new issues not yet announced, `changed` the notable changes
160/// worth a toast, `urgent` the issues a rule has just called urgent. An urgent
161/// issue gets the urgent toast in place of its new or changed one - two toasts
162/// about one issue in the same breath would be one too many, and the urgent
163/// one says more. Urgent is not held back by `notify_changes`: the rule is an
164/// explicit ask for this toast.
165pub 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
185/// The line a summary toast opens with: what arrived, by kind.
186fn 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/// Outcome of a single inbox sync pass.
200#[derive(Debug, Default)]
201pub struct SyncOutcome {
202    pub fetched: usize,
203    pub new_keys: Vec<String>,
204    pub updated: usize,
205    /// Existing issues whose status/priority/score visibly changed.
206    pub changed: Vec<ChangedIssue>,
207    /// Issues that stopped appearing in the poll this pass.
208    pub gone_keys: Vec<String>,
209    /// Taken issues Jira said were resolved this pass, whose tasks are still open.
210    ///
211    /// The ones to offer marking done. Learnt once per departure, so a sync
212    /// that reports one here will not report it again.
213    pub resolved: Vec<ResolvedIssue>,
214    /// What the inbox rules did this pass.
215    pub ruled: Vec<Ruled>,
216    /// Rules that could not be read, and were skipped.
217    pub rule_problems: Vec<RuleProblem>,
218    pub notified: usize,
219    /// True when sync was skipped (no jira config / disabled / no credentials).
220    pub skipped: bool,
221}
222
223/// A taken issue Jira resolved, with the task that is still open.
224#[derive(Debug, Clone, PartialEq, Eq)]
225pub struct ResolvedIssue {
226    pub issue_key: String,
227    pub summary: String,
228    /// The resolution Jira gave ("Done", "Won't Fix").
229    pub resolution: Option<String>,
230}
231
232/// How many departed taken issues one poll asks Jira about.
233///
234/// The first poll after an upgrade finds every taken issue that ever left
235/// unexplained; one request each would hold that poll for minutes. The rest
236/// are asked about on the polls that follow.
237const DEPARTURES_PER_POLL: usize = 20;
238
239/// Asks Jira why each taken issue that left the inbox left, and keeps the answer.
240///
241/// Returns the ones resolved while their task is still open. An answer that
242/// does not come - VPN down, Jira busy - leaves the row unexplained, and the
243/// next poll asks again; a sync is not failed for it, because the issues that
244/// did arrive are already reconciled and that is the part that matters most.
245async 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
277/// Runs one interactive sync (may prompt for Jira password).
278///
279/// `--sync` always fetches even when the inbox poller is disabled in config;
280/// toasts respect `jira_inbox.notify` when that section exists. A sync run
281/// by hand gets a budget of its own: it is one poll, asked for.
282pub 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    // The caller is a terminal: resolved issues are asked about there, not toasted.
298    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/// Who is polling, and so where its news goes.
307#[derive(Debug, Clone, Copy)]
308struct Poll {
309    mode: SessionMode,
310    /// Whether toasts are shown at all.
311    notify: bool,
312    /// Whether a resolved issue's offer goes on a toast, or back to the caller.
313    offer_by_toast: bool,
314}
315
316/// Runs one non-interactive sync for the background watcher.
317///
318/// `budget` is the poller's, carried from poll to poll, so the toasts it
319/// allows are counted across polls rather than within one.
320pub 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            // Jira's own answer, falling back to the key's prefix, which is
391            // what a Jira key is.
392            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    // Rules act on what an issue has just come to be, so what it was is
403    // read before the poll overwrites it.
404    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    // Reconcile: issues missing from this poll are gone (closed, reassigned),
412    // not frozen in the list forever.
413    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    // Compiled after the upsert, so a priority name first seen in this very
417    // poll is already known to a rule that names it.
418    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    // A taken issue that left is either finished or no longer yours, and only
425    // Jira knows which. The answer decides whether its task is offered as done.
426    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    // Every toast this poll would show is gathered first and shown under
435    // one decision per lane, so the budget sees the poll as a whole.
436    let mut pending = Vec::new();
437    if notify {
438        // A new issue a rule dismissed is not listed here at all: hiding it
439        // was the rule's whole point.
440        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            // Read again: a rule may have dismissed the issue in this very poll.
454            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        // Notified means announced or deliberately not: a new issue held back
465        // by the budget is in the list with its NEW badge, and toasting it an
466        // hour later would be old news.
467        db.mark_notified(&fresh)?;
468    }
469    if notify && inbox_cfg.notify_gone {
470        // A resolved issue with an open task gets its own toast below; a
471        // second one saying it left would be the same news twice.
472        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    // Not gated by `notify_gone`: this is not news that an issue left, it is a
480    // question about the person's own task.
481    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
509/// Shows one lane's toasts as its budget decided; returns how many appeared.
510fn 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
530/// Returns every issue whose snooze has run out, toasting each one.
531///
532/// Returns how many came back. A snooze the user set is a promise to be
533/// reminded, so the return is announced even though the issue was already
534/// known - that reminder is the whole reason to snooze rather than dismiss.
535pub 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            // A summary toast whose click did nothing would be the worst of
543            // both: the base comes off an issue's own browse URL, so the list
544            // opens without this reaching for the Jira config.
545            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
562/// The assigned-open-issues list, derived from an issue's browse URL.
563///
564/// Waking is local, so it must not need the Jira config to be readable; the
565/// base is already carried on every row as `{base}/browse/{key}`. A URL that
566/// does not have that shape yields the row's own URL, which still opens
567/// something true.
568fn 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
575/// Shows a toast for an issue whose snooze has run out.
576pub fn show_snoozed_toast(item: &JiraInboxItem) -> bool {
577    let body = format!("Back from snooze - {}", item.summary);
578    show_issue_toast(item, &body)
579}
580
581/// Shows a desktop toast for a newly discovered inbox item.
582///
583/// Clicking the toast body opens [`JiraInboxItem::url`] in the default
584/// browser; the buttons decide the issue without leaving the desktop.
585pub fn show_toast(item: &JiraInboxItem) -> bool {
586    show_issue_toast(item, &toast_body(item))
587}
588
589/// Shows a toast that stays: an issue a rule calls urgent.
590///
591/// The same three buttons as any issue toast, and the rule named in the body,
592/// so the person can tell an escalation from a slow afternoon - and knows
593/// which rule to change if it keeps being wrong. On Windows the toast stays
594/// on screen until acted on; on Linux it is sent as critical.
595pub 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
607/// Shows a toast for a visible change on an existing inbox item.
608pub fn show_change_toast(item: &JiraInboxItem, change: &str) -> bool {
609    let body = format!("{change} — {}", item.summary);
610    show_issue_toast(item, &body)
611}
612
613/// Shows one toast standing in for many; clicking opens the open-issues list in Jira.
614///
615/// No buttons: a summary is about a pile, and there is no one issue for Take
616/// to take. The pile is triaged in `kasl inbox triage`, which the body says.
617pub 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
621/// Shows a toast for an issue that left the inbox (closed or reassigned).
622///
623/// No buttons either: the issue is already gone, so all three decisions are
624/// about work that is no longer there.
625pub 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
630/// Shows a toast for a taken issue Jira resolved while its task is open.
631///
632/// One button, **Mark done**: the issue's own decisions are behind it - it is
633/// resolved - and the only question left is the person's task.
634pub 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
640/// A toast about one live issue: the body opens it, the buttons decide it.
641fn 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/// How insistent a toast is.
646#[derive(Debug, Clone, Copy, PartialEq, Eq)]
647enum Urgency {
648    /// Shows, then slides into the notification centre.
649    Normal,
650    /// Stays until acted on, where the platform allows it.
651    Urgent,
652}
653
654/// Platform dispatch for a toast with a click-to-open URL and buttons.
655fn 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/// Materializes the embedded brand logo for toast notifications.
679///
680/// Toast XML references images by file path, so the PNG compiled into the
681/// binary is written to the data directory on first use. A logo failure only
682/// degrades the toast, so all errors collapse to `None`.
683#[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    // Rewrite when the embedded logo changes (e.g. after a self-update).
688    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/// The buttons for one issue, each pointing at its own shortcut.
695///
696/// A shortcut that cannot be written costs that button and nothing else: a
697/// toast with two buttons is still useful, and a toast that failed to appear
698/// because of a file permission would not be.
699#[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    // The reminder scenario keeps the toast on screen until it is acted on.
725    // Not the "urgent" scenario: that one is Windows 11 only and needs a
726    // per-app permission an unpackaged program does not get.
727    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/// Linux and other XDG desktops: buttons are D-Bus actions on the
750/// notification, so the press arrives in this process directly and there is
751/// no shortcut and no courier - the mailbox is posted to in-process.
752///
753/// The wait runs on its own thread because `wait_for_action` blocks until the
754/// notification is closed, and the poller must not stop for it.
755#[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    // Critical notifications stay until dismissed on XDG desktops.
763    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            // Wait for click off the poller thread so sync stays responsive.
773            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                        // Posted rather than applied here so both platforms
782                        // settle a decision in exactly one place: the drain.
783                        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/// macOS: notify-rust cannot wait for notification clicks (no actions API),
801/// so the toast is display-only, and both opening and deciding stay on the
802/// CLI (`kasl inbox triage`). The buttons are not rendered rather than
803/// rendered dead: a button that does nothing is worse than no button.
804#[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
818/// Opens a URL in the platform default browser / handler.
819pub 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
835/// Performs every toast button press waiting in the mailbox.
836///
837/// Returns how many were carried out. Each one answers on a toast, because
838/// the decision was made on a toast: a button that changes the database in
839/// silence leaves the user unsure whether the press registered, and pressing
840/// again is the natural response to that doubt.
841///
842/// One failing request does not stop the rest. A click is a user action that
843/// already happened; refusing to carry out the next one because the previous
844/// key was missing would lose work the user did.
845pub 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        // Read the row before acting: the answering toast should still open
855        // the issue it is about, and a dismiss or a take may be the last
856        // moment its URL is easy to reach.
857        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                    // The buttons of a decided issue are shortcuts that still
863                    // run; nothing points at them any more, so they go.
864                    #[cfg(windows)]
865                    crate::libs::toast_shortcut::forget(&request.issue_key);
866                }
867                msg_info!(Message::ToastActionApplied(outcome.summary(&request.issue_key)));
868                // The answer opens the issue, not nothing: an empty launch
869                // target makes the toast body a dead click area, which reads
870                // as the toast being broken.
871                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
883/// How often the daemon looks in the toast mailbox.
884///
885/// A button has to answer in the time a person waits after pressing one, so
886/// this is seconds, not the poll interval. It cannot ride on the Jira poll:
887/// that runs every five minutes by default, and a button that answers in
888/// five minutes is not a button - the user would press it again, or conclude
889/// it does not work.
890const MAILBOX_INTERVAL_SECS: u64 = 2;
891
892/// Watches the toast mailbox and performs what lands in it.
893///
894/// Its own loop rather than a step of the Jira poll, and it keeps running
895/// when polling is disabled or Jira is unreachable: a press on a toast that
896/// is already on screen is a decision about a local row, and owes nothing to
897/// the network.
898pub 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
909/// Background poll loop used by `kasl watch`.
910///
911/// Idle when `jira_inbox` is absent or disabled; re-reads config each wake so
912/// `kasl setup` can enable polling without restarting the watcher in most cases
913/// (restart still recommended after config changes).
914pub 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            // Section not configured — do not poll until user runs init.
928            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        // Waking is local bookkeeping and runs before the sync, on its own:
938        // an issue deferred to Monday is due on Monday whether or not Jira
939        // answers, and hanging the return off a successful poll would mean a
940        // VPN outage silently held issues asleep past their moment.
941        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}