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}