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
48const ISSUE_URL: &str = "rest/api/2/issue";
49
50const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
57
58#[derive(Serialize, Clone, Debug)]
60pub struct LoginCredentials {
61 username: String,
62 password: String,
63}
64
65#[derive(Serialize, Deserialize, Debug)]
66struct JiraSessionResponse {
67 session: JiraSession,
68}
69
70#[derive(Serialize, Deserialize, Debug)]
72struct JiraSession {
73 name: String,
74 value: String,
75}
76
77#[derive(Serialize, Deserialize, Debug)]
79pub struct JiraIssue {
80 pub id: String,
82 pub key: String,
84 pub fields: JiraIssueFields,
85}
86
87#[derive(Serialize, Deserialize, Debug)]
89pub struct JiraIssueFields {
90 pub summary: String,
92 #[serde(default)]
94 pub description: Option<String>,
95 pub status: JiraStatus,
97 #[serde(default)]
99 pub resolutiondate: Option<String>,
100 #[serde(default)]
102 pub priority: Option<JiraPriority>,
103 #[serde(default)]
105 pub updated: Option<String>,
106 #[serde(flatten)]
108 pub extra: HashMap<String, Value>,
109}
110
111#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
113pub struct JiraStatus {
114 #[serde(default, deserialize_with = "deserialize_jira_id")]
116 pub id: String,
117 pub name: String,
119 #[serde(default, rename = "statusCategory", skip_serializing_if = "Option::is_none")]
121 pub category: Option<JiraCategory>,
122}
123
124impl JiraStatus {
125 pub fn category(&self) -> StatusCategory {
127 self.category
128 .as_ref()
129 .map(|c| StatusCategory::from_key(&c.key))
130 .unwrap_or(StatusCategory::Unknown)
131 }
132}
133
134#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
136pub struct JiraCategory {
137 pub key: String,
139}
140
141#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub enum StatusCategory {
148 ToDo,
150 InProgress,
152 Done,
154 Unknown,
156}
157
158impl StatusCategory {
159 pub fn from_key(key: &str) -> Self {
161 match key {
162 "new" => StatusCategory::ToDo,
163 "indeterminate" => StatusCategory::InProgress,
164 "done" => StatusCategory::Done,
165 _ => StatusCategory::Unknown,
166 }
167 }
168}
169
170#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
172pub struct JiraTransition {
173 #[serde(deserialize_with = "deserialize_jira_id")]
174 pub id: String,
175 pub name: String,
177 pub to: JiraStatus,
179 #[serde(default)]
181 pub fields: HashMap<String, JiraTransitionField>,
182}
183
184#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
186pub struct JiraTransitionField {
187 #[serde(default)]
188 pub required: bool,
189 #[serde(default, rename = "allowedValues")]
190 pub allowed_values: Vec<JiraNamedValue>,
191}
192
193#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
195pub struct JiraNamedValue {
196 #[serde(default, deserialize_with = "deserialize_jira_id")]
197 pub id: String,
198 #[serde(default)]
199 pub name: String,
200}
201
202#[derive(Debug, Clone, PartialEq, Eq)]
204pub struct IssueState {
205 pub status: JiraStatus,
206 pub resolution: Option<String>,
208}
209
210#[derive(Debug, Clone, Copy, PartialEq, Eq)]
212pub enum SessionMode {
213 Interactive,
215 Unattended,
218}
219
220fn deserialize_jira_id<'de, D>(deserializer: D) -> std::result::Result<String, D::Error>
222where
223 D: Deserializer<'de>,
224{
225 let value = Option::<Value>::deserialize(deserializer)?;
226 Ok(match value {
227 Some(Value::String(s)) => s,
228 Some(Value::Number(n)) => n.to_string(),
229 _ => String::new(),
230 })
231}
232
233#[derive(Serialize, Deserialize, Debug, Clone)]
238pub struct JiraPriority {
239 pub name: String,
241 #[serde(default)]
243 pub id: Option<String>,
244}
245
246#[derive(Serialize, Deserialize, Debug)]
248pub struct JiraSearchResults {
249 #[serde(default, rename = "startAt")]
251 pub start_at: u32,
252 #[serde(default, rename = "maxResults")]
254 pub max_results: u32,
255 #[serde(default)]
257 pub total: u32,
258 pub issues: Vec<JiraIssue>,
260}
261
262#[derive(Debug)]
264pub struct Jira {
265 client: Client,
266 config: JiraConfig,
267 credentials: Option<LoginCredentials>,
269 retries: i32,
270}
271
272impl Session for Jira {
273 async fn login(&self) -> Result<String> {
275 let credentials = self.credentials.clone().expect("Credentials not set!");
276
277 let auth_url = format!("{}/{}", self.config.api_url, AUTH_URL);
278 let auth_res = self.client.post(auth_url).json(&credentials).send().await?;
279
280 if !auth_res.status().is_success() {
281 anyhow::bail!("Jira authenticate failed")
282 }
283
284 let session_res = auth_res.json::<JiraSessionResponse>().await?;
285
286 let session_id = format!("{}={}", session_res.session.name, session_res.session.value);
287 Ok(session_id)
288 }
289
290 fn set_credentials(&mut self, password: &str) -> Result<()> {
291 self.credentials = Some(LoginCredentials {
292 username: self.config.login.to_string(),
293 password: password.to_owned(),
294 });
295 Ok(())
296 }
297
298 fn session_id_file(&self) -> &str {
299 SESSION_ID_FILE
300 }
301
302 fn secret(&self) -> Secret {
303 Secret::new(SECRET_FILE, "Enter your Jira password")
304 }
305
306 fn retry(&self) -> i32 {
307 self.retries
308 }
309
310 fn inc_retry(&mut self) {
311 self.retries += 1;
312 }
313
314 fn reset_retry(&mut self) {
315 self.retries = 0;
316 }
317}
318
319impl Jira {
320 pub fn new(config: &JiraConfig) -> Self {
333 let client = Client::builder()
334 .timeout(REQUEST_TIMEOUT)
335 .connect_timeout(Duration::from_secs(10))
336 .build()
337 .unwrap_or_else(|_| Client::new());
338 Self {
339 client,
340 config: config.clone(),
341 credentials: None,
342 retries: 0,
343 }
344 }
345
346 pub async fn get_completed_issues(&mut self, date: &NaiveDate) -> Result<Vec<JiraIssue>> {
374 let mut local_retries = 0;
375 loop {
376 let session_id = self.get_session_id().await?;
377
378 match self.fetch_completed_pages(&session_id, date).await {
379 Ok(issues) => return Ok(issues),
380 Err(SearchPageError::Unauthorized) if local_retries < MAX_RETRY_COUNT => {
381 let _ = self.delete_session_id();
382 local_retries += 1;
383 tokio::time::sleep(Duration::from_secs(1)).await;
384 }
385 Err(SearchPageError::Unauthorized) => {
386 anyhow::bail!("Jira session unauthorized after retries")
387 }
388 Err(SearchPageError::Other(msg)) => {
389 anyhow::bail!("Jira completed-issues search failed: {msg}")
390 }
391 }
392 }
393 }
394
395 async fn fetch_completed_pages(&self, session_id: &str, date: &NaiveDate) -> std::result::Result<Vec<JiraIssue>, SearchPageError> {
397 let date_str = date.format("%Y-%m-%d").to_string();
401 let jql = format!(
402 "assignee = currentUser() AND resolved >= \"{}\" AND resolved <= \"{} 23:59\"",
403 date_str, date_str
404 );
405 let url = format!("{}/{}", self.config.api_url, SEARCH_URL);
406
407 let mut all = Vec::new();
408 let mut start_at: u32 = 0;
409
410 loop {
411 let mut headers = HeaderMap::new();
412 headers.insert(
413 COOKIE,
414 HeaderValue::from_str(session_id).map_err(|e| SearchPageError::Other(format!("invalid session cookie: {e}")))?,
415 );
416
417 let res = self
418 .client
419 .get(&url)
420 .headers(headers)
421 .query(&[
422 ("jql", jql.as_str()),
423 ("fields", "summary,status,priority,updated,resolutiondate"),
424 ("startAt", &start_at.to_string()),
425 ("maxResults", &SEARCH_PAGE_SIZE.to_string()),
426 ])
427 .send()
428 .await
429 .map_err(|e| SearchPageError::Other(format!("request failed: {e}")))?;
430
431 if session_is_anonymous(res.headers()) {
432 return Err(SearchPageError::Unauthorized);
433 }
434 match res.status() {
435 StatusCode::UNAUTHORIZED => return Err(SearchPageError::Unauthorized),
436 status if !status.is_success() => {
437 let body = res.text().await.unwrap_or_default();
438 return Err(SearchPageError::Other(format!("HTTP {status}: {body}")));
439 }
440 _ => {}
441 }
442
443 let page: JiraSearchResults = res.json().await.map_err(|e| SearchPageError::Other(format!("invalid JSON: {e}")))?;
444 let batch_len = page.issues.len() as u32;
445 all.extend(page.issues);
446
447 start_at += batch_len;
448 if batch_len == 0 || start_at >= page.total {
449 break;
450 }
451 }
452
453 Ok(all)
454 }
455
456 pub async fn get_assigned_open_issues(&mut self, extra_field_ids: &[String]) -> Result<Vec<JiraIssue>> {
467 let mut local_retries = 0;
468 loop {
469 let session_id = self.get_session_id().await?;
470
471 match self.fetch_assigned_open_pages(&session_id, extra_field_ids).await {
472 Ok(issues) => return Ok(issues),
473 Err(SearchPageError::Unauthorized) if local_retries < MAX_RETRY_COUNT => {
474 let _ = self.delete_session_id();
475 local_retries += 1;
476 tokio::time::sleep(Duration::from_secs(1)).await;
477 }
478 Err(e) => return Err(e.into_poll_error()),
479 }
480 }
481 }
482
483 pub async fn get_assigned_open_issues_noninteractive(&mut self, extra_field_ids: &[String]) -> Result<Option<Vec<JiraIssue>>> {
491 let mut local_retries = 0;
492 loop {
493 let Some(session_id) = self.session_id_noninteractive().await? else {
494 return Ok(None);
495 };
496
497 match self.fetch_assigned_open_pages(&session_id, extra_field_ids).await {
498 Ok(issues) => return Ok(Some(issues)),
499 Err(SearchPageError::Unauthorized) if local_retries < MAX_RETRY_COUNT => {
500 let _ = self.delete_session_id();
501 local_retries += 1;
502 tokio::time::sleep(Duration::from_secs(1)).await;
503 }
504 Err(e) => return Err(e.into_poll_error()),
505 }
506 }
507 }
508
509 async fn fetch_assigned_open_pages(&self, session_id: &str, extra_field_ids: &[String]) -> std::result::Result<Vec<JiraIssue>, SearchPageError> {
519 let jql = "assignee = currentUser() AND resolution is EMPTY ORDER BY key ASC";
520 let fields = build_search_fields(extra_field_ids);
521 let url = format!("{}/{}", self.config.api_url, SEARCH_URL);
522
523 let mut all: Vec<JiraIssue> = Vec::new();
524 let mut start_at: u32 = 0;
525 let mut first_total: Option<u32> = None;
526
527 loop {
528 let mut headers = HeaderMap::new();
529 headers.insert(
530 COOKIE,
531 HeaderValue::from_str(session_id).map_err(|e| SearchPageError::Other(format!("invalid session cookie: {e}")))?,
532 );
533
534 let res = self
535 .client
536 .get(&url)
537 .headers(headers)
538 .query(&[
539 ("jql", jql),
540 ("fields", fields.as_str()),
541 ("startAt", &start_at.to_string()),
542 ("maxResults", &SEARCH_PAGE_SIZE.to_string()),
543 ])
544 .send()
545 .await
546 .map_err(|e| SearchPageError::Other(format!("request failed: {e}")))?;
547
548 if session_is_anonymous(res.headers()) {
549 return Err(SearchPageError::Unauthorized);
550 }
551 match res.status() {
552 StatusCode::UNAUTHORIZED => return Err(SearchPageError::Unauthorized),
553 status if !status.is_success() => {
554 let body = res.text().await.unwrap_or_default();
555 return Err(SearchPageError::Other(format!("HTTP {status}: {body}")));
556 }
557 _ => {}
558 }
559
560 let page: JiraSearchResults = res.json().await.map_err(|e| SearchPageError::Other(format!("invalid JSON: {e}")))?;
561 let batch_len = page.issues.len() as u32;
562 first_total.get_or_insert(page.total);
563 all.extend(page.issues);
564
565 start_at += batch_len;
566 if batch_len == 0 || start_at >= page.total {
567 break;
568 }
569 }
570
571 check_complete(all, first_total.unwrap_or(0))
572 }
573
574 async fn session_id_noninteractive(&mut self) -> Result<Option<String>> {
576 let session_id_file_path = crate::libs::data_storage::DataStorage::new().get_path(SESSION_ID_FILE)?;
577 let path_str = session_id_file_path.to_str().unwrap_or_default();
578
579 if let Ok(session_id) = Self::read_session_id(path_str) {
580 return Ok(Some(session_id));
581 }
582
583 let Some(password) = self.secret().try_get_cached() else {
584 return Ok(None);
585 };
586
587 self.set_credentials(&password)?;
588 match self.login().await {
589 Ok(session_id) => {
590 let _ = Self::write_session_id(path_str, &session_id);
591 self.reset_retry();
592 Ok(Some(session_id))
593 }
594 Err(_) => Ok(None),
595 }
596 }
597
598 async fn session(&mut self, mode: SessionMode) -> Result<String> {
600 match mode {
601 SessionMode::Interactive => self.get_session_id().await,
602 SessionMode::Unattended => self
603 .session_id_noninteractive()
604 .await?
605 .ok_or_else(|| anyhow::anyhow!("not signed in to Jira; run `kasl inbox sync` on a terminal to sign in")),
606 }
607 }
608
609 async fn send(&mut self, mode: SessionMode, build: impl Fn(&Client) -> reqwest::RequestBuilder) -> Result<reqwest::Response> {
617 let mut retries = 0;
618 loop {
619 let session_id = self.session(mode).await?;
620 let res = build(&self.client).header(COOKIE, HeaderValue::from_str(&session_id)?).send().await?;
621 let rejected = res.status() == StatusCode::UNAUTHORIZED || session_is_anonymous(res.headers());
622 if !rejected {
623 return Ok(res);
624 }
625 if retries >= MAX_RETRY_COUNT {
626 anyhow::bail!("Jira rejected the session {MAX_RETRY_COUNT} times; run `kasl inbox sync` to sign in again");
627 }
628 let _ = self.delete_session_id();
629 retries += 1;
630 tokio::time::sleep(Duration::from_secs(1)).await;
631 }
632 }
633
634 fn issue_url(&self, key: &str, tail: &str) -> String {
635 format!("{}/{}/{}{}", self.config.api_url.trim_end_matches('/'), ISSUE_URL, key, tail)
636 }
637
638 pub async fn issue_state(&mut self, key: &str, mode: SessionMode) -> Result<Option<IssueState>> {
644 let url = self.issue_url(key, "");
645 let res = self.send(mode, |c| c.get(&url).query(&[("fields", "status,resolution")])).await?;
646 if res.status() == StatusCode::NOT_FOUND {
647 return Ok(None);
648 }
649 let res = ok_or_jira_error(res, &format!("reading {key}")).await?;
650
651 #[derive(Deserialize)]
652 struct Body {
653 fields: Fields,
654 }
655 #[derive(Deserialize)]
656 struct Fields {
657 status: JiraStatus,
658 #[serde(default)]
659 resolution: Option<JiraNamedValue>,
660 }
661 let body: Body = res.json().await?;
662 Ok(Some(IssueState {
663 status: body.fields.status,
664 resolution: body.fields.resolution.map(|r| r.name),
665 }))
666 }
667
668 pub async fn transitions(&mut self, key: &str, mode: SessionMode) -> Result<Vec<JiraTransition>> {
670 let url = self.issue_url(key, "/transitions");
671 let res = self.send(mode, |c| c.get(&url).query(&[("expand", "transitions.fields")])).await?;
672 let res = ok_or_jira_error(res, &format!("reading the transitions of {key}")).await?;
673
674 #[derive(Deserialize)]
675 struct Body {
676 #[serde(default)]
677 transitions: Vec<JiraTransition>,
678 }
679 Ok(res.json::<Body>().await?.transitions)
680 }
681
682 pub async fn transition(&mut self, key: &str, id: &str, resolution_id: Option<&str>, mode: SessionMode) -> Result<()> {
684 let url = self.issue_url(key, "/transitions");
685 let mut body = serde_json::json!({ "transition": { "id": id } });
686 if let Some(resolution) = resolution_id {
687 body["fields"] = serde_json::json!({ "resolution": { "id": resolution } });
688 }
689 let res = self.send(mode, |c| c.post(&url).json(&body)).await?;
690 ok_or_jira_error(res, &format!("moving {key}")).await?;
691 Ok(())
692 }
693
694 pub async fn add_comment(&mut self, key: &str, text: &str, mode: SessionMode) -> Result<()> {
696 let url = self.issue_url(key, "/comment");
697 let body = serde_json::json!({ "body": text });
698 let res = self.send(mode, |c| c.post(&url).json(&body)).await?;
699 ok_or_jira_error(res, &format!("commenting on {key}")).await?;
700 Ok(())
701 }
702
703 pub fn open_issues_url(&self) -> String {
708 let base = self.config.api_url.trim_end_matches('/');
709 format!("{base}/issues/?jql=assignee%20%3D%20currentUser()%20AND%20resolution%20is%20EMPTY")
710 }
711
712 pub fn issue_browse_url(&self, key: &str) -> String {
714 let base = self.config.api_url.trim_end_matches('/');
715 format!("{}/browse/{}", base, key)
716 }
717
718 pub fn priority_rank(priority: &Option<JiraPriority>) -> i32 {
720 priority
721 .as_ref()
722 .and_then(|p| p.id.as_ref())
723 .and_then(|id| id.parse::<i32>().ok())
724 .unwrap_or(999)
725 }
726
727 pub fn extract_number(value: &Value) -> Option<f64> {
731 match value {
732 Value::Number(n) => n.as_f64(),
733 Value::String(s) => s.trim().parse().ok(),
734 Value::Object(map) => map
735 .get("value")
736 .and_then(Self::extract_number)
737 .or_else(|| map.get("amount").and_then(Self::extract_number)),
738 _ => None,
739 }
740 }
741
742 pub fn sort_value_from_issue(issue: &JiraIssue, field_id: &str) -> Option<f64> {
744 issue.fields.extra.get(field_id).and_then(Self::extract_number)
745 }
746}
747
748enum SearchPageError {
750 Unauthorized,
751 Other(String),
752}
753
754impl SearchPageError {
755 fn into_poll_error(self) -> anyhow::Error {
757 match self {
758 SearchPageError::Unauthorized => {
759 anyhow::anyhow!("Jira rejected the session {MAX_RETRY_COUNT} times; run `kasl inbox sync` to sign in again")
760 }
761 SearchPageError::Other(msg) => anyhow::anyhow!("Jira inbox poll failed: {msg}"),
762 }
763 }
764}
765
766fn session_is_anonymous(headers: &HeaderMap) -> bool {
776 let header = |name: &str| headers.get(name).and_then(|v| v.to_str().ok()).unwrap_or_default();
777 let reason = header("x-seraph-loginreason");
778 header("x-ausername").eq_ignore_ascii_case("anonymous") || reason.contains("AUTHENTICATED_FAILED") || reason.contains("AUTHENTICATION_DENIED")
779}
780
781async fn ok_or_jira_error(res: reqwest::Response, what: &str) -> Result<reqwest::Response> {
788 let status = res.status();
789 if status.is_success() {
790 return Ok(res);
791 }
792 let body = res.text().await.unwrap_or_default();
793 anyhow::bail!("Jira refused {what} (HTTP {}): {}", status.as_u16(), jira_error_text(&body))
794}
795
796pub fn jira_error_text(body: &str) -> String {
798 #[derive(Deserialize, Default)]
799 struct ErrorBody {
800 #[serde(default, rename = "errorMessages")]
801 messages: Vec<String>,
802 #[serde(default)]
803 errors: HashMap<String, String>,
804 }
805 let parsed: ErrorBody = serde_json::from_str(body).unwrap_or_default();
806 let mut parts = parsed.messages;
807 let mut fields: Vec<(String, String)> = parsed.errors.into_iter().collect();
808 fields.sort();
809 parts.extend(fields.into_iter().map(|(field, message)| format!("{field}: {message}")));
810 if parts.is_empty() {
811 let trimmed = body.trim();
812 return if trimmed.is_empty() {
813 "no explanation given".to_string()
814 } else {
815 trimmed.chars().take(300).collect()
816 };
817 }
818 parts.join("; ")
819}
820
821fn check_complete(issues: Vec<JiraIssue>, total: u32) -> std::result::Result<Vec<JiraIssue>, SearchPageError> {
831 let mut seen = std::collections::HashSet::new();
832 let unique: Vec<JiraIssue> = issues.into_iter().filter(|issue| seen.insert(issue.key.clone())).collect();
833 if (unique.len() as u64) < u64::from(total) {
834 return Err(SearchPageError::Other(format!(
835 "the pages held {} of {} issues; the list changed while it was read",
836 unique.len(),
837 total
838 )));
839 }
840 Ok(unique)
841}
842
843fn build_search_fields(extra_field_ids: &[String]) -> String {
844 let mut fields = vec!["summary".to_string(), "status".to_string(), "priority".to_string(), "updated".to_string()];
845 for id in extra_field_ids {
846 let trimmed = id.trim();
847 if !trimmed.is_empty() && !fields.iter().any(|f| f == trimmed) {
848 fields.push(trimmed.to_string());
849 }
850 }
851 fields.join(",")
852}
853
854#[cfg(test)]
855mod tests {
856 use super::*;
857 use serde_json::json;
858
859 #[test]
860 fn extract_number_from_primitives_and_objects() {
861 assert_eq!(Jira::extract_number(&json!(12.5)), Some(12.5));
862 assert_eq!(Jira::extract_number(&json!("42")), Some(42.0));
863 assert_eq!(Jira::extract_number(&json!({"value": 7})), Some(7.0));
864 assert_eq!(Jira::extract_number(&json!({"amount": "3.5"})), Some(3.5));
865 assert_eq!(Jira::extract_number(&json!(null)), None);
866 }
867
868 #[test]
869 fn a_status_is_placed_by_its_category_key_not_its_name() {
870 let status: JiraStatus = serde_json::from_value(json!({
871 "id": 3, "name": "В работе", "statusCategory": {"id": 4, "key": "indeterminate", "name": "В работе"}
872 }))
873 .unwrap();
874 assert_eq!(status.category(), StatusCategory::InProgress);
875
876 let bare: JiraStatus = serde_json::from_value(json!({"id": "1", "name": "Open"})).unwrap();
877 assert_eq!(bare.category(), StatusCategory::Unknown, "no category sent is unknown, not to-do");
878 }
879
880 #[test]
881 fn a_jira_refusal_reads_as_its_own_sentences() {
882 let body = r#"{"errorMessages":["You do not have permission to transition this issue."],"errors":{"resolution":"Resolution is required."}}"#;
883 assert_eq!(
884 jira_error_text(body),
885 "You do not have permission to transition this issue.; resolution: Resolution is required."
886 );
887 assert_eq!(jira_error_text("<html>Bad gateway</html>"), "<html>Bad gateway</html>");
888 assert_eq!(jira_error_text(""), "no explanation given");
889 }
890
891 #[test]
892 fn build_search_fields_includes_custom_ids() {
893 let fields = build_search_fields(&["customfield_10001".to_string(), "summary".to_string()]);
894 assert!(fields.contains("summary"));
895 assert!(fields.contains("customfield_10001"));
896 assert_eq!(fields.matches("summary").count(), 1);
897 }
898}
899
900#[derive(Serialize, Deserialize, Clone, Debug)]
902pub struct JiraConfig {
903 pub login: String,
905
906 pub api_url: String,
908
909 #[serde(default = "default_completed_statuses")]
915 pub completed_statuses: Vec<String>,
916}
917
918fn default_completed_statuses() -> Vec<String> {
920 Vec::new()
921}
922
923impl JiraConfig {
924 pub fn module() -> ConfigModule {
926 ConfigModule {
927 key: "jira".to_string(),
928 name: "Jira".to_string(),
929 }
930 }
931
932 pub fn init(config: &Option<Self>) -> Result<Self> {
949 let config = config.clone().unwrap_or(Self {
951 login: "".to_string(),
952 api_url: "".to_string(),
953 completed_statuses: default_completed_statuses(),
954 });
955
956 msg_print!(Message::ConfigModuleJira);
958
959 Ok(Self {
961 completed_statuses: config.completed_statuses.clone(),
962 login: Input::with_theme(&ColorfulTheme::default())
963 .with_prompt("Enter your Jira login")
964 .default(config.login)
965 .interact_text()?,
966 api_url: Input::with_theme(&ColorfulTheme::default())
967 .with_prompt("Enter the Jira API URL")
968 .default(config.api_url)
969 .interact_text()?,
970 })
971 }
972}