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