1use base64::Engine;
2use base64::engine::general_purpose::STANDARD as BASE64;
3use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderValue};
4use serde::de::DeserializeOwned;
5use std::collections::BTreeMap;
6
7use super::ApiError;
8use super::AuthType;
9use super::types::*;
10
11pub struct JiraClient {
12 http: reqwest::Client,
13 base_url: String,
14 agile_base_url: String,
15 site_url: String,
16 host: String,
17 api_version: u8,
18}
19
20const SEARCH_FIELDS: [&str; 7] = [
21 "summary",
22 "status",
23 "assignee",
24 "priority",
25 "issuetype",
26 "created",
27 "updated",
28];
29const SEARCH_GET_JQL_LIMIT: usize = 1500;
30
31const SEARCH_JQL_MAX_PAGE: usize = 100;
35
36const SEARCH_JQL_SKIP_PAGE: usize = 1000;
39
40impl JiraClient {
41 pub fn new(
42 host: &str,
43 email: &str,
44 token: &str,
45 auth_type: AuthType,
46 api_version: u8,
47 ) -> Result<Self, ApiError> {
48 let (scheme, domain) = if host.starts_with("http://") {
51 (
52 "http",
53 host.trim_start_matches("http://").trim_end_matches('/'),
54 )
55 } else {
56 (
57 "https",
58 host.trim_start_matches("https://").trim_end_matches('/'),
59 )
60 };
61
62 if domain.is_empty() {
63 return Err(ApiError::Other("Host cannot be empty".into()));
64 }
65
66 let auth_value = match auth_type {
67 AuthType::Basic => {
68 let credentials = BASE64.encode(format!("{email}:{token}"));
69 format!("Basic {credentials}")
70 }
71 AuthType::Pat => format!("Bearer {token}"),
72 };
73
74 let mut headers = HeaderMap::new();
75 headers.insert(
76 AUTHORIZATION,
77 HeaderValue::from_str(&auth_value).map_err(|e| ApiError::Other(e.to_string()))?,
78 );
79
80 let http = reqwest::Client::builder()
81 .default_headers(headers)
82 .timeout(std::time::Duration::from_secs(30))
83 .build()
84 .map_err(ApiError::Http)?;
85
86 let site_url = format!("{scheme}://{domain}");
87 let base_url = format!("{site_url}/rest/api/{api_version}");
88 let agile_base_url = format!("{site_url}/rest/agile/1.0");
89
90 Ok(Self {
91 http,
92 base_url,
93 agile_base_url,
94 site_url,
95 host: domain.to_string(),
96 api_version,
97 })
98 }
99
100 pub fn host(&self) -> &str {
101 &self.host
102 }
103
104 pub fn api_version(&self) -> u8 {
105 self.api_version
106 }
107
108 pub fn browse_base_url(&self) -> &str {
109 &self.site_url
110 }
111
112 pub fn browse_url(&self, issue_key: &str) -> String {
113 format!("{}/browse/{issue_key}", self.browse_base_url())
114 }
115
116 fn map_status(status: u16, body: String) -> ApiError {
117 let message = summarize_error_body(status, &body);
118 match status {
119 401 | 403 => ApiError::Auth(message),
120 404 => ApiError::NotFound(message),
121 429 => ApiError::RateLimit,
122 _ => ApiError::Api { status, message },
123 }
124 }
125
126 async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T, ApiError> {
127 let url = format!("{}/{path}", self.base_url);
128 let resp = self.http.get(&url).send().await?;
129 let status = resp.status();
130 if !status.is_success() {
131 let body = resp.text().await.unwrap_or_default();
132 return Err(Self::map_status(status.as_u16(), body));
133 }
134 resp.json::<T>().await.map_err(ApiError::Http)
135 }
136
137 async fn agile_get<T: DeserializeOwned>(&self, path: &str) -> Result<T, ApiError> {
138 let url = format!("{}/{path}", self.agile_base_url);
139 let resp = self.http.get(&url).send().await?;
140 let status = resp.status();
141 if !status.is_success() {
142 let body = resp.text().await.unwrap_or_default();
143 return Err(Self::map_status(status.as_u16(), body));
144 }
145 resp.json::<T>().await.map_err(ApiError::Http)
146 }
147
148 async fn post<T: DeserializeOwned>(
149 &self,
150 path: &str,
151 body: &serde_json::Value,
152 ) -> Result<T, ApiError> {
153 let url = format!("{}/{path}", self.base_url);
154 let resp = self.http.post(&url).json(body).send().await?;
155 let status = resp.status();
156 if !status.is_success() {
157 let body_text = resp.text().await.unwrap_or_default();
158 return Err(Self::map_status(status.as_u16(), body_text));
159 }
160 resp.json::<T>().await.map_err(ApiError::Http)
161 }
162
163 async fn post_empty_response(
164 &self,
165 path: &str,
166 body: &serde_json::Value,
167 ) -> Result<(), ApiError> {
168 let url = format!("{}/{path}", self.base_url);
169 let resp = self.http.post(&url).json(body).send().await?;
170 let status = resp.status();
171 if !status.is_success() {
172 let body_text = resp.text().await.unwrap_or_default();
173 return Err(Self::map_status(status.as_u16(), body_text));
174 }
175 Ok(())
176 }
177
178 async fn put_empty_response(
179 &self,
180 path: &str,
181 body: &serde_json::Value,
182 ) -> Result<(), ApiError> {
183 let url = format!("{}/{path}", self.base_url);
184 let resp = self.http.put(&url).json(body).send().await?;
185 let status = resp.status();
186 if !status.is_success() {
187 let body_text = resp.text().await.unwrap_or_default();
188 return Err(Self::map_status(status.as_u16(), body_text));
189 }
190 Ok(())
191 }
192
193 pub async fn search(
206 &self,
207 jql: &str,
208 max_results: usize,
209 start_at: usize,
210 ) -> Result<SearchResponse, ApiError> {
211 if self.api_version >= 3 {
212 self.search_jql_v3(jql, max_results, start_at).await
213 } else {
214 self.search_v2(jql, max_results, start_at).await
215 }
216 }
217
218 async fn search_v2(
219 &self,
220 jql: &str,
221 max_results: usize,
222 start_at: usize,
223 ) -> Result<SearchResponse, ApiError> {
224 let fields = SEARCH_FIELDS.join(",");
225 let encoded_jql = percent_encode(jql);
226 #[derive(serde::Deserialize)]
227 struct RawV2 {
228 issues: Vec<Issue>,
229 #[serde(default)]
230 total: usize,
231 #[serde(rename = "startAt", default)]
232 start_at: usize,
233 #[serde(rename = "maxResults", default)]
234 max_results: usize,
235 }
236 let raw: RawV2 = if encoded_jql.len() <= SEARCH_GET_JQL_LIMIT {
237 let path = format!(
238 "search?jql={encoded_jql}&maxResults={max_results}&startAt={start_at}&fields={fields}"
239 );
240 self.get(&path).await?
241 } else {
242 self.post(
243 "search",
244 &serde_json::json!({
245 "jql": jql,
246 "maxResults": max_results,
247 "startAt": start_at,
248 "fields": SEARCH_FIELDS,
249 }),
250 )
251 .await?
252 };
253 let is_last = raw.start_at + raw.issues.len() >= raw.total;
254 Ok(SearchResponse {
255 issues: raw.issues,
256 total: Some(raw.total),
257 start_at: raw.start_at,
258 max_results: raw.max_results,
259 is_last,
260 })
261 }
262
263 async fn search_jql_page(
269 &self,
270 jql: &str,
271 page_size: usize,
272 next_token: Option<&str>,
273 ) -> Result<SearchJqlPage, ApiError> {
274 let mut body = serde_json::json!({
275 "jql": jql,
276 "maxResults": page_size,
277 "fields": SEARCH_FIELDS,
278 });
279 if let Some(t) = next_token {
280 body["nextPageToken"] = serde_json::Value::String(t.to_string());
281 }
282 self.post("search/jql", &body).await
283 }
284
285 async fn search_jql_skip_page(
291 &self,
292 jql: &str,
293 page_size: usize,
294 next_token: Option<&str>,
295 ) -> Result<SearchJqlSkipPage, ApiError> {
296 let mut body = serde_json::json!({
297 "jql": jql,
298 "maxResults": page_size,
299 "fields": ["id"],
300 });
301 if let Some(t) = next_token {
302 body["nextPageToken"] = serde_json::Value::String(t.to_string());
303 }
304 self.post("search/jql", &body).await
305 }
306
307 async fn search_jql_v3(
308 &self,
309 jql: &str,
310 max_results: usize,
311 start_at: usize,
312 ) -> Result<SearchResponse, ApiError> {
313 let mut next_token: Option<String> = None;
318 let mut skipped = 0usize;
319 while skipped < start_at {
320 let want = (start_at - skipped).min(SEARCH_JQL_SKIP_PAGE);
321 let page = self
322 .search_jql_skip_page(jql, want, next_token.as_deref())
323 .await?;
324 let got = page.issues.len();
325 skipped += got;
326 if got == 0 || page.is_last {
327 return Ok(SearchResponse {
329 issues: Vec::new(),
330 total: None,
331 start_at,
332 max_results: 0,
333 is_last: true,
334 });
335 }
336 next_token = page.next_page_token;
337 if next_token.is_none() {
338 return Ok(SearchResponse {
341 issues: Vec::new(),
342 total: None,
343 start_at,
344 max_results: 0,
345 is_last: true,
346 });
347 }
348 }
349
350 let mut collected: Vec<Issue> = Vec::new();
353 let mut is_last = false;
354 while collected.len() < max_results {
355 let remaining = max_results - collected.len();
356 let want = remaining.min(SEARCH_JQL_MAX_PAGE);
357 let page = self
358 .search_jql_page(jql, want, next_token.as_deref())
359 .await?;
360 let got = page.issues.len();
361 collected.extend(page.issues);
362 if page.is_last || got == 0 {
363 is_last = true;
364 break;
365 }
366 next_token = page.next_page_token;
367 if next_token.is_none() {
368 is_last = true;
369 break;
370 }
371 }
372
373 let returned = collected.len();
374 Ok(SearchResponse {
375 issues: collected,
376 total: None,
378 start_at,
379 max_results: returned,
380 is_last,
381 })
382 }
383
384 pub async fn get_issue(&self, key: &str) -> Result<Issue, ApiError> {
390 validate_issue_key(key)?;
391 let fields = "summary,status,assignee,reporter,priority,issuetype,description,labels,components,created,updated,comment,issuelinks";
392 let path = format!("issue/{key}?fields={fields}");
393 let mut issue: Issue = self.get(&path).await?;
394
395 if let Some(ref mut comment_list) = issue.fields.comment
397 && comment_list.total > comment_list.comments.len()
398 {
399 let mut start_at = comment_list.comments.len();
400 while comment_list.comments.len() < comment_list.total {
401 let page: CommentList = self
402 .get(&format!(
403 "issue/{key}/comment?startAt={start_at}&maxResults=100"
404 ))
405 .await?;
406 if page.comments.is_empty() {
407 break;
408 }
409 start_at += page.comments.len();
410 comment_list.comments.extend(page.comments);
411 }
412 }
413
414 Ok(issue)
415 }
416
417 pub async fn create_issue(
419 &self,
420 draft: &IssueDraft<'_>,
421 custom_fields: &[(String, serde_json::Value)],
422 ) -> Result<CreateIssueResponse, ApiError> {
423 let mut fields = serde_json::json!({
424 "project": { "key": draft.project_key },
425 "issuetype": { "name": draft.issue_type },
426 "summary": draft.summary,
427 });
428
429 if let Some(desc) = draft.description {
430 fields["description"] = self.make_body(desc);
431 }
432 if let Some(p) = draft.priority {
433 fields["priority"] = serde_json::json!({ "name": p });
434 }
435 if let Some(lbls) = draft.labels
436 && !lbls.is_empty()
437 {
438 fields["labels"] = serde_json::json!(lbls);
439 }
440 if let Some(comps) = draft.components
441 && !comps.is_empty()
442 {
443 let payload: Vec<serde_json::Value> = comps
444 .iter()
445 .map(|name| serde_json::json!({ "name": name }))
446 .collect();
447 fields["components"] = serde_json::Value::Array(payload);
448 }
449 if let Some(id) = draft.assignee {
450 fields["assignee"] = self.assignee_payload(id);
451 }
452 if let Some(parent_key) = draft.parent {
453 fields["parent"] = serde_json::json!({ "key": parent_key });
454 }
455 for (key, value) in custom_fields {
456 fields[key] = value.clone();
457 }
458
459 self.post("issue", &serde_json::json!({ "fields": fields }))
460 .await
461 }
462
463 pub async fn log_work(
468 &self,
469 key: &str,
470 time_spent: &str,
471 comment: Option<&str>,
472 started: Option<&str>,
473 ) -> Result<WorklogEntry, ApiError> {
474 validate_issue_key(key)?;
475 let mut payload = serde_json::json!({ "timeSpent": time_spent });
476 if let Some(c) = comment {
477 payload["comment"] = self.make_body(c);
478 }
479 if let Some(s) = started {
480 payload["started"] = serde_json::Value::String(s.to_string());
481 }
482 self.post(&format!("issue/{key}/worklog"), &payload).await
483 }
484
485 pub async fn add_comment(&self, key: &str, body: &str) -> Result<Comment, ApiError> {
487 validate_issue_key(key)?;
488 let payload = serde_json::json!({ "body": self.make_body(body) });
489 self.post(&format!("issue/{key}/comment"), &payload).await
490 }
491
492 pub async fn get_transitions(&self, key: &str) -> Result<Vec<Transition>, ApiError> {
494 validate_issue_key(key)?;
495 let resp: TransitionsResponse = self.get(&format!("issue/{key}/transitions")).await?;
496 Ok(resp.transitions)
497 }
498
499 pub async fn do_transition(&self, key: &str, transition_id: &str) -> Result<(), ApiError> {
501 validate_issue_key(key)?;
502 let payload = serde_json::json!({ "transition": { "id": transition_id } });
503 self.post_empty_response(&format!("issue/{key}/transitions"), &payload)
504 .await
505 }
506
507 pub async fn assign_issue(&self, key: &str, account_id: Option<&str>) -> Result<(), ApiError> {
512 validate_issue_key(key)?;
513 let payload = match account_id {
514 Some(id) => self.assignee_payload(id),
515 None => {
516 if self.api_version >= 3 {
517 serde_json::json!({ "accountId": null })
518 } else {
519 serde_json::json!({ "name": null })
520 }
521 }
522 };
523 self.put_empty_response(&format!("issue/{key}/assignee"), &payload)
524 .await
525 }
526
527 fn assignee_payload(&self, id: &str) -> serde_json::Value {
531 if self.api_version >= 3 {
532 serde_json::json!({ "accountId": id })
533 } else {
534 serde_json::json!({ "name": id })
535 }
536 }
537
538 pub async fn get_myself(&self) -> Result<Myself, ApiError> {
540 self.get("myself").await
541 }
542
543 pub async fn update_issue(
548 &self,
549 key: &str,
550 update: &IssueUpdate<'_>,
551 custom_fields: &[(String, serde_json::Value)],
552 ) -> Result<(), ApiError> {
553 validate_issue_key(key)?;
554 let mut fields = serde_json::Map::new();
555 if let Some(s) = update.summary {
556 fields.insert("summary".into(), serde_json::Value::String(s.into()));
557 }
558 if let Some(d) = update.description {
559 fields.insert("description".into(), self.make_body(d));
560 }
561 if let Some(p) = update.priority {
562 fields.insert("priority".into(), serde_json::json!({ "name": p }));
563 }
564 if let Some(comps) = update.components {
565 let payload: Vec<serde_json::Value> = comps
566 .iter()
567 .map(|name| serde_json::json!({ "name": name }))
568 .collect();
569 fields.insert("components".into(), serde_json::Value::Array(payload));
570 }
571 for (k, value) in custom_fields {
572 fields.insert(k.clone(), value.clone());
573 }
574 if fields.is_empty() {
575 return Err(ApiError::InvalidInput(
576 "At least one field (--summary, --description, --priority, --components, or --field) is required"
577 .into(),
578 ));
579 }
580 self.put_empty_response(
581 &format!("issue/{key}"),
582 &serde_json::json!({ "fields": fields }),
583 )
584 .await
585 }
586
587 fn make_body(&self, text: &str) -> serde_json::Value {
592 if self.api_version >= 3 {
593 text_to_adf(text)
594 } else {
595 serde_json::Value::String(text.to_string())
596 }
597 }
598
599 pub async fn search_users(&self, query: &str) -> Result<Vec<User>, ApiError> {
605 let encoded = percent_encode(query);
606 let param = if self.api_version >= 3 {
607 "query"
608 } else {
609 "username"
610 };
611 let path = format!("user/search?{param}={encoded}&maxResults=50");
612 self.get::<Vec<User>>(&path).await
613 }
614
615 pub async fn get_link_types(&self) -> Result<Vec<IssueLinkType>, ApiError> {
619 #[derive(serde::Deserialize)]
620 struct Wrapper {
621 #[serde(rename = "issueLinkTypes")]
622 types: Vec<IssueLinkType>,
623 }
624 let w: Wrapper = self.get("issueLinkType").await?;
625 Ok(w.types)
626 }
627
628 pub async fn link_issues(
634 &self,
635 from_key: &str,
636 to_key: &str,
637 link_type: &str,
638 ) -> Result<(), ApiError> {
639 validate_issue_key(from_key)?;
640 validate_issue_key(to_key)?;
641 let payload = serde_json::json!({
642 "type": { "name": link_type },
643 "inwardIssue": { "key": from_key },
644 "outwardIssue": { "key": to_key },
645 });
646 let url = format!("{}/issueLink", self.base_url);
647 let resp = self.http.post(&url).json(&payload).send().await?;
648 let status = resp.status();
649 if !status.is_success() {
650 let body = resp.text().await.unwrap_or_default();
651 return Err(Self::map_status(status.as_u16(), body));
652 }
653 Ok(())
654 }
655
656 pub async fn unlink_issues(&self, link_id: &str) -> Result<(), ApiError> {
658 let url = format!("{}/issueLink/{link_id}", self.base_url);
659 let resp = self.http.delete(&url).send().await?;
660 let status = resp.status();
661 if !status.is_success() {
662 let body = resp.text().await.unwrap_or_default();
663 return Err(Self::map_status(status.as_u16(), body));
664 }
665 Ok(())
666 }
667
668 pub async fn list_boards(&self) -> Result<Vec<Board>, ApiError> {
672 let mut all = Vec::new();
673 let mut start_at = 0usize;
674 const PAGE: usize = 50;
675 loop {
676 let path = format!("board?startAt={start_at}&maxResults={PAGE}");
677 let page: BoardSearchResponse = self.agile_get(&path).await?;
678 let received = page.values.len();
679 all.extend(page.values);
680 if page.is_last || received == 0 {
681 break;
682 }
683 start_at += received;
684 }
685 Ok(all)
686 }
687
688 pub async fn list_sprints(
692 &self,
693 board_id: u64,
694 state: Option<&str>,
695 ) -> Result<Vec<Sprint>, ApiError> {
696 let mut all = Vec::new();
697 let mut start_at = 0usize;
698 const PAGE: usize = 50;
699 loop {
700 let state_param = state.map(|s| format!("&state={s}")).unwrap_or_default();
701 let path = format!(
702 "board/{board_id}/sprint?startAt={start_at}&maxResults={PAGE}{state_param}"
703 );
704 let page: SprintSearchResponse = self.agile_get(&path).await?;
705 let received = page.values.len();
706 all.extend(page.values);
707 if page.is_last || received == 0 {
708 break;
709 }
710 start_at += received;
711 }
712 Ok(all)
713 }
714
715 pub async fn list_projects(&self) -> Result<Vec<Project>, ApiError> {
723 if self.api_version < 3 {
724 return self.get::<Vec<Project>>("project").await;
725 }
726
727 let mut all: Vec<Project> = Vec::new();
728 let mut start_at: usize = 0;
729 const PAGE: usize = 50;
730
731 loop {
732 let path = format!("project/search?startAt={start_at}&maxResults={PAGE}&orderBy=key");
733 let page: ProjectSearchResponse = self.get(&path).await?;
734 let page_start = page.start_at;
735 let received = page.values.len();
736 let total = page.total;
737 all.extend(page.values);
738
739 if page.is_last || all.len() >= total {
740 break;
741 }
742
743 if received == 0 {
744 return Err(ApiError::Other(
745 "Project pagination returned an empty non-terminal page".into(),
746 ));
747 }
748
749 start_at = page_start.saturating_add(received);
750 }
751
752 Ok(all)
753 }
754
755 pub async fn get_project(&self, key: &str) -> Result<Project, ApiError> {
757 self.get(&format!("project/{key}")).await
758 }
759
760 pub async fn list_components(&self, project_key: &str) -> Result<Vec<Component>, ApiError> {
765 self.get::<Vec<Component>>(&format!("project/{project_key}/components"))
766 .await
767 }
768
769 pub async fn list_fields(&self) -> Result<Vec<Field>, ApiError> {
773 self.get::<Vec<Field>>("field").await
774 }
775
776 pub async fn move_issue_to_sprint(
780 &self,
781 issue_key: &str,
782 sprint_id: u64,
783 ) -> Result<(), ApiError> {
784 validate_issue_key(issue_key)?;
785 let url = format!("{}/sprint/{sprint_id}/issue", self.agile_base_url);
786 let payload = serde_json::json!({ "issues": [issue_key] });
787 let resp = self.http.post(&url).json(&payload).send().await?;
788 let status = resp.status();
789 if !status.is_success() {
790 let body = resp.text().await.unwrap_or_default();
791 return Err(Self::map_status(status.as_u16(), body));
792 }
793 Ok(())
794 }
795
796 pub async fn get_sprint(&self, sprint_id: u64) -> Result<Sprint, ApiError> {
798 self.agile_get::<Sprint>(&format!("sprint/{sprint_id}"))
799 .await
800 }
801
802 pub async fn resolve_sprint(&self, specifier: &str) -> Result<Sprint, ApiError> {
809 if let Ok(id) = specifier.parse::<u64>() {
810 return self.get_sprint(id).await;
811 }
812
813 let boards = self.list_boards().await?;
814 if boards.is_empty() {
815 return Err(ApiError::NotFound("No boards found".into()));
816 }
817
818 let target_state = if specifier.eq_ignore_ascii_case("active") {
819 Some("active")
820 } else {
821 None
822 };
823
824 for board in &boards {
825 let sprints = self.list_sprints(board.id, target_state).await?;
826 for sprint in sprints {
827 if specifier.eq_ignore_ascii_case("active") {
828 if sprint.state == "active" {
829 return Ok(sprint);
830 }
831 } else if sprint
832 .name
833 .to_lowercase()
834 .contains(&specifier.to_lowercase())
835 {
836 return Ok(sprint);
837 }
838 }
839 }
840
841 Err(ApiError::NotFound(format!(
842 "No sprint found matching '{specifier}'"
843 )))
844 }
845
846 pub async fn resolve_sprint_id(&self, specifier: &str) -> Result<u64, ApiError> {
850 if let Ok(id) = specifier.parse::<u64>() {
851 return Ok(id);
852 }
853 self.resolve_sprint(specifier).await.map(|s| s.id)
854 }
855}
856
857fn validate_issue_key(key: &str) -> Result<(), ApiError> {
863 let mut parts = key.splitn(2, '-');
864 let project = parts.next().unwrap_or("");
865 let number = parts.next().unwrap_or("");
866
867 let valid = !project.is_empty()
868 && !number.is_empty()
869 && project
870 .chars()
871 .next()
872 .is_some_and(|c| c.is_ascii_uppercase())
873 && project
874 .chars()
875 .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit())
876 && number.chars().all(|c| c.is_ascii_digit());
877
878 if valid {
879 Ok(())
880 } else {
881 Err(ApiError::InvalidInput(format!(
882 "Invalid issue key '{key}'. Expected format: PROJECT-123"
883 )))
884 }
885}
886
887fn percent_encode(s: &str) -> String {
891 let mut encoded = String::with_capacity(s.len() * 2);
892 for byte in s.bytes() {
893 match byte {
894 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
895 encoded.push(byte as char)
896 }
897 b => encoded.push_str(&format!("%{b:02X}")),
898 }
899 }
900 encoded
901}
902
903fn truncate_error_body(body: &str) -> String {
905 const MAX: usize = 200;
906 if body.chars().count() <= MAX {
907 body.to_string()
908 } else {
909 let truncated: String = body.chars().take(MAX).collect();
910 format!("{truncated}… (truncated)")
911 }
912}
913
914fn summarize_error_body(status: u16, body: &str) -> String {
915 if should_include_raw_error_body() && !body.trim().is_empty() {
916 return truncate_error_body(body);
917 }
918
919 if let Some(message) = summarize_json_error_body(body) {
920 return message;
921 }
922
923 default_status_message(status)
924}
925
926fn summarize_json_error_body(body: &str) -> Option<String> {
927 let parsed: JiraErrorPayload = serde_json::from_str(body).ok()?;
928 let mut parts = Vec::new();
929
930 if !parsed.error_messages.is_empty() {
931 parts.push(format_error_messages(&parsed.error_messages));
932 }
933
934 if !parsed.errors.is_empty() {
935 let fields = parsed.errors.keys().take(5).cloned().collect::<Vec<_>>();
936 parts.push(format!(
937 "validation errors for fields: {}",
938 fields.join(", ")
939 ));
940 }
941
942 if parts.is_empty() {
943 None
944 } else {
945 Some(parts.join("; "))
946 }
947}
948
949const MAX_ERROR_MESSAGES_SHOWN: usize = 3;
952
953const MAX_ERROR_MESSAGE_LEN: usize = 240;
956
957fn format_error_messages(messages: &[String]) -> String {
958 let shown: Vec<String> = messages
959 .iter()
960 .take(MAX_ERROR_MESSAGES_SHOWN)
961 .map(|m| truncate_message(m.trim()))
962 .collect();
963 let joined = shown.join(" | ");
964 let remaining = messages.len().saturating_sub(MAX_ERROR_MESSAGES_SHOWN);
965 if remaining > 0 {
966 format!("{joined} (+{remaining} more)")
967 } else {
968 joined
969 }
970}
971
972fn truncate_message(msg: &str) -> String {
973 if msg.chars().count() <= MAX_ERROR_MESSAGE_LEN {
974 msg.to_string()
975 } else {
976 let truncated: String = msg.chars().take(MAX_ERROR_MESSAGE_LEN).collect();
977 format!("{truncated}…")
978 }
979}
980
981fn default_status_message(status: u16) -> String {
982 match status {
983 401 | 403 => "request unauthorized".into(),
984 404 => "resource not found".into(),
985 429 => "rate limited by Jira".into(),
986 400..=499 => format!("request failed with status {status}"),
987 _ => format!("Jira request failed with status {status}"),
988 }
989}
990
991fn should_include_raw_error_body() -> bool {
992 matches!(
993 std::env::var("JIRA_DEBUG_HTTP").ok().as_deref(),
994 Some("1" | "true" | "TRUE" | "yes" | "YES")
995 )
996}
997
998#[derive(Debug, serde::Deserialize)]
999#[serde(rename_all = "camelCase")]
1000struct JiraErrorPayload {
1001 #[serde(default)]
1002 error_messages: Vec<String>,
1003 #[serde(default)]
1004 errors: BTreeMap<String, String>,
1005}
1006
1007#[cfg(test)]
1008mod tests {
1009 use super::*;
1010
1011 #[test]
1012 fn percent_encode_spaces_use_percent_20() {
1013 assert_eq!(percent_encode("project = FOO"), "project%20%3D%20FOO");
1014 }
1015
1016 #[test]
1017 fn percent_encode_complex_jql() {
1018 let jql = r#"project = "MY PROJECT""#;
1019 let encoded = percent_encode(jql);
1020 assert!(encoded.contains("project"));
1021 assert!(!encoded.contains('"'));
1022 assert!(!encoded.contains(' '));
1023 }
1024
1025 #[test]
1026 fn validate_issue_key_valid() {
1027 assert!(validate_issue_key("PROJ-123").is_ok());
1028 assert!(validate_issue_key("ABC-1").is_ok());
1029 assert!(validate_issue_key("MYPROJECT-9999").is_ok());
1030 assert!(validate_issue_key("ABC2-123").is_ok());
1032 assert!(validate_issue_key("P1-1").is_ok());
1033 }
1034
1035 #[test]
1036 fn validate_issue_key_invalid() {
1037 assert!(validate_issue_key("proj-123").is_err()); assert!(validate_issue_key("PROJ123").is_err()); assert!(validate_issue_key("PROJ-abc").is_err()); assert!(validate_issue_key("../etc/passwd").is_err());
1041 assert!(validate_issue_key("").is_err());
1042 assert!(validate_issue_key("1PROJ-123").is_err()); }
1044
1045 #[test]
1046 fn truncate_error_body_short() {
1047 let body = "short error";
1048 assert_eq!(truncate_error_body(body), body);
1049 }
1050
1051 #[test]
1052 fn truncate_error_body_long() {
1053 let body = "x".repeat(300);
1054 let result = truncate_error_body(&body);
1055 assert!(result.len() < body.len());
1056 assert!(result.ends_with("(truncated)"));
1057 }
1058
1059 #[test]
1060 fn summarize_json_error_body_surfaces_messages_and_redacts_field_values() {
1061 let body = serde_json::json!({
1062 "errorMessages": ["JQL validation failed"],
1063 "errors": {
1064 "summary": "Summary must not contain secret project name",
1065 "description": "Description cannot include api token"
1066 }
1067 })
1068 .to_string();
1069
1070 let message = summarize_error_body(400, &body);
1071 assert!(message.contains("JQL validation failed"));
1073 assert!(message.contains("summary"));
1076 assert!(message.contains("description"));
1077 assert!(!message.contains("secret project name"));
1078 assert!(!message.contains("api token"));
1079 }
1080
1081 #[test]
1082 fn summarize_json_error_body_reports_retired_api() {
1083 let body = serde_json::json!({
1085 "errorMessages": [
1086 "The requested API has been removed. Please migrate to the /rest/api/3/search/jql API."
1087 ],
1088 "errors": {}
1089 })
1090 .to_string();
1091
1092 let message = summarize_error_body(410, &body);
1093 assert!(message.contains("The requested API has been removed"));
1094 assert!(message.contains("/rest/api/3/search/jql"));
1095 }
1096
1097 #[test]
1098 fn summarize_json_error_body_joins_multiple_messages() {
1099 let body = serde_json::json!({
1100 "errorMessages": ["first problem", "second problem"],
1101 "errors": {}
1102 })
1103 .to_string();
1104
1105 let message = summarize_error_body(400, &body);
1106 assert!(message.contains("first problem"));
1107 assert!(message.contains("second problem"));
1108 assert!(message.contains(" | "));
1109 }
1110
1111 #[test]
1112 fn summarize_json_error_body_collapses_overflow_messages() {
1113 let body = serde_json::json!({
1114 "errorMessages": ["a", "b", "c", "d", "e"],
1115 "errors": {}
1116 })
1117 .to_string();
1118
1119 let message = summarize_error_body(400, &body);
1120 assert!(message.contains("(+2 more)"));
1121 }
1122
1123 #[test]
1124 fn summarize_json_error_body_truncates_oversized_message() {
1125 let huge = "x".repeat(1000);
1126 let body = serde_json::json!({
1127 "errorMessages": [huge],
1128 "errors": {}
1129 })
1130 .to_string();
1131
1132 let message = summarize_error_body(400, &body);
1133 assert!(message.chars().count() < 500);
1134 assert!(message.contains('…'));
1135 }
1136
1137 #[test]
1138 fn browse_url_preserves_explicit_http_hosts() {
1139 let client = JiraClient::new(
1140 "http://localhost:8080",
1141 "me@example.com",
1142 "token",
1143 AuthType::Basic,
1144 3,
1145 )
1146 .unwrap();
1147 assert_eq!(
1148 client.browse_url("PROJ-1"),
1149 "http://localhost:8080/browse/PROJ-1"
1150 );
1151 }
1152
1153 #[test]
1154 fn new_with_pat_auth_does_not_require_email() {
1155 let client = JiraClient::new(
1156 "https://jira.example.com",
1157 "",
1158 "my-pat-token",
1159 AuthType::Pat,
1160 3,
1161 );
1162 assert!(client.is_ok());
1163 }
1164
1165 #[test]
1166 fn new_with_api_v2_uses_v2_base_url() {
1167 let client = JiraClient::new(
1168 "https://jira.example.com",
1169 "me@example.com",
1170 "token",
1171 AuthType::Basic,
1172 2,
1173 )
1174 .unwrap();
1175 assert_eq!(client.api_version(), 2);
1176 }
1177}