1use crate::api::jira::{Jira, JiraStatus, JiraTransition, SessionMode, StatusCategory};
28use crate::db::jira_inbox::{JiraInbox, LeftReason};
29use crate::db::jira_statuses::JiraStatuses;
30use crate::libs::config::Config;
31use crate::libs::messages::Message;
32use crate::libs::prompt::is_interactive;
33use crate::{msg_info, msg_success, msg_warning};
34use anyhow::{Result, bail};
35use dialoguer::{Input, Select, theme::ColorfulTheme};
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum Goal {
40 Start,
42 Close,
44}
45
46impl Goal {
47 pub fn category(self) -> StatusCategory {
49 match self {
50 Goal::Start => StatusCategory::InProgress,
51 Goal::Close => StatusCategory::Done,
52 }
53 }
54
55 pub fn verb(self) -> &'static str {
57 match self {
58 Goal::Start => "start",
59 Goal::Close => "close",
60 }
61 }
62}
63
64#[derive(Debug, Clone)]
66pub struct MoveOptions {
67 pub session: SessionMode,
69 pub to: Option<String>,
71 pub resolution: Option<String>,
73}
74
75impl MoveOptions {
76 pub fn unattended() -> Self {
78 Self {
79 session: SessionMode::Unattended,
80 to: None,
81 resolution: None,
82 }
83 }
84
85 pub fn from_cli(to: Option<String>, resolution: Option<String>) -> Self {
87 Self {
88 session: SessionMode::Interactive,
89 to,
90 resolution,
91 }
92 }
93
94 fn can_ask(&self) -> bool {
99 self.session == SessionMode::Interactive && is_interactive()
100 }
101}
102
103#[derive(Debug, Clone, PartialEq, Eq)]
105pub enum Moved {
106 Moved { to: String },
108 Already { status: String },
110 NoWay { status: String },
112 Ambiguous { options: Vec<String> },
114 Missing,
116}
117
118impl Moved {
119 pub fn arrived(&self) -> bool {
121 matches!(self, Moved::Moved { .. } | Moved::Already { .. })
122 }
123
124 pub fn sentence(&self, key: &str, goal: Goal) -> String {
127 let verb = goal.verb();
128 match self {
129 Moved::Moved { to } => format!("{key} is now {to} in Jira"),
130 Moved::Already { status } => format!("{key} is already {status} in Jira"),
131 Moved::NoWay { status } => format!("Jira offers no way to {verb} {key} from {status}"),
132 Moved::Ambiguous { options } => format!(
133 "Jira offers {} ways to {verb} {key} ({}); run `kasl inbox {verb} {key}` on a terminal, or pass --to",
134 options.len(),
135 options.join(", ")
136 ),
137 Moved::Missing => format!("Jira does not know {key}, or does not show it to you"),
138 }
139 }
140}
141
142#[derive(Debug, PartialEq, Eq)]
144pub enum Route<'a> {
145 Already,
147 NoWay,
149 One(&'a JiraTransition),
151 Several(Vec<&'a JiraTransition>),
153}
154
155pub fn already_there(status: &str, category: StatusCategory, goal: Goal, to: Option<&str>) -> bool {
160 match to {
161 Some(name) => status.eq_ignore_ascii_case(name),
162 None => category == goal.category(),
163 }
164}
165
166pub fn route<'a>(current_status: &str, current: StatusCategory, transitions: &'a [JiraTransition], goal: Goal, to: Option<&str>) -> Route<'a> {
172 if already_there(current_status, current, goal, to) {
173 return Route::Already;
174 }
175
176 let candidates: Vec<&JiraTransition> = transitions
177 .iter()
178 .filter(|t| t.to.category() == goal.category())
179 .filter(|t| to.is_none_or(|name| t.to.name.eq_ignore_ascii_case(name) || t.name.eq_ignore_ascii_case(name)))
180 .collect();
181
182 match candidates.len() {
183 0 => Route::NoWay,
184 1 => Route::One(candidates[0]),
185 _ => Route::Several(candidates),
186 }
187}
188
189fn transition_label(transition: &JiraTransition) -> String {
192 if transition.name.eq_ignore_ascii_case(&transition.to.name) {
193 transition.name.clone()
194 } else {
195 format!("{} → {}", transition.name, transition.to.name)
196 }
197}
198
199struct Plan {
201 transition_id: String,
202 resolution_id: Option<String>,
203 to: JiraStatus,
204}
205
206enum Planned {
208 Go(Plan),
209 Settled(Moved),
210}
211
212async fn plan_move(jira: &mut Jira, key: &str, goal: Goal, options: &MoveOptions) -> Result<Planned> {
219 let Some(state) = jira.issue_state(key, options.session).await? else {
220 return Ok(Planned::Settled(Moved::Missing));
221 };
222
223 if already_there(&state.status.name, state.status.category(), goal, options.to.as_deref()) {
226 return Ok(Planned::Settled(Moved::Already { status: state.status.name }));
227 }
228 let transitions = jira.transitions(key, options.session).await?;
229
230 let transition = match route(&state.status.name, state.status.category(), &transitions, goal, options.to.as_deref()) {
231 Route::Already => return Ok(Planned::Settled(Moved::Already { status: state.status.name })),
232 Route::NoWay => return Ok(Planned::Settled(Moved::NoWay { status: state.status.name })),
233 Route::One(transition) => transition,
234 Route::Several(options_offered) => {
235 let labels: Vec<String> = options_offered.iter().map(|t| transition_label(t)).collect();
236 if !options.can_ask() {
237 return Ok(Planned::Settled(Moved::Ambiguous { options: labels }));
238 }
239 let prompt = format!("Jira offers several ways to {} {key}", goal.verb());
240 let index = Select::with_theme(&ColorfulTheme::default())
241 .with_prompt(prompt)
242 .items(&labels)
243 .default(0)
244 .interact()?;
245 options_offered[index]
246 }
247 };
248
249 let resolution_id = match goal {
250 Goal::Close => resolution_for(key, transition, options)?,
251 Goal::Start => None,
252 };
253 Ok(Planned::Go(Plan {
254 transition_id: transition.id.clone(),
255 resolution_id,
256 to: transition.to.clone(),
257 }))
258}
259
260async fn send_move(jira: &mut Jira, key: &str, plan: Plan, options: &MoveOptions) -> Result<Moved> {
267 jira.transition(key, &plan.transition_id, plan.resolution_id.as_deref(), options.session)
268 .await?;
269 if let Err(e) = remember_status(key, &plan.to) {
270 tracing::warn!("{} moved in Jira, but the inbox row was not updated: {}", key, e);
271 }
272 Ok(Moved::Moved { to: plan.to.name })
273}
274
275fn remember_status(key: &str, status: &JiraStatus) -> Result<()> {
277 if status.id.is_empty() {
278 return Ok(());
279 }
280 JiraStatuses::new()?.upsert(&status.id, &status.name)?;
281 JiraInbox::new()?.set_status(key, &status.id)?;
282 Ok(())
283}
284
285pub async fn move_issue(jira: &mut Jira, key: &str, goal: Goal, options: &MoveOptions) -> Result<Moved> {
287 match plan_move(jira, key, goal, options).await? {
288 Planned::Go(plan) => send_move(jira, key, plan, options).await,
289 Planned::Settled(moved) => Ok(moved),
290 }
291}
292
293fn resolution_for(key: &str, transition: &JiraTransition, options: &MoveOptions) -> Result<Option<String>> {
301 let Some(field) = transition.fields.get("resolution").filter(|f| f.required) else {
302 return Ok(None);
303 };
304 let allowed = &field.allowed_values;
305 if let Some(wanted) = &options.resolution {
306 return match allowed.iter().find(|v| v.name.eq_ignore_ascii_case(wanted)) {
307 Some(value) => Ok(Some(value.id.clone())),
308 None => bail!(
309 "Jira does not offer the resolution '{wanted}' for {key}; it offers {}",
310 names(allowed.iter().map(|v| v.name.as_str()))
311 ),
312 };
313 }
314 match allowed.len() {
315 0 => Ok(None),
316 1 => Ok(Some(allowed[0].id.clone())),
317 _ if options.can_ask() => {
318 let labels: Vec<&str> = allowed.iter().map(|v| v.name.as_str()).collect();
319 let index = Select::with_theme(&ColorfulTheme::default())
320 .with_prompt(format!("Resolution for {key}"))
321 .items(&labels)
322 .default(0)
323 .interact()?;
324 Ok(Some(allowed[index].id.clone()))
325 }
326 _ => bail!(
327 "closing {key} needs a resolution - {}; pass --resolution NAME",
328 names(allowed.iter().map(|v| v.name.as_str()))
329 ),
330 }
331}
332
333fn names<'a>(values: impl Iterator<Item = &'a str>) -> String {
334 values.collect::<Vec<_>>().join(", ")
335}
336
337pub async fn close_issue(jira: &mut Jira, key: &str, comment: Option<&str>, options: &MoveOptions) -> Result<Moved> {
351 let planned = plan_move(jira, key, Goal::Close, options).await?;
352 let worth_a_comment = matches!(planned, Planned::Go(_) | Planned::Settled(Moved::Already { .. }));
353 if worth_a_comment && let Some(body) = comment.map(str::trim).filter(|b| !b.is_empty()) {
354 jira.add_comment(key, body, options.session).await?;
355 msg_success!(Message::JiraCommented(key.to_string()));
356 }
357 match planned {
358 Planned::Go(plan) => send_move(jira, key, plan, options).await,
359 Planned::Settled(moved) => Ok(moved),
360 }
361}
362
363pub fn client() -> Result<Jira> {
365 let config = Config::read()?;
366 match config.jira {
367 Some(jira) => Ok(Jira::new(&jira)),
368 None => bail!("Jira is not configured; run `kasl setup` and choose Jira"),
369 }
370}
371
372#[derive(Debug, Clone, Copy, PartialEq, Eq)]
374enum Offer {
375 Leave,
376 Close,
377 Comment,
378}
379
380pub async fn offer_after_done(key: &str) {
390 if !is_interactive() {
391 return;
392 }
393 if let Err(e) = offer_after_done_inner(key).await {
394 msg_warning!(Message::JiraWriteFailed(key.to_string(), e.to_string()));
395 }
396}
397
398async fn offer_after_done_inner(key: &str) -> Result<()> {
399 if Config::read()?.jira.is_none() {
400 return Ok(());
401 }
402 if let Some(item) = JiraInbox::new()?.get_by_key(key)?
403 && item.left_reason == Some(LeftReason::Resolved)
404 {
405 return Ok(());
406 }
407
408 let choices = [Offer::Leave, Offer::Close, Offer::Comment];
409 let labels = [format!("Leave {key} as it is"), format!("Close {key}"), format!("Comment on {key}")];
410 let index = Select::with_theme(&ColorfulTheme::default())
411 .with_prompt(Message::PromptJiraAfterDone(key.to_string()).to_string())
412 .items(&labels)
413 .default(0)
414 .interact()?;
415
416 match choices[index] {
417 Offer::Leave => Ok(()),
418 Offer::Close => {
419 let comment: String = Input::with_theme(&ColorfulTheme::default())
420 .with_prompt(Message::PromptJiraCloseComment.to_string())
421 .allow_empty(true)
422 .interact_text()?;
423 let mut jira = client()?;
424 let options = MoveOptions::from_cli(None, None);
425 let moved = close_issue(&mut jira, key, Some(&comment), &options).await?;
426 report_move(key, Goal::Close, &moved);
427 Ok(())
428 }
429 Offer::Comment => {
430 let comment: String = Input::with_theme(&ColorfulTheme::default())
431 .with_prompt(Message::PromptJiraComment.to_string())
432 .interact_text()?;
433 let mut jira = client()?;
434 jira.add_comment(key, comment.trim(), SessionMode::Interactive).await?;
435 msg_success!(Message::JiraCommented(key.to_string()));
436 Ok(())
437 }
438 }
439}
440
441pub fn report_move(key: &str, goal: Goal, moved: &Moved) {
443 match moved {
444 Moved::Moved { .. } => msg_success!(Message::JiraMoved(moved.sentence(key, goal))),
445 Moved::Already { .. } => msg_info!(Message::JiraMoved(moved.sentence(key, goal))),
446 _ => msg_warning!(Message::JiraMoved(moved.sentence(key, goal))),
447 }
448}
449
450#[cfg(test)]
451mod tests {
452 use super::*;
453 use crate::api::jira::{JiraCategory, JiraStatus};
454 use std::collections::HashMap;
455
456 fn transition(id: &str, name: &str, to: &str, category: &str) -> JiraTransition {
457 JiraTransition {
458 id: id.to_string(),
459 name: name.to_string(),
460 to: JiraStatus {
461 id: String::new(),
462 name: to.to_string(),
463 category: Some(JiraCategory { key: category.to_string() }),
464 },
465 fields: HashMap::new(),
466 }
467 }
468
469 fn workflow() -> Vec<JiraTransition> {
470 vec![
471 transition("11", "Start Progress", "In Progress", "indeterminate"),
472 transition("21", "Resolve Issue", "Resolved", "done"),
473 transition("31", "Close Issue", "Closed", "done"),
474 ]
475 }
476
477 #[test]
478 fn a_move_is_chosen_by_category_not_by_name() {
479 let localized = vec![
482 transition("5", "Взять в работу", "В работе", "indeterminate"),
483 transition("6", "Отложить", "Открыт", "new"),
484 ];
485 match route("Открыт", StatusCategory::ToDo, &localized, Goal::Start, None) {
486 Route::One(t) => assert_eq!(t.id, "5"),
487 other => panic!("expected one way to start, got {other:?}"),
488 }
489 }
490
491 #[test]
492 fn an_issue_already_in_the_category_is_not_moved() {
493 assert_eq!(route("In Review", StatusCategory::InProgress, &workflow(), Goal::Start, None), Route::Already);
494 assert_eq!(route("Closed", StatusCategory::Done, &workflow(), Goal::Close, None), Route::Already);
495 }
496
497 #[test]
498 fn several_ways_are_a_choice_not_a_guess() {
499 match route("In Progress", StatusCategory::InProgress, &workflow(), Goal::Close, None) {
500 Route::Several(ts) => assert_eq!(ts.iter().map(|t| t.id.as_str()).collect::<Vec<_>>(), ["21", "31"]),
501 other => panic!("two ways to close must be offered, not picked: {other:?}"),
502 }
503 }
504
505 #[test]
506 fn to_settles_the_choice_by_status_or_transition_name() {
507 match route("In Progress", StatusCategory::InProgress, &workflow(), Goal::Close, Some("closed")) {
508 Route::One(t) => assert_eq!(t.id, "31"),
509 other => panic!("--to Closed names the status: {other:?}"),
510 }
511 match route("In Progress", StatusCategory::InProgress, &workflow(), Goal::Close, Some("Resolve Issue")) {
512 Route::One(t) => assert_eq!(t.id, "21"),
513 other => panic!("--to may name the transition too: {other:?}"),
514 }
515 assert_eq!(
517 route("In Progress", StatusCategory::InProgress, &workflow(), Goal::Close, Some("Start Progress")),
518 Route::NoWay
519 );
520 }
521
522 #[test]
523 fn to_means_that_very_status() {
524 let flow = vec![transition("12", "Back to work", "In Progress", "indeterminate")];
526 match route("In Review", StatusCategory::InProgress, &flow, Goal::Start, Some("In Progress")) {
527 Route::One(t) => assert_eq!(t.id, "12"),
528 other => panic!("{other:?}"),
529 }
530 assert_eq!(
531 route("In Progress", StatusCategory::InProgress, &flow, Goal::Start, Some("in progress")),
532 Route::Already
533 );
534 }
535
536 #[test]
537 fn no_transition_into_the_category_is_no_way() {
538 let flow = vec![transition("6", "Reopen", "Open", "new")];
539 assert_eq!(route("Resolved", StatusCategory::Done, &flow, Goal::Start, None), Route::NoWay);
540 }
541
542 #[test]
543 fn an_ambiguous_move_names_its_way_out() {
544 let text = Moved::Ambiguous {
545 options: vec!["Resolve Issue → Resolved".into(), "Close Issue → Closed".into()],
546 }
547 .sentence("KA-1", Goal::Close);
548 assert!(text.contains("kasl inbox close KA-1") && text.contains("--to"), "{text}");
549 }
550}