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::{theme::ColorfulTheme, Input};
35use reqwest::{
36    header::{HeaderMap, HeaderValue, COOKIE},
37    Client, StatusCode,
38};
39use serde::{Deserialize, Serialize};
40use std::time::Duration;
41
42/// Maximum number of authentication retries before giving up.
43/// This prevents infinite loops when credentials are consistently invalid.
44const MAX_RETRY_COUNT: i32 = 3;
45
46/// Filename for storing Jira session tokens in the user data directory.
47const SESSION_ID_FILE: &str = ".jira_session_id";
48
49/// Filename for storing encrypted Jira credentials for password caching.
50const SECRET_FILE: &str = ".jira_secret";
51
52/// Jira REST API endpoint for session-based authentication.
53const AUTH_URL: &str = "rest/auth/1/session";
54
55/// Jira REST API endpoint for issue searching using JQL queries.
56const SEARCH_URL: &str = "rest/api/2/search";
57
58/// User credentials for Jira authentication.
59///
60/// This structure holds the login information required for establishing
61/// a session with the Jira API. Credentials are only held in memory
62/// during the authentication process and are never persisted to disk.
63///
64/// ## Security Considerations
65///
66/// - Passwords are stored in plain text only during authentication
67/// - Credentials are cleared from memory after session establishment
68/// - No persistence to avoid credential theft from configuration files
69#[derive(Serialize, Clone, Debug)]
70pub struct LoginCredentials {
71    /// Jira username (not email address unless configured as such)
72    username: String,
73    /// User password in plain text (only during auth process)
74    password: String,
75}
76
77/// Response structure for Jira session authentication.
78///
79/// Contains the session information returned by Jira after successful
80/// authentication, including the session cookie name and value that
81/// must be used in subsequent API requests.
82#[derive(Serialize, Deserialize, Debug)]
83struct JiraSessionResponse {
84    /// Session object containing cookie information
85    session: JiraSession,
86}
87
88/// Jira session cookie information.
89///
90/// Represents the session cookie that must be included in subsequent
91/// API requests to authenticate the user. This cookie typically expires
92/// after a period of inactivity or when explicitly invalidated.
93#[derive(Serialize, Deserialize, Debug)]
94struct JiraSession {
95    /// Cookie name (typically "JSESSIONID" for server instances)
96    name: String,
97    /// Cookie value (the actual session token)
98    value: String,
99}
100
101/// Represents a Jira issue with essential fields for task creation.
102///
103/// This structure contains the core information needed to create tasks
104/// from Jira issues, focusing on identification and descriptive content
105/// rather than the full complexity of Jira's data model.
106#[derive(Serialize, Deserialize, Debug)]
107pub struct JiraIssue {
108    /// Unique issue identifier assigned by Jira (numeric)
109    pub id: String,
110    /// Human-readable issue key (e.g., "PROJECT-123")
111    pub key: String,
112    /// Issue fields containing detailed information
113    pub fields: JiraIssueFields,
114}
115
116/// Detailed fields from a Jira issue.
117///
118/// Contains the descriptive and status information from issues that
119/// is relevant for task creation and tracking. This represents a subset
120/// of Jira's extensive field system, focusing on essential data.
121#[derive(Serialize, Deserialize, Debug)]
122pub struct JiraIssueFields {
123    /// Issue title/summary (required field in Jira)
124    pub summary: String,
125    /// Detailed description (may be empty or contain rich text)
126    pub description: Option<String>,
127    /// Current workflow status information
128    pub status: JiraStatus,
129    /// Date when the issue was resolved (ISO format if completed)
130    pub resolutiondate: Option<String>,
131}
132
133/// Jira issue status information.
134///
135/// Represents the current workflow status of an issue, used for filtering
136/// completed vs. in-progress work. Status names vary by Jira configuration
137/// and localization settings.
138#[derive(Serialize, Deserialize, Debug)]
139pub struct JiraStatus {
140    /// Status name (e.g., "Done", "In Progress", "Решена" for Russian locale)
141    pub name: String,
142}
143
144/// Response structure for Jira issue search queries.
145///
146/// Contains the results of JQL (Jira Query Language) searches,
147/// including the matching issues and pagination information.
148/// For simplicity, only the issues array is currently used.
149#[derive(Serialize, Deserialize, Debug)]
150pub struct JiraSearchResults {
151    /// Array of issues matching the search criteria
152    pub issues: Vec<JiraIssue>,
153}
154
155/// Jira API client with session management capabilities.
156///
157/// This client handles authentication, session caching, and issue retrieval
158/// from Jira instances. It implements the [`Session`] trait for automatic
159/// credential management and retry logic.
160///
161/// ## Thread Safety
162///
163/// The client is not thread-safe due to mutable retry state. Each thread
164/// should use its own client instance for concurrent operations.
165///
166/// ## Session Lifecycle
167///
168/// 1. **Initialization**: Client created with configuration
169/// 2. **Authentication**: Credentials prompted when first needed
170/// 3. **Session Caching**: Successful sessions stored for reuse
171/// 4. **Automatic Retry**: Expired sessions trigger re-authentication
172/// 5. **Error Handling**: Persistent failures return empty results
173#[derive(Debug)]
174pub struct Jira {
175    /// HTTP client for making API requests with connection pooling
176    client: Client,
177    /// Configuration containing API endpoint and user information
178    config: JiraConfig,
179    /// In-memory storage for authentication credentials during auth process
180    credentials: Option<LoginCredentials>,
181    /// Counter for tracking authentication retry attempts
182    retries: i32,
183}
184
185impl Session for Jira {
186    /// Performs session-based authentication with Jira.
187    ///
188    /// This method implements Jira's session authentication flow using the
189    /// REST API. It sends user credentials to the authentication endpoint
190    /// and receives a session cookie that can be used for subsequent requests.
191    ///
192    /// ## Authentication Process
193    ///
194    /// 1. **Credential Validation**: Ensures credentials are set before proceeding
195    /// 2. **HTTP Request**: POST to the session authentication endpoint with JSON credentials
196    /// 3. **Response Validation**: Checks for successful HTTP status codes
197    /// 4. **Cookie Extraction**: Parses session information from the response
198    /// 5. **Format Preparation**: Creates properly formatted cookie string for headers
199    ///
200    /// ## Session Cookie Format
201    ///
202    /// The returned session ID is formatted as `{cookie_name}={cookie_value}` and
203    /// should be included in the `Cookie` header of subsequent API requests.
204    ///
205    /// # Returns
206    ///
207    /// Returns a formatted session cookie string on successful authentication.
208    ///
209    /// # Errors
210    ///
211    /// Returns an error if:
212    /// - No credentials have been set (programming error)
213    /// - HTTP request fails due to network issues
214    /// - Credentials are invalid (401 response)
215    /// - Jira returns an unexpected response format
216    /// - Session parsing fails
217    async fn login(&self) -> Result<String> {
218        // Ensure credentials are available for authentication
219        let credentials = self.credentials.clone().expect("Credentials not set!");
220
221        // Build authentication endpoint URL
222        let auth_url = format!("{}/{}", self.config.api_url, AUTH_URL);
223
224        // Send authentication request with JSON credentials
225        let auth_res = self.client.post(auth_url).json(&credentials).send().await?;
226
227        // Validate response status
228        if !auth_res.status().is_success() {
229            anyhow::bail!("Jira authenticate failed")
230        }
231
232        // Parse session information from response
233        let session_res = auth_res.json::<JiraSessionResponse>().await?;
234
235        // Format session cookie for use in subsequent requests
236        let session_id = format!("{}={}", session_res.session.name, session_res.session.value);
237        Ok(session_id)
238    }
239
240    /// Sets user credentials for Jira authentication.
241    ///
242    /// Stores the provided username and password in memory for use during
243    /// the authentication process. This method is called by the session
244    /// management system when credentials are needed.
245    ///
246    /// ## Security Notes
247    ///
248    /// - Credentials are only stored in memory temporarily
249    /// - Password is stored in plain text for authentication
250    /// - No persistence to disk or configuration files
251    /// - Credentials are cleared after successful authentication
252    ///
253    /// # Arguments
254    ///
255    /// * `password` - The user's Jira password in plain text
256    ///
257    /// # Returns
258    ///
259    /// Always returns `Ok(())` as this operation cannot fail.
260    fn set_credentials(&mut self, password: &str) -> Result<()> {
261        self.credentials = Some(LoginCredentials {
262            username: self.config.login.to_string(),
263            password: password.to_owned(),
264        });
265        Ok(())
266    }
267
268    /// Returns the filename for storing Jira session tokens.
269    ///
270    /// The session file is stored in the user's application data directory
271    /// and contains the cached session token for automatic login restoration.
272    fn session_id_file(&self) -> &str {
273        SESSION_ID_FILE
274    }
275
276    /// Returns a configured Secret instance for secure password prompting.
277    ///
278    /// The Secret manager handles secure password input with hidden characters
279    /// and optional encrypted caching in the user's data directory.
280    ///
281    /// # Returns
282    ///
283    /// A configured `Secret` instance with Jira-specific prompts and file names.
284    fn secret(&self) -> Secret {
285        Secret::new(SECRET_FILE, "Enter your Jira password")
286    }
287
288    /// Returns the current authentication retry count.
289    ///
290    /// Used by the session management system to track failed authentication
291    /// attempts and implement retry limits.
292    fn retry(&self) -> i32 {
293        self.retries
294    }
295
296    /// Increments the authentication retry counter.
297    ///
298    /// Called after each failed authentication attempt to track progress
299    /// toward the maximum retry limit defined in the session management system.
300    fn inc_retry(&mut self) {
301        self.retries += 1;
302    }
303
304    /// Resets the authentication retry counter to zero.
305    ///
306    /// Called after successful authentication to ensure future session
307    /// requests start with a clean slate.
308    fn reset_retry(&mut self) {
309        self.retries = 0;
310    }
311}
312
313impl Jira {
314    /// Creates a new Jira API client instance.
315    ///
316    /// Initializes the HTTP client with default settings suitable for Jira API
317    /// interactions. The client is configured for JSON requests and includes
318    /// appropriate timeout and connection settings.
319    ///
320    /// # Arguments
321    ///
322    /// * `config` - Configuration containing Jira URL and login information
323    ///
324    /// # Examples
325    ///
326    /// ```rust,no_run
327    /// use kasl::api::{Jira, JiraConfig};
328    ///
329    /// let config = JiraConfig {
330    ///     login: "username".to_string(),
331    ///     api_url: "https://jira.company.com".to_string(),
332    /// };
333    /// let jira = Jira::new(&config);
334    /// ```
335    pub fn new(config: &JiraConfig) -> Self {
336        Self {
337            client: Client::new(),
338            config: config.clone(),
339            credentials: None,
340            retries: 0,
341        }
342    }
343
344    /// Retrieves all issues completed by the current user on a specific date.
345    ///
346    /// This method performs a sophisticated issue search using JQL (Jira Query Language)
347    /// to find issues that were marked as completed on the specified date. The search
348    /// includes robust error handling and automatic session management with retry logic.
349    ///
350    /// ## JQL Query Details
351    ///
352    /// The search uses the following criteria:
353    /// - **Status Filter**: Issues with status "Done" or "Решена" (supports localized Jira)
354    /// - **Resolution Date**: Issues resolved within the full day range (00:00 to 23:59)
355    /// - **Assignee Filter**: Only issues assigned to the current user (`currentUser()`)
356    ///
357    /// ## Session Management
358    ///
359    /// The method implements sophisticated session handling:
360    /// 1. **Session Retrieval**: Get or create a valid session token using the Session trait
361    /// 2. **API Request**: Execute the JQL search with session cookie authentication
362    /// 3. **Error Handling**: Detect HTTP 401 (Unauthorized) responses indicating expired sessions
363    /// 4. **Automatic Retry**: Clear cached session and retry authentication up to the limit
364    /// 5. **Graceful Degradation**: Return empty results on persistent authentication failures
365    ///
366    /// ## Error Recovery Strategy
367    ///
368    /// Unlike other API integrations, Jira errors are allowed to propagate rather
369    /// than returning empty results silently. This is because Jira data is typically more
370    /// critical for work tracking, and users should be aware of connection issues.
371    ///
372    /// However, authentication failures are handled gracefully with automatic
373    /// retry logic and eventual fallback to empty results after exhausting retries.
374    ///
375    /// ## Date Handling
376    ///
377    /// The method formats the provided date to ensure proper JQL syntax and
378    /// covers the entire day from midnight to 23:59 to capture all possible
379    /// resolution times within the target date.
380    ///
381    /// # Arguments
382    ///
383    /// * `date` - The date to search for completed issues (in any timezone)
384    ///
385    /// # Returns
386    ///
387    /// Returns a vector of [`JiraIssue`] objects representing completed work.
388    /// Returns an empty vector if:
389    /// - No issues are found matching the criteria
390    /// - Authentication fails persistently after all retries
391    /// - Network errors occur during the request
392    ///
393    /// # Errors
394    ///
395    /// May return errors for:
396    /// - JSON parsing failures in API responses
397    /// - Unexpected HTTP response formats
398    /// - Session token formatting errors
399    ///
400    /// Network errors and authentication failures are handled gracefully
401    /// and result in empty results rather than propagated errors.
402    ///
403    /// # Examples
404    ///
405    /// ```rust,no_run
406    /// # use kasl::api::{Jira, JiraConfig};
407    /// # use chrono::NaiveDate;
408    /// # use anyhow::Result;
409    /// # async fn example() -> Result<()> {
410    /// let config = JiraConfig {
411    ///     login: "username".to_string(),
412    ///     api_url: "https://jira.company.com".to_string(),
413    /// };
414    /// let mut jira = Jira::new(&config);
415    ///
416    /// let today = chrono::Local::now().date_naive();
417    /// let issues = jira.get_completed_issues(&today).await?;
418    ///
419    /// for issue in issues {
420    ///     println!("Completed: {} - {}", issue.key, issue.fields.summary);
421    /// }
422    /// # Ok(())
423    /// # }
424    /// ```
425    pub async fn get_completed_issues(&mut self, date: &NaiveDate) -> Result<Vec<JiraIssue>> {
426        let mut local_retries = 0;
427        loop {
428            // Step 1: Ensure we have a valid session token
429            let session_id = match self.get_session_id().await {
430                Ok(id) => id,
431                Err(_) => return Ok(Vec::new()), // Give up on persistent auth failures
432            };
433
434            // Step 2: Build JQL query for completed issues on the specified date
435            let date_str = date.format("%Y-%m-%d").to_string();
436            let jql = format!(
437                "status in (Done, Решена) AND resolved >= \"{}\" AND resolved <= \"{} 23:59\" AND assignee in (currentUser())",
438                &date_str, &date_str
439            );
440
441            // Step 3: Prepare request with session authentication
442            let mut headers = HeaderMap::new();
443            headers.insert(COOKIE, HeaderValue::from_str(&session_id)?);
444            let url = format!("{}/{}?jql={}", &self.config.api_url, SEARCH_URL, &jql);
445
446            // Step 4: Execute the search request
447            let res = match self.client.get(&url).headers(headers).send().await {
448                Ok(response) => response,
449                Err(_) => return Ok(Vec::new()), // Network errors return empty results
450            };
451
452            // Step 5: Handle response and potential session expiration
453            match res.status() {
454                StatusCode::UNAUTHORIZED if local_retries < MAX_RETRY_COUNT => {
455                    // Session expired - clear cache and retry
456                    self.delete_session_id()?;
457                    local_retries += 1;
458                    // Brief delay before retry to avoid hammering the server
459                    tokio::time::sleep(Duration::from_secs(1)).await;
460                    continue;
461                }
462                _ => {
463                    // Success or non-recoverable error - parse and return results
464                    let search_results = res.json::<JiraSearchResults>().await?;
465                    return Ok(search_results.issues);
466                }
467            }
468        }
469    }
470}
471
472/// Configuration for Jira API integration.
473///
474/// This structure holds the necessary information for connecting to Jira
475/// instances, including both cloud and server/data center deployments.
476/// The configuration is designed to be serializable for storage in
477/// configuration files.
478///
479/// ## Security Notes
480///
481/// - Passwords are never stored in configuration files
482/// - Only usernames and API endpoints are persisted
483/// - Session tokens are cached separately with encryption
484/// - Configuration files should have restricted permissions
485///
486/// ## Supported Jira Instances
487///
488/// - **Atlassian Cloud**: Uses `https://company.atlassian.net` format
489/// - **Server/Data Center**: Uses custom domain like `https://jira.company.com`
490/// - **Local Development**: Can use `http://localhost:8080` for testing
491#[derive(Serialize, Deserialize, Clone, Debug)]
492pub struct JiraConfig {
493    /// Jira username for authentication.
494    ///
495    /// This should be the actual username, not an email address,
496    /// unless your Jira instance is configured to use email addresses
497    /// as usernames. Check with your Jira administrator if unsure.
498    ///
499    /// For Atlassian Cloud instances, this is typically the email address
500    /// used to register the account.
501    pub login: String,
502
503    /// Base URL of the Jira instance.
504    ///
505    /// Examples:
506    /// - Atlassian Cloud: `https://company.atlassian.net`
507    /// - Server/Data Center: `https://jira.company.com`
508    /// - Local development: `http://localhost:8080`
509    ///
510    /// Do not include the `/rest/api/` path as it will be added automatically.
511    /// The URL should point to the root of your Jira installation.
512    pub api_url: String,
513}
514
515impl JiraConfig {
516    /// Returns the configuration module metadata for Jira.
517    ///
518    /// Used by the configuration system to identify and manage
519    /// Jira-specific settings during interactive setup. This provides
520    /// the human-readable name and internal key for the module.
521    ///
522    /// # Returns
523    ///
524    /// A `ConfigModule` with Jira identification information.
525    pub fn module() -> ConfigModule {
526        ConfigModule {
527            key: "jira".to_string(),
528            name: "Jira".to_string(),
529        }
530    }
531
532    /// Runs an interactive configuration setup for Jira integration.
533    ///
534    /// Prompts the user for Jira instance URL and username, using existing
535    /// configuration values as defaults if available. This method provides
536    /// a user-friendly way to configure Jira integration during initial
537    /// setup or reconfiguration.
538    ///
539    /// ## Interactive Prompts
540    ///
541    /// 1. **Username**: Prompts for Jira username (or email for cloud instances)
542    /// 2. **API URL**: Prompts for Jira instance URL with validation hints
543    ///
544    /// Both prompts will show existing values as defaults if configuration
545    /// already exists, making it easy to update only specific values without
546    /// re-entering everything.
547    ///
548    /// ## Configuration Validation
549    ///
550    /// While this method doesn't validate the actual connection to Jira,
551    /// it provides helpful prompts and examples to guide users toward
552    /// correct configuration values.
553    ///
554    /// # Arguments
555    ///
556    /// * `config` - Existing Jira configuration to use as defaults (if any)
557    ///
558    /// # Returns
559    ///
560    /// * `Result<Self>` - New Jira configuration with user input
561    ///
562    /// # Errors
563    ///
564    /// Returns an error if:
565    /// - Terminal input/output fails
566    /// - User cancels the configuration process
567    /// - Input validation fails
568    ///
569    /// # Example
570    ///
571    /// ```rust,no_run
572    /// # use kasl::api::JiraConfig;
573    /// # use anyhow::Result;
574    /// # fn example() -> Result<()> {
575    /// let existing_config = Some(JiraConfig {
576    ///     login: "olduser".to_string(),
577    ///     api_url: "https://old-jira.com".to_string(),
578    /// });
579    ///
580    /// let new_config = JiraConfig::init(&existing_config)?;
581    /// # Ok(())
582    /// # }
583    /// ```
584    pub fn init(config: &Option<Self>) -> Result<Self> {
585        // Use existing configuration as defaults, or create empty defaults
586        let config = config
587            .clone()
588            .or(Some(Self {
589                login: "".to_string(),
590                api_url: "".to_string(),
591            }))
592            .unwrap();
593
594        // Display configuration module header
595        msg_print!(Message::ConfigModuleJira);
596
597        // Interactive configuration with existing values as defaults
598        Ok(Self {
599            login: Input::with_theme(&ColorfulTheme::default())
600                .with_prompt("Enter your Jira login")
601                .default(config.login)
602                .interact_text()?,
603            api_url: Input::with_theme(&ColorfulTheme::default())
604                .with_prompt("Enter the Jira API URL")
605                .default(config.api_url)
606                .interact_text()?,
607        })
608    }
609}