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(default)]
108 pub issuetype: Option<JiraIssueType>,
109 #[serde(default)]
111 pub project: Option<JiraProject>,
112 #[serde(flatten)]
114 pub extra: HashMap<String, Value>,
115}
116
117#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
119pub struct JiraStatus {
120 #[serde(default, deserialize_with = "deserialize_jira_id")]
122 pub id: String,
123 pub name: String,
125 #[serde(default, rename = "statusCategory", skip_serializing_if = "Option::is_none")]
127 pub category: Option<JiraCategory>,
128}
129
130impl JiraStatus {
131 pub fn category(&self) -> StatusCategory {
133 self.category
134 .as_ref()
135 .map(|c| StatusCategory::from_key(&c.key))
136 .unwrap_or(StatusCategory::Unknown)
137 }
138}
139
140#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
142pub struct JiraCategory {
143 pub key: String,
145}
146
147#[derive(Debug, Clone, Copy, PartialEq, Eq)]
153pub enum StatusCategory {
154 ToDo,
156 InProgress,
158 Done,
160 Unknown,
162}
163
164impl StatusCategory {
165 pub fn from_key(key: &str) -> Self {
167 match key {
168 "new" => StatusCategory::ToDo,
169 "indeterminate" => StatusCategory::InProgress,
170 "done" => StatusCategory::Done,
171 _ => StatusCategory::Unknown,
172 }
173 }
174}
175
176#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
178pub struct JiraTransition {
179 #[serde(deserialize_with = "deserialize_jira_id")]
180 pub id: String,
181 pub name: String,
183 pub to: JiraStatus,
185 #[serde(default)]
187 pub fields: HashMap<String, JiraTransitionField>,
188}
189
190#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
192pub struct JiraTransitionField {
193 #[serde(default)]
194 pub required: bool,
195 #[serde(default, rename = "allowedValues")]
196 pub allowed_values: Vec<JiraNamedValue>,
197}
198
199#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
201pub struct JiraNamedValue {
202 #[serde(default, deserialize_with = "deserialize_jira_id")]
203 pub id: String,
204 #[serde(default)]
205 pub name: String,
206}
207
208#[derive(Debug, Clone, PartialEq, Eq)]
210pub struct IssueState {
211 pub status: JiraStatus,
212 pub resolution: Option<String>,
214}
215
216#[derive(Debug, Clone, Copy, PartialEq, Eq)]
218pub enum SessionMode {
219 Interactive,
221 Unattended,
224}
225
226fn deserialize_jira_id<'de, D>(deserializer: D) -> std::result::Result<String, D::Error>
228where
229 D: Deserializer<'de>,
230{
231 let value = Option::<Value>::deserialize(deserializer)?;
232 Ok(match value {
233 Some(Value::String(s)) => s,
234 Some(Value::Number(n)) => n.to_string(),
235 _ => String::new(),
236 })
237}
238
239#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
241pub struct JiraIssueType {
242 pub name: String,
243}
244
245#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
247pub struct JiraProject {
248 pub key: String,
249}
250
251#[derive(Serialize, Deserialize, Debug, Clone)]
256pub struct JiraPriority {
257 pub name: String,
259 #[serde(default)]
261 pub id: Option<String>,
262}
263
264#[derive(Serialize, Deserialize, Debug)]
266pub struct JiraSearchResults {
267 #[serde(default, rename = "startAt")]
269 pub start_at: u32,
270 #[serde(default, rename = "maxResults")]
272 pub max_results: u32,
273 #[serde(default)]
275 pub total: u32,
276 pub issues: Vec<JiraIssue>,
278}
279
280#[derive(Debug)]
282pub struct Jira {
283 client: Client,
284 config: JiraConfig,
285 credentials: Option<LoginCredentials>,
287 retries: i32,
288}
289
290impl Session for Jira {
291 async fn login(&self) -> Result<String> {
293 let credentials = self.credentials.clone().expect("Credentials not set!");
294
295 let auth_url = format!("{}/{}", self.config.api_url, AUTH_URL);
296 let auth_res = self.client.post(auth_url).json(&credentials).send().await?;
297
298 if !auth_res.status().is_success() {
299 anyhow::bail!("Jira authenticate failed")
300 }
301
302 let session_res = auth_res.json::<JiraSessionResponse>().await?;
303
304 let session_id = format!("{}={}", session_res.session.name, session_res.session.value);
305 Ok(session_id)
306 }
307
308 fn set_credentials(&mut self, password: &str) -> Result<()> {
309 self.credentials = Some(LoginCredentials {
310 username: self.config.login.to_string(),
311 password: password.to_owned(),
312 });
313 Ok(())
314 }
315
316 fn session_id_file(&self) -> &str {
317 SESSION_ID_FILE
318 }
319
320 fn secret(&self) -> Secret {
321 Secret::new(SECRET_FILE, "Enter your Jira password")
322 }
323
324 fn retry(&self) -> i32 {
325 self.retries
326 }
327
328 fn inc_retry(&mut self) {
329 self.retries += 1;
330 }
331
332 fn reset_retry(&mut self) {
333 self.retries = 0;
334 }
335}
336
337impl Jira {
338 pub fn new(config: &JiraConfig) -> Self {
351 let client = Client::builder()
352 .timeout(REQUEST_TIMEOUT)
353 .connect_timeout(Duration::from_secs(10))
354 .build()
355 .unwrap_or_else(|_| Client::new());
356 Self {
357 client,
358 config: config.clone(),
359 credentials: None,
360 retries: 0,
361 }
362 }
363
364 pub async fn get_completed_issues(&mut self, date: &NaiveDate) -> Result<Vec<JiraIssue>> {
392 let mut local_retries = 0;
393 loop {
394 let session_id = self.get_session_id().await?;
395
396 match self.fetch_completed_pages(&session_id, date).await {
397 Ok(issues) => return Ok(issues),
398 Err(SearchPageError::Unauthorized) if local_retries < MAX_RETRY_COUNT => {
399 let _ = self.delete_session_id();
400 local_retries += 1;
401 tokio::time::sleep(Duration::from_secs(1)).await;
402 }
403 Err(SearchPageError::Unauthorized) => {
404 anyhow::bail!("Jira session unauthorized after retries")
405 }
406 Err(SearchPageError::Other(msg)) => {
407 anyhow::bail!("Jira completed-issues search failed: {msg}")
408 }
409 }
410 }
411 }
412
413 async fn fetch_completed_pages(&self, session_id: &str, date: &NaiveDate) -> std::result::Result<Vec<JiraIssue>, SearchPageError> {
415 let date_str = date.format("%Y-%m-%d").to_string();
419 let jql = format!(
420 "assignee = currentUser() AND resolved >= \"{}\" AND resolved <= \"{} 23:59\"",
421 date_str, date_str
422 );
423 let url = format!("{}/{}", self.config.api_url, SEARCH_URL);
424
425 let mut all = Vec::new();
426 let mut start_at: u32 = 0;
427
428 loop {
429 let mut headers = HeaderMap::new();
430 headers.insert(
431 COOKIE,
432 HeaderValue::from_str(session_id).map_err(|e| SearchPageError::Other(format!("invalid session cookie: {e}")))?,
433 );
434
435 let res = self
436 .client
437 .get(&url)
438 .headers(headers)
439 .query(&[
440 ("jql", jql.as_str()),
441 ("fields", "summary,status,priority,updated,resolutiondate"),
442 ("startAt", &start_at.to_string()),
443 ("maxResults", &SEARCH_PAGE_SIZE.to_string()),
444 ])
445 .send()
446 .await
447 .map_err(|e| SearchPageError::Other(format!("request failed: {e}")))?;
448
449 if session_is_anonymous(res.headers()) {
450 return Err(SearchPageError::Unauthorized);
451 }
452 match res.status() {
453 StatusCode::UNAUTHORIZED => return Err(SearchPageError::Unauthorized),
454 status if !status.is_success() => {
455 let body = res.text().await.unwrap_or_default();
456 return Err(SearchPageError::Other(format!("HTTP {status}: {body}")));
457 }
458 _ => {}
459 }
460
461 let page: JiraSearchResults = res.json().await.map_err(|e| SearchPageError::Other(format!("invalid JSON: {e}")))?;
462 let batch_len = page.issues.len() as u32;
463 all.extend(page.issues);
464
465 start_at += batch_len;
466 if batch_len == 0 || start_at >= page.total {
467 break;
468 }
469 }
470
471 Ok(all)
472 }
473
474 pub async fn get_assigned_open_issues(&mut self, extra_field_ids: &[String]) -> Result<Vec<JiraIssue>> {
485 let mut local_retries = 0;
486 loop {
487 let session_id = self.get_session_id().await?;
488
489 match self.fetch_assigned_open_pages(&session_id, extra_field_ids).await {
490 Ok(issues) => return Ok(issues),
491 Err(SearchPageError::Unauthorized) if local_retries < MAX_RETRY_COUNT => {
492 let _ = self.delete_session_id();
493 local_retries += 1;
494 tokio::time::sleep(Duration::from_secs(1)).await;
495 }
496 Err(e) => return Err(e.into_poll_error()),
497 }
498 }
499 }
500
501 pub async fn get_assigned_open_issues_noninteractive(&mut self, extra_field_ids: &[String]) -> Result<Option<Vec<JiraIssue>>> {
509 let mut local_retries = 0;
510 loop {
511 let Some(session_id) = self.session_id_noninteractive().await? else {
512 return Ok(None);
513 };
514
515 match self.fetch_assigned_open_pages(&session_id, extra_field_ids).await {
516 Ok(issues) => return Ok(Some(issues)),
517 Err(SearchPageError::Unauthorized) if local_retries < MAX_RETRY_COUNT => {
518 let _ = self.delete_session_id();
519 local_retries += 1;
520 tokio::time::sleep(Duration::from_secs(1)).await;
521 }
522 Err(e) => return Err(e.into_poll_error()),
523 }
524 }
525 }
526
527 async fn fetch_assigned_open_pages(&self, session_id: &str, extra_field_ids: &[String]) -> std::result::Result<Vec<JiraIssue>, SearchPageError> {
537 let jql = "assignee = currentUser() AND resolution is EMPTY ORDER BY key ASC";
538 let fields = build_search_fields(extra_field_ids);
539 let url = format!("{}/{}", self.config.api_url, SEARCH_URL);
540
541 let mut all: Vec<JiraIssue> = Vec::new();
542 let mut start_at: u32 = 0;
543 let mut first_total: Option<u32> = None;
544
545 loop {
546 let mut headers = HeaderMap::new();
547 headers.insert(
548 COOKIE,
549 HeaderValue::from_str(session_id).map_err(|e| SearchPageError::Other(format!("invalid session cookie: {e}")))?,
550 );
551
552 let res = self
553 .client
554 .get(&url)
555 .headers(headers)
556 .query(&[
557 ("jql", jql),
558 ("fields", fields.as_str()),
559 ("startAt", &start_at.to_string()),
560 ("maxResults", &SEARCH_PAGE_SIZE.to_string()),
561 ])
562 .send()
563 .await
564 .map_err(|e| SearchPageError::Other(format!("request failed: {e}")))?;
565
566 if session_is_anonymous(res.headers()) {
567 return Err(SearchPageError::Unauthorized);
568 }
569 match res.status() {
570 StatusCode::UNAUTHORIZED => return Err(SearchPageError::Unauthorized),
571 status if !status.is_success() => {
572 let body = res.text().await.unwrap_or_default();
573 return Err(SearchPageError::Other(format!("HTTP {status}: {body}")));
574 }
575 _ => {}
576 }
577
578 let page: JiraSearchResults = res.json().await.map_err(|e| SearchPageError::Other(format!("invalid JSON: {e}")))?;
579 let batch_len = page.issues.len() as u32;
580 first_total.get_or_insert(page.total);
581 all.extend(page.issues);
582
583 start_at += batch_len;
584 if batch_len == 0 || start_at >= page.total {
585 break;
586 }
587 }
588
589 check_complete(all, first_total.unwrap_or(0))
590 }
591
592 async fn session_id_noninteractive(&mut self) -> Result<Option<String>> {
594 let session_id_file_path = crate::libs::data_storage::DataStorage::new().get_path(SESSION_ID_FILE)?;
595 let path_str = session_id_file_path.to_str().unwrap_or_default();
596
597 if let Ok(session_id) = Self::read_session_id(path_str) {
598 return Ok(Some(session_id));
599 }
600
601 let Some(password) = self.secret().try_get_cached() else {
602 return Ok(None);
603 };
604
605 self.set_credentials(&password)?;
606 match self.login().await {
607 Ok(session_id) => {
608 let _ = Self::write_session_id(path_str, &session_id);
609 self.reset_retry();
610 Ok(Some(session_id))
611 }
612 Err(_) => Ok(None),
613 }
614 }
615
616 async fn session(&mut self, mode: SessionMode) -> Result<String> {
618 match mode {
619 SessionMode::Interactive => self.get_session_id().await,
620 SessionMode::Unattended => self
621 .session_id_noninteractive()
622 .await?
623 .ok_or_else(|| anyhow::anyhow!("not signed in to Jira; run `kasl inbox sync` on a terminal to sign in")),
624 }
625 }
626
627 async fn send(&mut self, mode: SessionMode, build: impl Fn(&Client) -> reqwest::RequestBuilder) -> Result<reqwest::Response> {
635 let mut retries = 0;
636 loop {
637 let session_id = self.session(mode).await?;
638 let res = build(&self.client).header(COOKIE, HeaderValue::from_str(&session_id)?).send().await?;
639 let rejected = res.status() == StatusCode::UNAUTHORIZED || session_is_anonymous(res.headers());
640 if !rejected {
641 return Ok(res);
642 }
643 if retries >= MAX_RETRY_COUNT {
644 anyhow::bail!("Jira rejected the session {MAX_RETRY_COUNT} times; run `kasl inbox sync` to sign in again");
645 }
646 let _ = self.delete_session_id();
647 retries += 1;
648 tokio::time::sleep(Duration::from_secs(1)).await;
649 }
650 }
651
652 fn issue_url(&self, key: &str, tail: &str) -> String {
653 format!("{}/{}/{}{}", self.config.api_url.trim_end_matches('/'), ISSUE_URL, key, tail)
654 }
655
656 pub async fn issue_state(&mut self, key: &str, mode: SessionMode) -> Result<Option<IssueState>> {
662 let url = self.issue_url(key, "");
663 let res = self.send(mode, |c| c.get(&url).query(&[("fields", "status,resolution")])).await?;
664 if res.status() == StatusCode::NOT_FOUND {
665 return Ok(None);
666 }
667 let res = ok_or_jira_error(res, &format!("reading {key}")).await?;
668
669 #[derive(Deserialize)]
670 struct Body {
671 fields: Fields,
672 }
673 #[derive(Deserialize)]
674 struct Fields {
675 status: JiraStatus,
676 #[serde(default)]
677 resolution: Option<JiraNamedValue>,
678 }
679 let body: Body = res.json().await?;
680 Ok(Some(IssueState {
681 status: body.fields.status,
682 resolution: body.fields.resolution.map(|r| r.name),
683 }))
684 }
685
686 pub async fn transitions(&mut self, key: &str, mode: SessionMode) -> Result<Vec<JiraTransition>> {
688 let url = self.issue_url(key, "/transitions");
689 let res = self.send(mode, |c| c.get(&url).query(&[("expand", "transitions.fields")])).await?;
690 let res = ok_or_jira_error(res, &format!("reading the transitions of {key}")).await?;
691
692 #[derive(Deserialize)]
693 struct Body {
694 #[serde(default)]
695 transitions: Vec<JiraTransition>,
696 }
697 Ok(res.json::<Body>().await?.transitions)
698 }
699
700 pub async fn transition(&mut self, key: &str, id: &str, resolution_id: Option<&str>, mode: SessionMode) -> Result<()> {
702 let url = self.issue_url(key, "/transitions");
703 let mut body = serde_json::json!({ "transition": { "id": id } });
704 if let Some(resolution) = resolution_id {
705 body["fields"] = serde_json::json!({ "resolution": { "id": resolution } });
706 }
707 let res = self.send(mode, |c| c.post(&url).json(&body)).await?;
708 ok_or_jira_error(res, &format!("moving {key}")).await?;
709 Ok(())
710 }
711
712 pub async fn add_comment(&mut self, key: &str, text: &str, mode: SessionMode) -> Result<()> {
714 let url = self.issue_url(key, "/comment");
715 let body = serde_json::json!({ "body": text });
716 let res = self.send(mode, |c| c.post(&url).json(&body)).await?;
717 ok_or_jira_error(res, &format!("commenting on {key}")).await?;
718 Ok(())
719 }
720
721 pub fn open_issues_url(&self) -> String {
726 let base = self.config.api_url.trim_end_matches('/');
727 format!("{base}/issues/?jql=assignee%20%3D%20currentUser()%20AND%20resolution%20is%20EMPTY")
728 }
729
730 pub fn issue_browse_url(&self, key: &str) -> String {
732 let base = self.config.api_url.trim_end_matches('/');
733 format!("{}/browse/{}", base, key)
734 }
735
736 pub fn priority_rank(priority: &Option<JiraPriority>) -> i32 {
738 priority
739 .as_ref()
740 .and_then(|p| p.id.as_ref())
741 .and_then(|id| id.parse::<i32>().ok())
742 .unwrap_or(999)
743 }
744
745 pub fn extract_number(value: &Value) -> Option<f64> {
749 match value {
750 Value::Number(n) => n.as_f64(),
751 Value::String(s) => s.trim().parse().ok(),
752 Value::Object(map) => map
753 .get("value")
754 .and_then(Self::extract_number)
755 .or_else(|| map.get("amount").and_then(Self::extract_number)),
756 _ => None,
757 }
758 }
759
760 pub fn sort_value_from_issue(issue: &JiraIssue, field_id: &str) -> Option<f64> {
762 issue.fields.extra.get(field_id).and_then(Self::extract_number)
763 }
764}
765
766enum SearchPageError {
768 Unauthorized,
769 Other(String),
770}
771
772impl SearchPageError {
773 fn into_poll_error(self) -> anyhow::Error {
775 match self {
776 SearchPageError::Unauthorized => {
777 anyhow::anyhow!("Jira rejected the session {MAX_RETRY_COUNT} times; run `kasl inbox sync` to sign in again")
778 }
779 SearchPageError::Other(msg) => anyhow::anyhow!("Jira inbox poll failed: {msg}"),
780 }
781 }
782}
783
784fn session_is_anonymous(headers: &HeaderMap) -> bool {
794 let header = |name: &str| headers.get(name).and_then(|v| v.to_str().ok()).unwrap_or_default();
795 let reason = header("x-seraph-loginreason");
796 header("x-ausername").eq_ignore_ascii_case("anonymous") || reason.contains("AUTHENTICATED_FAILED") || reason.contains("AUTHENTICATION_DENIED")
797}
798
799async fn ok_or_jira_error(res: reqwest::Response, what: &str) -> Result<reqwest::Response> {
806 let status = res.status();
807 if status.is_success() {
808 return Ok(res);
809 }
810 let body = res.text().await.unwrap_or_default();
811 anyhow::bail!("Jira refused {what} (HTTP {}): {}", status.as_u16(), jira_error_text(&body))
812}
813
814pub fn jira_error_text(body: &str) -> String {
816 #[derive(Deserialize, Default)]
817 struct ErrorBody {
818 #[serde(default, rename = "errorMessages")]
819 messages: Vec<String>,
820 #[serde(default)]
821 errors: HashMap<String, String>,
822 }
823 let parsed: ErrorBody = serde_json::from_str(body).unwrap_or_default();
824 let mut parts = parsed.messages;
825 let mut fields: Vec<(String, String)> = parsed.errors.into_iter().collect();
826 fields.sort();
827 parts.extend(fields.into_iter().map(|(field, message)| format!("{field}: {message}")));
828 if parts.is_empty() {
829 let trimmed = body.trim();
830 return if trimmed.is_empty() {
831 "no explanation given".to_string()
832 } else {
833 trimmed.chars().take(300).collect()
834 };
835 }
836 parts.join("; ")
837}
838
839fn check_complete(issues: Vec<JiraIssue>, total: u32) -> std::result::Result<Vec<JiraIssue>, SearchPageError> {
849 let mut seen = std::collections::HashSet::new();
850 let unique: Vec<JiraIssue> = issues.into_iter().filter(|issue| seen.insert(issue.key.clone())).collect();
851 if (unique.len() as u64) < u64::from(total) {
852 return Err(SearchPageError::Other(format!(
853 "the pages held {} of {} issues; the list changed while it was read",
854 unique.len(),
855 total
856 )));
857 }
858 Ok(unique)
859}
860
861fn build_search_fields(extra_field_ids: &[String]) -> String {
862 let mut fields = vec![
863 "summary".to_string(),
864 "status".to_string(),
865 "priority".to_string(),
866 "updated".to_string(),
867 "issuetype".to_string(),
868 "project".to_string(),
869 ];
870 for id in extra_field_ids {
871 let trimmed = id.trim();
872 if !trimmed.is_empty() && !fields.iter().any(|f| f == trimmed) {
873 fields.push(trimmed.to_string());
874 }
875 }
876 fields.join(",")
877}
878
879#[cfg(test)]
880mod tests {
881 use super::*;
882 use serde_json::json;
883
884 #[test]
885 fn extract_number_from_primitives_and_objects() {
886 assert_eq!(Jira::extract_number(&json!(12.5)), Some(12.5));
887 assert_eq!(Jira::extract_number(&json!("42")), Some(42.0));
888 assert_eq!(Jira::extract_number(&json!({"value": 7})), Some(7.0));
889 assert_eq!(Jira::extract_number(&json!({"amount": "3.5"})), Some(3.5));
890 assert_eq!(Jira::extract_number(&json!(null)), None);
891 }
892
893 #[test]
894 fn a_status_is_placed_by_its_category_key_not_its_name() {
895 let status: JiraStatus = serde_json::from_value(json!({
896 "id": 3, "name": "В работе", "statusCategory": {"id": 4, "key": "indeterminate", "name": "В работе"}
897 }))
898 .unwrap();
899 assert_eq!(status.category(), StatusCategory::InProgress);
900
901 let bare: JiraStatus = serde_json::from_value(json!({"id": "1", "name": "Open"})).unwrap();
902 assert_eq!(bare.category(), StatusCategory::Unknown, "no category sent is unknown, not to-do");
903 }
904
905 #[test]
906 fn a_jira_refusal_reads_as_its_own_sentences() {
907 let body = r#"{"errorMessages":["You do not have permission to transition this issue."],"errors":{"resolution":"Resolution is required."}}"#;
908 assert_eq!(
909 jira_error_text(body),
910 "You do not have permission to transition this issue.; resolution: Resolution is required."
911 );
912 assert_eq!(jira_error_text("<html>Bad gateway</html>"), "<html>Bad gateway</html>");
913 assert_eq!(jira_error_text(""), "no explanation given");
914 }
915
916 #[test]
917 fn build_search_fields_includes_custom_ids() {
918 let fields = build_search_fields(&["customfield_10001".to_string(), "summary".to_string()]);
919 assert!(fields.contains("summary"));
920 assert!(fields.contains("customfield_10001"));
921 assert_eq!(fields.matches("summary").count(), 1);
922 }
923}
924
925#[derive(Serialize, Deserialize, Clone, Debug)]
927pub struct JiraConfig {
928 pub login: String,
930
931 pub api_url: String,
933
934 #[serde(default = "default_completed_statuses")]
940 pub completed_statuses: Vec<String>,
941}
942
943fn default_completed_statuses() -> Vec<String> {
945 Vec::new()
946}
947
948impl JiraConfig {
949 pub fn module() -> ConfigModule {
951 ConfigModule {
952 key: "jira".to_string(),
953 name: "Jira".to_string(),
954 }
955 }
956
957 pub fn init(config: &Option<Self>) -> Result<Self> {
974 let config = config.clone().unwrap_or(Self {
976 login: "".to_string(),
977 api_url: "".to_string(),
978 completed_statuses: default_completed_statuses(),
979 });
980
981 msg_print!(Message::ConfigModuleJira);
983
984 Ok(Self {
986 completed_statuses: config.completed_statuses.clone(),
987 login: Input::with_theme(&ColorfulTheme::default())
988 .with_prompt("Enter your Jira login")
989 .default(config.login)
990 .interact_text()?,
991 api_url: Input::with_theme(&ColorfulTheme::default())
992 .with_prompt("Enter the Jira API URL")
993 .default(config.api_url)
994 .interact_text()?,
995 })
996 }
997}