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}
42
43impl JiraInboxItem {
44    /// Badge for the list view: `gone`, `NEW`, or the recent change text.
45    ///
46    /// `gone` always wins (such rows only show up with `--all`); a freshly
47    /// discovered issue shows `NEW`; otherwise a change within the freshness
48    /// window shows its description. Older rows get no badge.
49    pub fn badge(&self, now: NaiveDateTime) -> Option<String> {
50        let fresh = |t: NaiveDateTime| now.signed_duration_since(t) < Duration::hours(FRESH_BADGE_HOURS);
51        if self.gone_at.is_some() {
52            return Some("gone".to_string());
53        }
54        if fresh(self.first_seen) {
55            return Some("NEW".to_string());
56        }
57        match (&self.last_change, self.changed_at) {
58            (Some(change), Some(at)) if fresh(at) => Some(change.clone()),
59            _ => None,
60        }
61    }
62}
63
64/// Input row used when upserting issues from a Jira poll.
65#[derive(Debug, Clone)]
66pub struct JiraInboxUpsert {
67    pub issue_key: String,
68    pub issue_id: String,
69    pub summary: String,
70    pub status_id: Option<String>,
71    /// Status display name for change descriptions (not stored).
72    pub status_name: String,
73    pub priority: Option<String>,
74    pub priority_rank: i32,
75    pub sort_value: Option<f64>,
76    pub url: String,
77    pub raw_updated: Option<String>,
78}
79
80/// An existing issue whose tracked fields changed during a sync.
81#[derive(Debug, Clone)]
82pub struct ChangedIssue {
83    pub issue_key: String,
84    /// Human-readable change summary, e.g. `status→In Progress, score 5→8`.
85    pub change: String,
86    pub dismissed: bool,
87}
88
89/// Result of an upsert batch: new keys and visibly changed issues.
90#[derive(Debug, Default)]
91pub struct UpsertBatchResult {
92    pub new_keys: Vec<String>,
93    pub updated: usize,
94    pub changed: Vec<ChangedIssue>,
95}
96
97/// Database operations for the Jira inbox table.
98pub struct JiraInbox {
99    db: Db,
100}
101
102impl JiraInbox {
103    pub fn new() -> Result<Self> {
104        Ok(Self { db: Db::new()? })
105    }
106
107    /// Inserts new issues and refreshes metadata for existing ones.
108    ///
109    /// Detects visible changes (status, priority, score) on existing rows and
110    /// clears `gone_at` for issues that reappeared in the poll. Returns brand
111    /// new keys (toast candidates) and the changed issues with descriptions.
112    pub fn upsert_batch(&self, items: &[JiraInboxUpsert]) -> Result<UpsertBatchResult> {
113        let now = Local::now().naive_local();
114        let mut result = UpsertBatchResult::default();
115
116        for item in items {
117            let existing: Option<ExistingRow> = self
118                .db
119                .conn
120                .query_row(
121                    "SELECT status_id, priority, priority_rank, sort_value, gone_at, dismissed
122                     FROM jira_inbox WHERE issue_key = ?1",
123                    params![item.issue_key],
124                    |row| {
125                        Ok(ExistingRow {
126                            status_id: row.get(0)?,
127                            priority: row.get(1)?,
128                            priority_rank: row.get(2)?,
129                            sort_value: row.get(3)?,
130                            gone_at: row.get(4)?,
131                            dismissed: row.get::<_, i32>(5)? != 0,
132                        })
133                    },
134                )
135                .optional()?;
136
137            if let Some(old) = existing {
138                self.db.conn.execute(
139                    "UPDATE jira_inbox SET
140                        issue_id = ?1,
141                        summary = ?2,
142                        status_id = ?3,
143                        priority = ?4,
144                        priority_rank = ?5,
145                        sort_value = ?6,
146                        url = ?7,
147                        last_seen = ?8,
148                        raw_updated = ?9,
149                        gone_at = NULL
150                     WHERE issue_key = ?10",
151                    params![
152                        item.issue_id,
153                        item.summary,
154                        item.status_id,
155                        item.priority,
156                        item.priority_rank,
157                        item.sort_value,
158                        item.url,
159                        now,
160                        item.raw_updated,
161                        item.issue_key,
162                    ],
163                )?;
164                result.updated += 1;
165
166                let change = describe_change(&old, item);
167                if let Some(change) = change {
168                    self.db.conn.execute(
169                        "UPDATE jira_inbox SET last_change = ?1, changed_at = ?2 WHERE issue_key = ?3",
170                        params![change, now, item.issue_key],
171                    )?;
172                    result.changed.push(ChangedIssue {
173                        issue_key: item.issue_key.clone(),
174                        change,
175                        dismissed: old.dismissed,
176                    });
177                }
178            } else {
179                self.db.conn.execute(
180                    "INSERT INTO jira_inbox (
181                        issue_key, issue_id, summary, status_id, priority, priority_rank,
182                        sort_value, url, first_seen, last_seen, notified, pinned, dismissed, raw_updated
183                    ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, 0, 0, 0, ?11)",
184                    params![
185                        item.issue_key,
186                        item.issue_id,
187                        item.summary,
188                        item.status_id,
189                        item.priority,
190                        item.priority_rank,
191                        item.sort_value,
192                        item.url,
193                        now,
194                        now,
195                        item.raw_updated,
196                    ],
197                )?;
198                result.new_keys.push(item.issue_key.clone());
199            }
200        }
201
202        Ok(result)
203    }
204
205    /// Marks issues missing from the current poll as gone.
206    ///
207    /// Every non-gone row whose key is not in `present_keys` gets `gone_at`
208    /// stamped. Returns keys that were both visible (not dismissed) and newly
209    /// gone — the candidates for a "left the inbox" toast.
210    pub fn mark_gone(&self, present_keys: &[String]) -> Result<Vec<String>> {
211        let now = Local::now().naive_local();
212        let placeholders = vec!["?"; present_keys.len()].join(", ");
213        let not_in = if present_keys.is_empty() {
214            String::new()
215        } else {
216            format!(" AND issue_key NOT IN ({placeholders})")
217        };
218
219        let select = format!("SELECT issue_key FROM jira_inbox WHERE gone_at IS NULL AND dismissed = 0{not_in}");
220        let mut stmt = self.db.conn.prepare(&select)?;
221        let newly_gone: Vec<String> = stmt
222            .query_map(rusqlite::params_from_iter(present_keys.iter()), |row| row.get(0))?
223            .collect::<Result<Vec<_>, _>>()?;
224        drop(stmt);
225
226        let update = format!("UPDATE jira_inbox SET gone_at = ?1 WHERE gone_at IS NULL{not_in}");
227        let mut update_params: Vec<&dyn rusqlite::types::ToSql> = vec![&now];
228        for key in present_keys {
229            update_params.push(key);
230        }
231        self.db.conn.execute(&update, &update_params[..])?;
232
233        Ok(newly_gone)
234    }
235
236    /// Active (non-dismissed) items: pinned, then sort_value DESC, then priority.
237    ///
238    /// Gone issues are hidden unless `include_gone` is set (`--all`), in which
239    /// case they sort below the present ones.
240    pub fn list_active(&self, include_gone: bool) -> Result<Vec<JiraInboxItem>> {
241        let gone_filter = if include_gone { "" } else { " AND i.gone_at IS NULL" };
242        let query = format!(
243            "SELECT i.issue_key, i.issue_id, i.summary, i.status_id, COALESCE(s.name, ''),
244                    i.priority, i.priority_rank, i.sort_value, i.url,
245                    i.first_seen, i.last_seen, i.notified, i.pinned, i.dismissed, i.raw_updated,
246                    i.gone_at, i.last_change, i.changed_at
247             FROM jira_inbox i
248             LEFT JOIN jira_statuses s ON s.id = i.status_id
249             WHERE i.dismissed = 0{gone_filter}
250             ORDER BY i.gone_at IS NOT NULL, i.pinned DESC, i.sort_value IS NULL, i.sort_value DESC,
251                      i.priority_rank ASC, i.last_seen DESC"
252        );
253        let mut stmt = self.db.conn.prepare(&query)?;
254
255        let rows = stmt.query_map([], map_row)?;
256        rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
257    }
258
259    pub fn get_by_key(&self, key: &str) -> Result<Option<JiraInboxItem>> {
260        self.db
261            .conn
262            .query_row(
263                "SELECT i.issue_key, i.issue_id, i.summary, i.status_id, COALESCE(s.name, ''),
264                        i.priority, i.priority_rank, i.sort_value, i.url,
265                        i.first_seen, i.last_seen, i.notified, i.pinned, i.dismissed, i.raw_updated,
266                        i.gone_at, i.last_change, i.changed_at
267                 FROM jira_inbox i
268                 LEFT JOIN jira_statuses s ON s.id = i.status_id
269                 WHERE i.issue_key = ?1",
270                params![key],
271                map_row,
272            )
273            .optional()
274            .map_err(Into::into)
275    }
276
277    pub fn set_pinned(&self, key: &str, pinned: bool) -> Result<bool> {
278        let n = self
279            .db
280            .conn
281            .execute("UPDATE jira_inbox SET pinned = ?1 WHERE issue_key = ?2", params![pinned as i32, key])?;
282        Ok(n > 0)
283    }
284
285    pub fn set_dismissed(&self, key: &str, dismissed: bool) -> Result<bool> {
286        let n = self
287            .db
288            .conn
289            .execute("UPDATE jira_inbox SET dismissed = ?1 WHERE issue_key = ?2", params![dismissed as i32, key])?;
290        Ok(n > 0)
291    }
292
293    pub fn mark_notified(&self, keys: &[String]) -> Result<()> {
294        for key in keys {
295            self.db.conn.execute("UPDATE jira_inbox SET notified = 1 WHERE issue_key = ?1", params![key])?;
296        }
297        Ok(())
298    }
299
300    /// Un-notified newly inserted items (for toast after sync).
301    pub fn list_unnotified_new(&self, keys: &[String]) -> Result<Vec<JiraInboxItem>> {
302        let mut items = Vec::new();
303        for key in keys {
304            if let Some(item) = self.get_by_key(key)?
305                && !item.notified
306                && !item.dismissed
307            {
308                items.push(item);
309            }
310        }
311        Ok(items)
312    }
313}
314
315/// Tracked fields of an existing row, used for change detection.
316struct ExistingRow {
317    status_id: Option<String>,
318    priority: Option<String>,
319    priority_rank: i32,
320    sort_value: Option<f64>,
321    gone_at: Option<NaiveDateTime>,
322    dismissed: bool,
323}
324
325/// Builds a human-readable change summary, or `None` when nothing visible changed.
326fn describe_change(old: &ExistingRow, new: &JiraInboxUpsert) -> Option<String> {
327    let mut parts = Vec::new();
328
329    if old.gone_at.is_some() {
330        parts.push("back".to_string());
331    }
332
333    if old.status_id != new.status_id {
334        let name = if new.status_name.is_empty() {
335            new.status_id.as_deref().unwrap_or("—")
336        } else {
337            &new.status_name
338        };
339        parts.push(format!("status→{name}"));
340    }
341
342    if old.priority_rank != new.priority_rank || old.priority != new.priority {
343        let name = new.priority.as_deref().unwrap_or("—");
344        // Lower rank = higher priority (rank sorts ascending).
345        let arrow = if new.priority_rank < old.priority_rank {
346            "↑prio"
347        } else if new.priority_rank > old.priority_rank {
348            "↓prio"
349        } else {
350            "prio→"
351        };
352        parts.push(format!("{arrow} {name}"));
353    }
354
355    let score_changed = match (old.sort_value, new.sort_value) {
356        (Some(a), Some(b)) => (a - b).abs() > f64::EPSILON,
357        (None, None) => false,
358        _ => true,
359    };
360    if score_changed {
361        let fmt = |v: Option<f64>| v.map(fmt_score).unwrap_or_else(|| "—".to_string());
362        parts.push(format!("score {}→{}", fmt(old.sort_value), fmt(new.sort_value)));
363    }
364
365    if parts.is_empty() { None } else { Some(parts.join(", ")) }
366}
367
368/// Formats a score without a trailing `.0` for whole numbers.
369fn fmt_score(v: f64) -> String {
370    if v.fract() == 0.0 { format!("{}", v as i64) } else { format!("{v}") }
371}
372
373fn map_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<JiraInboxItem> {
374    Ok(JiraInboxItem {
375        issue_key: row.get(0)?,
376        issue_id: row.get(1)?,
377        summary: row.get(2)?,
378        status_id: row.get(3)?,
379        status_name: row.get(4)?,
380        priority: row.get(5)?,
381        priority_rank: row.get(6)?,
382        sort_value: row.get(7)?,
383        url: row.get(8)?,
384        first_seen: row.get(9)?,
385        last_seen: row.get(10)?,
386        notified: row.get::<_, i32>(11)? != 0,
387        pinned: row.get::<_, i32>(12)? != 0,
388        dismissed: row.get::<_, i32>(13)? != 0,
389        raw_updated: row.get(14)?,
390        gone_at: row.get(15)?,
391        last_change: row.get(16)?,
392        changed_at: row.get(17)?,
393    })
394}