use crate::db::db::Db;
use anyhow::Result;
use chrono::{Duration, Local, NaiveDateTime};
use rusqlite::{OptionalExtension, params};
pub const FRESH_BADGE_HOURS: i64 = 24;
#[derive(Debug, Clone)]
pub struct JiraInboxItem {
pub issue_key: String,
pub issue_id: String,
pub summary: String,
pub status_id: Option<String>,
pub status_name: String,
pub priority: Option<String>,
pub priority_rank: i32,
pub sort_value: Option<f64>,
pub url: String,
pub first_seen: NaiveDateTime,
pub last_seen: NaiveDateTime,
pub notified: bool,
pub pinned: bool,
pub dismissed: bool,
pub raw_updated: Option<String>,
pub gone_at: Option<NaiveDateTime>,
pub last_change: Option<String>,
pub changed_at: Option<NaiveDateTime>,
pub taken_at: Option<NaiveDateTime>,
pub snoozed_until: Option<NaiveDateTime>,
pub woke_at: Option<NaiveDateTime>,
pub left_reason: Option<LeftReason>,
pub resolution: Option<String>,
pub issue_type: Option<String>,
pub project: Option<String>,
pub ruled_by: Option<String>,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct Include {
pub gone: bool,
pub snoozed: bool,
pub dismissed: bool,
}
impl Include {
pub const EVERYTHING: Include = Include {
gone: true,
snoozed: true,
dismissed: true,
};
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RuledMark {
Pin,
Dismiss,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LeftReason {
Resolved,
Moved,
}
impl LeftReason {
pub fn as_str(self) -> &'static str {
match self {
LeftReason::Resolved => "resolved",
LeftReason::Moved => "moved",
}
}
fn parse(text: &str) -> Option<Self> {
match text {
"resolved" => Some(LeftReason::Resolved),
"moved" => Some(LeftReason::Moved),
_ => None,
}
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct InboxCounts {
pub total: i64,
pub taken: i64,
pub fresh: i64,
}
impl InboxCounts {
pub fn is_empty(&self) -> bool {
self.total == 0
}
}
impl JiraInboxItem {
pub fn badge(&self, now: NaiveDateTime) -> Option<String> {
let fresh = |t: NaiveDateTime| now.signed_duration_since(t) < Duration::hours(FRESH_BADGE_HOURS);
if self.gone_at.is_some() {
if self.left_reason == Some(LeftReason::Resolved) {
return Some("resolved".to_string());
}
return Some("gone".to_string());
}
if self.dismissed {
return Some(match &self.ruled_by {
Some(rule) => format!("ruled out: {rule}"),
None => "dismissed".to_string(),
});
}
if let Some(until) = self.snoozed_until
&& until > now
{
return Some(format!("zzz {}", until.format("%b %-d")));
}
if self.taken_at.is_some() {
return Some("taken".to_string());
}
if let Some(woke) = self.woke_at
&& fresh(woke)
{
return Some("back".to_string());
}
if fresh(self.first_seen) {
return Some("NEW".to_string());
}
match (&self.last_change, self.changed_at) {
(Some(change), Some(at)) if fresh(at) => Some(change.clone()),
_ => None,
}
}
}
#[derive(Debug, Clone)]
pub struct JiraInboxUpsert {
pub issue_key: String,
pub issue_id: String,
pub summary: String,
pub status_id: Option<String>,
pub status_name: String,
pub priority: Option<String>,
pub priority_rank: i32,
pub sort_value: Option<f64>,
pub url: String,
pub raw_updated: Option<String>,
pub issue_type: Option<String>,
pub project: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ChangedIssue {
pub issue_key: String,
pub change: String,
pub dismissed: bool,
pub notable: bool,
}
#[derive(Debug, Default)]
pub struct UpsertBatchResult {
pub new_keys: Vec<String>,
pub updated: usize,
pub changed: Vec<ChangedIssue>,
}
pub struct JiraInbox {
db: Db,
}
impl JiraInbox {
pub fn new() -> Result<Self> {
Ok(Self { db: Db::new()? })
}
pub fn upsert_batch(&self, items: &[JiraInboxUpsert]) -> Result<UpsertBatchResult> {
let now = Local::now().naive_local();
let mut result = UpsertBatchResult::default();
for item in items {
if let Some(name) = &item.priority {
self.db.conn.execute(
"INSERT INTO jira_priorities (name, rank) VALUES (?1, ?2)
ON CONFLICT(name) DO UPDATE SET rank = excluded.rank",
params![name, item.priority_rank],
)?;
}
let existing: Option<ExistingRow> = self
.db
.conn
.query_row(
"SELECT status_id, priority, priority_rank, sort_value, gone_at, dismissed
FROM jira_inbox WHERE issue_key = ?1",
params![item.issue_key],
|row| {
Ok(ExistingRow {
status_id: row.get(0)?,
priority: row.get(1)?,
priority_rank: row.get(2)?,
sort_value: row.get(3)?,
gone_at: row.get(4)?,
dismissed: row.get::<_, i32>(5)? != 0,
})
},
)
.optional()?;
if let Some(old) = existing {
self.db.conn.execute(
"UPDATE jira_inbox SET
issue_id = ?1,
summary = ?2,
status_id = ?3,
priority = ?4,
priority_rank = ?5,
sort_value = ?6,
url = ?7,
last_seen = ?8,
raw_updated = ?9,
issue_type = ?10,
project = ?11,
gone_at = NULL,
left_reason = NULL,
resolution = NULL
WHERE issue_key = ?12",
params![
item.issue_id,
item.summary,
item.status_id,
item.priority,
item.priority_rank,
item.sort_value,
item.url,
now,
item.raw_updated,
item.issue_type,
item.project,
item.issue_key,
],
)?;
result.updated += 1;
if let Some(Change { text: change, notable }) = describe_change(&old, item) {
self.db.conn.execute(
"UPDATE jira_inbox SET last_change = ?1, changed_at = ?2 WHERE issue_key = ?3",
params![change, now, item.issue_key],
)?;
result.changed.push(ChangedIssue {
issue_key: item.issue_key.clone(),
change,
dismissed: old.dismissed,
notable,
});
}
} else {
self.db.conn.execute(
"INSERT INTO jira_inbox (
issue_key, issue_id, summary, status_id, priority, priority_rank,
sort_value, url, first_seen, last_seen, notified, pinned, dismissed, raw_updated,
issue_type, project
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, 0, 0, 0, ?11, ?12, ?13)",
params![
item.issue_key,
item.issue_id,
item.summary,
item.status_id,
item.priority,
item.priority_rank,
item.sort_value,
item.url,
now,
now,
item.raw_updated,
item.issue_type,
item.project,
],
)?;
result.new_keys.push(item.issue_key.clone());
}
}
Ok(result)
}
pub fn mark_gone(&self, present_keys: &[String]) -> Result<Vec<String>> {
let now = Local::now().naive_local();
let placeholders = vec!["?"; present_keys.len()].join(", ");
let not_in = if present_keys.is_empty() {
String::new()
} else {
format!(" AND issue_key NOT IN ({placeholders})")
};
let select = format!("SELECT issue_key FROM jira_inbox WHERE gone_at IS NULL AND dismissed = 0{not_in}");
let mut stmt = self.db.conn.prepare(&select)?;
let newly_gone: Vec<String> = stmt
.query_map(rusqlite::params_from_iter(present_keys.iter()), |row| row.get(0))?
.collect::<Result<Vec<_>, _>>()?;
drop(stmt);
let update = format!("UPDATE jira_inbox SET gone_at = ?1 WHERE gone_at IS NULL{not_in}");
let mut update_params: Vec<&dyn rusqlite::types::ToSql> = vec![&now];
for key in present_keys {
update_params.push(key);
}
self.db.conn.execute(&update, &update_params[..])?;
Ok(newly_gone)
}
pub fn unexplained_departures(&self, limit: usize) -> Result<Vec<String>> {
let mut stmt = self.db.conn.prepare(
"SELECT issue_key FROM jira_inbox
WHERE gone_at IS NOT NULL AND taken_at IS NOT NULL AND left_reason IS NULL
ORDER BY gone_at, issue_key
LIMIT ?1",
)?;
let keys = stmt.query_map(params![limit as i64], |row| row.get(0))?.collect::<Result<Vec<String>, _>>()?;
Ok(keys)
}
pub fn set_status(&self, key: &str, status_id: &str) -> Result<bool> {
let n = self
.db
.conn
.execute("UPDATE jira_inbox SET status_id = ?1 WHERE issue_key = ?2", params![status_id, key])?;
Ok(n > 0)
}
pub fn set_left(&self, key: &str, reason: LeftReason, resolution: Option<&str>) -> Result<bool> {
let n = self.db.conn.execute(
"UPDATE jira_inbox SET left_reason = ?1, resolution = ?2 WHERE issue_key = ?3 AND gone_at IS NOT NULL",
params![reason.as_str(), resolution, key],
)?;
Ok(n > 0)
}
pub fn list_active(&self, include_gone: bool) -> Result<Vec<JiraInboxItem>> {
self.list(Include {
gone: include_gone,
..Include::default()
})
}
pub fn list(&self, include: Include) -> Result<Vec<JiraInboxItem>> {
let mut filters = Vec::new();
if !include.dismissed {
filters.push("i.dismissed = 0");
}
if !include.gone {
filters.push("i.gone_at IS NULL");
}
if !include.snoozed {
filters.push("(i.snoozed_until IS NULL OR i.snoozed_until <= datetime(CURRENT_TIMESTAMP, 'localtime'))");
}
let filter = if filters.is_empty() {
String::new()
} else {
format!(" WHERE {}", filters.join(" AND "))
};
let query = format!(
"{SELECT_ITEM}{filter}
ORDER BY i.gone_at IS NOT NULL, i.dismissed, i.pinned DESC, i.sort_value IS NULL, i.sort_value DESC,
i.priority_rank ASC, i.last_seen DESC"
);
let mut stmt = self.db.conn.prepare(&query)?;
let rows = stmt.query_map([], map_row)?;
rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
}
pub fn get_by_key(&self, key: &str) -> Result<Option<JiraInboxItem>> {
self.db
.conn
.query_row(&format!("{SELECT_ITEM} WHERE i.issue_key = ?1"), params![key], map_row)
.optional()
.map_err(Into::into)
}
pub fn priorities(&self) -> Result<Vec<(String, i32)>> {
let mut stmt = self.db.conn.prepare("SELECT name, rank FROM jira_priorities ORDER BY rank, name")?;
let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?;
rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
}
pub fn set_pinned(&self, key: &str, pinned: bool) -> Result<bool> {
let n = self.db.conn.execute(
"UPDATE jira_inbox SET pinned = ?1, ruled_by = NULL WHERE issue_key = ?2",
params![pinned as i32, key],
)?;
Ok(n > 0)
}
pub fn set_dismissed(&self, key: &str, dismissed: bool) -> Result<bool> {
let n = self.db.conn.execute(
"UPDATE jira_inbox SET dismissed = ?1, ruled_by = NULL WHERE issue_key = ?2",
params![dismissed as i32, key],
)?;
Ok(n > 0)
}
pub fn set_ruled(&self, key: &str, mark: RuledMark, rule: &str) -> Result<bool> {
let column = match mark {
RuledMark::Pin => "pinned",
RuledMark::Dismiss => "dismissed",
};
let n = self.db.conn.execute(
&format!("UPDATE jira_inbox SET {column} = 1, ruled_by = ?1 WHERE issue_key = ?2"),
params![rule, key],
)?;
Ok(n > 0)
}
pub fn counts(&self) -> Result<InboxCounts> {
let now = Local::now().naive_local();
let fresh_since = now - Duration::hours(FRESH_BADGE_HOURS);
self.db
.conn
.query_row(
"SELECT COUNT(*),
COUNT(taken_at),
SUM(CASE WHEN taken_at IS NULL AND first_seen >= ?1 THEN 1 ELSE 0 END)
FROM jira_inbox
WHERE dismissed = 0 AND gone_at IS NULL
AND (snoozed_until IS NULL OR snoozed_until <= ?2)",
params![fresh_since, now],
|row| {
Ok(InboxCounts {
total: row.get(0)?,
taken: row.get(1)?,
fresh: row.get::<_, Option<i64>>(2)?.unwrap_or(0),
})
},
)
.map_err(Into::into)
}
pub fn set_taken(&self, key: &str, taken: bool) -> Result<bool> {
let value = taken.then(|| Local::now().naive_local());
let n = self
.db
.conn
.execute("UPDATE jira_inbox SET taken_at = ?1 WHERE issue_key = ?2", params![value, key])?;
Ok(n > 0)
}
pub fn set_snoozed(&self, key: &str, until: Option<NaiveDateTime>) -> Result<bool> {
let n = self.db.conn.execute(
"UPDATE jira_inbox SET snoozed_until = ?1, woke_at = NULL WHERE issue_key = ?2",
params![until, key],
)?;
Ok(n > 0)
}
pub fn wake_due(&self) -> Result<Vec<JiraInboxItem>> {
let now = Local::now().naive_local();
let mut stmt = self
.db
.conn
.prepare("SELECT issue_key FROM jira_inbox WHERE snoozed_until IS NOT NULL AND snoozed_until <= ?1 AND dismissed = 0")?;
let due: Vec<String> = stmt.query_map(params![now], |row| row.get(0))?.collect::<Result<Vec<_>, _>>()?;
drop(stmt);
let mut woken = Vec::with_capacity(due.len());
for key in due {
self.db.conn.execute(
"UPDATE jira_inbox SET snoozed_until = NULL, woke_at = ?1 WHERE issue_key = ?2",
params![now, key],
)?;
if let Some(item) = self.get_by_key(&key)? {
woken.push(item);
}
}
Ok(woken)
}
pub fn count_snoozed(&self) -> Result<i64> {
let now = Local::now().naive_local();
self.db
.conn
.query_row(
"SELECT COUNT(*) FROM jira_inbox WHERE dismissed = 0 AND gone_at IS NULL AND snoozed_until > ?1",
params![now],
|row| row.get(0),
)
.map_err(Into::into)
}
pub fn mark_notified(&self, keys: &[String]) -> Result<()> {
for key in keys {
self.db.conn.execute("UPDATE jira_inbox SET notified = 1 WHERE issue_key = ?1", params![key])?;
}
Ok(())
}
pub fn list_unnotified_new(&self, keys: &[String]) -> Result<Vec<JiraInboxItem>> {
let mut items = Vec::new();
for key in keys {
if let Some(item) = self.get_by_key(key)?
&& !item.notified
&& !item.dismissed
{
items.push(item);
}
}
Ok(items)
}
}
struct ExistingRow {
status_id: Option<String>,
priority: Option<String>,
priority_rank: i32,
sort_value: Option<f64>,
gone_at: Option<NaiveDateTime>,
dismissed: bool,
}
struct Change {
text: String,
notable: bool,
}
fn describe_change(old: &ExistingRow, new: &JiraInboxUpsert) -> Option<Change> {
let mut parts = Vec::new();
if old.gone_at.is_some() {
parts.push("back".to_string());
}
if old.status_id != new.status_id {
let name = if new.status_name.is_empty() {
new.status_id.as_deref().unwrap_or("—")
} else {
&new.status_name
};
parts.push(format!("status→{name}"));
}
if old.priority_rank != new.priority_rank || old.priority != new.priority {
let name = new.priority.as_deref().unwrap_or("—");
let arrow = if new.priority_rank < old.priority_rank {
"↑prio"
} else if new.priority_rank > old.priority_rank {
"↓prio"
} else {
"prio→"
};
parts.push(format!("{arrow} {name}"));
}
let notable = old.status_id != new.status_id || old.priority_rank != new.priority_rank || old.priority != new.priority;
let score_changed = match (old.sort_value, new.sort_value) {
(Some(a), Some(b)) => (a - b).abs() > f64::EPSILON,
(None, None) => false,
_ => true,
};
if score_changed {
let fmt = |v: Option<f64>| v.map(fmt_score).unwrap_or_else(|| "—".to_string());
parts.push(format!("score {}→{}", fmt(old.sort_value), fmt(new.sort_value)));
}
if parts.is_empty() {
None
} else {
Some(Change {
text: parts.join(", "),
notable,
})
}
}
fn fmt_score(v: f64) -> String {
if v.fract() == 0.0 { format!("{}", v as i64) } else { format!("{v}") }
}
const SELECT_ITEM: &str = "SELECT i.issue_key, i.issue_id, i.summary, i.status_id, COALESCE(s.name, ''),
i.priority, i.priority_rank, i.sort_value, i.url,
i.first_seen, i.last_seen, i.notified, i.pinned, i.dismissed, i.raw_updated,
i.gone_at, i.last_change, i.changed_at, i.taken_at, i.snoozed_until, i.woke_at,
i.left_reason, i.resolution, i.issue_type, i.project, i.ruled_by
FROM jira_inbox i
LEFT JOIN jira_statuses s ON s.id = i.status_id";
fn map_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<JiraInboxItem> {
Ok(JiraInboxItem {
issue_key: row.get(0)?,
issue_id: row.get(1)?,
summary: row.get(2)?,
status_id: row.get(3)?,
status_name: row.get(4)?,
priority: row.get(5)?,
priority_rank: row.get(6)?,
sort_value: row.get(7)?,
url: row.get(8)?,
first_seen: row.get(9)?,
last_seen: row.get(10)?,
notified: row.get::<_, i32>(11)? != 0,
pinned: row.get::<_, i32>(12)? != 0,
dismissed: row.get::<_, i32>(13)? != 0,
raw_updated: row.get(14)?,
gone_at: row.get(15)?,
last_change: row.get(16)?,
changed_at: row.get(17)?,
taken_at: row.get(18)?,
snoozed_until: row.get(19)?,
woke_at: row.get(20)?,
left_reason: row.get::<_, Option<String>>(21)?.as_deref().and_then(LeftReason::parse),
resolution: row.get(22)?,
issue_type: row.get(23)?,
project: row.get(24)?,
ruled_by: row.get(25)?,
})
}