Skip to main content

kasl/libs/
inbox_filter.rs

1//! Slicing the inbox: which issues to show, and in what order.
2//!
3//! An inbox of two hundred open issues is not a list anyone reads top to
4//! bottom; it is a pile that gets useful only once it can be cut - what
5//! arrived this week, what scores above five, what is High or worse. The cuts
6//! live here as plain functions over already-loaded items: the table is small
7//! enough that SQL buys nothing, and a function is what the tests can hold.
8//!
9//! The same cut is what an inbox rule's `when` compiles to
10//! ([`crate::libs::inbox_rules`]), so "the issues `--priority High+` lists"
11//! and "the issues a `priority: High+` rule pins" cannot come apart.
12
13use crate::db::jira_inbox::JiraInboxItem;
14use anyhow::{Result, bail};
15use chrono::{Duration, NaiveDateTime};
16
17/// A time window such as `7d`, `12h`, `2w`, or a bare number of days.
18///
19/// The unit is one letter and the number is whole: this is typed on the
20/// command line by someone who wants "this week", not a duration grammar.
21pub fn parse_window(text: &str) -> Result<Duration> {
22    let text = text.trim();
23    let (digits, unit) = match text.char_indices().find(|(_, c)| !c.is_ascii_digit()) {
24        Some((at, _)) => text.split_at(at),
25        None => (text, "d"),
26    };
27    let amount: i64 = match digits.parse() {
28        Ok(n) if n > 0 => n,
29        _ => bail!("'{text}' is not a window; try 1d, 7d, 12h or 2w"),
30    };
31    match unit {
32        "h" => Ok(Duration::hours(amount)),
33        "d" => Ok(Duration::days(amount)),
34        "w" => Ok(Duration::weeks(amount)),
35        _ => bail!("'{text}' is not a window; the unit is h, d or w"),
36    }
37}
38
39/// A cut by priority rank. Lower rank is more urgent in Jira (`1` = Highest).
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum PriorityCut {
42    /// That priority only.
43    Exactly(i32),
44    /// That priority and everything more urgent - the `NAME+` form.
45    AtLeast(i32),
46}
47
48impl PriorityCut {
49    fn keeps(self, rank: i32) -> bool {
50        match self {
51            PriorityCut::Exactly(wanted) => rank == wanted,
52            PriorityCut::AtLeast(wanted) => rank <= wanted,
53        }
54    }
55}
56
57/// What the inbox is cut by. Every set field must hold; unset fields pass everything.
58#[derive(Debug, Default, Clone, PartialEq)]
59pub struct InboxFilter {
60    /// First seen no earlier than this long ago.
61    pub since: Option<Duration>,
62    /// Visibly changed no earlier than this long ago.
63    pub changed: Option<Duration>,
64    /// Ranking field (e.g. Scoring) at least this; issues without a score fail.
65    pub min_score: Option<f64>,
66    /// Priority, exact or "and above".
67    pub priority: Option<PriorityCut>,
68    /// Status names or ids, any of them; matched without regard to case.
69    pub status: Vec<String>,
70    /// Issue types, any of them; matched without regard to case.
71    pub types: Vec<String>,
72    /// Projects, any of them; matched without regard to case.
73    pub projects: Vec<String>,
74}
75
76impl InboxFilter {
77    /// True when nothing is set and the list would come back whole.
78    pub fn is_empty(&self) -> bool {
79        *self == Self::default()
80    }
81
82    /// Whether one issue passes every set cut.
83    pub fn keeps(&self, item: &JiraInboxItem, now: NaiveDateTime) -> bool {
84        let within = |at: Option<NaiveDateTime>, window: Duration| at.is_some_and(|at| now.signed_duration_since(at) <= window);
85        if let Some(window) = self.since
86            && !within(Some(item.first_seen), window)
87        {
88            return false;
89        }
90        if let Some(window) = self.changed
91            && !within(item.changed_at, window)
92        {
93            return false;
94        }
95        if let Some(floor) = self.min_score
96            && !item.sort_value.is_some_and(|score| score >= floor)
97        {
98            return false;
99        }
100        if let Some(cut) = self.priority
101            && !cut.keeps(item.priority_rank)
102        {
103            return false;
104        }
105        if !self.status.is_empty()
106            && !self
107                .status
108                .iter()
109                .any(|status| same_word(&item.status_name, status) || item.status_id.as_deref().is_some_and(|id| same_word(id, status)))
110        {
111            return false;
112        }
113        if !one_of(item.issue_type.as_deref(), &self.types) {
114            return false;
115        }
116        if !one_of(item.project.as_deref(), &self.projects) {
117            return false;
118        }
119        true
120    }
121
122    /// Keeps the issues that pass, in their incoming order.
123    pub fn apply(&self, items: Vec<JiraInboxItem>, now: NaiveDateTime) -> Vec<JiraInboxItem> {
124        items.into_iter().filter(|item| self.keeps(item, now)).collect()
125    }
126}
127
128/// The cut behind a priority name, against priorities known by name and rank.
129///
130/// Priority names belong to the Jira instance (a Russian Jira says
131/// «Высокий», not "High"), so they are looked up among the priorities the
132/// inbox has seen ([`crate::db::jira_inbox::JiraInbox::priorities`]) rather
133/// than in a table of English defaults. A trailing `+` widens the cut to
134/// that priority and everything more urgent.
135pub fn priority_cut_in(known: &[(String, i32)], wanted: &str) -> Result<PriorityCut> {
136    let (name, and_above) = match wanted.strip_suffix('+') {
137        Some(name) => (name.trim(), true),
138        None => (wanted.trim(), false),
139    };
140    let rank = known.iter().filter(|(known, _)| same_word(known, name)).map(|(_, rank)| *rank).min();
141    let Some(rank) = rank else {
142        let mut names: Vec<&str> = known.iter().map(|(name, _)| name.as_str()).collect();
143        names.sort_unstable();
144        names.dedup();
145        if names.is_empty() {
146            bail!("no priority named '{name}' in the inbox, and no issue carries a priority yet");
147        }
148        bail!("no priority named '{name}' in the inbox; known: {}", names.join(", "));
149    };
150    Ok(if and_above { PriorityCut::AtLeast(rank) } else { PriorityCut::Exactly(rank) })
151}
152
153/// Whether two names are the same word, whatever the case.
154///
155/// Full Unicode lowercasing, not ASCII: the names are the instance's own, and
156/// a Russian Jira's type typed in lower case is still the same type.
157fn same_word(a: &str, b: &str) -> bool {
158    a.trim().to_lowercase() == b.trim().to_lowercase()
159}
160
161/// Whether a field passes an any-of cut: an empty cut passes everything, and
162/// an empty field passes no cut.
163fn one_of(value: Option<&str>, wanted: &[String]) -> bool {
164    wanted.is_empty() || value.is_some_and(|value| wanted.iter().any(|w| same_word(value, w)))
165}
166
167/// What the list is ordered by. Pinned issues lead, and gone then dismissed issues trail, whatever the key.
168#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
169pub enum InboxSort {
170    /// Ranking field descending, then priority - the inbox's own order.
171    #[default]
172    Score,
173    /// Most urgent first, then score.
174    Priority,
175    /// Most recently discovered first.
176    New,
177    /// Most recently changed first; issues never changed trail.
178    Changed,
179}
180
181/// Reorders in place. Stable, so equal keys keep the inbox's own order.
182pub fn sort(items: &mut [JiraInboxItem], by: InboxSort) {
183    items.sort_by(|a, b| {
184        let frame = a
185            .gone_at
186            .is_some()
187            .cmp(&b.gone_at.is_some())
188            .then_with(|| a.dismissed.cmp(&b.dismissed))
189            .then_with(|| b.pinned.cmp(&a.pinned));
190        frame.then_with(|| match by {
191            InboxSort::Score => std::cmp::Ordering::Equal,
192            InboxSort::Priority => a.priority_rank.cmp(&b.priority_rank),
193            InboxSort::New => b.first_seen.cmp(&a.first_seen),
194            InboxSort::Changed => b.changed_at.cmp(&a.changed_at),
195        })
196    });
197}
198
199/// One line of the answer to "why is this issue here?".
200#[derive(Debug, Clone, PartialEq)]
201pub struct Reason {
202    /// What is being explained: `score`, `priority`, `pinned`.
203    pub what: &'static str,
204    /// The value as the row carries it.
205    pub value: String,
206    /// Where it comes from and what it does to the order.
207    pub because: String,
208}
209
210/// Explains an issue's place in the list.
211///
212/// Deliberately not a decomposed score. kasl does not compute importance: the
213/// score is a number read straight out of a Jira field the user named, and the
214/// priority rank is Jira's own priority id. Inventing a local formula to
215/// decompose - "High +3, due tomorrow +2" - would put a second, disagreeing
216/// answer next to Jira's about which issue matters, and the user would have no
217/// way to tell which one is real. So this explains what the order is actually
218/// made of and where each part came from.
219///
220/// `score_label` is what the configured ranking field is called (typically
221/// `Scoring`); naming it is most of the answer, because the number means
222/// nothing without knowing which field it is.
223pub fn explain(item: &JiraInboxItem, score_label: Option<&str>, now: NaiveDateTime) -> Vec<Reason> {
224    let mut out = Vec::new();
225    let label = score_label.unwrap_or("the ranking field");
226
227    match item.sort_value {
228        Some(score) => out.push(Reason {
229            what: "score",
230            value: format!("{score}"),
231            because: format!("read from {label} in Jira; the list is ordered by it, highest first"),
232        }),
233        None => out.push(Reason {
234            what: "score",
235            value: "—".to_string(),
236            because: format!("{label} is empty on this issue in Jira; issues without a score sort below those with one"),
237        }),
238    }
239
240    out.push(Reason {
241        what: "priority",
242        value: item.priority.clone().unwrap_or_else(|| "—".to_string()),
243        because: match item.priority.as_deref() {
244            Some(_) => format!(
245                "Jira priority id {}, which breaks ties on equal scores - lower is more urgent",
246                item.priority_rank
247            ),
248            None => "no priority set in Jira, so this sorts last among equal scores".to_string(),
249        },
250    });
251
252    if item.pinned && !item.dismissed {
253        out.push(Reason {
254            what: "pinned",
255            value: "yes".to_string(),
256            because: match &item.ruled_by {
257                Some(rule) => format!("pinned by the rule '{rule}'; pinned issues lead the list whatever the order, and `kasl inbox unpin` takes it back"),
258                None => "pinned by hand; pinned issues lead the list whatever the order".to_string(),
259            },
260        });
261    }
262
263    if item.dismissed {
264        out.push(Reason {
265            what: "dismissed",
266            value: "yes".to_string(),
267            because: match &item.ruled_by {
268                Some(rule) => format!("hidden by the rule '{rule}'; only `--dismissed` lists it, and `kasl inbox undismiss` brings it back"),
269                None => "dismissed by hand; only `--dismissed` lists it, and `kasl inbox undismiss` brings it back".to_string(),
270            },
271        });
272    }
273
274    if let Some(until) = item.snoozed_until
275        && until > now
276    {
277        out.push(Reason {
278            what: "snoozed",
279            value: until.format("%b %-d %H:%M").to_string(),
280            because: "asleep until then, so it is out of the list and out of the waiting count".to_string(),
281        });
282    }
283
284    if let Some(taken) = item.taken_at {
285        out.push(Reason {
286            what: "taken",
287            value: taken.format("%b %-d").to_string(),
288            because: "already a task; it stays in the list so what is in hand stays visible".to_string(),
289        });
290    }
291
292    if let Some(gone) = item.gone_at {
293        let because = match item.left_reason {
294            Some(crate::db::jira_inbox::LeftReason::Resolved) => "Jira resolved it, so it left the poll; only `--all` shows it",
295            Some(crate::db::jira_inbox::LeftReason::Moved) => "left the poll unresolved - reassigned or out of reach; only `--all` shows it",
296            None => "stopped appearing in the Jira poll - closed or reassigned; only `--all` shows it",
297        };
298        out.push(Reason {
299            what: "gone",
300            value: gone.format("%b %-d").to_string(),
301            because: because.to_string(),
302        });
303    }
304
305    if let (Some(change), Some(at)) = (&item.last_change, item.changed_at) {
306        out.push(Reason {
307            what: "changed",
308            value: change.clone(),
309            because: format!("seen on {}; the badge fades after a day", at.format("%b %-d %H:%M")),
310        });
311    }
312
313    out
314}