Skip to main content

kasl/api/
jira.rs

1//! Jira client: completed issues for task discovery, assigned open
2//! issues for the inbox poller.
3//!
4//! ```rust,no_run
5//! # use kasl::api::jira::{Jira, JiraConfig};
6//! # use chrono::Local;
7//! # async fn f() -> anyhow::Result<()> {
8//! let config = JiraConfig {
9//!     login: "username".to_string(),
10//!     api_url: "https://jira.company.com".to_string(),
11//!     completed_statuses: Vec::new(),
12//! };
13//!
14//! let mut jira = Jira::new(&config);
15//! let today = Local::now().date_naive();
16//! let issues = jira.get_completed_issues(&today).await?;
17//! # Ok(())
18//! # }
19//! ```
20
21use super::Session;
22use crate::libs::{config::ConfigModule, messages::Message, secret::Secret};
23use crate::msg_print;
24use anyhow::Result;
25use chrono::NaiveDate;
26use dialoguer::{Input, theme::ColorfulTheme};
27use reqwest::{
28    Client, StatusCode,
29    header::{COOKIE, HeaderMap, HeaderValue},
30};
31use serde::{Deserialize, Deserializer, Serialize};
32use serde_json::Value;
33use std::collections::HashMap;
34use std::time::Duration;
35
36const MAX_RETRY_COUNT: i32 = 3;
37
38const SEARCH_PAGE_SIZE: u32 = 100;
39
40const SESSION_ID_FILE: &str = ".jira_session_id";
41
42const SECRET_FILE: &str = ".jira_secret";
43
44const AUTH_URL: &str = "rest/auth/1/session";
45
46const SEARCH_URL: &str = "rest/api/2/search";
47
48/// Login pair, held in memory only while authenticating.
49#[derive(Serialize, Clone, Debug)]
50pub struct LoginCredentials {
51    username: String,
52    password: String,
53}
54
55#[derive(Serialize, Deserialize, Debug)]
56struct JiraSessionResponse {
57    session: JiraSession,
58}
59
60/// Session cookie (typically `JSESSIONID`) sent on every API request.
61#[derive(Serialize, Deserialize, Debug)]
62struct JiraSession {
63    name: String,
64    value: String,
65}
66
67/// The slice of a Jira issue kasl works with.
68#[derive(Serialize, Deserialize, Debug)]
69pub struct JiraIssue {
70    /// Numeric issue id assigned by Jira.
71    pub id: String,
72    /// Human-readable key, e.g. "PROJECT-123".
73    pub key: String,
74    pub fields: JiraIssueFields,
75}
76
77/// The fields kasl reads; custom fields land in `extra` via flatten.
78#[derive(Serialize, Deserialize, Debug)]
79pub struct JiraIssueFields {
80    /// Issue title/summary (required field in Jira)
81    pub summary: String,
82    /// Detailed description (may be empty or contain rich text)
83    #[serde(default)]
84    pub description: Option<String>,
85    /// Current workflow status information
86    pub status: JiraStatus,
87    /// Date when the issue was resolved (ISO format if completed)
88    #[serde(default)]
89    pub resolutiondate: Option<String>,
90    /// Issue priority (Highest / High / Medium / …)
91    #[serde(default)]
92    pub priority: Option<JiraPriority>,
93    /// Last update timestamp from Jira (ISO-8601)
94    #[serde(default)]
95    pub updated: Option<String>,
96    /// Custom and other fields keyed by Jira field id (e.g. `customfield_12345`).
97    #[serde(flatten)]
98    pub extra: HashMap<String, Value>,
99}
100
101/// Workflow status; names vary by configuration and locale.
102#[derive(Serialize, Deserialize, Debug, Clone)]
103pub struct JiraStatus {
104    /// Stable status id from Jira (preferred for storage / joins)
105    #[serde(default, deserialize_with = "deserialize_jira_id")]
106    pub id: String,
107    /// Status name (e.g., "Done", "In Progress", "Решена" for Russian locale)
108    pub name: String,
109}
110
111/// Accepts Jira ids as JSON string or number (`"3"` / `3`).
112fn deserialize_jira_id<'de, D>(deserializer: D) -> std::result::Result<String, D::Error>
113where
114    D: Deserializer<'de>,
115{
116    let value = Option::<Value>::deserialize(deserializer)?;
117    Ok(match value {
118        Some(Value::String(s)) => s,
119        Some(Value::Number(n)) => n.to_string(),
120        _ => String::new(),
121    })
122}
123
124/// Jira issue priority.
125///
126/// Classic Jira uses numeric ids where lower means higher urgency
127/// (e.g. `"1"` = Highest). Used for inbox sorting.
128#[derive(Serialize, Deserialize, Debug, Clone)]
129pub struct JiraPriority {
130    /// Priority display name (e.g. "High", "Highest")
131    pub name: String,
132    /// Numeric priority id as string (lower = more urgent in classic schemes)
133    #[serde(default)]
134    pub id: Option<String>,
135}
136
137/// One page of a JQL search.
138#[derive(Serialize, Deserialize, Debug)]
139pub struct JiraSearchResults {
140    /// Index of the first issue in this page
141    #[serde(default, rename = "startAt")]
142    pub start_at: u32,
143    /// Requested page size
144    #[serde(default, rename = "maxResults")]
145    pub max_results: u32,
146    /// Total matching issues across all pages
147    #[serde(default)]
148    pub total: u32,
149    /// Array of issues matching the search criteria
150    pub issues: Vec<JiraIssue>,
151}
152
153/// Jira client with cached-session management via the [`Session`] trait.
154#[derive(Debug)]
155pub struct Jira {
156    client: Client,
157    config: JiraConfig,
158    /// Held in memory only while authenticating.
159    credentials: Option<LoginCredentials>,
160    retries: i32,
161}
162
163impl Session for Jira {
164    /// Logs in and returns the session cookie as `name=value`.
165    async fn login(&self) -> Result<String> {
166        let credentials = self.credentials.clone().expect("Credentials not set!");
167
168        let auth_url = format!("{}/{}", self.config.api_url, AUTH_URL);
169        let auth_res = self.client.post(auth_url).json(&credentials).send().await?;
170
171        if !auth_res.status().is_success() {
172            anyhow::bail!("Jira authenticate failed")
173        }
174
175        let session_res = auth_res.json::<JiraSessionResponse>().await?;
176
177        let session_id = format!("{}={}", session_res.session.name, session_res.session.value);
178        Ok(session_id)
179    }
180
181    fn set_credentials(&mut self, password: &str) -> Result<()> {
182        self.credentials = Some(LoginCredentials {
183            username: self.config.login.to_string(),
184            password: password.to_owned(),
185        });
186        Ok(())
187    }
188
189    fn session_id_file(&self) -> &str {
190        SESSION_ID_FILE
191    }
192
193    fn secret(&self) -> Secret {
194        Secret::new(SECRET_FILE, "Enter your Jira password")
195    }
196
197    fn retry(&self) -> i32 {
198        self.retries
199    }
200
201    fn inc_retry(&mut self) {
202        self.retries += 1;
203    }
204
205    fn reset_retry(&mut self) {
206        self.retries = 0;
207    }
208}
209
210impl Jira {
211    /// Builds a client from the config; no network activity yet.
212    ///
213    /// ```rust,no_run
214    /// use kasl::api::jira::{Jira, JiraConfig};
215    ///
216    /// let config = JiraConfig {
217    ///     login: "username".to_string(),
218    ///     api_url: "https://jira.company.com".to_string(),
219    ///     completed_statuses: Vec::new(),
220    /// };
221    /// let jira = Jira::new(&config);
222    /// ```
223    pub fn new(config: &JiraConfig) -> Self {
224        Self {
225            client: Client::new(),
226            config: config.clone(),
227            credentials: None,
228            retries: 0,
229        }
230    }
231
232    /// Fetches the user's issues resolved on `date` (full-day range).
233    ///
234    /// A 401 drops the cached session and retries up to the limit; other
235    /// failures propagate - completed issues feed the report, so a silent
236    /// empty result would hide real problems.
237    ///
238    /// ```rust,no_run
239    /// # use kasl::api::jira::{Jira, JiraConfig};
240    /// # use chrono::NaiveDate;
241    /// # use anyhow::Result;
242    /// # async fn example() -> Result<()> {
243    /// let config = JiraConfig {
244    ///     login: "username".to_string(),
245    ///     api_url: "https://jira.company.com".to_string(),
246    ///     completed_statuses: Vec::new(),
247    /// };
248    /// let mut jira = Jira::new(&config);
249    ///
250    /// let today = chrono::Local::now().date_naive();
251    /// let issues = jira.get_completed_issues(&today).await?;
252    ///
253    /// for issue in issues {
254    ///     println!("Completed: {} - {}", issue.key, issue.fields.summary);
255    /// }
256    /// # Ok(())
257    /// # }
258    /// ```
259    pub async fn get_completed_issues(&mut self, date: &NaiveDate) -> Result<Vec<JiraIssue>> {
260        let mut local_retries = 0;
261        loop {
262            let session_id = self.get_session_id().await?;
263
264            match self.fetch_completed_pages(&session_id, date).await {
265                Ok(issues) => return Ok(issues),
266                Err(SearchPageError::Unauthorized) if local_retries < MAX_RETRY_COUNT => {
267                    let _ = self.delete_session_id();
268                    local_retries += 1;
269                    tokio::time::sleep(Duration::from_secs(1)).await;
270                }
271                Err(SearchPageError::Unauthorized) => {
272                    anyhow::bail!("Jira session unauthorized after retries")
273                }
274                Err(SearchPageError::Other(msg)) => {
275                    anyhow::bail!("Jira completed-issues search failed: {msg}")
276                }
277            }
278        }
279    }
280
281    /// Fetches all pages of completed issues for a valid session cookie.
282    async fn fetch_completed_pages(&self, session_id: &str, date: &NaiveDate) -> std::result::Result<Vec<JiraIssue>, SearchPageError> {
283        // Filter by resolution date only. Do not use `status in (...)` with English
284        // defaults like "Done"/"Resolved": on localized Jira those names are invalid
285        // and the whole JQL fails (HTTP 400), which used to look like "no issues".
286        let date_str = date.format("%Y-%m-%d").to_string();
287        let jql = format!(
288            "assignee = currentUser() AND resolved >= \"{}\" AND resolved <= \"{} 23:59\"",
289            date_str, date_str
290        );
291        let url = format!("{}/{}", self.config.api_url, SEARCH_URL);
292
293        let mut all = Vec::new();
294        let mut start_at: u32 = 0;
295
296        loop {
297            let mut headers = HeaderMap::new();
298            headers.insert(
299                COOKIE,
300                HeaderValue::from_str(session_id).map_err(|e| SearchPageError::Other(format!("invalid session cookie: {e}")))?,
301            );
302
303            let res = self
304                .client
305                .get(&url)
306                .headers(headers)
307                .query(&[
308                    ("jql", jql.as_str()),
309                    ("fields", "summary,status,priority,updated,resolutiondate"),
310                    ("startAt", &start_at.to_string()),
311                    ("maxResults", &SEARCH_PAGE_SIZE.to_string()),
312                ])
313                .send()
314                .await
315                .map_err(|e| SearchPageError::Other(format!("request failed: {e}")))?;
316
317            if session_is_anonymous(res.headers()) {
318                return Err(SearchPageError::Unauthorized);
319            }
320            match res.status() {
321                StatusCode::UNAUTHORIZED => return Err(SearchPageError::Unauthorized),
322                status if !status.is_success() => {
323                    let body = res.text().await.unwrap_or_default();
324                    return Err(SearchPageError::Other(format!("HTTP {status}: {body}")));
325                }
326                _ => {}
327            }
328
329            let page: JiraSearchResults = res.json().await.map_err(|e| SearchPageError::Other(format!("invalid JSON: {e}")))?;
330            let batch_len = page.issues.len() as u32;
331            all.extend(page.issues);
332
333            start_at += batch_len;
334            if batch_len == 0 || start_at >= page.total {
335                break;
336            }
337        }
338
339        Ok(all)
340    }
341
342    /// Fetches open issues currently assigned to the authenticated user.
343    ///
344    /// Uses JQL `assignee = currentUser() AND resolution is EMPTY`. Paginates
345    /// through all matching issues (`startAt` / `total`). Extra field ids
346    /// (custom fields such as Scoring) are included in the `fields` query.
347    ///
348    /// A poll that fails is an error, never an empty list. The caller
349    /// reconciles the inbox against whatever comes back, so an empty answer
350    /// for a dropped VPN would mark every issue gone - and the next good poll
351    /// would bring all of them "back", one change toast per issue.
352    pub async fn get_assigned_open_issues(&mut self, extra_field_ids: &[String]) -> Result<Vec<JiraIssue>> {
353        let mut local_retries = 0;
354        loop {
355            let session_id = self.get_session_id().await?;
356
357            match self.fetch_assigned_open_pages(&session_id, extra_field_ids).await {
358                Ok(issues) => return Ok(issues),
359                Err(SearchPageError::Unauthorized) if local_retries < MAX_RETRY_COUNT => {
360                    let _ = self.delete_session_id();
361                    local_retries += 1;
362                    tokio::time::sleep(Duration::from_secs(1)).await;
363                }
364                Err(e) => return Err(e.into_poll_error()),
365            }
366        }
367    }
368
369    /// Like [`get_assigned_open_issues`], but never prompts for a password.
370    ///
371    /// Uses a cached session cookie and/or the keyring secret. Returns
372    /// `Ok(None)` when neither is available so background daemons can skip
373    /// the poll without blocking on stdin. A poll that fails is an error, for
374    /// the same reason as in [`get_assigned_open_issues`]: the daemon must
375    /// skip the reconcile, not reconcile against nothing.
376    pub async fn get_assigned_open_issues_noninteractive(&mut self, extra_field_ids: &[String]) -> Result<Option<Vec<JiraIssue>>> {
377        let mut local_retries = 0;
378        loop {
379            let Some(session_id) = self.session_id_noninteractive().await? else {
380                return Ok(None);
381            };
382
383            match self.fetch_assigned_open_pages(&session_id, extra_field_ids).await {
384                Ok(issues) => return Ok(Some(issues)),
385                Err(SearchPageError::Unauthorized) if local_retries < MAX_RETRY_COUNT => {
386                    let _ = self.delete_session_id();
387                    local_retries += 1;
388                    tokio::time::sleep(Duration::from_secs(1)).await;
389                }
390                Err(e) => return Err(e.into_poll_error()),
391            }
392        }
393    }
394
395    /// Fetches all pages of assigned open issues for a valid session cookie.
396    ///
397    /// The order is by key, not by priority or update time: pages are cut by
398    /// offset, and an order that an ordinary edit can reshuffle moves issues
399    /// across a page boundary while the pages are read - one issue skipped is
400    /// one issue marked gone, then toasted "back" a poll later. Key order only
401    /// moves when an issue is created or resolved, and that case is caught by
402    /// [`check_complete`]. The inbox sorts on its own, so nothing here reads
403    /// this order.
404    async fn fetch_assigned_open_pages(&self, session_id: &str, extra_field_ids: &[String]) -> std::result::Result<Vec<JiraIssue>, SearchPageError> {
405        let jql = "assignee = currentUser() AND resolution is EMPTY ORDER BY key ASC";
406        let fields = build_search_fields(extra_field_ids);
407        let url = format!("{}/{}", self.config.api_url, SEARCH_URL);
408
409        let mut all: Vec<JiraIssue> = Vec::new();
410        let mut start_at: u32 = 0;
411        let mut first_total: Option<u32> = None;
412
413        loop {
414            let mut headers = HeaderMap::new();
415            headers.insert(
416                COOKIE,
417                HeaderValue::from_str(session_id).map_err(|e| SearchPageError::Other(format!("invalid session cookie: {e}")))?,
418            );
419
420            let res = self
421                .client
422                .get(&url)
423                .headers(headers)
424                .query(&[
425                    ("jql", jql),
426                    ("fields", fields.as_str()),
427                    ("startAt", &start_at.to_string()),
428                    ("maxResults", &SEARCH_PAGE_SIZE.to_string()),
429                ])
430                .send()
431                .await
432                .map_err(|e| SearchPageError::Other(format!("request failed: {e}")))?;
433
434            if session_is_anonymous(res.headers()) {
435                return Err(SearchPageError::Unauthorized);
436            }
437            match res.status() {
438                StatusCode::UNAUTHORIZED => return Err(SearchPageError::Unauthorized),
439                status if !status.is_success() => {
440                    let body = res.text().await.unwrap_or_default();
441                    return Err(SearchPageError::Other(format!("HTTP {status}: {body}")));
442                }
443                _ => {}
444            }
445
446            let page: JiraSearchResults = res.json().await.map_err(|e| SearchPageError::Other(format!("invalid JSON: {e}")))?;
447            let batch_len = page.issues.len() as u32;
448            first_total.get_or_insert(page.total);
449            all.extend(page.issues);
450
451            start_at += batch_len;
452            if batch_len == 0 || start_at >= page.total {
453                break;
454            }
455        }
456
457        check_complete(all, first_total.unwrap_or(0))
458    }
459
460    /// Resolves a session from cache / secret without prompting.
461    async fn session_id_noninteractive(&mut self) -> Result<Option<String>> {
462        let session_id_file_path = crate::libs::data_storage::DataStorage::new().get_path(SESSION_ID_FILE)?;
463        let path_str = session_id_file_path.to_str().unwrap_or_default();
464
465        if let Ok(session_id) = Self::read_session_id(path_str) {
466            return Ok(Some(session_id));
467        }
468
469        let Some(password) = self.secret().try_get_cached() else {
470            return Ok(None);
471        };
472
473        self.set_credentials(&password)?;
474        match self.login().await {
475            Ok(session_id) => {
476                let _ = Self::write_session_id(path_str, &session_id);
477                self.reset_retry();
478                Ok(Some(session_id))
479            }
480            Err(_) => Ok(None),
481        }
482    }
483
484    /// Builds the issue-navigator URL listing the user's open issues.
485    ///
486    /// Summary toasts point here: they speak for many issues at once, so no
487    /// single browse URL fits.
488    pub fn open_issues_url(&self) -> String {
489        let base = self.config.api_url.trim_end_matches('/');
490        format!("{base}/issues/?jql=assignee%20%3D%20currentUser()%20AND%20resolution%20is%20EMPTY")
491    }
492
493    /// Builds a browse URL for an issue key using this client's API base.
494    pub fn issue_browse_url(&self, key: &str) -> String {
495        let base = self.config.api_url.trim_end_matches('/');
496        format!("{}/browse/{}", base, key)
497    }
498
499    /// Maps a Jira priority id to a sortable rank (lower = more urgent).
500    pub fn priority_rank(priority: &Option<JiraPriority>) -> i32 {
501        priority
502            .as_ref()
503            .and_then(|p| p.id.as_ref())
504            .and_then(|id| id.parse::<i32>().ok())
505            .unwrap_or(999)
506    }
507
508    /// Extracts a numeric value from a Jira custom-field JSON value.
509    ///
510    /// Supports bare numbers, numeric strings, and objects with `value` / `amount`.
511    pub fn extract_number(value: &Value) -> Option<f64> {
512        match value {
513            Value::Number(n) => n.as_f64(),
514            Value::String(s) => s.trim().parse().ok(),
515            Value::Object(map) => map
516                .get("value")
517                .and_then(Self::extract_number)
518                .or_else(|| map.get("amount").and_then(Self::extract_number)),
519            _ => None,
520        }
521    }
522
523    /// Reads a numeric custom field from issue extras by field id.
524    pub fn sort_value_from_issue(issue: &JiraIssue, field_id: &str) -> Option<f64> {
525        issue.fields.extra.get(field_id).and_then(Self::extract_number)
526    }
527}
528
529/// Internal error for paginated search (auth vs other failures).
530enum SearchPageError {
531    Unauthorized,
532    Other(String),
533}
534
535impl SearchPageError {
536    /// Names what went wrong with an inbox poll, and what fixes it.
537    fn into_poll_error(self) -> anyhow::Error {
538        match self {
539            SearchPageError::Unauthorized => {
540                anyhow::anyhow!("Jira rejected the session {MAX_RETRY_COUNT} times; run `kasl inbox sync` to sign in again")
541            }
542            SearchPageError::Other(msg) => anyhow::anyhow!("Jira inbox poll failed: {msg}"),
543        }
544    }
545}
546
547/// Whether Jira answered as nobody: the cookie was sent but the session behind it is gone.
548///
549/// Jira Server and Data Center do not always answer an expired session with
550/// 401. The request goes through as the anonymous user, with 200, and
551/// `currentUser()` in the JQL then matches nothing. Read as an answer, that
552/// empty page marks the whole inbox gone overnight, and the first poll with
553/// a fresh session brings every issue "back". Jira says so in headers:
554/// `X-AUSERNAME: anonymous`, and `X-Seraph-LoginReason` when the cookie it
555/// was given failed.
556fn session_is_anonymous(headers: &HeaderMap) -> bool {
557    let header = |name: &str| headers.get(name).and_then(|v| v.to_str().ok()).unwrap_or_default();
558    let reason = header("x-seraph-loginreason");
559    header("x-ausername").eq_ignore_ascii_case("anonymous") || reason.contains("AUTHENTICATED_FAILED") || reason.contains("AUTHENTICATION_DENIED")
560}
561
562/// Keeps a paged search only if it holds every issue Jira counted.
563///
564/// Pages are cut by offset, so an issue created or resolved while they are
565/// read shifts the rest by one, and one issue falls between two pages. Such
566/// a list is short by an issue that is still open, and the sync would mark
567/// it gone. A poll is either the whole inbox or not an answer; the next poll
568/// a few minutes later reads a list that holds still. More than counted is
569/// fine - an issue read twice is still one issue, and a new one is simply
570/// early - so duplicates are folded and only a shortfall fails.
571fn check_complete(issues: Vec<JiraIssue>, total: u32) -> std::result::Result<Vec<JiraIssue>, SearchPageError> {
572    let mut seen = std::collections::HashSet::new();
573    let unique: Vec<JiraIssue> = issues.into_iter().filter(|issue| seen.insert(issue.key.clone())).collect();
574    if (unique.len() as u64) < u64::from(total) {
575        return Err(SearchPageError::Other(format!(
576            "the pages held {} of {} issues; the list changed while it was read",
577            unique.len(),
578            total
579        )));
580    }
581    Ok(unique)
582}
583
584fn build_search_fields(extra_field_ids: &[String]) -> String {
585    let mut fields = vec!["summary".to_string(), "status".to_string(), "priority".to_string(), "updated".to_string()];
586    for id in extra_field_ids {
587        let trimmed = id.trim();
588        if !trimmed.is_empty() && !fields.iter().any(|f| f == trimmed) {
589            fields.push(trimmed.to_string());
590        }
591    }
592    fields.join(",")
593}
594
595#[cfg(test)]
596mod tests {
597    use super::*;
598    use serde_json::json;
599
600    #[test]
601    fn extract_number_from_primitives_and_objects() {
602        assert_eq!(Jira::extract_number(&json!(12.5)), Some(12.5));
603        assert_eq!(Jira::extract_number(&json!("42")), Some(42.0));
604        assert_eq!(Jira::extract_number(&json!({"value": 7})), Some(7.0));
605        assert_eq!(Jira::extract_number(&json!({"amount": "3.5"})), Some(3.5));
606        assert_eq!(Jira::extract_number(&json!(null)), None);
607    }
608
609    #[test]
610    fn build_search_fields_includes_custom_ids() {
611        let fields = build_search_fields(&["customfield_10001".to_string(), "summary".to_string()]);
612        assert!(fields.contains("summary"));
613        assert!(fields.contains("customfield_10001"));
614        assert_eq!(fields.matches("summary").count(), 1);
615    }
616}
617
618/// Jira connection settings; passwords are never stored here.
619#[derive(Serialize, Deserialize, Clone, Debug)]
620pub struct JiraConfig {
621    /// Username (email for Atlassian Cloud accounts).
622    pub login: String,
623
624    /// Instance root URL, without the `/rest/api/` path.
625    pub api_url: String,
626
627    /// Deprecated: previously used in `status in (...)` for completed-issue search.
628    ///
629    /// Kept for config compatibility. Discovery now filters by `resolved` date only,
630    /// because non-existent status names (e.g. English "Done" on a Russian Jira)
631    /// make the whole JQL fail.
632    #[serde(default = "default_completed_statuses")]
633    pub completed_statuses: Vec<String>,
634}
635
636/// Empty default — status names are no longer used in completed-issue JQL.
637fn default_completed_statuses() -> Vec<String> {
638    Vec::new()
639}
640
641impl JiraConfig {
642    /// Module metadata for the setup wizard.
643    pub fn module() -> ConfigModule {
644        ConfigModule {
645            key: "jira".to_string(),
646            name: "Jira".to_string(),
647        }
648    }
649
650    /// Interactive setup; existing values become the prompt defaults.
651    ///
652    /// ```rust,no_run
653    /// # use kasl::api::JiraConfig;
654    /// # use anyhow::Result;
655    /// # fn example() -> Result<()> {
656    /// let existing_config = Some(JiraConfig {
657    ///     login: "olduser".to_string(),
658    ///     api_url: "https://old-jira.com".to_string(),
659    ///     completed_statuses: Vec::new(),
660    /// });
661    ///
662    /// let new_config = JiraConfig::init(&existing_config)?;
663    /// # Ok(())
664    /// # }
665    /// ```
666    pub fn init(config: &Option<Self>) -> Result<Self> {
667        // Use existing configuration as defaults, or create empty defaults
668        let config = config.clone().unwrap_or(Self {
669            login: "".to_string(),
670            api_url: "".to_string(),
671            completed_statuses: default_completed_statuses(),
672        });
673
674        // Display configuration module header
675        msg_print!(Message::ConfigModuleJira);
676
677        // Interactive configuration with existing values as defaults
678        Ok(Self {
679            completed_statuses: config.completed_statuses.clone(),
680            login: Input::with_theme(&ColorfulTheme::default())
681                .with_prompt("Enter your Jira login")
682                .default(config.login)
683                .interact_text()?,
684            api_url: Input::with_theme(&ColorfulTheme::default())
685                .with_prompt("Enter the Jira API URL")
686                .default(config.api_url)
687                .interact_text()?,
688        })
689    }
690}