use crate::db::jira_inbox::{Include, JiraInbox, JiraInboxItem};
use crate::libs::config::Config;
use crate::libs::inbox_filter::{self, InboxFilter, InboxSort, parse_window, priority_cut_in};
use crate::libs::inbox_rules::{self, RuleBook};
use crate::libs::jira_bridge::{self, Goal, MoveOptions};
use crate::libs::jira_inbox::{self as inbox_lib, ResolvedIssue};
use crate::libs::messages::Message;
use crate::libs::pick::{self, TriageAction, TriageAnswer};
use crate::libs::prompt::is_interactive;
use crate::libs::toast_apply::{self, Applied};
use crate::libs::view::View;
use crate::{msg_error, msg_info, msg_print, msg_success, msg_warning};
use anyhow::{Result, bail};
use chrono::Local;
use clap::{Args, Subcommand, ValueEnum};
use dialoguer::{Input, MultiSelect, theme::ColorfulTheme};
#[derive(Debug, Args)]
pub struct InboxArgs {
#[command(subcommand)]
command: Option<InboxCommand>,
#[arg(long, short = 'n', value_name = "N")]
limit: Option<usize>,
#[arg(long)]
all: bool,
#[arg(long)]
snoozed: bool,
#[arg(long)]
dismissed: bool,
#[command(flatten)]
filter: FilterArgs,
}
#[derive(Debug, Args, Clone, Default)]
pub struct FilterArgs {
#[arg(long, value_name = "WINDOW")]
since: Option<String>,
#[arg(long, conflicts_with = "since")]
new: bool,
#[arg(long, value_name = "WINDOW")]
changed: Option<String>,
#[arg(long, value_name = "N")]
min_score: Option<f64>,
#[arg(long, value_name = "NAME[+]")]
priority: Option<String>,
#[arg(long, value_name = "NAME")]
status: Vec<String>,
#[arg(long = "type", value_name = "NAME")]
issue_type: Vec<String>,
#[arg(long, value_name = "KEY")]
project: Vec<String>,
#[arg(long, value_enum, value_name = "KEY")]
sort: Option<SortKey>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
enum SortKey {
Score,
Priority,
New,
Changed,
}
impl From<SortKey> for InboxSort {
fn from(key: SortKey) -> Self {
match key {
SortKey::Score => InboxSort::Score,
SortKey::Priority => InboxSort::Priority,
SortKey::New => InboxSort::New,
SortKey::Changed => InboxSort::Changed,
}
}
}
impl FilterArgs {
fn or(self, fallback: FilterArgs) -> FilterArgs {
FilterArgs {
since: self.since.or(fallback.since),
new: self.new || fallback.new,
changed: self.changed.or(fallback.changed),
min_score: self.min_score.or(fallback.min_score),
priority: self.priority.or(fallback.priority),
status: either(self.status, fallback.status),
issue_type: either(self.issue_type, fallback.issue_type),
project: either(self.project, fallback.project),
sort: self.sort.or(fallback.sort),
}
}
fn to_filter(&self, priorities: &[(String, i32)]) -> Result<InboxFilter> {
let since = match (&self.since, self.new) {
(Some(window), _) => Some(parse_window(window)?),
(None, true) => Some(chrono::Duration::hours(crate::db::jira_inbox::FRESH_BADGE_HOURS)),
(None, false) => None,
};
Ok(InboxFilter {
since,
changed: self.changed.as_deref().map(parse_window).transpose()?,
min_score: self.min_score,
priority: self.priority.as_deref().map(|name| priority_cut_in(priorities, name)).transpose()?,
status: self.status.clone(),
types: self.issue_type.clone(),
projects: self.project.clone(),
})
}
fn describe(&self) -> Vec<String> {
let mut parts = Vec::new();
if let Some(window) = &self.since {
parts.push(format!("since {window}"));
} else if self.new {
parts.push("new".to_string());
}
if let Some(window) = &self.changed {
parts.push(format!("changed {window}"));
}
if let Some(score) = self.min_score {
parts.push(format!("score ≥ {score}"));
}
if let Some(priority) = &self.priority {
parts.push(format!("priority {priority}"));
}
for (what, names) in [("status", &self.status), ("type", &self.issue_type), ("project", &self.project)] {
if !names.is_empty() {
parts.push(format!("{what} {}", names.join(" or ")));
}
}
if let Some(sort) = self.sort {
parts.push(format!("by {}", sort.to_possible_value().map(|v| v.get_name().to_string()).unwrap_or_default()));
}
parts
}
}
fn either(own: Vec<String>, fallback: Vec<String>) -> Vec<String> {
if own.is_empty() { fallback } else { own }
}
#[derive(Debug, Subcommand)]
enum InboxCommand {
#[command(about = "Sync inbox from Jira")]
Sync,
#[command(about = "List active inbox issues")]
List {
#[arg(long, short = 'n', value_name = "N")]
limit: Option<usize>,
#[arg(long)]
all: bool,
#[arg(long)]
snoozed: bool,
#[arg(long)]
dismissed: bool,
#[command(flatten)]
filter: FilterArgs,
},
#[command(about = "Pin an inbox issue")]
Pin {
#[arg(value_name = "KEY")]
key: Option<String>,
#[command(flatten)]
filter: FilterArgs,
},
#[command(about = "Unpin an inbox issue")]
Unpin {
#[arg(value_name = "KEY")]
key: Option<String>,
#[command(flatten)]
filter: FilterArgs,
},
#[command(about = "Dismiss an inbox issue")]
Dismiss {
#[arg(value_name = "KEY")]
key: Option<String>,
#[command(flatten)]
filter: FilterArgs,
},
#[command(about = "Undismiss an inbox issue")]
Undismiss {
#[arg(value_name = "KEY")]
key: Option<String>,
},
#[command(about = "Show and apply inbox rules")]
Rules {
#[arg(long)]
apply: bool,
},
#[command(about = "Open issue URL in browser")]
Open {
#[arg(value_name = "KEY")]
key: Option<String>,
#[command(flatten)]
filter: FilterArgs,
},
#[command(about = "Triage the inbox issue by issue")]
Triage {
#[arg(long, value_name = "FOR", default_value = "1d")]
snooze_for: String,
#[command(flatten)]
filter: FilterArgs,
},
#[command(about = "Show one inbox issue")]
Show {
#[arg(value_name = "KEY")]
key: Option<String>,
#[arg(long)]
why: bool,
#[command(flatten)]
filter: FilterArgs,
},
#[command(about = "Snooze an inbox issue")]
Snooze {
#[arg(value_name = "KEY")]
key: Option<String>,
#[arg(value_name = "FOR")]
duration: Option<String>,
#[command(flatten)]
filter: FilterArgs,
},
#[command(about = "Wake a snoozed inbox issue")]
Unsnooze {
#[arg(value_name = "KEY")]
key: Option<String>,
},
#[command(about = "Import issue into tasks")]
Take {
#[arg(value_name = "KEY")]
key: Option<String>,
#[command(flatten)]
filter: FilterArgs,
},
#[command(about = "Start an issue in Jira")]
Start {
#[arg(value_name = "KEY")]
key: Option<String>,
#[arg(long, value_name = "NAME")]
to: Option<String>,
#[command(flatten)]
filter: FilterArgs,
},
#[command(about = "Close an issue in Jira")]
Close {
#[arg(value_name = "KEY")]
key: Option<String>,
#[arg(long, short = 'm', value_name = "TEXT")]
comment: Option<String>,
#[arg(long, value_name = "NAME")]
to: Option<String>,
#[arg(long, value_name = "NAME")]
resolution: Option<String>,
#[command(flatten)]
filter: FilterArgs,
},
#[command(about = "Comment on an issue in Jira")]
Comment {
#[arg(value_name = "KEY")]
key: Option<String>,
#[arg(value_name = "TEXT")]
text: Option<String>,
#[command(flatten)]
filter: FilterArgs,
},
}
pub async fn cmd(args: InboxArgs) -> Result<()> {
let top = args.filter;
match args.command {
Some(InboxCommand::Sync) => {
let outcome = inbox_lib::sync_interactive(true).await?;
if !outcome.skipped {
msg_success!(Message::JiraInboxSynced {
fetched: outcome.fetched,
new_count: outcome.new_keys.len(),
changed: outcome.changed.len(),
gone: outcome.gone_keys.len(),
});
report_rules(&outcome.ruled, &outcome.rule_problems);
offer_resolved(&outcome.resolved)?;
}
Ok(())
}
Some(InboxCommand::List {
limit,
all,
snoozed,
dismissed,
filter,
}) => {
let include = Include {
gone: all || args.all,
snoozed: snoozed || args.snoozed,
dismissed: dismissed || args.dismissed,
};
list_inbox(limit.or(args.limit), include, &filter.or(top))
}
Some(InboxCommand::Pin { key, filter }) => set_pinned(&resolve_key(key, "Pin which issue?", false, &filter.or(top))?, true),
Some(InboxCommand::Unpin { key, filter }) => set_pinned(&resolve_key(key, "Unpin which issue?", true, &filter.or(top))?, false),
Some(InboxCommand::Dismiss { key, filter }) => dismiss(&resolve_key(key, "Dismiss which issue?", false, &filter.or(top))?),
Some(InboxCommand::Undismiss { key }) => undismiss(key),
Some(InboxCommand::Rules { apply }) => rules(apply),
Some(InboxCommand::Open { key, filter }) => open_issue(&resolve_key(key, "Open which issue?", false, &filter.or(top))?),
Some(InboxCommand::Triage { snooze_for, filter }) => triage(&snooze_for, &filter.or(top)).await,
Some(InboxCommand::Show { key, why, filter }) => {
let key = resolve_key(key, "Show which issue?", false, &filter.or(top))?;
show_issue(&key, why)
}
Some(InboxCommand::Snooze { key, duration, filter }) => {
let key = resolve_key(key, "Snooze which issue?", false, &filter.or(top))?;
snooze(&key, duration.as_deref())
}
Some(InboxCommand::Unsnooze { key }) => unsnooze(key),
Some(InboxCommand::Take { key, filter }) => take_issue(&resolve_key(key, "Take which issue?", false, &filter.or(top))?).await,
Some(InboxCommand::Start { key, to, filter }) => {
let key = resolve_key(key, "Start which issue?", false, &filter.or(top))?;
start_issue(&key, to).await
}
Some(InboxCommand::Close {
key,
comment,
to,
resolution,
filter,
}) => {
let key = resolve_key(key, "Close which issue?", false, &filter.or(top))?;
close_issue(&key, comment, to, resolution).await
}
Some(InboxCommand::Comment { key, text, filter }) => {
let key = resolve_key(key, "Comment on which issue?", false, &filter.or(top))?;
comment_issue(&key, text).await
}
None => {
let include = Include {
gone: args.all,
snoozed: args.snoozed,
dismissed: args.dismissed,
};
list_inbox(args.limit, include, &top)
}
}
}
fn sliced(include: Include, args: &FilterArgs) -> Result<(Vec<JiraInboxItem>, usize)> {
let db = JiraInbox::new()?;
let items = db.list(include)?;
let total = items.len();
let filter = args.to_filter(&db.priorities()?)?;
let mut items = filter.apply(items, Local::now().naive_local());
if let Some(sort) = args.sort {
inbox_filter::sort(&mut items, sort.into());
}
Ok((items, total))
}
fn resolve_key(key: Option<String>, prompt: &str, pinned_only: bool, filter: &FilterArgs) -> Result<String> {
if let Some(key) = key {
return Ok(key);
}
let (items, total) = sliced(Include::default(), filter)?;
if items.is_empty() && total > 0 {
bail!("no issue matches the filter ({}) - {total} in the inbox", filter.describe().join(", "));
}
if pinned_only {
let pinned: Vec<_> = items.into_iter().filter(|i| i.pinned).collect();
if pinned.is_empty() {
bail!("no pinned issues - `kasl inbox pin KEY` pins one");
}
return pick::inbox_issue(&pinned, prompt);
}
pick::inbox_issue(&items, prompt)
}
fn list_inbox(limit: Option<usize>, include: Include, filter: &FilterArgs) -> Result<()> {
warn_skipped_rules();
let (mut items, total) = sliced(include, filter)?;
if total == 0 {
let asleep = if include.snoozed { 0 } else { JiraInbox::new()?.count_snoozed()? };
if asleep > 0 {
msg_info!(Message::JiraInboxAllSnoozed(asleep));
} else {
msg_info!(Message::JiraInboxEmpty);
}
return Ok(());
}
let cuts = filter.describe();
if items.is_empty() {
msg_info!(Message::JiraInboxNoMatch { total, what: cuts.join(", ") });
return Ok(());
}
if let Some(n) = limit {
items.truncate(n);
}
if items.len() == total && cuts.is_empty() {
msg_print!(Message::JiraInboxListHeader, true);
} else {
msg_print!(
Message::JiraInboxListSliced {
shown: items.len(),
total,
what: cuts.join(", ")
},
true
);
}
View::jira_inbox(&items)
}
fn set_pinned(key: &str, pinned: bool) -> Result<()> {
let db = JiraInbox::new()?;
if !db.set_pinned(key, pinned)? {
msg_error!(Message::JiraInboxNotFound(key.to_string()));
return Ok(());
}
if pinned {
msg_success!(Message::JiraInboxPinned(key.to_string()));
} else {
msg_success!(Message::JiraInboxUnpinned(key.to_string()));
}
Ok(())
}
fn dismiss(key: &str) -> Result<()> {
match toast_apply::dismiss(key)? {
Applied::NotFound => msg_error!(Message::JiraInboxNotFound(key.to_string())),
_ => msg_success!(Message::JiraInboxDismissed(key.to_string())),
}
Ok(())
}
fn open_issue(key: &str) -> Result<()> {
let db = JiraInbox::new()?;
let Some(item) = db.get_by_key(key)? else {
msg_error!(Message::JiraInboxNotFound(key.to_string()));
return Ok(());
};
match inbox_lib::open_url(&item.url) {
Ok(()) => msg_success!(Message::JiraInboxOpened(key.to_string())),
Err(e) => msg_error!(Message::JiraInboxOpenFailed(e.to_string())),
}
Ok(())
}
async fn triage(snooze_for: &str, filter: &FilterArgs) -> Result<()> {
inbox_filter::parse_window(snooze_for)?;
let (items, total) = sliced(Include::default(), filter)?;
if total == 0 {
msg_info!(Message::JiraInboxEmpty);
return Ok(());
}
if items.is_empty() {
msg_info!(Message::JiraInboxNoMatch {
total,
what: filter.describe().join(", ")
});
return Ok(());
}
let count = items.len();
let mut done = TriageTally::default();
for (index, item) in items.iter().enumerate() {
let action = match pick::triage_action(item, index + 1, count)? {
TriageAnswer::Decided(action) => action,
TriageAnswer::Open => {
open_issue(&item.issue_key)?;
match pick::triage_action(item, index + 1, count)? {
TriageAnswer::Decided(action) => action,
TriageAnswer::Open => TriageAction::Skip,
}
}
};
match action {
TriageAction::Take => {
take_issue(&item.issue_key).await?;
done.taken += 1;
}
TriageAction::Snooze => {
snooze(&item.issue_key, Some(snooze_for))?;
done.snoozed += 1;
}
TriageAction::Dismiss => {
dismiss(&item.issue_key)?;
done.dismissed += 1;
}
TriageAction::Skip => done.skipped += 1,
TriageAction::Quit => {
done.left = count - index;
break;
}
}
}
msg_success!(Message::JiraInboxTriaged {
taken: done.taken,
snoozed: done.snoozed,
dismissed: done.dismissed,
skipped: done.skipped,
left: done.left,
});
Ok(())
}
#[derive(Default)]
struct TriageTally {
taken: usize,
snoozed: usize,
dismissed: usize,
skipped: usize,
left: usize,
}
fn show_issue(key: &str, why: bool) -> Result<()> {
let db = JiraInbox::new()?;
let Some(item) = db.get_by_key(key)? else {
msg_error!(Message::JiraInboxNotFound(key.to_string()));
return Ok(());
};
View::jira_inbox_issue(&item)?;
if why {
let label = Config::read()
.ok()
.and_then(|c| c.jira_inbox)
.and_then(|cfg| cfg.sort_by_field.and_then(|id| score_field_label(&cfg.custom_fields, &id)));
let mut reasons = inbox_filter::explain(&item, label.as_deref(), Local::now().naive_local());
reasons.extend(inbox_rules::explain(&RuleBook::from_config(&db)?, &item));
View::jira_inbox_why(&reasons)?;
}
Ok(())
}
fn score_field_label(fields: &[crate::libs::config::JiraCustomField], id: &str) -> Option<String> {
fields
.iter()
.find(|f| f.id.trim() == id.trim())
.map(|f| f.label.clone())
.or_else(|| Some(id.to_string()))
}
fn snooze(key: &str, duration: Option<&str>) -> Result<()> {
let window = inbox_filter::parse_window(duration.unwrap_or("1d"))?;
match toast_apply::snooze(key, window)? {
Applied::Snoozed(until) => msg_success!(Message::JiraInboxSnoozed(key.to_string(), until.format("%b %-d %H:%M").to_string())),
_ => msg_error!(Message::JiraInboxNotFound(key.to_string())),
}
Ok(())
}
fn unsnooze(key: Option<String>) -> Result<()> {
let db = JiraInbox::new()?;
let key = match key {
Some(key) => key,
None => {
let now = Local::now().naive_local();
let asleep: Vec<JiraInboxItem> = db
.list(Include {
snoozed: true,
..Include::default()
})?
.into_iter()
.filter(|i| i.snoozed_until.is_some_and(|until| until > now))
.collect();
if asleep.is_empty() {
bail!("no issues are asleep - `kasl inbox snooze KEY 3d` puts one to sleep");
}
pick::inbox_issue(&asleep, "Wake which issue?")?
}
};
if !db.set_snoozed(&key, None)? {
msg_error!(Message::JiraInboxNotFound(key.clone()));
return Ok(());
}
msg_success!(Message::JiraInboxUnsnoozed(key));
Ok(())
}
async fn take_issue(key: &str) -> Result<()> {
match toast_apply::take(key, &MoveOptions::from_cli(None, None)).await? {
Applied::Taken { started, tags } => {
msg_success!(Message::JiraInboxTaken(key.to_string()));
if !tags.is_empty() {
msg_info!(Message::TagsAddedToTask(tags.join(", ")));
}
match started {
Some(Ok(moved)) => jira_bridge::report_move(key, Goal::Start, &moved),
Some(Err(why)) => msg_warning!(Message::JiraWriteFailed(key.to_string(), why)),
None => {}
}
}
Applied::AlreadyTaken(name) => msg_info!(Message::JiraInboxAlreadyTaken(key.to_string(), name)),
_ => msg_error!(Message::JiraInboxNotFound(key.to_string())),
}
Ok(())
}
fn undismiss(key: Option<String>) -> Result<()> {
let db = JiraInbox::new()?;
let key = match key {
Some(key) => key,
None => {
let hidden: Vec<JiraInboxItem> = db
.list(Include {
snoozed: true,
dismissed: true,
..Include::default()
})?
.into_iter()
.filter(|i| i.dismissed)
.collect();
if hidden.is_empty() {
bail!("no issues are dismissed - `kasl inbox dismiss KEY` dismisses one");
}
pick::inbox_issue(&hidden, "Bring back which issue?")?
}
};
if !db.set_dismissed(&key, false)? {
msg_error!(Message::JiraInboxNotFound(key));
return Ok(());
}
msg_success!(Message::JiraInboxUndismissed(key));
Ok(())
}
fn rules(apply: bool) -> Result<()> {
let db = JiraInbox::new()?;
let Some(config) = Config::read()?.jira_inbox.filter(|inbox| !inbox.rules.is_empty()) else {
msg_info!(Message::InboxRulesNone);
return Ok(());
};
let book = RuleBook::of_inbox(&config, &db)?;
for problem in &book.problems {
msg_warning!(Message::InboxRuleSkipped(problem.number, problem.reason.clone()));
}
if book.is_empty() {
return Ok(());
}
if apply {
let ruled = inbox_rules::apply_now(&db, &book)?;
for one in &ruled {
msg_info!(Message::InboxRuleActed(inbox_rules::sentence(one)));
}
msg_success!(Message::InboxRulesApplied(ruled.len()));
}
let items = db.list(Include {
snoozed: true,
dismissed: true,
..Include::default()
})?;
let rows: Vec<inbox_rules::RuleStanding> = book.rules.iter().map(|rule| inbox_rules::standing(rule, &items)).collect();
View::inbox_rules(&rows)?;
let waiting: usize = rows.iter().map(|row| row.waiting).sum();
if waiting > 0 && !apply {
msg_info!(Message::InboxRulesNotApplied(waiting));
}
Ok(())
}
fn report_rules(ruled: &[inbox_rules::Ruled], problems: &[inbox_rules::RuleProblem]) {
for problem in problems {
msg_warning!(Message::InboxRuleSkipped(problem.number, problem.reason.clone()));
}
for one in ruled {
msg_info!(Message::InboxRuleActed(inbox_rules::sentence(one)));
}
}
fn warn_skipped_rules() {
let skipped = Config::read()
.ok()
.and_then(|c| c.jira_inbox)
.map(|inbox| RuleBook::compile(&inbox.rules, &[]).problems.len())
.unwrap_or(0);
if skipped > 0 {
msg_warning!(Message::InboxRulesSkipped(skipped));
}
}
async fn start_issue(key: &str, to: Option<String>) -> Result<()> {
let mut jira = jira_bridge::client()?;
let moved = jira_bridge::move_issue(&mut jira, key, Goal::Start, &MoveOptions::from_cli(to, None)).await?;
finish_move(key, Goal::Start, moved)
}
async fn close_issue(key: &str, comment: Option<String>, to: Option<String>, resolution: Option<String>) -> Result<()> {
let mut jira = jira_bridge::client()?;
let moved = jira_bridge::close_issue(&mut jira, key, comment.as_deref(), &MoveOptions::from_cli(to, resolution)).await?;
finish_move(key, Goal::Close, moved)
}
fn finish_move(key: &str, goal: Goal, moved: jira_bridge::Moved) -> Result<()> {
if moved.arrived() {
jira_bridge::report_move(key, goal, &moved);
Ok(())
} else {
bail!("{}", moved.sentence(key, goal))
}
}
async fn comment_issue(key: &str, text: Option<String>) -> Result<()> {
let text = match text {
Some(text) => text,
None if is_interactive() => Input::with_theme(&ColorfulTheme::default())
.with_prompt(Message::PromptJiraComment.to_string())
.interact_text()?,
None => bail!("comment text is required; pass TEXT outside an interactive terminal"),
};
let text = text.trim();
if text.is_empty() {
bail!("an empty comment is not a comment; nothing was sent");
}
let mut jira = jira_bridge::client()?;
jira.add_comment(key, text, crate::api::jira::SessionMode::Interactive).await?;
msg_success!(Message::JiraCommented(key.to_string()));
Ok(())
}
fn offer_resolved(resolved: &[ResolvedIssue]) -> Result<()> {
if resolved.is_empty() {
return Ok(());
}
let in_parens = |issue: &ResolvedIssue| issue.resolution.as_deref().map(|r| format!(" ({r})")).unwrap_or_default();
if !is_interactive() {
for issue in resolved {
msg_info!(Message::JiraResolvedTaskOpen(issue.issue_key.clone(), in_parens(issue)));
}
return Ok(());
}
let labels: Vec<String> = resolved
.iter()
.map(|issue| format!("{} {}{}", issue.issue_key, issue.summary, in_parens(issue)))
.collect();
let ticked = vec![true; labels.len()];
let chosen = MultiSelect::with_theme(&ColorfulTheme::default())
.with_prompt(Message::PromptMarkResolvedDone.to_string())
.items(&labels)
.defaults(&ticked)
.interact()?;
for index in chosen {
let key = &resolved[index].issue_key;
msg_success!(Message::ToastActionApplied(toast_apply::complete(key)?.summary(key)));
}
Ok(())
}