Skip to main content

kasl/libs/
jira_bridge.rs

1//! The two things kasl writes to Jira: an issue's place in its workflow, and a comment.
2//!
3//! Everything else about Jira is read: the inbox polls it, `task find` asks it
4//! what was resolved today. This module is where kasl acts on it instead, and
5//! it is kept to two moves - start and close - because those are the two
6//! moments a local task and its issue meet: taking the issue, and finishing
7//! the work.
8//!
9//! ## Moves by category, never by name
10//!
11//! A move is a transition whose target status falls into one of Jira's fixed
12//! categories: `indeterminate` (in progress) to start, `done` to close. Status
13//! and transition names are per-workflow and localized - "In Progress", "В
14//! работе", "Start Review" - while the three categories are the same on every
15//! Jira. The completed-issue search learned this the hard way: an English
16//! "Done" in its JQL failed the whole query on a Russian Jira.
17//!
18//! When more than one transition leads into the target category, kasl does
19//! not pick. On a terminal it asks; unattended (a toast button, a script) it
20//! does nothing and says how to settle it, because moving a shared issue to
21//! the wrong status is worse than not moving it - the first is a mistake
22//! someone else sees, the second is a step still to take.
23//!
24//! The commands speak in these two moves rather than in transition ids, so a
25//! source other than Jira can answer the same questions later.
26
27use 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/// Where a move takes an issue.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum Goal {
40    /// Into the in-progress category.
41    Start,
42    /// Into the done category.
43    Close,
44}
45
46impl Goal {
47    /// The category a transition must lead into to count as this move.
48    pub fn category(self) -> StatusCategory {
49        match self {
50            Goal::Start => StatusCategory::InProgress,
51            Goal::Close => StatusCategory::Done,
52        }
53    }
54
55    /// The verb the messages use.
56    pub fn verb(self) -> &'static str {
57        match self {
58            Goal::Start => "start",
59            Goal::Close => "close",
60        }
61    }
62}
63
64/// How a move may proceed: whom it may ask, and what the caller already chose.
65#[derive(Debug, Clone)]
66pub struct MoveOptions {
67    /// Whether signing in may prompt for a password.
68    pub session: SessionMode,
69    /// Target status or transition name, for when Jira offers several ways.
70    pub to: Option<String>,
71    /// Resolution name, for a close whose screen asks for one.
72    pub resolution: Option<String>,
73}
74
75impl MoveOptions {
76    /// A move with nobody to ask: a toast button, the watcher.
77    pub fn unattended() -> Self {
78        Self {
79            session: SessionMode::Unattended,
80            to: None,
81            resolution: None,
82        }
83    }
84
85    /// A move from the command line, which may sign in and ask on a terminal.
86    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    /// Whether a choice can be put to a person.
95    ///
96    /// Both have to hold: an interactive session in a script still has
97    /// nobody at the keyboard, and a terminal under the watcher is not one.
98    fn can_ask(&self) -> bool {
99        self.session == SessionMode::Interactive && is_interactive()
100    }
101}
102
103/// What a move came to.
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub enum Moved {
106    /// The issue is now in this status.
107    Moved { to: String },
108    /// The issue was already there; the status it is in.
109    Already { status: String },
110    /// No transition from the current status leads where the move goes.
111    NoWay { status: String },
112    /// Several transitions do, and nobody was there to choose.
113    Ambiguous { options: Vec<String> },
114    /// Jira does not know the issue, or will not show it to this account.
115    Missing,
116}
117
118impl Moved {
119    /// Whether the issue ended where the move meant it to be.
120    pub fn arrived(&self) -> bool {
121        matches!(self, Moved::Moved { .. } | Moved::Already { .. })
122    }
123
124    /// One line saying what happened, with the command that settles a move
125    /// that did not happen.
126    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/// The transitions a move could take, decided before anything is sent.
143#[derive(Debug, PartialEq, Eq)]
144pub enum Route<'a> {
145    /// The issue is already where the move goes.
146    Already,
147    /// Nothing leads there from the current status.
148    NoWay,
149    /// Exactly one transition does.
150    One(&'a JiraTransition),
151    /// More than one does; someone has to choose.
152    Several(Vec<&'a JiraTransition>),
153}
154
155/// Whether an issue in `status` (of `category`) is already where the move goes.
156///
157/// With `to` set, "there" means that very status: an issue In Review that is
158/// asked to go to In Progress is not there yet, though both are in progress.
159pub 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
166/// Picks the transitions that carry an issue in `current_status` to `goal`.
167///
168/// `to` narrows by name, matching either the target status ("Closed") or the
169/// transition itself ("Resolve Issue"), case-insensitively - whichever word
170/// the person knows from their Jira screen.
171pub 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
189/// How a transition is named in a choice: the transition, and where it leads
190/// when the two differ.
191fn 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
199/// A move decided but not yet sent: which transition, with which resolution.
200struct Plan {
201    transition_id: String,
202    resolution_id: Option<String>,
203    to: JiraStatus,
204}
205
206/// What deciding a move came to: a move to send, or an outcome already.
207enum Planned {
208    Go(Plan),
209    Settled(Moved),
210}
211
212/// Decides how `key` moves towards `goal`, asking whatever has to be asked,
213/// and sends nothing.
214///
215/// Everything that can stop a move is settled here - the issue missing, no
216/// way or several, a resolution nobody named - so that a caller with a second
217/// write to make (a comment) can make it only once the move is certain.
218async 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    // Settled before asking Jira for its transitions: an issue already there
224    // needs no second request to say so.
225    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
260/// Sends a decided move, and writes where the issue went into the inbox.
261///
262/// The local row is brought up to date at once rather than at the next poll.
263/// Otherwise the inbox shows the old status until then - and the poll, seeing
264/// the status change, raises a "status→In Progress" toast about the move the
265/// person has just made themselves.
266async 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
275/// Records `status` as the issue's current one in the local inbox.
276fn 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
285/// Moves `key` along its workflow towards `goal`.
286pub 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
293/// The resolution a close sends, when its screen asks for one.
294///
295/// Jira refuses a close whose screen requires a resolution and gets none, and
296/// there is no default kasl could honestly assume: "Fixed", "Done" and "Won't
297/// Do" all close an issue and mean different things on the team's reports.
298/// One allowed value is no choice at all; several are asked about on a
299/// terminal and named by `--resolution` everywhere else.
300fn 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
337/// Comments on `key`, then closes it.
338///
339/// The move is decided first and the comment sent only when the close is
340/// certain - or already done, since then the note is still what the person
341/// asked to leave. A comment sent ahead of a close that then turned out to
342/// need `--to` would be sent again by the retry: one note, twice, on a shared
343/// issue.
344///
345/// Sent, the comment goes before the transition. A closed issue is often not
346/// editable - `jira.issue.editable = false` on the final status refuses
347/// comments too - so closing first could leave the note with nowhere to go. A
348/// comment that fails stops the close: the person asked for both, and an issue
349/// closed without the explanation they wrote is the half they did not want.
350pub 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
363/// Builds a Jira client from the config, or says why there is none.
364pub 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/// What the offer after finishing a linked task can do in Jira.
373#[derive(Debug, Clone, Copy, PartialEq, Eq)]
374enum Offer {
375    Leave,
376    Close,
377    Comment,
378}
379
380/// Offers to close or comment on the issue a task came from, once the task is done.
381///
382/// Asked only on a terminal, only when Jira is configured, and not when the
383/// inbox already knows the issue was resolved - then the work is done on both
384/// sides and the question would be noise. Leaving it is the default: Enter on
385/// a question about a shared tracker should change nothing in it.
386///
387/// Never fails the caller. The task is already saved when this runs; a Jira
388/// that does not answer is reported and the local edit stands.
389pub 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
441/// Prints what a move came to: success for arriving, a warning for the rest.
442pub 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        // A localized workflow: no English word in sight, and the move still
480        // finds the one transition into the in-progress category.
481        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        // A name outside the move's category does not smuggle in another move.
516        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        // In Review is in progress, but the person asked for In Progress.
525        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}