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