Skip to main content

kasl/api/
jira.rs

1//! Jira API integration for issue tracking and task synchronization.
2//!
3//! Provides functionality to connect to Jira instances and retrieve completed
4//! issues for automatic task generation and time tracking integration.
5//!
6//! ## Features
7//!
8//! - **Issue Retrieval**: Fetch completed issues for specific dates
9//! - **Session Management**: Automatic login and session token caching
10//! - **Error Recovery**: Robust retry logic for authentication failures
11//! - **JQL Integration**: Flexible issue querying using Jira Query Language
12//!
13//! ## Usage
14//!
15//! ```rust,no_run
16//! # use kasl::api::jira::{Jira, JiraConfig};
17//! # use chrono::Local;
18//! # async fn f() -> anyhow::Result<()> {
19//! let config = JiraConfig {
20//!     login: "username".to_string(),
21//!     api_url: "https://jira.company.com".to_string(),
22//!     completed_statuses: Vec::new(),
23//! };
24//!
25//! let mut jira = Jira::new(&config);
26//! let today = Local::now().date_naive();
27//! let issues = jira.get_completed_issues(&today).await?;
28//! # Ok(())
29//! # }
30//! ```
31
32use super::Session;
33use crate::libs::{config::ConfigModule, messages::Message, secret::Secret};
34use crate::msg_print;
35use anyhow::Result;
36use chrono::NaiveDate;
37use dialoguer::{Input, theme::ColorfulTheme};
38use reqwest::{
39    Client, StatusCode,
40    header::{COOKIE, HeaderMap, HeaderValue},
41};
42use serde::{Deserialize, Deserializer, Serialize};
43use serde_json::Value;
44use std::collections::HashMap;
45use std::time::Duration;
46
47/// Maximum number of authentication retries before giving up.
48/// This prevents infinite loops when credentials are consistently invalid.
49const MAX_RETRY_COUNT: i32 = 3;
50
51/// Page size for Jira issue search pagination.
52const SEARCH_PAGE_SIZE: u32 = 100;
53
54/// Filename for storing Jira session tokens in the user data directory.
55const SESSION_ID_FILE: &str = ".jira_session_id";
56
57/// Filename for storing encrypted Jira credentials for password caching.
58const SECRET_FILE: &str = ".jira_secret";
59
60/// Jira REST API endpoint for session-based authentication.
61const AUTH_URL: &str = "rest/auth/1/session";
62
63/// Jira REST API endpoint for issue searching using JQL queries.
64const SEARCH_URL: &str = "rest/api/2/search";
65
66/// User credentials for Jira authentication.
67///
68/// This structure holds the login information required for establishing
69/// a session with the Jira API. Credentials are only held in memory
70/// during the authentication process and are never persisted to disk.
71///
72/// ## Security Considerations
73///
74/// - Passwords are stored in plain text only during authentication
75/// - Credentials are cleared from memory after session establishment
76/// - No persistence to avoid credential theft from configuration files
77#[derive(Serialize, Clone, Debug)]
78pub struct LoginCredentials {
79    /// Jira username (not email address unless configured as such)
80    username: String,
81    /// User password in plain text (only during auth process)
82    password: String,
83}
84
85/// Response structure for Jira session authentication.
86///
87/// Contains the session information returned by Jira after successful
88/// authentication, including the session cookie name and value that
89/// must be used in subsequent API requests.
90#[derive(Serialize, Deserialize, Debug)]
91struct JiraSessionResponse {
92    /// Session object containing cookie information
93    session: JiraSession,
94}
95
96/// Jira session cookie information.
97///
98/// Represents the session cookie that must be included in subsequent
99/// API requests to authenticate the user. This cookie typically expires
100/// after a period of inactivity or when explicitly invalidated.
101#[derive(Serialize, Deserialize, Debug)]
102struct JiraSession {
103    /// Cookie name (typically "JSESSIONID" for server instances)
104    name: String,
105    /// Cookie value (the actual session token)
106    value: String,
107}
108
109/// Represents a Jira issue with essential fields for task creation.
110///
111/// This structure contains the core information needed to create tasks
112/// from Jira issues, focusing on identification and descriptive content
113/// rather than the full complexity of Jira's data model.
114#[derive(Serialize, Deserialize, Debug)]
115pub struct JiraIssue {
116    /// Unique issue identifier assigned by Jira (numeric)
117    pub id: String,
118    /// Human-readable issue key (e.g., "PROJECT-123")
119    pub key: String,
120    /// Issue fields containing detailed information
121    pub fields: JiraIssueFields,
122}
123
124/// Detailed fields from a Jira issue.
125///
126/// Contains the descriptive and status information from issues that
127/// is relevant for task creation and tracking. This represents a subset
128/// of Jira's extensive field system, focusing on essential data.
129/// Unknown / custom fields are captured in [`extra`] via serde flatten.
130#[derive(Serialize, Deserialize, Debug)]
131pub struct JiraIssueFields {
132    /// Issue title/summary (required field in Jira)
133    pub summary: String,
134    /// Detailed description (may be empty or contain rich text)
135    #[serde(default)]
136    pub description: Option<String>,
137    /// Current workflow status information
138    pub status: JiraStatus,
139    /// Date when the issue was resolved (ISO format if completed)
140    #[serde(default)]
141    pub resolutiondate: Option<String>,
142    /// Issue priority (Highest / High / Medium / …)
143    #[serde(default)]
144    pub priority: Option<JiraPriority>,
145    /// Last update timestamp from Jira (ISO-8601)
146    #[serde(default)]
147    pub updated: Option<String>,
148    /// Custom and other fields keyed by Jira field id (e.g. `customfield_12345`).
149    #[serde(flatten)]
150    pub extra: HashMap<String, Value>,
151}
152
153/// Jira issue status information.
154///
155/// Represents the current workflow status of an issue, used for filtering
156/// completed vs. in-progress work. Status names vary by Jira configuration
157/// and localization settings.
158#[derive(Serialize, Deserialize, Debug, Clone)]
159pub struct JiraStatus {
160    /// Stable status id from Jira (preferred for storage / joins)
161    #[serde(default, deserialize_with = "deserialize_jira_id")]
162    pub id: String,
163    /// Status name (e.g., "Done", "In Progress", "Решена" for Russian locale)
164    pub name: String,
165}
166
167/// Accepts Jira ids as JSON string or number (`"3"` / `3`).
168fn deserialize_jira_id<'de, D>(deserializer: D) -> std::result::Result<String, D::Error>
169where
170    D: Deserializer<'de>,
171{
172    let value = Option::<Value>::deserialize(deserializer)?;
173    Ok(match value {
174        Some(Value::String(s)) => s,
175        Some(Value::Number(n)) => n.to_string(),
176        _ => String::new(),
177    })
178}
179
180/// Jira issue priority.
181///
182/// Classic Jira uses numeric ids where lower means higher urgency
183/// (e.g. `"1"` = Highest). Used for inbox sorting.
184#[derive(Serialize, Deserialize, Debug, Clone)]
185pub struct JiraPriority {
186    /// Priority display name (e.g. "High", "Highest")
187    pub name: String,
188    /// Numeric priority id as string (lower = more urgent in classic schemes)
189    #[serde(default)]
190    pub id: Option<String>,
191}
192
193/// Response structure for Jira issue search queries.
194///
195/// Contains the results of JQL (Jira Query Language) searches,
196/// including the matching issues and pagination information.
197#[derive(Serialize, Deserialize, Debug)]
198pub struct JiraSearchResults {
199    /// Index of the first issue in this page
200    #[serde(default, rename = "startAt")]
201    pub start_at: u32,
202    /// Requested page size
203    #[serde(default, rename = "maxResults")]
204    pub max_results: u32,
205    /// Total matching issues across all pages
206    #[serde(default)]
207    pub total: u32,
208    /// Array of issues matching the search criteria
209    pub issues: Vec<JiraIssue>,
210}
211
212/// Jira API client with session management capabilities.
213///
214/// This client handles authentication, session caching, and issue retrieval
215/// from Jira instances. It implements the [`Session`] trait for automatic
216/// credential management and retry logic.
217///
218/// ## Thread Safety
219///
220/// The client is not thread-safe due to mutable retry state. Each thread
221/// should use its own client instance for concurrent operations.
222///
223/// ## Session Lifecycle
224///
225/// 1. **Initialization**: Client created with configuration
226/// 2. **Authentication**: Credentials prompted when first needed
227/// 3. **Session Caching**: Successful sessions stored for reuse
228/// 4. **Automatic Retry**: Expired sessions trigger re-authentication
229/// 5. **Error Handling**: Persistent failures return empty results
230#[derive(Debug)]
231pub struct Jira {
232    /// HTTP client for making API requests with connection pooling
233    client: Client,
234    /// Configuration containing API endpoint and user information
235    config: JiraConfig,
236    /// In-memory storage for authentication credentials during auth process
237    credentials: Option<LoginCredentials>,
238    /// Counter for tracking authentication retry attempts
239    retries: i32,
240}
241
242impl Session for Jira {
243    /// Performs session-based authentication with Jira.
244    ///
245    /// This method implements Jira's session authentication flow using the
246    /// REST API. It sends user credentials to the authentication endpoint
247    /// and receives a session cookie that can be used for subsequent requests.
248    ///
249    /// ## Authentication Process
250    ///
251    /// 1. **Credential Validation**: Ensures credentials are set before proceeding
252    /// 2. **HTTP Request**: POST to the session authentication endpoint with JSON credentials
253    /// 3. **Response Validation**: Checks for successful HTTP status codes
254    /// 4. **Cookie Extraction**: Parses session information from the response
255    /// 5. **Format Preparation**: Creates properly formatted cookie string for headers
256    ///
257    /// ## Session Cookie Format
258    ///
259    /// The returned session ID is formatted as `{cookie_name}={cookie_value}` and
260    /// should be included in the `Cookie` header of subsequent API requests.
261    ///
262    /// # Returns
263    ///
264    /// Returns a formatted session cookie string on successful authentication.
265    ///
266    /// # Errors
267    ///
268    /// Returns an error if:
269    /// - No credentials have been set (programming error)
270    /// - HTTP request fails due to network issues
271    /// - Credentials are invalid (401 response)
272    /// - Jira returns an unexpected response format
273    /// - Session parsing fails
274    async fn login(&self) -> Result<String> {
275        // Ensure credentials are available for authentication
276        let credentials = self.credentials.clone().expect("Credentials not set!");
277
278        // Build authentication endpoint URL
279        let auth_url = format!("{}/{}", self.config.api_url, AUTH_URL);
280
281        // Send authentication request with JSON credentials
282        let auth_res = self.client.post(auth_url).json(&credentials).send().await?;
283
284        // Validate response status
285        if !auth_res.status().is_success() {
286            anyhow::bail!("Jira authenticate failed")
287        }
288
289        // Parse session information from response
290        let session_res = auth_res.json::<JiraSessionResponse>().await?;
291
292        // Format session cookie for use in subsequent requests
293        let session_id = format!("{}={}", session_res.session.name, session_res.session.value);
294        Ok(session_id)
295    }
296
297    /// Sets user credentials for Jira authentication.
298    ///
299    /// Stores the provided username and password in memory for use during
300    /// the authentication process. This method is called by the session
301    /// management system when credentials are needed.
302    ///
303    /// ## Security Notes
304    ///
305    /// - Credentials are only stored in memory temporarily
306    /// - Password is stored in plain text for authentication
307    /// - No persistence to disk or configuration files
308    /// - Credentials are cleared after successful authentication
309    ///
310    /// # Arguments
311    ///
312    /// * `password` - The user's Jira password in plain text
313    ///
314    /// # Returns
315    ///
316    /// Always returns `Ok(())` as this operation cannot fail.
317    fn set_credentials(&mut self, password: &str) -> Result<()> {
318        self.credentials = Some(LoginCredentials {
319            username: self.config.login.to_string(),
320            password: password.to_owned(),
321        });
322        Ok(())
323    }
324
325    /// Returns the filename for storing Jira session tokens.
326    ///
327    /// The session file is stored in the user's application data directory
328    /// and contains the cached session token for automatic login restoration.
329    fn session_id_file(&self) -> &str {
330        SESSION_ID_FILE
331    }
332
333    /// Returns a configured Secret instance for secure password prompting.
334    ///
335    /// The Secret manager handles secure password input with hidden characters
336    /// and optional encrypted caching in the user's data directory.
337    ///
338    /// # Returns
339    ///
340    /// A configured `Secret` instance with Jira-specific prompts and file names.
341    fn secret(&self) -> Secret {
342        Secret::new(SECRET_FILE, "Enter your Jira password")
343    }
344
345    /// Returns the current authentication retry count.
346    ///
347    /// Used by the session management system to track failed authentication
348    /// attempts and implement retry limits.
349    fn retry(&self) -> i32 {
350        self.retries
351    }
352
353    /// Increments the authentication retry counter.
354    ///
355    /// Called after each failed authentication attempt to track progress
356    /// toward the maximum retry limit defined in the session management system.
357    fn inc_retry(&mut self) {
358        self.retries += 1;
359    }
360
361    /// Resets the authentication retry counter to zero.
362    ///
363    /// Called after successful authentication to ensure future session
364    /// requests start with a clean slate.
365    fn reset_retry(&mut self) {
366        self.retries = 0;
367    }
368}
369
370impl Jira {
371    /// Creates a new Jira API client instance.
372    ///
373    /// Initializes the HTTP client with default settings suitable for Jira API
374    /// interactions. The client is configured for JSON requests and includes
375    /// appropriate timeout and connection settings.
376    ///
377    /// # Arguments
378    ///
379    /// * `config` - Configuration containing Jira URL and login information
380    ///
381    /// # Examples
382    ///
383    /// ```rust,no_run
384    /// use kasl::api::jira::{Jira, JiraConfig};
385    ///
386    /// let config = JiraConfig {
387    ///     login: "username".to_string(),
388    ///     api_url: "https://jira.company.com".to_string(),
389    ///     completed_statuses: Vec::new(),
390    /// };
391    /// let jira = Jira::new(&config);
392    /// ```
393    pub fn new(config: &JiraConfig) -> Self {
394        Self {
395            client: Client::new(),
396            config: config.clone(),
397            credentials: None,
398            retries: 0,
399        }
400    }
401
402    /// Retrieves all issues completed by the current user on a specific date.
403    ///
404    /// This method performs a sophisticated issue search using JQL (Jira Query Language)
405    /// to find issues that were marked as completed on the specified date. The search
406    /// includes robust error handling and automatic session management with retry logic.
407    ///
408    /// ## JQL Query Details
409    ///
410    /// The search uses the following criteria:
411    /// - **Resolution Date**: Issues resolved within the full day range (00:00 to 23:59)
412    /// - **Assignee Filter**: Only issues assigned to the current user (`currentUser()`)
413    ///
414    /// ## Session Management
415    ///
416    /// The method implements sophisticated session handling:
417    /// 1. **Session Retrieval**: Get or create a valid session token using the Session trait
418    /// 2. **API Request**: Execute the JQL search with session cookie authentication
419    /// 3. **Error Handling**: Detect HTTP 401 (Unauthorized) responses indicating expired sessions
420    /// 4. **Automatic Retry**: Clear cached session and retry authentication up to the limit
421    /// 5. **Graceful Degradation**: Return empty results on persistent authentication failures
422    ///
423    /// ## Error Recovery Strategy
424    ///
425    /// Unlike other API integrations, Jira errors are allowed to propagate rather
426    /// than returning empty results silently. This is because Jira data is typically more
427    /// critical for work tracking, and users should be aware of connection issues.
428    ///
429    /// However, authentication failures are handled gracefully with automatic
430    /// retry logic and eventual fallback to empty results after exhausting retries.
431    ///
432    /// ## Date Handling
433    ///
434    /// The method formats the provided date to ensure proper JQL syntax and
435    /// covers the entire day from midnight to 23:59 to capture all possible
436    /// resolution times within the target date.
437    ///
438    /// # Arguments
439    ///
440    /// * `date` - The date to search for completed issues (in any timezone)
441    ///
442    /// # Returns
443    ///
444    /// Returns a vector of [`JiraIssue`] objects representing completed work.
445    /// Returns an empty vector if:
446    /// - No issues are found matching the criteria
447    /// - Authentication fails persistently after all retries
448    /// - Network errors occur during the request
449    ///
450    /// # Errors
451    ///
452    /// May return errors for:
453    /// - JSON parsing failures in API responses
454    /// - Unexpected HTTP response formats
455    /// - Session token formatting errors
456    ///
457    /// Network errors and authentication failures are handled gracefully
458    /// and result in empty results rather than propagated errors.
459    ///
460    /// # Examples
461    ///
462    /// ```rust,no_run
463    /// # use kasl::api::jira::{Jira, JiraConfig};
464    /// # use chrono::NaiveDate;
465    /// # use anyhow::Result;
466    /// # async fn example() -> Result<()> {
467    /// let config = JiraConfig {
468    ///     login: "username".to_string(),
469    ///     api_url: "https://jira.company.com".to_string(),
470    ///     completed_statuses: Vec::new(),
471    /// };
472    /// let mut jira = Jira::new(&config);
473    ///
474    /// let today = chrono::Local::now().date_naive();
475    /// let issues = jira.get_completed_issues(&today).await?;
476    ///
477    /// for issue in issues {
478    ///     println!("Completed: {} - {}", issue.key, issue.fields.summary);
479    /// }
480    /// # Ok(())
481    /// # }
482    /// ```
483    pub async fn get_completed_issues(&mut self, date: &NaiveDate) -> Result<Vec<JiraIssue>> {
484        let mut local_retries = 0;
485        loop {
486            let session_id = self.get_session_id().await?;
487
488            match self.fetch_completed_pages(&session_id, date).await {
489                Ok(issues) => return Ok(issues),
490                Err(SearchPageError::Unauthorized) if local_retries < MAX_RETRY_COUNT => {
491                    let _ = self.delete_session_id();
492                    local_retries += 1;
493                    tokio::time::sleep(Duration::from_secs(1)).await;
494                }
495                Err(SearchPageError::Unauthorized) => {
496                    anyhow::bail!("Jira session unauthorized after retries")
497                }
498                Err(SearchPageError::Other(msg)) => {
499                    anyhow::bail!("Jira completed-issues search failed: {msg}")
500                }
501            }
502        }
503    }
504
505    /// Fetches all pages of completed issues for a valid session cookie.
506    async fn fetch_completed_pages(&self, session_id: &str, date: &NaiveDate) -> std::result::Result<Vec<JiraIssue>, SearchPageError> {
507        // Filter by resolution date only. Do not use `status in (...)` with English
508        // defaults like "Done"/"Resolved": on localized Jira those names are invalid
509        // and the whole JQL fails (HTTP 400), which used to look like "no issues".
510        let date_str = date.format("%Y-%m-%d").to_string();
511        let jql = format!(
512            "assignee = currentUser() AND resolved >= \"{}\" AND resolved <= \"{} 23:59\"",
513            date_str, date_str
514        );
515        let url = format!("{}/{}", self.config.api_url, SEARCH_URL);
516
517        let mut all = Vec::new();
518        let mut start_at: u32 = 0;
519
520        loop {
521            let mut headers = HeaderMap::new();
522            headers.insert(
523                COOKIE,
524                HeaderValue::from_str(session_id).map_err(|e| SearchPageError::Other(format!("invalid session cookie: {e}")))?,
525            );
526
527            let res = self
528                .client
529                .get(&url)
530                .headers(headers)
531                .query(&[
532                    ("jql", jql.as_str()),
533                    ("fields", "summary,status,priority,updated,resolutiondate"),
534                    ("startAt", &start_at.to_string()),
535                    ("maxResults", &SEARCH_PAGE_SIZE.to_string()),
536                ])
537                .send()
538                .await
539                .map_err(|e| SearchPageError::Other(format!("request failed: {e}")))?;
540
541            match res.status() {
542                StatusCode::UNAUTHORIZED => return Err(SearchPageError::Unauthorized),
543                status if !status.is_success() => {
544                    let body = res.text().await.unwrap_or_default();
545                    return Err(SearchPageError::Other(format!("HTTP {status}: {body}")));
546                }
547                _ => {}
548            }
549
550            let page: JiraSearchResults = res.json().await.map_err(|e| SearchPageError::Other(format!("invalid JSON: {e}")))?;
551            let batch_len = page.issues.len() as u32;
552            all.extend(page.issues);
553
554            start_at += batch_len;
555            if batch_len == 0 || start_at >= page.total {
556                break;
557            }
558        }
559
560        Ok(all)
561    }
562
563    /// Fetches open issues currently assigned to the authenticated user.
564    ///
565    /// Uses JQL `assignee = currentUser() AND resolution is EMPTY`. Paginates
566    /// through all matching issues (`startAt` / `total`). Extra field ids
567    /// (custom fields such as Scoring) are included in the `fields` query.
568    ///
569    /// Auth failures and network errors return an empty list (same pattern as
570    /// [`get_completed_issues`]) so callers can keep polling safely.
571    pub async fn get_assigned_open_issues(&mut self, extra_field_ids: &[String]) -> Result<Vec<JiraIssue>> {
572        let mut local_retries = 0;
573        loop {
574            let session_id = match self.get_session_id().await {
575                Ok(id) => id,
576                Err(_) => return Ok(Vec::new()),
577            };
578
579            match self.fetch_assigned_open_pages(&session_id, extra_field_ids).await {
580                Ok(issues) => return Ok(issues),
581                Err(SearchPageError::Unauthorized) if local_retries < MAX_RETRY_COUNT => {
582                    let _ = self.delete_session_id();
583                    local_retries += 1;
584                    tokio::time::sleep(Duration::from_secs(1)).await;
585                }
586                Err(_) => return Ok(Vec::new()),
587            }
588        }
589    }
590
591    /// Like [`get_assigned_open_issues`], but never prompts for a password.
592    ///
593    /// Uses a cached session cookie and/or encrypted `.jira_secret`. Returns
594    /// `Ok(None)` when neither is available so background daemons can skip
595    /// the poll without blocking on stdin.
596    pub async fn get_assigned_open_issues_noninteractive(&mut self, extra_field_ids: &[String]) -> Result<Option<Vec<JiraIssue>>> {
597        let mut local_retries = 0;
598        loop {
599            let Some(session_id) = self.session_id_noninteractive().await? else {
600                return Ok(None);
601            };
602
603            match self.fetch_assigned_open_pages(&session_id, extra_field_ids).await {
604                Ok(issues) => return Ok(Some(issues)),
605                Err(SearchPageError::Unauthorized) if local_retries < MAX_RETRY_COUNT => {
606                    let _ = self.delete_session_id();
607                    local_retries += 1;
608                    tokio::time::sleep(Duration::from_secs(1)).await;
609                }
610                Err(_) => return Ok(Some(Vec::new())),
611            }
612        }
613    }
614
615    /// Fetches all pages of assigned open issues for a valid session cookie.
616    async fn fetch_assigned_open_pages(&self, session_id: &str, extra_field_ids: &[String]) -> std::result::Result<Vec<JiraIssue>, SearchPageError> {
617        let jql = "assignee = currentUser() AND resolution is EMPTY ORDER BY priority ASC, updated DESC";
618        let fields = build_search_fields(extra_field_ids);
619        let url = format!("{}/{}", self.config.api_url, SEARCH_URL);
620
621        let mut all = Vec::new();
622        let mut start_at: u32 = 0;
623
624        loop {
625            let mut headers = HeaderMap::new();
626            headers.insert(
627                COOKIE,
628                HeaderValue::from_str(session_id).map_err(|e| SearchPageError::Other(format!("invalid session cookie: {e}")))?,
629            );
630
631            let res = self
632                .client
633                .get(&url)
634                .headers(headers)
635                .query(&[
636                    ("jql", jql),
637                    ("fields", fields.as_str()),
638                    ("startAt", &start_at.to_string()),
639                    ("maxResults", &SEARCH_PAGE_SIZE.to_string()),
640                ])
641                .send()
642                .await
643                .map_err(|e| SearchPageError::Other(format!("request failed: {e}")))?;
644
645            match res.status() {
646                StatusCode::UNAUTHORIZED => return Err(SearchPageError::Unauthorized),
647                status if !status.is_success() => {
648                    let body = res.text().await.unwrap_or_default();
649                    return Err(SearchPageError::Other(format!("HTTP {status}: {body}")));
650                }
651                _ => {}
652            }
653
654            let page: JiraSearchResults = res.json().await.map_err(|e| SearchPageError::Other(format!("invalid JSON: {e}")))?;
655            let batch_len = page.issues.len() as u32;
656            all.extend(page.issues);
657
658            start_at += batch_len;
659            if batch_len == 0 || start_at >= page.total {
660                break;
661            }
662        }
663
664        Ok(all)
665    }
666
667    /// Resolves a session from cache / secret without prompting.
668    async fn session_id_noninteractive(&mut self) -> Result<Option<String>> {
669        let session_id_file_path = crate::libs::data_storage::DataStorage::new().get_path(SESSION_ID_FILE)?;
670        let path_str = session_id_file_path.to_str().unwrap_or_default();
671
672        if let Ok(session_id) = Self::read_session_id(path_str) {
673            return Ok(Some(session_id));
674        }
675
676        let Some(password) = self.secret().try_get_cached() else {
677            return Ok(None);
678        };
679
680        self.set_credentials(&password)?;
681        match self.login().await {
682            Ok(session_id) => {
683                let _ = Self::write_session_id(path_str, &session_id);
684                self.reset_retry();
685                Ok(Some(session_id))
686            }
687            Err(_) => Ok(None),
688        }
689    }
690
691    /// Builds a browse URL for an issue key using this client's API base.
692    pub fn issue_browse_url(&self, key: &str) -> String {
693        let base = self.config.api_url.trim_end_matches('/');
694        format!("{}/browse/{}", base, key)
695    }
696
697    /// Maps a Jira priority id to a sortable rank (lower = more urgent).
698    pub fn priority_rank(priority: &Option<JiraPriority>) -> i32 {
699        priority
700            .as_ref()
701            .and_then(|p| p.id.as_ref())
702            .and_then(|id| id.parse::<i32>().ok())
703            .unwrap_or(999)
704    }
705
706    /// Extracts a numeric value from a Jira custom-field JSON value.
707    ///
708    /// Supports bare numbers, numeric strings, and objects with `value` / `amount`.
709    pub fn extract_number(value: &Value) -> Option<f64> {
710        match value {
711            Value::Number(n) => n.as_f64(),
712            Value::String(s) => s.trim().parse().ok(),
713            Value::Object(map) => map
714                .get("value")
715                .and_then(Self::extract_number)
716                .or_else(|| map.get("amount").and_then(Self::extract_number)),
717            _ => None,
718        }
719    }
720
721    /// Reads a numeric custom field from issue extras by field id.
722    pub fn sort_value_from_issue(issue: &JiraIssue, field_id: &str) -> Option<f64> {
723        issue.fields.extra.get(field_id).and_then(Self::extract_number)
724    }
725}
726
727/// Internal error for paginated search (auth vs other failures).
728enum SearchPageError {
729    Unauthorized,
730    Other(String),
731}
732
733fn build_search_fields(extra_field_ids: &[String]) -> String {
734    let mut fields = vec!["summary".to_string(), "status".to_string(), "priority".to_string(), "updated".to_string()];
735    for id in extra_field_ids {
736        let trimmed = id.trim();
737        if !trimmed.is_empty() && !fields.iter().any(|f| f == trimmed) {
738            fields.push(trimmed.to_string());
739        }
740    }
741    fields.join(",")
742}
743
744#[cfg(test)]
745mod tests {
746    use super::*;
747    use serde_json::json;
748
749    #[test]
750    fn extract_number_from_primitives_and_objects() {
751        assert_eq!(Jira::extract_number(&json!(12.5)), Some(12.5));
752        assert_eq!(Jira::extract_number(&json!("42")), Some(42.0));
753        assert_eq!(Jira::extract_number(&json!({"value": 7})), Some(7.0));
754        assert_eq!(Jira::extract_number(&json!({"amount": "3.5"})), Some(3.5));
755        assert_eq!(Jira::extract_number(&json!(null)), None);
756    }
757
758    #[test]
759    fn build_search_fields_includes_custom_ids() {
760        let fields = build_search_fields(&["customfield_10001".to_string(), "summary".to_string()]);
761        assert!(fields.contains("summary"));
762        assert!(fields.contains("customfield_10001"));
763        assert_eq!(fields.matches("summary").count(), 1);
764    }
765}
766
767/// Configuration for Jira API integration.
768///
769/// This structure holds the necessary information for connecting to Jira
770/// instances, including both cloud and server/data center deployments.
771/// The configuration is designed to be serializable for storage in
772/// configuration files.
773///
774/// ## Security Notes
775///
776/// - Passwords are never stored in configuration files
777/// - Only usernames and API endpoints are persisted
778/// - Session tokens are cached separately with encryption
779/// - Configuration files should have restricted permissions
780///
781/// ## Supported Jira Instances
782///
783/// - **Atlassian Cloud**: Uses `https://company.atlassian.net` format
784/// - **Server/Data Center**: Uses custom domain like `https://jira.company.com`
785/// - **Local Development**: Can use `http://localhost:8080` for testing
786#[derive(Serialize, Deserialize, Clone, Debug)]
787pub struct JiraConfig {
788    /// Jira username for authentication.
789    ///
790    /// This should be the actual username, not an email address,
791    /// unless your Jira instance is configured to use email addresses
792    /// as usernames. Check with your Jira administrator if unsure.
793    ///
794    /// For Atlassian Cloud instances, this is typically the email address
795    /// used to register the account.
796    pub login: String,
797
798    /// Base URL of the Jira instance.
799    ///
800    /// Examples:
801    /// - Atlassian Cloud: `https://company.atlassian.net`
802    /// - Server/Data Center: `https://jira.company.com`
803    /// - Local development: `http://localhost:8080`
804    ///
805    /// Do not include the `/rest/api/` path as it will be added automatically.
806    /// The URL should point to the root of your Jira installation.
807    pub api_url: String,
808
809    /// Deprecated: previously used in `status in (...)` for completed-issue search.
810    ///
811    /// Kept for config compatibility. Discovery now filters by `resolved` date only,
812    /// because non-existent status names (e.g. English "Done" on a Russian Jira)
813    /// make the whole JQL fail.
814    #[serde(default = "default_completed_statuses")]
815    pub completed_statuses: Vec<String>,
816}
817
818/// Empty default — status names are no longer used in completed-issue JQL.
819fn default_completed_statuses() -> Vec<String> {
820    Vec::new()
821}
822
823impl JiraConfig {
824    /// Returns the configuration module metadata for Jira.
825    ///
826    /// Used by the configuration system to identify and manage
827    /// Jira-specific settings during interactive setup. This provides
828    /// the human-readable name and internal key for the module.
829    ///
830    /// # Returns
831    ///
832    /// A `ConfigModule` with Jira identification information.
833    pub fn module() -> ConfigModule {
834        ConfigModule {
835            key: "jira".to_string(),
836            name: "Jira".to_string(),
837        }
838    }
839
840    /// Runs an interactive configuration setup for Jira integration.
841    ///
842    /// Prompts the user for Jira instance URL and username, using existing
843    /// configuration values as defaults if available. This method provides
844    /// a user-friendly way to configure Jira integration during initial
845    /// setup or reconfiguration.
846    ///
847    /// ## Interactive Prompts
848    ///
849    /// 1. **Username**: Prompts for Jira username (or email for cloud instances)
850    /// 2. **API URL**: Prompts for Jira instance URL with validation hints
851    ///
852    /// Both prompts will show existing values as defaults if configuration
853    /// already exists, making it easy to update only specific values without
854    /// re-entering everything.
855    ///
856    /// ## Configuration Validation
857    ///
858    /// While this method doesn't validate the actual connection to Jira,
859    /// it provides helpful prompts and examples to guide users toward
860    /// correct configuration values.
861    ///
862    /// # Arguments
863    ///
864    /// * `config` - Existing Jira configuration to use as defaults (if any)
865    ///
866    /// # Returns
867    ///
868    /// * `Result<Self>` - New Jira configuration with user input
869    ///
870    /// # Errors
871    ///
872    /// Returns an error if:
873    /// - Terminal input/output fails
874    /// - User cancels the configuration process
875    /// - Input validation fails
876    ///
877    /// # Example
878    ///
879    /// ```rust,no_run
880    /// # use kasl::api::JiraConfig;
881    /// # use anyhow::Result;
882    /// # fn example() -> Result<()> {
883    /// let existing_config = Some(JiraConfig {
884    ///     login: "olduser".to_string(),
885    ///     api_url: "https://old-jira.com".to_string(),
886    ///     completed_statuses: Vec::new(),
887    /// });
888    ///
889    /// let new_config = JiraConfig::init(&existing_config)?;
890    /// # Ok(())
891    /// # }
892    /// ```
893    pub fn init(config: &Option<Self>) -> Result<Self> {
894        // Use existing configuration as defaults, or create empty defaults
895        let config = config.clone().unwrap_or(Self {
896            login: "".to_string(),
897            api_url: "".to_string(),
898            completed_statuses: default_completed_statuses(),
899        });
900
901        // Display configuration module header
902        msg_print!(Message::ConfigModuleJira);
903
904        // Interactive configuration with existing values as defaults
905        Ok(Self {
906            completed_statuses: config.completed_statuses.clone(),
907            login: Input::with_theme(&ColorfulTheme::default())
908                .with_prompt("Enter your Jira login")
909                .default(config.login)
910                .interact_text()?,
911            api_url: Input::with_theme(&ColorfulTheme::default())
912                .with_prompt("Enter the Jira API URL")
913                .default(config.api_url)
914                .interact_text()?,
915        })
916    }
917}