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