use crate::api::jira::{Jira, JiraStatus, JiraTransition, SessionMode, StatusCategory};
use crate::db::jira_inbox::{JiraInbox, LeftReason};
use crate::db::jira_statuses::JiraStatuses;
use crate::libs::config::Config;
use crate::libs::messages::Message;
use crate::libs::prompt::is_interactive;
use crate::{msg_info, msg_success, msg_warning};
use anyhow::{Result, bail};
use dialoguer::{Input, Select, theme::ColorfulTheme};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Goal {
Start,
Close,
}
impl Goal {
pub fn category(self) -> StatusCategory {
match self {
Goal::Start => StatusCategory::InProgress,
Goal::Close => StatusCategory::Done,
}
}
pub fn verb(self) -> &'static str {
match self {
Goal::Start => "start",
Goal::Close => "close",
}
}
}
#[derive(Debug, Clone)]
pub struct MoveOptions {
pub session: SessionMode,
pub to: Option<String>,
pub resolution: Option<String>,
}
impl MoveOptions {
pub fn unattended() -> Self {
Self {
session: SessionMode::Unattended,
to: None,
resolution: None,
}
}
pub fn from_cli(to: Option<String>, resolution: Option<String>) -> Self {
Self {
session: SessionMode::Interactive,
to,
resolution,
}
}
fn can_ask(&self) -> bool {
self.session == SessionMode::Interactive && is_interactive()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Moved {
Moved { to: String },
Already { status: String },
NoWay { status: String },
Ambiguous { options: Vec<String> },
Missing,
}
impl Moved {
pub fn arrived(&self) -> bool {
matches!(self, Moved::Moved { .. } | Moved::Already { .. })
}
pub fn sentence(&self, key: &str, goal: Goal) -> String {
let verb = goal.verb();
match self {
Moved::Moved { to } => format!("{key} is now {to} in Jira"),
Moved::Already { status } => format!("{key} is already {status} in Jira"),
Moved::NoWay { status } => format!("Jira offers no way to {verb} {key} from {status}"),
Moved::Ambiguous { options } => format!(
"Jira offers {} ways to {verb} {key} ({}); run `kasl inbox {verb} {key}` on a terminal, or pass --to",
options.len(),
options.join(", ")
),
Moved::Missing => format!("Jira does not know {key}, or does not show it to you"),
}
}
}
#[derive(Debug, PartialEq, Eq)]
pub enum Route<'a> {
Already,
NoWay,
One(&'a JiraTransition),
Several(Vec<&'a JiraTransition>),
}
pub fn already_there(status: &str, category: StatusCategory, goal: Goal, to: Option<&str>) -> bool {
match to {
Some(name) => status.eq_ignore_ascii_case(name),
None => category == goal.category(),
}
}
pub fn route<'a>(current_status: &str, current: StatusCategory, transitions: &'a [JiraTransition], goal: Goal, to: Option<&str>) -> Route<'a> {
if already_there(current_status, current, goal, to) {
return Route::Already;
}
let candidates: Vec<&JiraTransition> = transitions
.iter()
.filter(|t| t.to.category() == goal.category())
.filter(|t| to.is_none_or(|name| t.to.name.eq_ignore_ascii_case(name) || t.name.eq_ignore_ascii_case(name)))
.collect();
match candidates.len() {
0 => Route::NoWay,
1 => Route::One(candidates[0]),
_ => Route::Several(candidates),
}
}
fn transition_label(transition: &JiraTransition) -> String {
if transition.name.eq_ignore_ascii_case(&transition.to.name) {
transition.name.clone()
} else {
format!("{} → {}", transition.name, transition.to.name)
}
}
struct Plan {
transition_id: String,
resolution_id: Option<String>,
to: JiraStatus,
}
enum Planned {
Go(Plan),
Settled(Moved),
}
async fn plan_move(jira: &mut Jira, key: &str, goal: Goal, options: &MoveOptions) -> Result<Planned> {
let Some(state) = jira.issue_state(key, options.session).await? else {
return Ok(Planned::Settled(Moved::Missing));
};
if already_there(&state.status.name, state.status.category(), goal, options.to.as_deref()) {
return Ok(Planned::Settled(Moved::Already { status: state.status.name }));
}
let transitions = jira.transitions(key, options.session).await?;
let transition = match route(&state.status.name, state.status.category(), &transitions, goal, options.to.as_deref()) {
Route::Already => return Ok(Planned::Settled(Moved::Already { status: state.status.name })),
Route::NoWay => return Ok(Planned::Settled(Moved::NoWay { status: state.status.name })),
Route::One(transition) => transition,
Route::Several(options_offered) => {
let labels: Vec<String> = options_offered.iter().map(|t| transition_label(t)).collect();
if !options.can_ask() {
return Ok(Planned::Settled(Moved::Ambiguous { options: labels }));
}
let prompt = format!("Jira offers several ways to {} {key}", goal.verb());
let index = Select::with_theme(&ColorfulTheme::default())
.with_prompt(prompt)
.items(&labels)
.default(0)
.interact()?;
options_offered[index]
}
};
let resolution_id = match goal {
Goal::Close => resolution_for(key, transition, options)?,
Goal::Start => None,
};
Ok(Planned::Go(Plan {
transition_id: transition.id.clone(),
resolution_id,
to: transition.to.clone(),
}))
}
async fn send_move(jira: &mut Jira, key: &str, plan: Plan, options: &MoveOptions) -> Result<Moved> {
jira.transition(key, &plan.transition_id, plan.resolution_id.as_deref(), options.session)
.await?;
if let Err(e) = remember_status(key, &plan.to) {
tracing::warn!("{} moved in Jira, but the inbox row was not updated: {}", key, e);
}
Ok(Moved::Moved { to: plan.to.name })
}
fn remember_status(key: &str, status: &JiraStatus) -> Result<()> {
if status.id.is_empty() {
return Ok(());
}
JiraStatuses::new()?.upsert(&status.id, &status.name)?;
JiraInbox::new()?.set_status(key, &status.id)?;
Ok(())
}
pub async fn move_issue(jira: &mut Jira, key: &str, goal: Goal, options: &MoveOptions) -> Result<Moved> {
match plan_move(jira, key, goal, options).await? {
Planned::Go(plan) => send_move(jira, key, plan, options).await,
Planned::Settled(moved) => Ok(moved),
}
}
fn resolution_for(key: &str, transition: &JiraTransition, options: &MoveOptions) -> Result<Option<String>> {
let Some(field) = transition.fields.get("resolution").filter(|f| f.required) else {
return Ok(None);
};
let allowed = &field.allowed_values;
if let Some(wanted) = &options.resolution {
return match allowed.iter().find(|v| v.name.eq_ignore_ascii_case(wanted)) {
Some(value) => Ok(Some(value.id.clone())),
None => bail!(
"Jira does not offer the resolution '{wanted}' for {key}; it offers {}",
names(allowed.iter().map(|v| v.name.as_str()))
),
};
}
match allowed.len() {
0 => Ok(None),
1 => Ok(Some(allowed[0].id.clone())),
_ if options.can_ask() => {
let labels: Vec<&str> = allowed.iter().map(|v| v.name.as_str()).collect();
let index = Select::with_theme(&ColorfulTheme::default())
.with_prompt(format!("Resolution for {key}"))
.items(&labels)
.default(0)
.interact()?;
Ok(Some(allowed[index].id.clone()))
}
_ => bail!(
"closing {key} needs a resolution - {}; pass --resolution NAME",
names(allowed.iter().map(|v| v.name.as_str()))
),
}
}
fn names<'a>(values: impl Iterator<Item = &'a str>) -> String {
values.collect::<Vec<_>>().join(", ")
}
pub async fn close_issue(jira: &mut Jira, key: &str, comment: Option<&str>, options: &MoveOptions) -> Result<Moved> {
let planned = plan_move(jira, key, Goal::Close, options).await?;
let worth_a_comment = matches!(planned, Planned::Go(_) | Planned::Settled(Moved::Already { .. }));
if worth_a_comment && let Some(body) = comment.map(str::trim).filter(|b| !b.is_empty()) {
jira.add_comment(key, body, options.session).await?;
msg_success!(Message::JiraCommented(key.to_string()));
}
match planned {
Planned::Go(plan) => send_move(jira, key, plan, options).await,
Planned::Settled(moved) => Ok(moved),
}
}
pub fn client() -> Result<Jira> {
let config = Config::read()?;
match config.jira {
Some(jira) => Ok(Jira::new(&jira)),
None => bail!("Jira is not configured; run `kasl setup` and choose Jira"),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Offer {
Leave,
Close,
Comment,
}
pub async fn offer_after_done(key: &str) {
if !is_interactive() {
return;
}
if let Err(e) = offer_after_done_inner(key).await {
msg_warning!(Message::JiraWriteFailed(key.to_string(), e.to_string()));
}
}
async fn offer_after_done_inner(key: &str) -> Result<()> {
if Config::read()?.jira.is_none() {
return Ok(());
}
if let Some(item) = JiraInbox::new()?.get_by_key(key)?
&& item.left_reason == Some(LeftReason::Resolved)
{
return Ok(());
}
let choices = [Offer::Leave, Offer::Close, Offer::Comment];
let labels = [format!("Leave {key} as it is"), format!("Close {key}"), format!("Comment on {key}")];
let index = Select::with_theme(&ColorfulTheme::default())
.with_prompt(Message::PromptJiraAfterDone(key.to_string()).to_string())
.items(&labels)
.default(0)
.interact()?;
match choices[index] {
Offer::Leave => Ok(()),
Offer::Close => {
let comment: String = Input::with_theme(&ColorfulTheme::default())
.with_prompt(Message::PromptJiraCloseComment.to_string())
.allow_empty(true)
.interact_text()?;
let mut jira = client()?;
let options = MoveOptions::from_cli(None, None);
let moved = close_issue(&mut jira, key, Some(&comment), &options).await?;
report_move(key, Goal::Close, &moved);
Ok(())
}
Offer::Comment => {
let comment: String = Input::with_theme(&ColorfulTheme::default())
.with_prompt(Message::PromptJiraComment.to_string())
.interact_text()?;
let mut jira = client()?;
jira.add_comment(key, comment.trim(), SessionMode::Interactive).await?;
msg_success!(Message::JiraCommented(key.to_string()));
Ok(())
}
}
}
pub fn report_move(key: &str, goal: Goal, moved: &Moved) {
match moved {
Moved::Moved { .. } => msg_success!(Message::JiraMoved(moved.sentence(key, goal))),
Moved::Already { .. } => msg_info!(Message::JiraMoved(moved.sentence(key, goal))),
_ => msg_warning!(Message::JiraMoved(moved.sentence(key, goal))),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::jira::{JiraCategory, JiraStatus};
use std::collections::HashMap;
fn transition(id: &str, name: &str, to: &str, category: &str) -> JiraTransition {
JiraTransition {
id: id.to_string(),
name: name.to_string(),
to: JiraStatus {
id: String::new(),
name: to.to_string(),
category: Some(JiraCategory { key: category.to_string() }),
},
fields: HashMap::new(),
}
}
fn workflow() -> Vec<JiraTransition> {
vec![
transition("11", "Start Progress", "In Progress", "indeterminate"),
transition("21", "Resolve Issue", "Resolved", "done"),
transition("31", "Close Issue", "Closed", "done"),
]
}
#[test]
fn a_move_is_chosen_by_category_not_by_name() {
let localized = vec![
transition("5", "Взять в работу", "В работе", "indeterminate"),
transition("6", "Отложить", "Открыт", "new"),
];
match route("Открыт", StatusCategory::ToDo, &localized, Goal::Start, None) {
Route::One(t) => assert_eq!(t.id, "5"),
other => panic!("expected one way to start, got {other:?}"),
}
}
#[test]
fn an_issue_already_in_the_category_is_not_moved() {
assert_eq!(route("In Review", StatusCategory::InProgress, &workflow(), Goal::Start, None), Route::Already);
assert_eq!(route("Closed", StatusCategory::Done, &workflow(), Goal::Close, None), Route::Already);
}
#[test]
fn several_ways_are_a_choice_not_a_guess() {
match route("In Progress", StatusCategory::InProgress, &workflow(), Goal::Close, None) {
Route::Several(ts) => assert_eq!(ts.iter().map(|t| t.id.as_str()).collect::<Vec<_>>(), ["21", "31"]),
other => panic!("two ways to close must be offered, not picked: {other:?}"),
}
}
#[test]
fn to_settles_the_choice_by_status_or_transition_name() {
match route("In Progress", StatusCategory::InProgress, &workflow(), Goal::Close, Some("closed")) {
Route::One(t) => assert_eq!(t.id, "31"),
other => panic!("--to Closed names the status: {other:?}"),
}
match route("In Progress", StatusCategory::InProgress, &workflow(), Goal::Close, Some("Resolve Issue")) {
Route::One(t) => assert_eq!(t.id, "21"),
other => panic!("--to may name the transition too: {other:?}"),
}
assert_eq!(
route("In Progress", StatusCategory::InProgress, &workflow(), Goal::Close, Some("Start Progress")),
Route::NoWay
);
}
#[test]
fn to_means_that_very_status() {
let flow = vec![transition("12", "Back to work", "In Progress", "indeterminate")];
match route("In Review", StatusCategory::InProgress, &flow, Goal::Start, Some("In Progress")) {
Route::One(t) => assert_eq!(t.id, "12"),
other => panic!("{other:?}"),
}
assert_eq!(
route("In Progress", StatusCategory::InProgress, &flow, Goal::Start, Some("in progress")),
Route::Already
);
}
#[test]
fn no_transition_into_the_category_is_no_way() {
let flow = vec![transition("6", "Reopen", "Open", "new")];
assert_eq!(route("Resolved", StatusCategory::Done, &flow, Goal::Start, None), Route::NoWay);
}
#[test]
fn an_ambiguous_move_names_its_way_out() {
let text = Moved::Ambiguous {
options: vec!["Resolve Issue → Resolved".into(), "Close Issue → Closed".into()],
}
.sentence("KA-1", Goal::Close);
assert!(text.contains("kasl inbox close KA-1") && text.contains("--to"), "{text}");
}
}