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
40fn name_object_array(items: &[&str]) -> serde_json::Value {
42 serde_json::Value::Array(
43 items
44 .iter()
45 .map(|name| serde_json::json!({ "name": name }))
46 .collect(),
47 )
48}
49
50impl JiraClient {
51 pub fn new(
52 host: &str,
53 email: &str,
54 token: &str,
55 auth_type: AuthType,
56 api_version: u8,
57 ) -> Result<Self, ApiError> {
58 let (scheme, domain) = if host.starts_with("http://") {
61 (
62 "http",
63 host.trim_start_matches("http://").trim_end_matches('/'),
64 )
65 } else {
66 (
67 "https",
68 host.trim_start_matches("https://").trim_end_matches('/'),
69 )
70 };
71
72 if domain.is_empty() {
73 return Err(ApiError::Other("Host cannot be empty".into()));
74 }
75
76 let auth_value = match auth_type {
77 AuthType::Basic => {
78 let credentials = BASE64.encode(format!("{email}:{token}"));
79 format!("Basic {credentials}")
80 }
81 AuthType::Pat => format!("Bearer {token}"),
82 };
83
84 let mut headers = HeaderMap::new();
85 headers.insert(
86 AUTHORIZATION,
87 HeaderValue::from_str(&auth_value).map_err(|e| ApiError::Other(e.to_string()))?,
88 );
89
90 let http = reqwest::Client::builder()
91 .default_headers(headers)
92 .timeout(std::time::Duration::from_secs(30))
93 .build()
94 .map_err(ApiError::Http)?;
95
96 let site_url = format!("{scheme}://{domain}");
97 let base_url = format!("{site_url}/rest/api/{api_version}");
98 let agile_base_url = format!("{site_url}/rest/agile/1.0");
99
100 Ok(Self {
101 http,
102 base_url,
103 agile_base_url,
104 site_url,
105 host: domain.to_string(),
106 api_version,
107 })
108 }
109
110 pub fn host(&self) -> &str {
111 &self.host
112 }
113
114 pub fn api_version(&self) -> u8 {
115 self.api_version
116 }
117
118 pub fn browse_base_url(&self) -> &str {
119 &self.site_url
120 }
121
122 pub fn browse_url(&self, issue_key: &str) -> String {
123 format!("{}/browse/{issue_key}", self.browse_base_url())
124 }
125
126 fn map_status(status: u16, body: String) -> ApiError {
127 let message = summarize_error_body(status, &body);
128 match status {
129 401 | 403 => ApiError::Auth(message),
130 404 => ApiError::NotFound(message),
131 409 => ApiError::Conflict(message),
132 429 => ApiError::RateLimit,
133 _ => ApiError::Api { status, message },
134 }
135 }
136
137 async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T, ApiError> {
138 let url = format!("{}/{path}", self.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 agile_get<T: DeserializeOwned>(&self, path: &str) -> Result<T, ApiError> {
149 let url = format!("{}/{path}", self.agile_base_url);
150 let resp = self.http.get(&url).send().await?;
151 let status = resp.status();
152 if !status.is_success() {
153 let body = resp.text().await.unwrap_or_default();
154 return Err(Self::map_status(status.as_u16(), body));
155 }
156 resp.json::<T>().await.map_err(ApiError::Http)
157 }
158
159 async fn post<T: DeserializeOwned>(
160 &self,
161 path: &str,
162 body: &serde_json::Value,
163 ) -> Result<T, ApiError> {
164 let url = format!("{}/{path}", self.base_url);
165 let resp = self.http.post(&url).json(body).send().await?;
166 let status = resp.status();
167 if !status.is_success() {
168 let body_text = resp.text().await.unwrap_or_default();
169 return Err(Self::map_status(status.as_u16(), body_text));
170 }
171 resp.json::<T>().await.map_err(ApiError::Http)
172 }
173
174 async fn post_empty_response(
175 &self,
176 path: &str,
177 body: &serde_json::Value,
178 ) -> Result<(), ApiError> {
179 let url = format!("{}/{path}", self.base_url);
180 let resp = self.http.post(&url).json(body).send().await?;
181 let status = resp.status();
182 if !status.is_success() {
183 let body_text = resp.text().await.unwrap_or_default();
184 return Err(Self::map_status(status.as_u16(), body_text));
185 }
186 Ok(())
187 }
188
189 async fn put_empty_response(
190 &self,
191 path: &str,
192 body: &serde_json::Value,
193 ) -> Result<(), ApiError> {
194 let url = format!("{}/{path}", self.base_url);
195 let resp = self.http.put(&url).json(body).send().await?;
196 let status = resp.status();
197 if !status.is_success() {
198 let body_text = resp.text().await.unwrap_or_default();
199 return Err(Self::map_status(status.as_u16(), body_text));
200 }
201 Ok(())
202 }
203
204 pub async fn search(
217 &self,
218 jql: &str,
219 max_results: usize,
220 start_at: usize,
221 ) -> Result<SearchResponse, ApiError> {
222 if self.api_version >= 3 {
223 self.search_jql_v3(jql, max_results, start_at).await
224 } else {
225 self.search_v2(jql, max_results, start_at).await
226 }
227 }
228
229 async fn search_v2(
230 &self,
231 jql: &str,
232 max_results: usize,
233 start_at: usize,
234 ) -> Result<SearchResponse, ApiError> {
235 let fields = SEARCH_FIELDS.join(",");
236 let encoded_jql = percent_encode(jql);
237 #[derive(serde::Deserialize)]
238 struct RawV2 {
239 issues: Vec<Issue>,
240 #[serde(default)]
241 total: usize,
242 #[serde(rename = "startAt", default)]
243 start_at: usize,
244 #[serde(rename = "maxResults", default)]
245 max_results: usize,
246 }
247 let raw: RawV2 = if encoded_jql.len() <= SEARCH_GET_JQL_LIMIT {
248 let path = format!(
249 "search?jql={encoded_jql}&maxResults={max_results}&startAt={start_at}&fields={fields}"
250 );
251 self.get(&path).await?
252 } else {
253 self.post(
254 "search",
255 &serde_json::json!({
256 "jql": jql,
257 "maxResults": max_results,
258 "startAt": start_at,
259 "fields": SEARCH_FIELDS,
260 }),
261 )
262 .await?
263 };
264 let is_last = raw.start_at + raw.issues.len() >= raw.total;
265 Ok(SearchResponse {
266 issues: raw.issues,
267 total: Some(raw.total),
268 start_at: raw.start_at,
269 max_results: raw.max_results,
270 is_last,
271 })
272 }
273
274 async fn search_jql_page(
280 &self,
281 jql: &str,
282 page_size: usize,
283 next_token: Option<&str>,
284 ) -> Result<SearchJqlPage, ApiError> {
285 let mut body = serde_json::json!({
286 "jql": jql,
287 "maxResults": page_size,
288 "fields": SEARCH_FIELDS,
289 });
290 if let Some(t) = next_token {
291 body["nextPageToken"] = serde_json::Value::String(t.to_string());
292 }
293 self.post("search/jql", &body).await
294 }
295
296 async fn search_jql_skip_page(
302 &self,
303 jql: &str,
304 page_size: usize,
305 next_token: Option<&str>,
306 ) -> Result<SearchJqlSkipPage, ApiError> {
307 let mut body = serde_json::json!({
308 "jql": jql,
309 "maxResults": page_size,
310 "fields": ["id"],
311 });
312 if let Some(t) = next_token {
313 body["nextPageToken"] = serde_json::Value::String(t.to_string());
314 }
315 self.post("search/jql", &body).await
316 }
317
318 async fn search_jql_v3(
319 &self,
320 jql: &str,
321 max_results: usize,
322 start_at: usize,
323 ) -> Result<SearchResponse, ApiError> {
324 let mut next_token: Option<String> = None;
329 let mut skipped = 0usize;
330 while skipped < start_at {
331 let want = (start_at - skipped).min(SEARCH_JQL_SKIP_PAGE);
332 let page = self
333 .search_jql_skip_page(jql, want, next_token.as_deref())
334 .await?;
335 let got = page.issues.len();
336 skipped += got;
337 if got == 0 || page.is_last {
338 return Ok(SearchResponse {
340 issues: Vec::new(),
341 total: None,
342 start_at,
343 max_results: 0,
344 is_last: true,
345 });
346 }
347 next_token = page.next_page_token;
348 if next_token.is_none() {
349 return Ok(SearchResponse {
352 issues: Vec::new(),
353 total: None,
354 start_at,
355 max_results: 0,
356 is_last: true,
357 });
358 }
359 }
360
361 let mut collected: Vec<Issue> = Vec::new();
364 let mut is_last = false;
365 while collected.len() < max_results {
366 let remaining = max_results - collected.len();
367 let want = remaining.min(SEARCH_JQL_MAX_PAGE);
368 let page = self
369 .search_jql_page(jql, want, next_token.as_deref())
370 .await?;
371 let got = page.issues.len();
372 collected.extend(page.issues);
373 if page.is_last || got == 0 {
374 is_last = true;
375 break;
376 }
377 next_token = page.next_page_token;
378 if next_token.is_none() {
379 is_last = true;
380 break;
381 }
382 }
383
384 let returned = collected.len();
385 Ok(SearchResponse {
386 issues: collected,
387 total: None,
389 start_at,
390 max_results: returned,
391 is_last,
392 })
393 }
394
395 pub async fn get_issue(&self, key: &str) -> Result<Issue, ApiError> {
401 validate_issue_key(key)?;
402 let fields = "summary,status,assignee,reporter,priority,issuetype,description,labels,components,fixVersions,versions,created,updated,comment,issuelinks";
403 let path = format!("issue/{key}?fields={fields}");
404 let mut issue: Issue = self.get(&path).await?;
405
406 if let Some(ref mut comment_list) = issue.fields.comment
408 && comment_list.total > comment_list.comments.len()
409 {
410 let mut start_at = comment_list.comments.len();
411 while comment_list.comments.len() < comment_list.total {
412 let page: CommentList = self
413 .get(&format!(
414 "issue/{key}/comment?startAt={start_at}&maxResults=100"
415 ))
416 .await?;
417 if page.comments.is_empty() {
418 break;
419 }
420 start_at += page.comments.len();
421 comment_list.comments.extend(page.comments);
422 }
423 }
424
425 Ok(issue)
426 }
427
428 pub async fn create_issue(
430 &self,
431 draft: &IssueDraft<'_>,
432 custom_fields: &[(String, serde_json::Value)],
433 ) -> Result<CreateIssueResponse, ApiError> {
434 let mut fields = serde_json::json!({
435 "project": { "key": draft.project_key },
436 "issuetype": { "name": draft.issue_type },
437 "summary": draft.summary,
438 });
439
440 if let Some(desc) = draft.description {
441 fields["description"] = self.make_body(desc);
442 }
443 if let Some(p) = draft.priority {
444 fields["priority"] = serde_json::json!({ "name": p });
445 }
446 if let Some(lbls) = draft.labels
447 && !lbls.is_empty()
448 {
449 fields["labels"] = serde_json::json!(lbls);
450 }
451 if let Some(comps) = draft.components
452 && !comps.is_empty()
453 {
454 fields["components"] = name_object_array(comps);
455 }
456 if let Some(fvs) = draft.fix_versions
457 && !fvs.is_empty()
458 {
459 fields["fixVersions"] = name_object_array(fvs);
460 }
461 if let Some(id) = draft.assignee {
462 fields["assignee"] = self.assignee_payload(id);
463 }
464 if let Some(parent_key) = draft.parent {
465 fields["parent"] = serde_json::json!({ "key": parent_key });
466 }
467 for (key, value) in custom_fields {
468 fields[key] = value.clone();
469 }
470
471 self.post("issue", &serde_json::json!({ "fields": fields }))
472 .await
473 }
474
475 pub async fn log_work(
480 &self,
481 key: &str,
482 time_spent: &str,
483 comment: Option<&str>,
484 started: Option<&str>,
485 ) -> Result<WorklogEntry, ApiError> {
486 validate_issue_key(key)?;
487 let mut payload = serde_json::json!({ "timeSpent": time_spent });
488 if let Some(c) = comment {
489 payload["comment"] = self.make_body(c);
490 }
491 if let Some(s) = started {
492 payload["started"] = serde_json::Value::String(s.to_string());
493 }
494 self.post(&format!("issue/{key}/worklog"), &payload).await
495 }
496
497 pub async fn add_comment(&self, key: &str, body: &str) -> Result<Comment, ApiError> {
499 validate_issue_key(key)?;
500 let payload = serde_json::json!({ "body": self.make_body(body) });
501 self.post(&format!("issue/{key}/comment"), &payload).await
502 }
503
504 pub async fn get_transitions(&self, key: &str) -> Result<Vec<Transition>, ApiError> {
506 validate_issue_key(key)?;
507 let resp: TransitionsResponse = self.get(&format!("issue/{key}/transitions")).await?;
508 Ok(resp.transitions)
509 }
510
511 pub async fn do_transition(&self, key: &str, transition_id: &str) -> Result<(), ApiError> {
513 validate_issue_key(key)?;
514 let payload = serde_json::json!({ "transition": { "id": transition_id } });
515 self.post_empty_response(&format!("issue/{key}/transitions"), &payload)
516 .await
517 }
518
519 pub async fn assign_issue(&self, key: &str, account_id: Option<&str>) -> Result<(), ApiError> {
524 validate_issue_key(key)?;
525 let payload = match account_id {
526 Some(id) => self.assignee_payload(id),
527 None => {
528 if self.api_version >= 3 {
529 serde_json::json!({ "accountId": null })
530 } else {
531 serde_json::json!({ "name": null })
532 }
533 }
534 };
535 self.put_empty_response(&format!("issue/{key}/assignee"), &payload)
536 .await
537 }
538
539 fn assignee_payload(&self, id: &str) -> serde_json::Value {
543 if self.api_version >= 3 {
544 serde_json::json!({ "accountId": id })
545 } else {
546 serde_json::json!({ "name": id })
547 }
548 }
549
550 pub async fn get_myself(&self) -> Result<Myself, ApiError> {
552 self.get("myself").await
553 }
554
555 pub async fn update_issue(
562 &self,
563 key: &str,
564 update: &IssueUpdate<'_>,
565 custom_fields: &[(String, serde_json::Value)],
566 ) -> Result<(), ApiError> {
567 validate_issue_key(key)?;
568 let mut fields = serde_json::Map::new();
569 if let Some(s) = update.summary {
570 fields.insert("summary".into(), serde_json::Value::String(s.into()));
571 }
572 if let Some(d) = update.description {
573 fields.insert("description".into(), self.make_body(d));
574 }
575 if let Some(p) = update.priority {
576 fields.insert("priority".into(), serde_json::json!({ "name": p }));
577 }
578 if let Some(comps) = update.components {
579 fields.insert("components".into(), name_object_array(comps));
580 }
581 if let Some(fvs) = update.fix_versions {
582 fields.insert("fixVersions".into(), name_object_array(fvs));
583 }
584 if let Some(lbls) = update.labels {
585 fields.insert("labels".into(), serde_json::json!(lbls));
586 }
587 if let Some(assignee_choice) = update.assignee {
588 let payload = match assignee_choice {
589 None => serde_json::Value::Null,
590 Some(id) => self.assignee_payload(id),
591 };
592 fields.insert("assignee".into(), payload);
593 }
594 for (k, value) in custom_fields {
595 fields.insert(k.clone(), value.clone());
596 }
597 if fields.is_empty() {
598 return Err(ApiError::InvalidInput(
599 "At least one field (--summary, --description, --priority, --components, --fix-versions, --labels, --assignee, or --field) is required"
600 .into(),
601 ));
602 }
603 self.put_empty_response(
604 &format!("issue/{key}"),
605 &serde_json::json!({ "fields": fields }),
606 )
607 .await
608 }
609
610 fn make_body(&self, text: &str) -> serde_json::Value {
615 if self.api_version >= 3 {
616 text_to_adf(text)
617 } else {
618 serde_json::Value::String(text.to_string())
619 }
620 }
621
622 pub async fn search_users(&self, query: &str) -> Result<Vec<User>, ApiError> {
628 let encoded = percent_encode(query);
629 let param = if self.api_version >= 3 {
630 "query"
631 } else {
632 "username"
633 };
634 let path = format!("user/search?{param}={encoded}&maxResults=50");
635 self.get::<Vec<User>>(&path).await
636 }
637
638 pub async fn get_link_types(&self) -> Result<Vec<IssueLinkType>, ApiError> {
642 #[derive(serde::Deserialize)]
643 struct Wrapper {
644 #[serde(rename = "issueLinkTypes")]
645 types: Vec<IssueLinkType>,
646 }
647 let w: Wrapper = self.get("issueLinkType").await?;
648 Ok(w.types)
649 }
650
651 pub async fn link_issues(
657 &self,
658 from_key: &str,
659 to_key: &str,
660 link_type: &str,
661 ) -> Result<(), ApiError> {
662 validate_issue_key(from_key)?;
663 validate_issue_key(to_key)?;
664 let payload = serde_json::json!({
665 "type": { "name": link_type },
666 "inwardIssue": { "key": from_key },
667 "outwardIssue": { "key": to_key },
668 });
669 let url = format!("{}/issueLink", self.base_url);
670 let resp = self.http.post(&url).json(&payload).send().await?;
671 let status = resp.status();
672 if !status.is_success() {
673 let body = resp.text().await.unwrap_or_default();
674 return Err(Self::map_status(status.as_u16(), body));
675 }
676 Ok(())
677 }
678
679 pub async fn unlink_issues(&self, link_id: &str) -> Result<(), ApiError> {
681 let url = format!("{}/issueLink/{link_id}", self.base_url);
682 let resp = self.http.delete(&url).send().await?;
683 let status = resp.status();
684 if !status.is_success() {
685 let body = resp.text().await.unwrap_or_default();
686 return Err(Self::map_status(status.as_u16(), body));
687 }
688 Ok(())
689 }
690
691 pub async fn list_boards(&self) -> Result<Vec<Board>, ApiError> {
695 let mut all = Vec::new();
696 let mut start_at = 0usize;
697 const PAGE: usize = 50;
698 loop {
699 let path = format!("board?startAt={start_at}&maxResults={PAGE}");
700 let page: BoardSearchResponse = self.agile_get(&path).await?;
701 let received = page.values.len();
702 all.extend(page.values);
703 if page.is_last || received == 0 {
704 break;
705 }
706 start_at += received;
707 }
708 Ok(all)
709 }
710
711 pub async fn list_sprints(
715 &self,
716 board_id: u64,
717 state: Option<&str>,
718 ) -> Result<Vec<Sprint>, ApiError> {
719 let mut all = Vec::new();
720 let mut start_at = 0usize;
721 const PAGE: usize = 50;
722 loop {
723 let state_param = state.map(|s| format!("&state={s}")).unwrap_or_default();
724 let path = format!(
725 "board/{board_id}/sprint?startAt={start_at}&maxResults={PAGE}{state_param}"
726 );
727 let page: SprintSearchResponse = self.agile_get(&path).await?;
728 let received = page.values.len();
729 all.extend(page.values);
730 if page.is_last || received == 0 {
731 break;
732 }
733 start_at += received;
734 }
735 Ok(all)
736 }
737
738 pub async fn list_projects(&self) -> Result<Vec<Project>, ApiError> {
746 if self.api_version < 3 {
747 return self.get::<Vec<Project>>("project").await;
748 }
749
750 let mut all: Vec<Project> = Vec::new();
751 let mut start_at: usize = 0;
752 const PAGE: usize = 50;
753
754 loop {
755 let path = format!("project/search?startAt={start_at}&maxResults={PAGE}&orderBy=key");
756 let page: ProjectSearchResponse = self.get(&path).await?;
757 let page_start = page.start_at;
758 let received = page.values.len();
759 let total = page.total;
760 all.extend(page.values);
761
762 if page.is_last || all.len() >= total {
763 break;
764 }
765
766 if received == 0 {
767 return Err(ApiError::Other(
768 "Project pagination returned an empty non-terminal page".into(),
769 ));
770 }
771
772 start_at = page_start.saturating_add(received);
773 }
774
775 Ok(all)
776 }
777
778 pub async fn get_project(&self, key: &str) -> Result<Project, ApiError> {
780 self.get(&format!("project/{key}")).await
781 }
782
783 pub async fn list_components(&self, project_key: &str) -> Result<Vec<Component>, ApiError> {
788 self.get::<Vec<Component>>(&format!("project/{project_key}/components"))
789 .await
790 }
791
792 pub async fn list_versions(&self, project_key: &str) -> Result<Vec<Version>, ApiError> {
797 self.get::<Vec<Version>>(&format!("project/{project_key}/versions"))
798 .await
799 }
800
801 pub async fn list_fields(&self) -> Result<Vec<Field>, ApiError> {
805 self.get::<Vec<Field>>("field").await
806 }
807
808 pub async fn move_issue_to_sprint(
812 &self,
813 issue_key: &str,
814 sprint_id: u64,
815 ) -> Result<(), ApiError> {
816 validate_issue_key(issue_key)?;
817 let url = format!("{}/sprint/{sprint_id}/issue", self.agile_base_url);
818 let payload = serde_json::json!({ "issues": [issue_key] });
819 let resp = self.http.post(&url).json(&payload).send().await?;
820 let status = resp.status();
821 if !status.is_success() {
822 let body = resp.text().await.unwrap_or_default();
823 return Err(Self::map_status(status.as_u16(), body));
824 }
825 Ok(())
826 }
827
828 pub async fn get_sprint(&self, sprint_id: u64) -> Result<Sprint, ApiError> {
830 self.agile_get::<Sprint>(&format!("sprint/{sprint_id}"))
831 .await
832 }
833
834 pub async fn resolve_sprint(&self, specifier: &str) -> Result<Sprint, ApiError> {
841 if let Ok(id) = specifier.parse::<u64>() {
842 return self.get_sprint(id).await;
843 }
844
845 let boards = self.list_boards().await?;
846 if boards.is_empty() {
847 return Err(ApiError::NotFound("No boards found".into()));
848 }
849
850 let target_state = if specifier.eq_ignore_ascii_case("active") {
851 Some("active")
852 } else {
853 None
854 };
855
856 for board in &boards {
857 let sprints = self.list_sprints(board.id, target_state).await?;
858 for sprint in sprints {
859 if specifier.eq_ignore_ascii_case("active") {
860 if sprint.state == "active" {
861 return Ok(sprint);
862 }
863 } else if sprint
864 .name
865 .to_lowercase()
866 .contains(&specifier.to_lowercase())
867 {
868 return Ok(sprint);
869 }
870 }
871 }
872
873 Err(ApiError::NotFound(format!(
874 "No sprint found matching '{specifier}'"
875 )))
876 }
877
878 pub async fn resolve_sprint_id(&self, specifier: &str) -> Result<u64, ApiError> {
882 if let Ok(id) = specifier.parse::<u64>() {
883 return Ok(id);
884 }
885 self.resolve_sprint(specifier).await.map(|s| s.id)
886 }
887}
888
889fn validate_issue_key(key: &str) -> Result<(), ApiError> {
895 let mut parts = key.splitn(2, '-');
896 let project = parts.next().unwrap_or("");
897 let number = parts.next().unwrap_or("");
898
899 let valid = !project.is_empty()
900 && !number.is_empty()
901 && project
902 .chars()
903 .next()
904 .is_some_and(|c| c.is_ascii_uppercase())
905 && project
906 .chars()
907 .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit())
908 && number.chars().all(|c| c.is_ascii_digit());
909
910 if valid {
911 Ok(())
912 } else {
913 Err(ApiError::InvalidInput(format!(
914 "Invalid issue key '{key}'. Expected format: PROJECT-123"
915 )))
916 }
917}
918
919fn percent_encode(s: &str) -> String {
923 let mut encoded = String::with_capacity(s.len() * 2);
924 for byte in s.bytes() {
925 match byte {
926 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
927 encoded.push(byte as char)
928 }
929 b => encoded.push_str(&format!("%{b:02X}")),
930 }
931 }
932 encoded
933}
934
935fn truncate_error_body(body: &str) -> String {
937 const MAX: usize = 200;
938 if body.chars().count() <= MAX {
939 body.to_string()
940 } else {
941 let truncated: String = body.chars().take(MAX).collect();
942 format!("{truncated}… (truncated)")
943 }
944}
945
946fn summarize_error_body(status: u16, body: &str) -> String {
947 if should_include_raw_error_body() && !body.trim().is_empty() {
948 return truncate_error_body(body);
949 }
950
951 if let Some(message) = summarize_json_error_body(body) {
952 return message;
953 }
954
955 default_status_message(status)
956}
957
958fn summarize_json_error_body(body: &str) -> Option<String> {
959 let parsed: JiraErrorPayload = serde_json::from_str(body).ok()?;
960 let mut parts = Vec::new();
961
962 if !parsed.error_messages.is_empty() {
963 parts.push(format_error_messages(&parsed.error_messages));
964 }
965
966 if !parsed.errors.is_empty() {
967 let fields = parsed.errors.keys().take(5).cloned().collect::<Vec<_>>();
968 parts.push(format!(
969 "validation errors for fields: {}",
970 fields.join(", ")
971 ));
972 }
973
974 if parts.is_empty() {
975 None
976 } else {
977 Some(parts.join("; "))
978 }
979}
980
981const MAX_ERROR_MESSAGES_SHOWN: usize = 3;
984
985const MAX_ERROR_MESSAGE_LEN: usize = 240;
988
989fn format_error_messages(messages: &[String]) -> String {
990 let shown: Vec<String> = messages
991 .iter()
992 .take(MAX_ERROR_MESSAGES_SHOWN)
993 .map(|m| truncate_message(m.trim()))
994 .collect();
995 let joined = shown.join(" | ");
996 let remaining = messages.len().saturating_sub(MAX_ERROR_MESSAGES_SHOWN);
997 if remaining > 0 {
998 format!("{joined} (+{remaining} more)")
999 } else {
1000 joined
1001 }
1002}
1003
1004fn truncate_message(msg: &str) -> String {
1005 if msg.chars().count() <= MAX_ERROR_MESSAGE_LEN {
1006 msg.to_string()
1007 } else {
1008 let truncated: String = msg.chars().take(MAX_ERROR_MESSAGE_LEN).collect();
1009 format!("{truncated}…")
1010 }
1011}
1012
1013fn default_status_message(status: u16) -> String {
1014 match status {
1015 401 | 403 => "request unauthorized".into(),
1016 404 => "resource not found".into(),
1017 409 => "conflicts with the current state of the resource".into(),
1018 429 => "rate limited by Jira".into(),
1019 400..=499 => format!("request failed with status {status}"),
1020 _ => format!("Jira request failed with status {status}"),
1021 }
1022}
1023
1024fn should_include_raw_error_body() -> bool {
1025 matches!(
1026 std::env::var("JIRA_DEBUG_HTTP").ok().as_deref(),
1027 Some("1" | "true" | "TRUE" | "yes" | "YES")
1028 )
1029}
1030
1031#[derive(Debug, serde::Deserialize)]
1032#[serde(rename_all = "camelCase")]
1033struct JiraErrorPayload {
1034 #[serde(default)]
1035 error_messages: Vec<String>,
1036 #[serde(default)]
1037 errors: BTreeMap<String, String>,
1038}
1039
1040#[cfg(test)]
1041mod tests {
1042 use super::*;
1043
1044 #[test]
1045 fn percent_encode_spaces_use_percent_20() {
1046 assert_eq!(percent_encode("project = FOO"), "project%20%3D%20FOO");
1047 }
1048
1049 #[test]
1050 fn percent_encode_complex_jql() {
1051 let jql = r#"project = "MY PROJECT""#;
1052 let encoded = percent_encode(jql);
1053 assert!(encoded.contains("project"));
1054 assert!(!encoded.contains('"'));
1055 assert!(!encoded.contains(' '));
1056 }
1057
1058 #[test]
1059 fn validate_issue_key_valid() {
1060 assert!(validate_issue_key("PROJ-123").is_ok());
1061 assert!(validate_issue_key("ABC-1").is_ok());
1062 assert!(validate_issue_key("MYPROJECT-9999").is_ok());
1063 assert!(validate_issue_key("ABC2-123").is_ok());
1065 assert!(validate_issue_key("P1-1").is_ok());
1066 }
1067
1068 #[test]
1069 fn validate_issue_key_invalid() {
1070 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());
1074 assert!(validate_issue_key("").is_err());
1075 assert!(validate_issue_key("1PROJ-123").is_err()); }
1077
1078 #[test]
1079 fn truncate_error_body_short() {
1080 let body = "short error";
1081 assert_eq!(truncate_error_body(body), body);
1082 }
1083
1084 #[test]
1085 fn truncate_error_body_long() {
1086 let body = "x".repeat(300);
1087 let result = truncate_error_body(&body);
1088 assert!(result.len() < body.len());
1089 assert!(result.ends_with("(truncated)"));
1090 }
1091
1092 #[test]
1093 fn summarize_json_error_body_surfaces_messages_and_redacts_field_values() {
1094 let body = serde_json::json!({
1095 "errorMessages": ["JQL validation failed"],
1096 "errors": {
1097 "summary": "Summary must not contain secret project name",
1098 "description": "Description cannot include api token"
1099 }
1100 })
1101 .to_string();
1102
1103 let message = summarize_error_body(400, &body);
1104 assert!(message.contains("JQL validation failed"));
1106 assert!(message.contains("summary"));
1109 assert!(message.contains("description"));
1110 assert!(!message.contains("secret project name"));
1111 assert!(!message.contains("api token"));
1112 }
1113
1114 #[test]
1115 fn summarize_json_error_body_reports_retired_api() {
1116 let body = serde_json::json!({
1118 "errorMessages": [
1119 "The requested API has been removed. Please migrate to the /rest/api/3/search/jql API."
1120 ],
1121 "errors": {}
1122 })
1123 .to_string();
1124
1125 let message = summarize_error_body(410, &body);
1126 assert!(message.contains("The requested API has been removed"));
1127 assert!(message.contains("/rest/api/3/search/jql"));
1128 }
1129
1130 #[test]
1131 fn summarize_json_error_body_joins_multiple_messages() {
1132 let body = serde_json::json!({
1133 "errorMessages": ["first problem", "second problem"],
1134 "errors": {}
1135 })
1136 .to_string();
1137
1138 let message = summarize_error_body(400, &body);
1139 assert!(message.contains("first problem"));
1140 assert!(message.contains("second problem"));
1141 assert!(message.contains(" | "));
1142 }
1143
1144 #[test]
1145 fn summarize_json_error_body_collapses_overflow_messages() {
1146 let body = serde_json::json!({
1147 "errorMessages": ["a", "b", "c", "d", "e"],
1148 "errors": {}
1149 })
1150 .to_string();
1151
1152 let message = summarize_error_body(400, &body);
1153 assert!(message.contains("(+2 more)"));
1154 }
1155
1156 #[test]
1157 fn summarize_json_error_body_truncates_oversized_message() {
1158 let huge = "x".repeat(1000);
1159 let body = serde_json::json!({
1160 "errorMessages": [huge],
1161 "errors": {}
1162 })
1163 .to_string();
1164
1165 let message = summarize_error_body(400, &body);
1166 assert!(message.chars().count() < 500);
1167 assert!(message.contains('…'));
1168 }
1169
1170 #[test]
1171 fn browse_url_preserves_explicit_http_hosts() {
1172 let client = JiraClient::new(
1173 "http://localhost:8080",
1174 "me@example.com",
1175 "token",
1176 AuthType::Basic,
1177 3,
1178 )
1179 .unwrap();
1180 assert_eq!(
1181 client.browse_url("PROJ-1"),
1182 "http://localhost:8080/browse/PROJ-1"
1183 );
1184 }
1185
1186 #[test]
1187 fn new_with_pat_auth_does_not_require_email() {
1188 let client = JiraClient::new(
1189 "https://jira.example.com",
1190 "",
1191 "my-pat-token",
1192 AuthType::Pat,
1193 3,
1194 );
1195 assert!(client.is_ok());
1196 }
1197
1198 #[test]
1199 fn new_with_api_v2_uses_v2_base_url() {
1200 let client = JiraClient::new(
1201 "https://jira.example.com",
1202 "me@example.com",
1203 "token",
1204 AuthType::Basic,
1205 2,
1206 )
1207 .unwrap();
1208 assert_eq!(client.api_version(), 2);
1209 }
1210}