Skip to main content

kasl/db/
jira_inbox.rs

1//! Persistent store for discovered Jira inbox issues.
2//!
3//! Holds assigned issues synced from Jira so the watcher can toast on
4//! new arrivals and visible changes, and the CLI can list, pin, dismiss,
5//! open, and import them. Issues that stop appearing in the poll are
6//! reconciled with `gone_at` instead of lingering forever.
7
8use crate::db::db::Db;
9use anyhow::Result;
10use chrono::{Duration, Local, NaiveDateTime};
11use rusqlite::{OptionalExtension, params};
12
13/// How long a NEW / change badge stays visible in the list.
14pub const FRESH_BADGE_HOURS: i64 = 24;
15
16/// A single Jira issue tracked in the local inbox.
17#[derive(Debug, Clone)]
18pub struct JiraInboxItem {
19    pub issue_key: String,
20    pub issue_id: String,
21    pub summary: String,
22    pub status_id: Option<String>,
23    /// Resolved status name (from `jira_statuses` join), may be empty.
24    pub status_name: String,
25    pub priority: Option<String>,
26    pub priority_rank: i32,
27    /// Numeric ranking value from configured sort custom field (e.g. Scoring).
28    pub sort_value: Option<f64>,
29    pub url: String,
30    pub first_seen: NaiveDateTime,
31    pub last_seen: NaiveDateTime,
32    pub notified: bool,
33    pub pinned: bool,
34    pub dismissed: bool,
35    pub raw_updated: Option<String>,
36    /// When the issue stopped appearing in the Jira poll (closed, reassigned).
37    pub gone_at: Option<NaiveDateTime>,
38    /// Most recent visible change, e.g. `status→In Progress` or `↑prio High`.
39    pub last_change: Option<String>,
40    pub changed_at: Option<NaiveDateTime>,
41    /// When `inbox take` turned this issue into a task.
42    ///
43    /// A taken issue stays in the list - the point is to see what is in hand,
44    /// not to hide it. Dismissal remains separate and still means "not mine".
45    pub taken_at: Option<NaiveDateTime>,
46}
47
48/// What the inbox holds right now, for the daily report line.
49#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
50pub struct InboxCounts {
51    /// Issues still asking for attention (not dismissed, not gone).
52    pub total: i64,
53    /// Of those, already turned into tasks.
54    pub taken: i64,
55    /// Of those, first seen within the freshness window and not yet taken.
56    pub fresh: i64,
57}
58
59impl InboxCounts {
60    /// Nothing to say when the inbox is empty.
61    pub fn is_empty(&self) -> bool {
62        self.total == 0
63    }
64}
65
66impl JiraInboxItem {
67    /// Badge for the list view: `gone`, `NEW`, or the recent change text.
68    ///
69    /// `gone` always wins (such rows only show up with `--all`); a freshly
70    /// discovered issue shows `NEW`; otherwise a change within the freshness
71    /// window shows its description. Older rows get no badge.
72    pub fn badge(&self, now: NaiveDateTime) -> Option<String> {
73        let fresh = |t: NaiveDateTime| now.signed_duration_since(t) < Duration::hours(FRESH_BADGE_HOURS);
74        if self.gone_at.is_some() {
75            return Some("gone".to_string());
76        }
77        // Taken outranks NEW and change badges: once an issue is in hand, that
78        // is the fact worth seeing, and it does not fade with time.
79        if self.taken_at.is_some() {
80            return Some("taken".to_string());
81        }
82        if fresh(self.first_seen) {
83            return Some("NEW".to_string());
84        }
85        match (&self.last_change, self.changed_at) {
86            (Some(change), Some(at)) if fresh(at) => Some(change.clone()),
87            _ => None,
88        }
89    }
90}
91
92/// Input row used when upserting issues from a Jira poll.
93#[derive(Debug, Clone)]
94pub struct JiraInboxUpsert {
95    pub issue_key: String,
96    pub issue_id: String,
97    pub summary: String,
98    pub status_id: Option<String>,
99    /// Status display name for change descriptions (not stored).
100    pub status_name: String,
101    pub priority: Option<String>,
102    pub priority_rank: i32,
103    pub sort_value: Option<f64>,
104    pub url: String,
105    pub raw_updated: Option<String>,
106}
107
108/// An existing issue whose tracked fields changed during a sync.
109#[derive(Debug, Clone)]
110pub struct ChangedIssue {
111    pub issue_key: String,
112    /// Human-readable change summary, e.g. `status→In Progress, score 5→8`.
113    pub change: String,
114    pub dismissed: bool,
115}
116
117/// Result of an upsert batch: new keys and visibly changed issues.
118#[derive(Debug, Default)]
119pub struct UpsertBatchResult {
120    pub new_keys: Vec<String>,
121    pub updated: usize,
122    pub changed: Vec<ChangedIssue>,
123}
124
125/// Database operations for the Jira inbox table.
126pub struct JiraInbox {
127    db: Db,
128}
129
130impl JiraInbox {
131    pub fn new() -> Result<Self> {
132        Ok(Self { db: Db::new()? })
133    }
134
135    /// Inserts new issues and refreshes metadata for existing ones.
136    ///
137    /// Detects visible changes (status, priority, score) on existing rows and
138    /// clears `gone_at` for issues that reappeared in the poll. Returns brand
139    /// new keys (toast candidates) and the changed issues with descriptions.
140    pub fn upsert_batch(&self, items: &[JiraInboxUpsert]) -> Result<UpsertBatchResult> {
141        let now = Local::now().naive_local();
142        let mut result = UpsertBatchResult::default();
143
144        for item in items {
145            let existing: Option<ExistingRow> = self
146                .db
147                .conn
148                .query_row(
149                    "SELECT status_id, priority, priority_rank, sort_value, gone_at, dismissed
150                     FROM jira_inbox WHERE issue_key = ?1",
151                    params![item.issue_key],
152                    |row| {
153                        Ok(ExistingRow {
154                            status_id: row.get(0)?,
155                            priority: row.get(1)?,
156                            priority_rank: row.get(2)?,
157                            sort_value: row.get(3)?,
158                            gone_at: row.get(4)?,
159                            dismissed: row.get::<_, i32>(5)? != 0,
160                        })
161                    },
162                )
163                .optional()?;
164
165            if let Some(old) = existing {
166                self.db.conn.execute(
167                    "UPDATE jira_inbox SET
168                        issue_id = ?1,
169                        summary = ?2,
170                        status_id = ?3,
171                        priority = ?4,
172                        priority_rank = ?5,
173                        sort_value = ?6,
174                        url = ?7,
175                        last_seen = ?8,
176                        raw_updated = ?9,
177                        gone_at = NULL
178                     WHERE issue_key = ?10",
179                    params![
180                        item.issue_id,
181                        item.summary,
182                        item.status_id,
183                        item.priority,
184                        item.priority_rank,
185                        item.sort_value,
186                        item.url,
187                        now,
188                        item.raw_updated,
189                        item.issue_key,
190                    ],
191                )?;
192                result.updated += 1;
193
194                let change = describe_change(&old, item);
195                if let Some(change) = change {
196                    self.db.conn.execute(
197                        "UPDATE jira_inbox SET last_change = ?1, changed_at = ?2 WHERE issue_key = ?3",
198                        params![change, now, item.issue_key],
199                    )?;
200                    result.changed.push(ChangedIssue {
201                        issue_key: item.issue_key.clone(),
202                        change,
203                        dismissed: old.dismissed,
204                    });
205                }
206            } else {
207                self.db.conn.execute(
208                    "INSERT INTO jira_inbox (
209                        issue_key, issue_id, summary, status_id, priority, priority_rank,
210                        sort_value, url, first_seen, last_seen, notified, pinned, dismissed, raw_updated
211                    ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, 0, 0, 0, ?11)",
212                    params![
213                        item.issue_key,
214                        item.issue_id,
215                        item.summary,
216                        item.status_id,
217                        item.priority,
218                        item.priority_rank,
219                        item.sort_value,
220                        item.url,
221                        now,
222                        now,
223                        item.raw_updated,
224                    ],
225                )?;
226                result.new_keys.push(item.issue_key.clone());
227            }
228        }
229
230        Ok(result)
231    }
232
233    /// Marks issues missing from the current poll as gone.
234    ///
235    /// Every non-gone row whose key is not in `present_keys` gets `gone_at`
236    /// stamped. Returns keys that were both visible (not dismissed) and newly
237    /// gone — the candidates for a "left the inbox" toast.
238    pub fn mark_gone(&self, present_keys: &[String]) -> Result<Vec<String>> {
239        let now = Local::now().naive_local();
240        let placeholders = vec!["?"; present_keys.len()].join(", ");
241        let not_in = if present_keys.is_empty() {
242            String::new()
243        } else {
244            format!(" AND issue_key NOT IN ({placeholders})")
245        };
246
247        let select = format!("SELECT issue_key FROM jira_inbox WHERE gone_at IS NULL AND dismissed = 0{not_in}");
248        let mut stmt = self.db.conn.prepare(&select)?;
249        let newly_gone: Vec<String> = stmt
250            .query_map(rusqlite::params_from_iter(present_keys.iter()), |row| row.get(0))?
251            .collect::<Result<Vec<_>, _>>()?;
252        drop(stmt);
253
254        let update = format!("UPDATE jira_inbox SET gone_at = ?1 WHERE gone_at IS NULL{not_in}");
255        let mut update_params: Vec<&dyn rusqlite::types::ToSql> = vec![&now];
256        for key in present_keys {
257            update_params.push(key);
258        }
259        self.db.conn.execute(&update, &update_params[..])?;
260
261        Ok(newly_gone)
262    }
263
264    /// Active (non-dismissed) items: pinned, then sort_value DESC, then priority.
265    ///
266    /// Gone issues are hidden unless `include_gone` is set (`--all`), in which
267    /// case they sort below the present ones.
268    pub fn list_active(&self, include_gone: bool) -> Result<Vec<JiraInboxItem>> {
269        let gone_filter = if include_gone { "" } else { " AND i.gone_at IS NULL" };
270        let query = format!(
271            "SELECT i.issue_key, i.issue_id, i.summary, i.status_id, COALESCE(s.name, ''),
272                    i.priority, i.priority_rank, i.sort_value, i.url,
273                    i.first_seen, i.last_seen, i.notified, i.pinned, i.dismissed, i.raw_updated,
274                    i.gone_at, i.last_change, i.changed_at, i.taken_at
275             FROM jira_inbox i
276             LEFT JOIN jira_statuses s ON s.id = i.status_id
277             WHERE i.dismissed = 0{gone_filter}
278             ORDER BY i.gone_at IS NOT NULL, i.pinned DESC, i.sort_value IS NULL, i.sort_value DESC,
279                      i.priority_rank ASC, i.last_seen DESC"
280        );
281        let mut stmt = self.db.conn.prepare(&query)?;
282
283        let rows = stmt.query_map([], map_row)?;
284        rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
285    }
286
287    pub fn get_by_key(&self, key: &str) -> Result<Option<JiraInboxItem>> {
288        self.db
289            .conn
290            .query_row(
291                "SELECT i.issue_key, i.issue_id, i.summary, i.status_id, COALESCE(s.name, ''),
292                        i.priority, i.priority_rank, i.sort_value, i.url,
293                        i.first_seen, i.last_seen, i.notified, i.pinned, i.dismissed, i.raw_updated,
294                        i.gone_at, i.last_change, i.changed_at, i.taken_at
295                 FROM jira_inbox i
296                 LEFT JOIN jira_statuses s ON s.id = i.status_id
297                 WHERE i.issue_key = ?1",
298                params![key],
299                map_row,
300            )
301            .optional()
302            .map_err(Into::into)
303    }
304
305    pub fn set_pinned(&self, key: &str, pinned: bool) -> Result<bool> {
306        let n = self
307            .db
308            .conn
309            .execute("UPDATE jira_inbox SET pinned = ?1 WHERE issue_key = ?2", params![pinned as i32, key])?;
310        Ok(n > 0)
311    }
312
313    pub fn set_dismissed(&self, key: &str, dismissed: bool) -> Result<bool> {
314        let n = self
315            .db
316            .conn
317            .execute("UPDATE jira_inbox SET dismissed = ?1 WHERE issue_key = ?2", params![dismissed as i32, key])?;
318        Ok(n > 0)
319    }
320
321    /// Counts what is waiting in the inbox, for the daily report line.
322    ///
323    /// Dismissed and gone rows are excluded - the point is what still asks for
324    /// attention, which is the same set `inbox list` shows.
325    pub fn counts(&self) -> Result<InboxCounts> {
326        let now = Local::now().naive_local();
327        let fresh_since = now - Duration::hours(FRESH_BADGE_HOURS);
328        self.db
329            .conn
330            .query_row(
331                "SELECT COUNT(*),
332                        COUNT(taken_at),
333                        SUM(CASE WHEN taken_at IS NULL AND first_seen >= ?1 THEN 1 ELSE 0 END)
334                 FROM jira_inbox
335                 WHERE dismissed = 0 AND gone_at IS NULL",
336                params![fresh_since],
337                |row| {
338                    Ok(InboxCounts {
339                        total: row.get(0)?,
340                        taken: row.get(1)?,
341                        fresh: row.get::<_, Option<i64>>(2)?.unwrap_or(0),
342                    })
343                },
344            )
345            .map_err(Into::into)
346    }
347
348    /// Marks the issue as taken, or clears the mark.
349    ///
350    /// Unlike dismissal this keeps the row in the list: `take` used to hide
351    /// the issue, which lost the fact that it had been picked up at all.
352    pub fn set_taken(&self, key: &str, taken: bool) -> Result<bool> {
353        let value = taken.then(|| Local::now().naive_local());
354        let n = self
355            .db
356            .conn
357            .execute("UPDATE jira_inbox SET taken_at = ?1 WHERE issue_key = ?2", params![value, key])?;
358        Ok(n > 0)
359    }
360
361    pub fn mark_notified(&self, keys: &[String]) -> Result<()> {
362        for key in keys {
363            self.db.conn.execute("UPDATE jira_inbox SET notified = 1 WHERE issue_key = ?1", params![key])?;
364        }
365        Ok(())
366    }
367
368    /// Un-notified newly inserted items (for toast after sync).
369    pub fn list_unnotified_new(&self, keys: &[String]) -> Result<Vec<JiraInboxItem>> {
370        let mut items = Vec::new();
371        for key in keys {
372            if let Some(item) = self.get_by_key(key)?
373                && !item.notified
374                && !item.dismissed
375            {
376                items.push(item);
377            }
378        }
379        Ok(items)
380    }
381}
382
383/// Tracked fields of an existing row, used for change detection.
384struct ExistingRow {
385    status_id: Option<String>,
386    priority: Option<String>,
387    priority_rank: i32,
388    sort_value: Option<f64>,
389    gone_at: Option<NaiveDateTime>,
390    dismissed: bool,
391}
392
393/// Builds a human-readable change summary, or `None` when nothing visible changed.
394fn describe_change(old: &ExistingRow, new: &JiraInboxUpsert) -> Option<String> {
395    let mut parts = Vec::new();
396
397    if old.gone_at.is_some() {
398        parts.push("back".to_string());
399    }
400
401    if old.status_id != new.status_id {
402        let name = if new.status_name.is_empty() {
403            new.status_id.as_deref().unwrap_or("—")
404        } else {
405            &new.status_name
406        };
407        parts.push(format!("status→{name}"));
408    }
409
410    if old.priority_rank != new.priority_rank || old.priority != new.priority {
411        let name = new.priority.as_deref().unwrap_or("—");
412        // Lower rank = higher priority (rank sorts ascending).
413        let arrow = if new.priority_rank < old.priority_rank {
414            "↑prio"
415        } else if new.priority_rank > old.priority_rank {
416            "↓prio"
417        } else {
418            "prio→"
419        };
420        parts.push(format!("{arrow} {name}"));
421    }
422
423    let score_changed = match (old.sort_value, new.sort_value) {
424        (Some(a), Some(b)) => (a - b).abs() > f64::EPSILON,
425        (None, None) => false,
426        _ => true,
427    };
428    if score_changed {
429        let fmt = |v: Option<f64>| v.map(fmt_score).unwrap_or_else(|| "—".to_string());
430        parts.push(format!("score {}→{}", fmt(old.sort_value), fmt(new.sort_value)));
431    }
432
433    if parts.is_empty() { None } else { Some(parts.join(", ")) }
434}
435
436/// Formats a score without a trailing `.0` for whole numbers.
437fn fmt_score(v: f64) -> String {
438    if v.fract() == 0.0 { format!("{}", v as i64) } else { format!("{v}") }
439}
440
441fn map_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<JiraInboxItem> {
442    Ok(JiraInboxItem {
443        issue_key: row.get(0)?,
444        issue_id: row.get(1)?,
445        summary: row.get(2)?,
446        status_id: row.get(3)?,
447        status_name: row.get(4)?,
448        priority: row.get(5)?,
449        priority_rank: row.get(6)?,
450        sort_value: row.get(7)?,
451        url: row.get(8)?,
452        first_seen: row.get(9)?,
453        last_seen: row.get(10)?,
454        notified: row.get::<_, i32>(11)? != 0,
455        pinned: row.get::<_, i32>(12)? != 0,
456        dismissed: row.get::<_, i32>(13)? != 0,
457        raw_updated: row.get(14)?,
458        gone_at: row.get(15)?,
459        last_change: row.get(16)?,
460        changed_at: row.get(17)?,
461        taken_at: row.get(18)?,
462    })
463}