Skip to main content

kasl/api/
jira.rs

1//! Jira client: completed issues for task discovery, assigned open
2//! issues for the inbox poller.
3//!
4//! ```rust,no_run
5//! # use kasl::api::jira::{Jira, JiraConfig};
6//! # use chrono::Local;
7//! # async fn f() -> anyhow::Result<()> {
8//! let config = JiraConfig {
9//!     login: "username".to_string(),
10//!     api_url: "https://jira.company.com".to_string(),
11//!     completed_statuses: Vec::new(),
12//! };
13//!
14//! let mut jira = Jira::new(&config);
15//! let today = Local::now().date_naive();
16//! let issues = jira.get_completed_issues(&today).await?;
17//! # Ok(())
18//! # }
19//! ```
20
21use super::Session;
22use crate::libs::{config::ConfigModule, messages::Message, secret::Secret};
23use crate::msg_print;
24use anyhow::Result;
25use chrono::NaiveDate;
26use dialoguer::{Input, theme::ColorfulTheme};
27use reqwest::{
28    Client, StatusCode,
29    header::{COOKIE, HeaderMap, HeaderValue},
30};
31use serde::{Deserialize, Deserializer, Serialize};
32use serde_json::Value;
33use std::collections::HashMap;
34use std::time::Duration;
35
36const MAX_RETRY_COUNT: i32 = 3;
37
38const SEARCH_PAGE_SIZE: u32 = 100;
39
40const SESSION_ID_FILE: &str = ".jira_session_id";
41
42const SECRET_FILE: &str = ".jira_secret";
43
44const AUTH_URL: &str = "rest/auth/1/session";
45
46const SEARCH_URL: &str = "rest/api/2/search";
47
48const ISSUE_URL: &str = "rest/api/2/issue";
49
50/// How long one Jira request may take before it is abandoned.
51///
52/// The watcher drains toast buttons and polls on the same runtime; a request
53/// that never answers would hold a pressed button, or every poll after it,
54/// forever. Generous, because a page of a hundred issues on a slow Jira is
55/// legitimately slow.
56const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
57
58/// Login pair, held in memory only while authenticating.
59#[derive(Serialize, Clone, Debug)]
60pub struct LoginCredentials {
61    username: String,
62    password: String,
63}
64
65#[derive(Serialize, Deserialize, Debug)]
66struct JiraSessionResponse {
67    session: JiraSession,
68}
69
70/// Session cookie (typically `JSESSIONID`) sent on every API request.
71#[derive(Serialize, Deserialize, Debug)]
72struct JiraSession {
73    name: String,
74    value: String,
75}
76
77/// The slice of a Jira issue kasl works with.
78#[derive(Serialize, Deserialize, Debug)]
79pub struct JiraIssue {
80    /// Numeric issue id assigned by Jira.
81    pub id: String,
82    /// Human-readable key, e.g. "PROJECT-123".
83    pub key: String,
84    pub fields: JiraIssueFields,
85}
86
87/// The fields kasl reads; custom fields land in `extra` via flatten.
88#[derive(Serialize, Deserialize, Debug)]
89pub struct JiraIssueFields {
90    /// Issue title/summary (required field in Jira)
91    pub summary: String,
92    /// Detailed description (may be empty or contain rich text)
93    #[serde(default)]
94    pub description: Option<String>,
95    /// Current workflow status information
96    pub status: JiraStatus,
97    /// Date when the issue was resolved (ISO format if completed)
98    #[serde(default)]
99    pub resolutiondate: Option<String>,
100    /// Issue priority (Highest / High / Medium / …)
101    #[serde(default)]
102    pub priority: Option<JiraPriority>,
103    /// Last update timestamp from Jira (ISO-8601)
104    #[serde(default)]
105    pub updated: Option<String>,
106    /// Issue type (Bug, Story, Epic - or the instance's own words for them)
107    #[serde(default)]
108    pub issuetype: Option<JiraIssueType>,
109    /// The project the issue belongs to
110    #[serde(default)]
111    pub project: Option<JiraProject>,
112    /// Custom and other fields keyed by Jira field id (e.g. `customfield_12345`).
113    #[serde(flatten)]
114    pub extra: HashMap<String, Value>,
115}
116
117/// Workflow status; names vary by configuration and locale.
118#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
119pub struct JiraStatus {
120    /// Stable status id from Jira (preferred for storage / joins)
121    #[serde(default, deserialize_with = "deserialize_jira_id")]
122    pub id: String,
123    /// Status name (e.g., "Done", "In Progress", "Решена" for Russian locale)
124    pub name: String,
125    /// The fixed category the status belongs to, whatever it is called.
126    #[serde(default, rename = "statusCategory", skip_serializing_if = "Option::is_none")]
127    pub category: Option<JiraCategory>,
128}
129
130impl JiraStatus {
131    /// Which of Jira's fixed categories the status is in.
132    pub fn category(&self) -> StatusCategory {
133        self.category
134            .as_ref()
135            .map(|c| StatusCategory::from_key(&c.key))
136            .unwrap_or(StatusCategory::Unknown)
137    }
138}
139
140/// A status category as Jira sends it; only the key is stable.
141#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
142pub struct JiraCategory {
143    /// `new`, `indeterminate` or `done` - the same on every Jira, in every language.
144    pub key: String,
145}
146
147/// Jira's three status categories, which every workflow status maps onto.
148///
149/// Status names are the workflow's own and come translated ("In Progress",
150/// "В работе"); the category keys do not. Anything kasl decides about where an
151/// issue stands is decided on these.
152#[derive(Debug, Clone, Copy, PartialEq, Eq)]
153pub enum StatusCategory {
154    /// `new`: not started.
155    ToDo,
156    /// `indeterminate`: in progress.
157    InProgress,
158    /// `done`: finished, whatever the resolution.
159    Done,
160    /// A key this version does not know, or none sent.
161    Unknown,
162}
163
164impl StatusCategory {
165    /// Reads a category key as Jira sends it.
166    pub fn from_key(key: &str) -> Self {
167        match key {
168            "new" => StatusCategory::ToDo,
169            "indeterminate" => StatusCategory::InProgress,
170            "done" => StatusCategory::Done,
171            _ => StatusCategory::Unknown,
172        }
173    }
174}
175
176/// One way out of an issue's current status, as Jira offers it.
177#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
178pub struct JiraTransition {
179    #[serde(deserialize_with = "deserialize_jira_id")]
180    pub id: String,
181    /// The transition's own name ("Resolve Issue"), which may differ from where it leads.
182    pub name: String,
183    /// The status the issue lands in.
184    pub to: JiraStatus,
185    /// Fields on the transition's screen, keyed by field id (`resolution`).
186    #[serde(default)]
187    pub fields: HashMap<String, JiraTransitionField>,
188}
189
190/// A field on a transition's screen.
191#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
192pub struct JiraTransitionField {
193    #[serde(default)]
194    pub required: bool,
195    #[serde(default, rename = "allowedValues")]
196    pub allowed_values: Vec<JiraNamedValue>,
197}
198
199/// An option of a field with a fixed list, such as a resolution.
200#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
201pub struct JiraNamedValue {
202    #[serde(default, deserialize_with = "deserialize_jira_id")]
203    pub id: String,
204    #[serde(default)]
205    pub name: String,
206}
207
208/// Where an issue stands right now.
209#[derive(Debug, Clone, PartialEq, Eq)]
210pub struct IssueState {
211    pub status: JiraStatus,
212    /// The resolution name when the issue is resolved ("Done", "Won't Fix").
213    pub resolution: Option<String>,
214}
215
216/// Whether a request may prompt for a password to sign in.
217#[derive(Debug, Clone, Copy, PartialEq, Eq)]
218pub enum SessionMode {
219    /// A command on a terminal: a lost session may be renewed by asking.
220    Interactive,
221    /// The watcher or a toast button: only a cached session or a stored
222    /// password, never a prompt nobody would see.
223    Unattended,
224}
225
226/// Accepts Jira ids as JSON string or number (`"3"` / `3`).
227fn deserialize_jira_id<'de, D>(deserializer: D) -> std::result::Result<String, D::Error>
228where
229    D: Deserializer<'de>,
230{
231    let value = Option::<Value>::deserialize(deserializer)?;
232    Ok(match value {
233        Some(Value::String(s)) => s,
234        Some(Value::Number(n)) => n.to_string(),
235        _ => String::new(),
236    })
237}
238
239/// An issue type; only the name is kept, and it is the instance's own word.
240#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
241pub struct JiraIssueType {
242    pub name: String,
243}
244
245/// The project an issue belongs to; the key is what issue keys start with.
246#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
247pub struct JiraProject {
248    pub key: String,
249}
250
251/// Jira issue priority.
252///
253/// Classic Jira uses numeric ids where lower means higher urgency
254/// (e.g. `"1"` = Highest). Used for inbox sorting.
255#[derive(Serialize, Deserialize, Debug, Clone)]
256pub struct JiraPriority {
257    /// Priority display name (e.g. "High", "Highest")
258    pub name: String,
259    /// Numeric priority id as string (lower = more urgent in classic schemes)
260    #[serde(default)]
261    pub id: Option<String>,
262}
263
264/// One page of a JQL search.
265#[derive(Serialize, Deserialize, Debug)]
266pub struct JiraSearchResults {
267    /// Index of the first issue in this page
268    #[serde(default, rename = "startAt")]
269    pub start_at: u32,
270    /// Requested page size
271    #[serde(default, rename = "maxResults")]
272    pub max_results: u32,
273    /// Total matching issues across all pages
274    #[serde(default)]
275    pub total: u32,
276    /// Array of issues matching the search criteria
277    pub issues: Vec<JiraIssue>,
278}
279
280/// Jira client with cached-session management via the [`Session`] trait.
281#[derive(Debug)]
282pub struct Jira {
283    client: Client,
284    config: JiraConfig,
285    /// Held in memory only while authenticating.
286    credentials: Option<LoginCredentials>,
287    retries: i32,
288}
289
290impl Session for Jira {
291    /// Logs in and returns the session cookie as `name=value`.
292    async fn login(&self) -> Result<String> {
293        let credentials = self.credentials.clone().expect("Credentials not set!");
294
295        let auth_url = format!("{}/{}", self.config.api_url, AUTH_URL);
296        let auth_res = self.client.post(auth_url).json(&credentials).send().await?;
297
298        if !auth_res.status().is_success() {
299            anyhow::bail!("Jira authenticate failed")
300        }
301
302        let session_res = auth_res.json::<JiraSessionResponse>().await?;
303
304        let session_id = format!("{}={}", session_res.session.name, session_res.session.value);
305        Ok(session_id)
306    }
307
308    fn set_credentials(&mut self, password: &str) -> Result<()> {
309        self.credentials = Some(LoginCredentials {
310            username: self.config.login.to_string(),
311            password: password.to_owned(),
312        });
313        Ok(())
314    }
315
316    fn session_id_file(&self) -> &str {
317        SESSION_ID_FILE
318    }
319
320    fn secret(&self) -> Secret {
321        Secret::new(SECRET_FILE, "Enter your Jira password")
322    }
323
324    fn retry(&self) -> i32 {
325        self.retries
326    }
327
328    fn inc_retry(&mut self) {
329        self.retries += 1;
330    }
331
332    fn reset_retry(&mut self) {
333        self.retries = 0;
334    }
335}
336
337impl Jira {
338    /// Builds a client from the config; no network activity yet.
339    ///
340    /// ```rust,no_run
341    /// use kasl::api::jira::{Jira, JiraConfig};
342    ///
343    /// let config = JiraConfig {
344    ///     login: "username".to_string(),
345    ///     api_url: "https://jira.company.com".to_string(),
346    ///     completed_statuses: Vec::new(),
347    /// };
348    /// let jira = Jira::new(&config);
349    /// ```
350    pub fn new(config: &JiraConfig) -> Self {
351        let client = Client::builder()
352            .timeout(REQUEST_TIMEOUT)
353            .connect_timeout(Duration::from_secs(10))
354            .build()
355            .unwrap_or_else(|_| Client::new());
356        Self {
357            client,
358            config: config.clone(),
359            credentials: None,
360            retries: 0,
361        }
362    }
363
364    /// Fetches the user's issues resolved on `date` (full-day range).
365    ///
366    /// A 401 drops the cached session and retries up to the limit; other
367    /// failures propagate - completed issues feed the report, so a silent
368    /// empty result would hide real problems.
369    ///
370    /// ```rust,no_run
371    /// # use kasl::api::jira::{Jira, JiraConfig};
372    /// # use chrono::NaiveDate;
373    /// # use anyhow::Result;
374    /// # async fn example() -> Result<()> {
375    /// let config = JiraConfig {
376    ///     login: "username".to_string(),
377    ///     api_url: "https://jira.company.com".to_string(),
378    ///     completed_statuses: Vec::new(),
379    /// };
380    /// let mut jira = Jira::new(&config);
381    ///
382    /// let today = chrono::Local::now().date_naive();
383    /// let issues = jira.get_completed_issues(&today).await?;
384    ///
385    /// for issue in issues {
386    ///     println!("Completed: {} - {}", issue.key, issue.fields.summary);
387    /// }
388    /// # Ok(())
389    /// # }
390    /// ```
391    pub async fn get_completed_issues(&mut self, date: &NaiveDate) -> Result<Vec<JiraIssue>> {
392        let mut local_retries = 0;
393        loop {
394            let session_id = self.get_session_id().await?;
395
396            match self.fetch_completed_pages(&session_id, date).await {
397                Ok(issues) => return Ok(issues),
398                Err(SearchPageError::Unauthorized) if local_retries < MAX_RETRY_COUNT => {
399                    let _ = self.delete_session_id();
400                    local_retries += 1;
401                    tokio::time::sleep(Duration::from_secs(1)).await;
402                }
403                Err(SearchPageError::Unauthorized) => {
404                    anyhow::bail!("Jira session unauthorized after retries")
405                }
406                Err(SearchPageError::Other(msg)) => {
407                    anyhow::bail!("Jira completed-issues search failed: {msg}")
408                }
409            }
410        }
411    }
412
413    /// Fetches all pages of completed issues for a valid session cookie.
414    async fn fetch_completed_pages(&self, session_id: &str, date: &NaiveDate) -> std::result::Result<Vec<JiraIssue>, SearchPageError> {
415        // Filter by resolution date only. Do not use `status in (...)` with English
416        // defaults like "Done"/"Resolved": on localized Jira those names are invalid
417        // and the whole JQL fails (HTTP 400), which used to look like "no issues".
418        let date_str = date.format("%Y-%m-%d").to_string();
419        let jql = format!(
420            "assignee = currentUser() AND resolved >= \"{}\" AND resolved <= \"{} 23:59\"",
421            date_str, date_str
422        );
423        let url = format!("{}/{}", self.config.api_url, SEARCH_URL);
424
425        let mut all = Vec::new();
426        let mut start_at: u32 = 0;
427
428        loop {
429            let mut headers = HeaderMap::new();
430            headers.insert(
431                COOKIE,
432                HeaderValue::from_str(session_id).map_err(|e| SearchPageError::Other(format!("invalid session cookie: {e}")))?,
433            );
434
435            let res = self
436                .client
437                .get(&url)
438                .headers(headers)
439                .query(&[
440                    ("jql", jql.as_str()),
441                    ("fields", "summary,status,priority,updated,resolutiondate"),
442                    ("startAt", &start_at.to_string()),
443                    ("maxResults", &SEARCH_PAGE_SIZE.to_string()),
444                ])
445                .send()
446                .await
447                .map_err(|e| SearchPageError::Other(format!("request failed: {e}")))?;
448
449            if session_is_anonymous(res.headers()) {
450                return Err(SearchPageError::Unauthorized);
451            }
452            match res.status() {
453                StatusCode::UNAUTHORIZED => return Err(SearchPageError::Unauthorized),
454                status if !status.is_success() => {
455                    let body = res.text().await.unwrap_or_default();
456                    return Err(SearchPageError::Other(format!("HTTP {status}: {body}")));
457                }
458                _ => {}
459            }
460
461            let page: JiraSearchResults = res.json().await.map_err(|e| SearchPageError::Other(format!("invalid JSON: {e}")))?;
462            let batch_len = page.issues.len() as u32;
463            all.extend(page.issues);
464
465            start_at += batch_len;
466            if batch_len == 0 || start_at >= page.total {
467                break;
468            }
469        }
470
471        Ok(all)
472    }
473
474    /// Fetches open issues currently assigned to the authenticated user.
475    ///
476    /// Uses JQL `assignee = currentUser() AND resolution is EMPTY`. Paginates
477    /// through all matching issues (`startAt` / `total`). Extra field ids
478    /// (custom fields such as Scoring) are included in the `fields` query.
479    ///
480    /// A poll that fails is an error, never an empty list. The caller
481    /// reconciles the inbox against whatever comes back, so an empty answer
482    /// for a dropped VPN would mark every issue gone - and the next good poll
483    /// would bring all of them "back", one change toast per issue.
484    pub async fn get_assigned_open_issues(&mut self, extra_field_ids: &[String]) -> Result<Vec<JiraIssue>> {
485        let mut local_retries = 0;
486        loop {
487            let session_id = self.get_session_id().await?;
488
489            match self.fetch_assigned_open_pages(&session_id, extra_field_ids).await {
490                Ok(issues) => return Ok(issues),
491                Err(SearchPageError::Unauthorized) if local_retries < MAX_RETRY_COUNT => {
492                    let _ = self.delete_session_id();
493                    local_retries += 1;
494                    tokio::time::sleep(Duration::from_secs(1)).await;
495                }
496                Err(e) => return Err(e.into_poll_error()),
497            }
498        }
499    }
500
501    /// Like [`get_assigned_open_issues`], but never prompts for a password.
502    ///
503    /// Uses a cached session cookie and/or the keyring secret. Returns
504    /// `Ok(None)` when neither is available so background daemons can skip
505    /// the poll without blocking on stdin. A poll that fails is an error, for
506    /// the same reason as in [`get_assigned_open_issues`]: the daemon must
507    /// skip the reconcile, not reconcile against nothing.
508    pub async fn get_assigned_open_issues_noninteractive(&mut self, extra_field_ids: &[String]) -> Result<Option<Vec<JiraIssue>>> {
509        let mut local_retries = 0;
510        loop {
511            let Some(session_id) = self.session_id_noninteractive().await? else {
512                return Ok(None);
513            };
514
515            match self.fetch_assigned_open_pages(&session_id, extra_field_ids).await {
516                Ok(issues) => return Ok(Some(issues)),
517                Err(SearchPageError::Unauthorized) if local_retries < MAX_RETRY_COUNT => {
518                    let _ = self.delete_session_id();
519                    local_retries += 1;
520                    tokio::time::sleep(Duration::from_secs(1)).await;
521                }
522                Err(e) => return Err(e.into_poll_error()),
523            }
524        }
525    }
526
527    /// Fetches all pages of assigned open issues for a valid session cookie.
528    ///
529    /// The order is by key, not by priority or update time: pages are cut by
530    /// offset, and an order that an ordinary edit can reshuffle moves issues
531    /// across a page boundary while the pages are read - one issue skipped is
532    /// one issue marked gone, then toasted "back" a poll later. Key order only
533    /// moves when an issue is created or resolved, and that case is caught by
534    /// [`check_complete`]. The inbox sorts on its own, so nothing here reads
535    /// this order.
536    async fn fetch_assigned_open_pages(&self, session_id: &str, extra_field_ids: &[String]) -> std::result::Result<Vec<JiraIssue>, SearchPageError> {
537        let jql = "assignee = currentUser() AND resolution is EMPTY ORDER BY key ASC";
538        let fields = build_search_fields(extra_field_ids);
539        let url = format!("{}/{}", self.config.api_url, SEARCH_URL);
540
541        let mut all: Vec<JiraIssue> = Vec::new();
542        let mut start_at: u32 = 0;
543        let mut first_total: Option<u32> = None;
544
545        loop {
546            let mut headers = HeaderMap::new();
547            headers.insert(
548                COOKIE,
549                HeaderValue::from_str(session_id).map_err(|e| SearchPageError::Other(format!("invalid session cookie: {e}")))?,
550            );
551
552            let res = self
553                .client
554                .get(&url)
555                .headers(headers)
556                .query(&[
557                    ("jql", jql),
558                    ("fields", fields.as_str()),
559                    ("startAt", &start_at.to_string()),
560                    ("maxResults", &SEARCH_PAGE_SIZE.to_string()),
561                ])
562                .send()
563                .await
564                .map_err(|e| SearchPageError::Other(format!("request failed: {e}")))?;
565
566            if session_is_anonymous(res.headers()) {
567                return Err(SearchPageError::Unauthorized);
568            }
569            match res.status() {
570                StatusCode::UNAUTHORIZED => return Err(SearchPageError::Unauthorized),
571                status if !status.is_success() => {
572                    let body = res.text().await.unwrap_or_default();
573                    return Err(SearchPageError::Other(format!("HTTP {status}: {body}")));
574                }
575                _ => {}
576            }
577
578            let page: JiraSearchResults = res.json().await.map_err(|e| SearchPageError::Other(format!("invalid JSON: {e}")))?;
579            let batch_len = page.issues.len() as u32;
580            first_total.get_or_insert(page.total);
581            all.extend(page.issues);
582
583            start_at += batch_len;
584            if batch_len == 0 || start_at >= page.total {
585                break;
586            }
587        }
588
589        check_complete(all, first_total.unwrap_or(0))
590    }
591
592    /// Resolves a session from cache / secret without prompting.
593    async fn session_id_noninteractive(&mut self) -> Result<Option<String>> {
594        let session_id_file_path = crate::libs::data_storage::DataStorage::new().get_path(SESSION_ID_FILE)?;
595        let path_str = session_id_file_path.to_str().unwrap_or_default();
596
597        if let Ok(session_id) = Self::read_session_id(path_str) {
598            return Ok(Some(session_id));
599        }
600
601        let Some(password) = self.secret().try_get_cached() else {
602            return Ok(None);
603        };
604
605        self.set_credentials(&password)?;
606        match self.login().await {
607            Ok(session_id) => {
608                let _ = Self::write_session_id(path_str, &session_id);
609                self.reset_retry();
610                Ok(Some(session_id))
611            }
612            Err(_) => Ok(None),
613        }
614    }
615
616    /// A session for `mode`, or an error naming how to get one.
617    async fn session(&mut self, mode: SessionMode) -> Result<String> {
618        match mode {
619            SessionMode::Interactive => self.get_session_id().await,
620            SessionMode::Unattended => self
621                .session_id_noninteractive()
622                .await?
623                .ok_or_else(|| anyhow::anyhow!("not signed in to Jira; run `kasl inbox sync` on a terminal to sign in")),
624        }
625    }
626
627    /// Sends one request with the session cookie, renewing a session Jira no longer honours.
628    ///
629    /// A session Jira has dropped answers with 401, or - on Server and Data
630    /// Center - as the anonymous user (see [`session_is_anonymous`]). Either
631    /// way the cached cookie is thrown away and the request goes again with a
632    /// fresh login, a bounded number of times. Everything else is the caller's
633    /// to read, including errors: what a 404 means depends on the question.
634    async fn send(&mut self, mode: SessionMode, build: impl Fn(&Client) -> reqwest::RequestBuilder) -> Result<reqwest::Response> {
635        let mut retries = 0;
636        loop {
637            let session_id = self.session(mode).await?;
638            let res = build(&self.client).header(COOKIE, HeaderValue::from_str(&session_id)?).send().await?;
639            let rejected = res.status() == StatusCode::UNAUTHORIZED || session_is_anonymous(res.headers());
640            if !rejected {
641                return Ok(res);
642            }
643            if retries >= MAX_RETRY_COUNT {
644                anyhow::bail!("Jira rejected the session {MAX_RETRY_COUNT} times; run `kasl inbox sync` to sign in again");
645            }
646            let _ = self.delete_session_id();
647            retries += 1;
648            tokio::time::sleep(Duration::from_secs(1)).await;
649        }
650    }
651
652    fn issue_url(&self, key: &str, tail: &str) -> String {
653        format!("{}/{}/{}{}", self.config.api_url.trim_end_matches('/'), ISSUE_URL, key, tail)
654    }
655
656    /// Where `key` stands: its status with category, and its resolution if any.
657    ///
658    /// `None` when Jira answers 404 - the issue was deleted, moved out of
659    /// reach, or is not visible to this account. All three mean the same to
660    /// kasl: there is nothing there to act on.
661    pub async fn issue_state(&mut self, key: &str, mode: SessionMode) -> Result<Option<IssueState>> {
662        let url = self.issue_url(key, "");
663        let res = self.send(mode, |c| c.get(&url).query(&[("fields", "status,resolution")])).await?;
664        if res.status() == StatusCode::NOT_FOUND {
665            return Ok(None);
666        }
667        let res = ok_or_jira_error(res, &format!("reading {key}")).await?;
668
669        #[derive(Deserialize)]
670        struct Body {
671            fields: Fields,
672        }
673        #[derive(Deserialize)]
674        struct Fields {
675            status: JiraStatus,
676            #[serde(default)]
677            resolution: Option<JiraNamedValue>,
678        }
679        let body: Body = res.json().await?;
680        Ok(Some(IssueState {
681            status: body.fields.status,
682            resolution: body.fields.resolution.map(|r| r.name),
683        }))
684    }
685
686    /// The transitions Jira offers out of the issue's current status, with their screens' fields.
687    pub async fn transitions(&mut self, key: &str, mode: SessionMode) -> Result<Vec<JiraTransition>> {
688        let url = self.issue_url(key, "/transitions");
689        let res = self.send(mode, |c| c.get(&url).query(&[("expand", "transitions.fields")])).await?;
690        let res = ok_or_jira_error(res, &format!("reading the transitions of {key}")).await?;
691
692        #[derive(Deserialize)]
693        struct Body {
694            #[serde(default)]
695            transitions: Vec<JiraTransition>,
696        }
697        Ok(res.json::<Body>().await?.transitions)
698    }
699
700    /// Performs transition `id` on `key`, with a resolution when its screen asks for one.
701    pub async fn transition(&mut self, key: &str, id: &str, resolution_id: Option<&str>, mode: SessionMode) -> Result<()> {
702        let url = self.issue_url(key, "/transitions");
703        let mut body = serde_json::json!({ "transition": { "id": id } });
704        if let Some(resolution) = resolution_id {
705            body["fields"] = serde_json::json!({ "resolution": { "id": resolution } });
706        }
707        let res = self.send(mode, |c| c.post(&url).json(&body)).await?;
708        ok_or_jira_error(res, &format!("moving {key}")).await?;
709        Ok(())
710    }
711
712    /// Adds a comment to `key`.
713    pub async fn add_comment(&mut self, key: &str, text: &str, mode: SessionMode) -> Result<()> {
714        let url = self.issue_url(key, "/comment");
715        let body = serde_json::json!({ "body": text });
716        let res = self.send(mode, |c| c.post(&url).json(&body)).await?;
717        ok_or_jira_error(res, &format!("commenting on {key}")).await?;
718        Ok(())
719    }
720
721    /// Builds the issue-navigator URL listing the user's open issues.
722    ///
723    /// Summary toasts point here: they speak for many issues at once, so no
724    /// single browse URL fits.
725    pub fn open_issues_url(&self) -> String {
726        let base = self.config.api_url.trim_end_matches('/');
727        format!("{base}/issues/?jql=assignee%20%3D%20currentUser()%20AND%20resolution%20is%20EMPTY")
728    }
729
730    /// Builds a browse URL for an issue key using this client's API base.
731    pub fn issue_browse_url(&self, key: &str) -> String {
732        let base = self.config.api_url.trim_end_matches('/');
733        format!("{}/browse/{}", base, key)
734    }
735
736    /// Maps a Jira priority id to a sortable rank (lower = more urgent).
737    pub fn priority_rank(priority: &Option<JiraPriority>) -> i32 {
738        priority
739            .as_ref()
740            .and_then(|p| p.id.as_ref())
741            .and_then(|id| id.parse::<i32>().ok())
742            .unwrap_or(999)
743    }
744
745    /// Extracts a numeric value from a Jira custom-field JSON value.
746    ///
747    /// Supports bare numbers, numeric strings, and objects with `value` / `amount`.
748    pub fn extract_number(value: &Value) -> Option<f64> {
749        match value {
750            Value::Number(n) => n.as_f64(),
751            Value::String(s) => s.trim().parse().ok(),
752            Value::Object(map) => map
753                .get("value")
754                .and_then(Self::extract_number)
755                .or_else(|| map.get("amount").and_then(Self::extract_number)),
756            _ => None,
757        }
758    }
759
760    /// Reads a numeric custom field from issue extras by field id.
761    pub fn sort_value_from_issue(issue: &JiraIssue, field_id: &str) -> Option<f64> {
762        issue.fields.extra.get(field_id).and_then(Self::extract_number)
763    }
764}
765
766/// Internal error for paginated search (auth vs other failures).
767enum SearchPageError {
768    Unauthorized,
769    Other(String),
770}
771
772impl SearchPageError {
773    /// Names what went wrong with an inbox poll, and what fixes it.
774    fn into_poll_error(self) -> anyhow::Error {
775        match self {
776            SearchPageError::Unauthorized => {
777                anyhow::anyhow!("Jira rejected the session {MAX_RETRY_COUNT} times; run `kasl inbox sync` to sign in again")
778            }
779            SearchPageError::Other(msg) => anyhow::anyhow!("Jira inbox poll failed: {msg}"),
780        }
781    }
782}
783
784/// Whether Jira answered as nobody: the cookie was sent but the session behind it is gone.
785///
786/// Jira Server and Data Center do not always answer an expired session with
787/// 401. The request goes through as the anonymous user, with 200, and
788/// `currentUser()` in the JQL then matches nothing. Read as an answer, that
789/// empty page marks the whole inbox gone overnight, and the first poll with
790/// a fresh session brings every issue "back". Jira says so in headers:
791/// `X-AUSERNAME: anonymous`, and `X-Seraph-LoginReason` when the cookie it
792/// was given failed.
793fn session_is_anonymous(headers: &HeaderMap) -> bool {
794    let header = |name: &str| headers.get(name).and_then(|v| v.to_str().ok()).unwrap_or_default();
795    let reason = header("x-seraph-loginreason");
796    header("x-ausername").eq_ignore_ascii_case("anonymous") || reason.contains("AUTHENTICATED_FAILED") || reason.contains("AUTHENTICATION_DENIED")
797}
798
799/// Passes a successful response through, or turns Jira's refusal into words.
800///
801/// Jira explains a refused write in `errorMessages` and per-field `errors` -
802/// "Resolution is required", "You do not have permission to transition this
803/// issue". Those sentences are the useful part; the status code alone tells
804/// the person nothing they can act on.
805async fn ok_or_jira_error(res: reqwest::Response, what: &str) -> Result<reqwest::Response> {
806    let status = res.status();
807    if status.is_success() {
808        return Ok(res);
809    }
810    let body = res.text().await.unwrap_or_default();
811    anyhow::bail!("Jira refused {what} (HTTP {}): {}", status.as_u16(), jira_error_text(&body))
812}
813
814/// The human part of a Jira error body, or the body itself when it has none.
815pub fn jira_error_text(body: &str) -> String {
816    #[derive(Deserialize, Default)]
817    struct ErrorBody {
818        #[serde(default, rename = "errorMessages")]
819        messages: Vec<String>,
820        #[serde(default)]
821        errors: HashMap<String, String>,
822    }
823    let parsed: ErrorBody = serde_json::from_str(body).unwrap_or_default();
824    let mut parts = parsed.messages;
825    let mut fields: Vec<(String, String)> = parsed.errors.into_iter().collect();
826    fields.sort();
827    parts.extend(fields.into_iter().map(|(field, message)| format!("{field}: {message}")));
828    if parts.is_empty() {
829        let trimmed = body.trim();
830        return if trimmed.is_empty() {
831            "no explanation given".to_string()
832        } else {
833            trimmed.chars().take(300).collect()
834        };
835    }
836    parts.join("; ")
837}
838
839/// Keeps a paged search only if it holds every issue Jira counted.
840///
841/// Pages are cut by offset, so an issue created or resolved while they are
842/// read shifts the rest by one, and one issue falls between two pages. Such
843/// a list is short by an issue that is still open, and the sync would mark
844/// it gone. A poll is either the whole inbox or not an answer; the next poll
845/// a few minutes later reads a list that holds still. More than counted is
846/// fine - an issue read twice is still one issue, and a new one is simply
847/// early - so duplicates are folded and only a shortfall fails.
848fn check_complete(issues: Vec<JiraIssue>, total: u32) -> std::result::Result<Vec<JiraIssue>, SearchPageError> {
849    let mut seen = std::collections::HashSet::new();
850    let unique: Vec<JiraIssue> = issues.into_iter().filter(|issue| seen.insert(issue.key.clone())).collect();
851    if (unique.len() as u64) < u64::from(total) {
852        return Err(SearchPageError::Other(format!(
853            "the pages held {} of {} issues; the list changed while it was read",
854            unique.len(),
855            total
856        )));
857    }
858    Ok(unique)
859}
860
861fn build_search_fields(extra_field_ids: &[String]) -> String {
862    let mut fields = vec![
863        "summary".to_string(),
864        "status".to_string(),
865        "priority".to_string(),
866        "updated".to_string(),
867        "issuetype".to_string(),
868        "project".to_string(),
869    ];
870    for id in extra_field_ids {
871        let trimmed = id.trim();
872        if !trimmed.is_empty() && !fields.iter().any(|f| f == trimmed) {
873            fields.push(trimmed.to_string());
874        }
875    }
876    fields.join(",")
877}
878
879#[cfg(test)]
880mod tests {
881    use super::*;
882    use serde_json::json;
883
884    #[test]
885    fn extract_number_from_primitives_and_objects() {
886        assert_eq!(Jira::extract_number(&json!(12.5)), Some(12.5));
887        assert_eq!(Jira::extract_number(&json!("42")), Some(42.0));
888        assert_eq!(Jira::extract_number(&json!({"value": 7})), Some(7.0));
889        assert_eq!(Jira::extract_number(&json!({"amount": "3.5"})), Some(3.5));
890        assert_eq!(Jira::extract_number(&json!(null)), None);
891    }
892
893    #[test]
894    fn a_status_is_placed_by_its_category_key_not_its_name() {
895        let status: JiraStatus = serde_json::from_value(json!({
896            "id": 3, "name": "В работе", "statusCategory": {"id": 4, "key": "indeterminate", "name": "В работе"}
897        }))
898        .unwrap();
899        assert_eq!(status.category(), StatusCategory::InProgress);
900
901        let bare: JiraStatus = serde_json::from_value(json!({"id": "1", "name": "Open"})).unwrap();
902        assert_eq!(bare.category(), StatusCategory::Unknown, "no category sent is unknown, not to-do");
903    }
904
905    #[test]
906    fn a_jira_refusal_reads_as_its_own_sentences() {
907        let body = r#"{"errorMessages":["You do not have permission to transition this issue."],"errors":{"resolution":"Resolution is required."}}"#;
908        assert_eq!(
909            jira_error_text(body),
910            "You do not have permission to transition this issue.; resolution: Resolution is required."
911        );
912        assert_eq!(jira_error_text("<html>Bad gateway</html>"), "<html>Bad gateway</html>");
913        assert_eq!(jira_error_text(""), "no explanation given");
914    }
915
916    #[test]
917    fn build_search_fields_includes_custom_ids() {
918        let fields = build_search_fields(&["customfield_10001".to_string(), "summary".to_string()]);
919        assert!(fields.contains("summary"));
920        assert!(fields.contains("customfield_10001"));
921        assert_eq!(fields.matches("summary").count(), 1);
922    }
923}
924
925/// Jira connection settings; passwords are never stored here.
926#[derive(Serialize, Deserialize, Clone, Debug)]
927pub struct JiraConfig {
928    /// Username (email for Atlassian Cloud accounts).
929    pub login: String,
930
931    /// Instance root URL, without the `/rest/api/` path.
932    pub api_url: String,
933
934    /// Deprecated: previously used in `status in (...)` for completed-issue search.
935    ///
936    /// Kept for config compatibility. Discovery now filters by `resolved` date only,
937    /// because non-existent status names (e.g. English "Done" on a Russian Jira)
938    /// make the whole JQL fail.
939    #[serde(default = "default_completed_statuses")]
940    pub completed_statuses: Vec<String>,
941}
942
943/// Empty default — status names are no longer used in completed-issue JQL.
944fn default_completed_statuses() -> Vec<String> {
945    Vec::new()
946}
947
948impl JiraConfig {
949    /// Module metadata for the setup wizard.
950    pub fn module() -> ConfigModule {
951        ConfigModule {
952            key: "jira".to_string(),
953            name: "Jira".to_string(),
954        }
955    }
956
957    /// Interactive setup; existing values become the prompt defaults.
958    ///
959    /// ```rust,no_run
960    /// # use kasl::api::JiraConfig;
961    /// # use anyhow::Result;
962    /// # fn example() -> Result<()> {
963    /// let existing_config = Some(JiraConfig {
964    ///     login: "olduser".to_string(),
965    ///     api_url: "https://old-jira.com".to_string(),
966    ///     completed_statuses: Vec::new(),
967    /// });
968    ///
969    /// let new_config = JiraConfig::init(&existing_config)?;
970    /// # Ok(())
971    /// # }
972    /// ```
973    pub fn init(config: &Option<Self>) -> Result<Self> {
974        // Use existing configuration as defaults, or create empty defaults
975        let config = config.clone().unwrap_or(Self {
976            login: "".to_string(),
977            api_url: "".to_string(),
978            completed_statuses: default_completed_statuses(),
979        });
980
981        // Display configuration module header
982        msg_print!(Message::ConfigModuleJira);
983
984        // Interactive configuration with existing values as defaults
985        Ok(Self {
986            completed_statuses: config.completed_statuses.clone(),
987            login: Input::with_theme(&ColorfulTheme::default())
988                .with_prompt("Enter your Jira login")
989                .default(config.login)
990                .interact_text()?,
991            api_url: Input::with_theme(&ColorfulTheme::default())
992                .with_prompt("Enter the Jira API URL")
993                .default(config.api_url)
994                .interact_text()?,
995        })
996    }
997}