1use 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#[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#[derive(Serialize, Deserialize, Debug)]
62struct JiraSession {
63 name: String,
64 value: String,
65}
66
67#[derive(Serialize, Deserialize, Debug)]
69pub struct JiraIssue {
70 pub id: String,
72 pub key: String,
74 pub fields: JiraIssueFields,
75}
76
77#[derive(Serialize, Deserialize, Debug)]
79pub struct JiraIssueFields {
80 pub summary: String,
82 #[serde(default)]
84 pub description: Option<String>,
85 pub status: JiraStatus,
87 #[serde(default)]
89 pub resolutiondate: Option<String>,
90 #[serde(default)]
92 pub priority: Option<JiraPriority>,
93 #[serde(default)]
95 pub updated: Option<String>,
96 #[serde(flatten)]
98 pub extra: HashMap<String, Value>,
99}
100
101#[derive(Serialize, Deserialize, Debug, Clone)]
103pub struct JiraStatus {
104 #[serde(default, deserialize_with = "deserialize_jira_id")]
106 pub id: String,
107 pub name: String,
109}
110
111fn 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#[derive(Serialize, Deserialize, Debug, Clone)]
129pub struct JiraPriority {
130 pub name: String,
132 #[serde(default)]
134 pub id: Option<String>,
135}
136
137#[derive(Serialize, Deserialize, Debug)]
139pub struct JiraSearchResults {
140 #[serde(default, rename = "startAt")]
142 pub start_at: u32,
143 #[serde(default, rename = "maxResults")]
145 pub max_results: u32,
146 #[serde(default)]
148 pub total: u32,
149 pub issues: Vec<JiraIssue>,
151}
152
153#[derive(Debug)]
155pub struct Jira {
156 client: Client,
157 config: JiraConfig,
158 credentials: Option<LoginCredentials>,
160 retries: i32,
161}
162
163impl Session for Jira {
164 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 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 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 async fn fetch_completed_pages(&self, session_id: &str, date: &NaiveDate) -> std::result::Result<Vec<JiraIssue>, SearchPageError> {
283 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 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 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 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 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 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 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 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 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 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
529enum SearchPageError {
531 Unauthorized,
532 Other(String),
533}
534
535impl SearchPageError {
536 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
547fn 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
562fn 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#[derive(Serialize, Deserialize, Clone, Debug)]
620pub struct JiraConfig {
621 pub login: String,
623
624 pub api_url: String,
626
627 #[serde(default = "default_completed_statuses")]
633 pub completed_statuses: Vec<String>,
634}
635
636fn default_completed_statuses() -> Vec<String> {
638 Vec::new()
639}
640
641impl JiraConfig {
642 pub fn module() -> ConfigModule {
644 ConfigModule {
645 key: "jira".to_string(),
646 name: "Jira".to_string(),
647 }
648 }
649
650 pub fn init(config: &Option<Self>) -> Result<Self> {
667 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 msg_print!(Message::ConfigModuleJira);
676
677 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}