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;
6use std::path::PathBuf;
7
8use super::ApiError;
9use super::AuthType;
10use super::types::*;
11
12pub struct JiraClient {
13 http: reqwest::Client,
14 base_url: String,
15 agile_base_url: String,
16 site_url: String,
17 host: String,
18 api_version: u8,
19}
20
21const SEARCH_FIELDS: [&str; 7] = [
22 "summary",
23 "status",
24 "assignee",
25 "priority",
26 "issuetype",
27 "created",
28 "updated",
29];
30const SEARCH_GET_JQL_LIMIT: usize = 1500;
31
32const SEARCH_JQL_MAX_PAGE: usize = 100;
36
37const SEARCH_JQL_SKIP_PAGE: usize = 1000;
40
41fn name_object_array(items: &[&str]) -> serde_json::Value {
43 serde_json::Value::Array(
44 items
45 .iter()
46 .map(|name| serde_json::json!({ "name": name }))
47 .collect(),
48 )
49}
50
51impl JiraClient {
52 pub fn new(
53 host: &str,
54 email: &str,
55 token: &str,
56 auth_type: AuthType,
57 api_version: u8,
58 ) -> Result<Self, ApiError> {
59 let (scheme, domain) = if host.starts_with("http://") {
62 (
63 "http",
64 host.trim_start_matches("http://").trim_end_matches('/'),
65 )
66 } else {
67 (
68 "https",
69 host.trim_start_matches("https://").trim_end_matches('/'),
70 )
71 };
72
73 if domain.is_empty() {
74 return Err(ApiError::Other("Host cannot be empty".into()));
75 }
76
77 let auth_value = match auth_type {
78 AuthType::Basic => {
79 let credentials = BASE64.encode(format!("{email}:{token}"));
80 format!("Basic {credentials}")
81 }
82 AuthType::Pat => format!("Bearer {token}"),
83 };
84
85 let mut headers = HeaderMap::new();
86 headers.insert(
87 AUTHORIZATION,
88 HeaderValue::from_str(&auth_value).map_err(|e| ApiError::Other(e.to_string()))?,
89 );
90
91 let http = reqwest::Client::builder()
92 .default_headers(headers)
93 .timeout(std::time::Duration::from_secs(30))
94 .build()
95 .map_err(ApiError::Http)?;
96
97 let site_url = format!("{scheme}://{domain}");
98 let base_url = format!("{site_url}/rest/api/{api_version}");
99 let agile_base_url = format!("{site_url}/rest/agile/1.0");
100
101 Ok(Self {
102 http,
103 base_url,
104 agile_base_url,
105 site_url,
106 host: domain.to_string(),
107 api_version,
108 })
109 }
110
111 pub fn host(&self) -> &str {
112 &self.host
113 }
114
115 pub fn api_version(&self) -> u8 {
116 self.api_version
117 }
118
119 pub fn browse_base_url(&self) -> &str {
120 &self.site_url
121 }
122
123 pub fn browse_url(&self, issue_key: &str) -> String {
124 format!("{}/browse/{issue_key}", self.browse_base_url())
125 }
126
127 fn map_status(status: u16, body: String) -> ApiError {
128 let message = summarize_error_body(status, &body);
129 match status {
130 401 | 403 => ApiError::Auth(message),
131 404 => ApiError::NotFound(message),
132 409 => ApiError::Conflict(message),
133 429 => ApiError::RateLimit,
134 _ => ApiError::Api { status, message },
135 }
136 }
137
138 async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T, ApiError> {
139 let url = format!("{}/{path}", self.base_url);
140 let resp = self.http.get(&url).send().await?;
141 let status = resp.status();
142 if !status.is_success() {
143 let body = resp.text().await.unwrap_or_default();
144 return Err(Self::map_status(status.as_u16(), body));
145 }
146 resp.json::<T>().await.map_err(ApiError::Http)
147 }
148
149 async fn agile_get<T: DeserializeOwned>(&self, path: &str) -> Result<T, ApiError> {
150 let url = format!("{}/{path}", self.agile_base_url);
151 let resp = self.http.get(&url).send().await?;
152 let status = resp.status();
153 if !status.is_success() {
154 let body = resp.text().await.unwrap_or_default();
155 return Err(Self::map_status(status.as_u16(), body));
156 }
157 resp.json::<T>().await.map_err(ApiError::Http)
158 }
159
160 async fn post<T: DeserializeOwned>(
161 &self,
162 path: &str,
163 body: &serde_json::Value,
164 ) -> Result<T, ApiError> {
165 let url = format!("{}/{path}", self.base_url);
166 let resp = self.http.post(&url).json(body).send().await?;
167 let status = resp.status();
168 if !status.is_success() {
169 let body_text = resp.text().await.unwrap_or_default();
170 return Err(Self::map_status(status.as_u16(), body_text));
171 }
172 resp.json::<T>().await.map_err(ApiError::Http)
173 }
174
175 async fn post_empty_response(
176 &self,
177 path: &str,
178 body: &serde_json::Value,
179 ) -> Result<(), ApiError> {
180 let url = format!("{}/{path}", self.base_url);
181 let resp = self.http.post(&url).json(body).send().await?;
182 let status = resp.status();
183 if !status.is_success() {
184 let body_text = resp.text().await.unwrap_or_default();
185 return Err(Self::map_status(status.as_u16(), body_text));
186 }
187 Ok(())
188 }
189
190 async fn put_empty_response(
191 &self,
192 path: &str,
193 body: &serde_json::Value,
194 ) -> Result<(), ApiError> {
195 let url = format!("{}/{path}", self.base_url);
196 let resp = self.http.put(&url).json(body).send().await?;
197 let status = resp.status();
198 if !status.is_success() {
199 let body_text = resp.text().await.unwrap_or_default();
200 return Err(Self::map_status(status.as_u16(), body_text));
201 }
202 Ok(())
203 }
204
205 async fn send(&self, request: reqwest::RequestBuilder) -> Result<reqwest::Response, ApiError> {
208 let resp = request.send().await?;
209 let status = resp.status();
210 if !status.is_success() {
211 let body = resp.text().await.unwrap_or_default();
212 return Err(Self::map_status(status.as_u16(), body));
213 }
214 Ok(resp)
215 }
216
217 pub async fn search(
230 &self,
231 jql: &str,
232 max_results: usize,
233 start_at: usize,
234 ) -> Result<SearchResponse, ApiError> {
235 if self.api_version >= 3 {
236 self.search_jql_v3(jql, max_results, start_at).await
237 } else {
238 self.search_v2(jql, max_results, start_at).await
239 }
240 }
241
242 async fn search_v2(
243 &self,
244 jql: &str,
245 max_results: usize,
246 start_at: usize,
247 ) -> Result<SearchResponse, ApiError> {
248 let fields = SEARCH_FIELDS.join(",");
249 let encoded_jql = percent_encode(jql);
250 #[derive(serde::Deserialize)]
255 struct RawV2 {
256 issues: Vec<Issue>,
257 total: Option<usize>,
258 #[serde(rename = "startAt")]
259 start_at: Option<usize>,
260 #[serde(rename = "maxResults")]
261 max_results: Option<usize>,
262 }
263 let raw: RawV2 = if encoded_jql.len() <= SEARCH_GET_JQL_LIMIT {
264 let path = format!(
265 "search?jql={encoded_jql}&maxResults={max_results}&startAt={start_at}&fields={fields}"
266 );
267 self.get(&path).await?
268 } else {
269 self.post(
270 "search",
271 &serde_json::json!({
272 "jql": jql,
273 "maxResults": max_results,
274 "startAt": start_at,
275 "fields": SEARCH_FIELDS,
276 }),
277 )
278 .await?
279 };
280 let echoed_start_at = raw.start_at.unwrap_or(start_at);
283 let is_last = match raw.total {
284 Some(total) => echoed_start_at + raw.issues.len() >= total,
285 None => raw.issues.len() < max_results,
288 };
289 Ok(SearchResponse {
290 issues: raw.issues,
291 total: raw.total,
292 start_at: echoed_start_at,
293 max_results: raw.max_results.unwrap_or(max_results),
294 is_last,
295 })
296 }
297
298 async fn search_jql_page(
304 &self,
305 jql: &str,
306 page_size: usize,
307 next_token: Option<&str>,
308 ) -> Result<SearchJqlPage, ApiError> {
309 let mut body = serde_json::json!({
310 "jql": jql,
311 "maxResults": page_size,
312 "fields": SEARCH_FIELDS,
313 });
314 if let Some(t) = next_token {
315 body["nextPageToken"] = serde_json::Value::String(t.to_string());
316 }
317 self.post("search/jql", &body).await
318 }
319
320 async fn search_jql_skip_page(
326 &self,
327 jql: &str,
328 page_size: usize,
329 next_token: Option<&str>,
330 ) -> Result<SearchJqlSkipPage, ApiError> {
331 let mut body = serde_json::json!({
332 "jql": jql,
333 "maxResults": page_size,
334 "fields": ["id"],
335 });
336 if let Some(t) = next_token {
337 body["nextPageToken"] = serde_json::Value::String(t.to_string());
338 }
339 self.post("search/jql", &body).await
340 }
341
342 async fn search_jql_v3(
343 &self,
344 jql: &str,
345 max_results: usize,
346 start_at: usize,
347 ) -> Result<SearchResponse, ApiError> {
348 let mut next_token: Option<String> = None;
353 let mut skipped = 0usize;
354 while skipped < start_at {
355 let want = (start_at - skipped).min(SEARCH_JQL_SKIP_PAGE);
356 let page = self
357 .search_jql_skip_page(jql, want, next_token.as_deref())
358 .await?;
359 let got = page.issues.len();
360 skipped += got;
361 if got == 0 || page.is_last {
362 return Ok(SearchResponse {
364 issues: Vec::new(),
365 total: None,
366 start_at,
367 max_results: 0,
368 is_last: true,
369 });
370 }
371 next_token = page.next_page_token;
372 if next_token.is_none() {
373 return Ok(SearchResponse {
376 issues: Vec::new(),
377 total: None,
378 start_at,
379 max_results: 0,
380 is_last: true,
381 });
382 }
383 }
384
385 let mut collected: Vec<Issue> = Vec::new();
388 let mut is_last = false;
389 while collected.len() < max_results {
390 let remaining = max_results - collected.len();
391 let want = remaining.min(SEARCH_JQL_MAX_PAGE);
392 let page = self
393 .search_jql_page(jql, want, next_token.as_deref())
394 .await?;
395 let got = page.issues.len();
396 collected.extend(page.issues);
397 if page.is_last || got == 0 {
398 is_last = true;
399 break;
400 }
401 next_token = page.next_page_token;
402 if next_token.is_none() {
403 is_last = true;
404 break;
405 }
406 }
407
408 let returned = collected.len();
409 Ok(SearchResponse {
410 issues: collected,
411 total: None,
413 start_at,
414 max_results: returned,
415 is_last,
416 })
417 }
418
419 pub async fn get_issue(&self, key: &str) -> Result<Issue, ApiError> {
425 validate_issue_key(key)?;
426 let fields = "summary,status,assignee,reporter,priority,issuetype,description,labels,components,fixVersions,versions,created,updated,comment,issuelinks";
427 let path = format!("issue/{key}?fields={fields}");
428 let mut issue: Issue = self.get(&path).await?;
429
430 if let Some(ref mut comment_list) = issue.fields.comment
432 && comment_list.total > comment_list.comments.len()
433 {
434 let mut start_at = comment_list.comments.len();
435 while comment_list.comments.len() < comment_list.total {
436 let page: CommentList = self
437 .get(&format!(
438 "issue/{key}/comment?startAt={start_at}&maxResults=100"
439 ))
440 .await?;
441 if page.comments.is_empty() {
442 break;
443 }
444 start_at += page.comments.len();
445 comment_list.comments.extend(page.comments);
446 }
447 }
448
449 Ok(issue)
450 }
451
452 pub async fn create_issue(
454 &self,
455 draft: &IssueDraft<'_>,
456 custom_fields: &[(String, serde_json::Value)],
457 ) -> Result<CreateIssueResponse, ApiError> {
458 let mut fields = serde_json::json!({
459 "project": { "key": draft.project_key },
460 "issuetype": { "name": draft.issue_type },
461 "summary": draft.summary,
462 });
463
464 if let Some(desc) = draft.description {
465 fields["description"] = self.make_body(desc);
466 }
467 if let Some(p) = draft.priority {
468 fields["priority"] = serde_json::json!({ "name": p });
469 }
470 if let Some(lbls) = draft.labels
471 && !lbls.is_empty()
472 {
473 fields["labels"] = serde_json::json!(lbls);
474 }
475 if let Some(comps) = draft.components
476 && !comps.is_empty()
477 {
478 fields["components"] = name_object_array(comps);
479 }
480 if let Some(fvs) = draft.fix_versions
481 && !fvs.is_empty()
482 {
483 fields["fixVersions"] = name_object_array(fvs);
484 }
485 if let Some(id) = draft.assignee {
486 fields["assignee"] = self.assignee_payload(id);
487 }
488 if let Some(parent_key) = draft.parent {
489 fields["parent"] = serde_json::json!({ "key": parent_key });
490 }
491 for (key, value) in custom_fields {
492 fields[key] = value.clone();
493 }
494
495 self.post("issue", &serde_json::json!({ "fields": fields }))
496 .await
497 }
498
499 pub async fn log_work(
504 &self,
505 key: &str,
506 time_spent: &str,
507 comment: Option<&str>,
508 started: Option<&str>,
509 ) -> Result<WorklogEntry, ApiError> {
510 validate_issue_key(key)?;
511 let mut payload = serde_json::json!({ "timeSpent": time_spent });
512 if let Some(c) = comment {
513 payload["comment"] = self.make_body(c);
514 }
515 if let Some(s) = started {
516 payload["started"] = serde_json::Value::String(s.to_string());
517 }
518 self.post(&format!("issue/{key}/worklog"), &payload).await
519 }
520
521 pub async fn add_comment(&self, key: &str, body: &str) -> Result<Comment, ApiError> {
523 validate_issue_key(key)?;
524 let payload = serde_json::json!({ "body": self.make_body(body) });
525 self.post(&format!("issue/{key}/comment"), &payload).await
526 }
527
528 pub async fn get_transitions(&self, key: &str) -> Result<Vec<Transition>, ApiError> {
530 validate_issue_key(key)?;
531 let resp: TransitionsResponse = self.get(&format!("issue/{key}/transitions")).await?;
532 Ok(resp.transitions)
533 }
534
535 pub async fn do_transition(&self, key: &str, transition_id: &str) -> Result<(), ApiError> {
537 validate_issue_key(key)?;
538 let payload = serde_json::json!({ "transition": { "id": transition_id } });
539 self.post_empty_response(&format!("issue/{key}/transitions"), &payload)
540 .await
541 }
542
543 pub async fn assign_issue(&self, key: &str, account_id: Option<&str>) -> Result<(), ApiError> {
548 validate_issue_key(key)?;
549 let payload = match account_id {
550 Some(id) => self.assignee_payload(id),
551 None => {
552 if self.api_version >= 3 {
553 serde_json::json!({ "accountId": null })
554 } else {
555 serde_json::json!({ "name": null })
556 }
557 }
558 };
559 self.put_empty_response(&format!("issue/{key}/assignee"), &payload)
560 .await
561 }
562
563 fn assignee_payload(&self, id: &str) -> serde_json::Value {
567 if self.api_version >= 3 {
568 serde_json::json!({ "accountId": id })
569 } else {
570 serde_json::json!({ "name": id })
571 }
572 }
573
574 pub async fn get_myself(&self) -> Result<Myself, ApiError> {
576 self.get("myself").await
577 }
578
579 pub async fn update_issue(
586 &self,
587 key: &str,
588 update: &IssueUpdate<'_>,
589 custom_fields: &[(String, serde_json::Value)],
590 ) -> Result<(), ApiError> {
591 validate_issue_key(key)?;
592 let mut fields = serde_json::Map::new();
593 if let Some(s) = update.summary {
594 fields.insert("summary".into(), serde_json::Value::String(s.into()));
595 }
596 if let Some(d) = update.description {
597 fields.insert("description".into(), self.make_body(d));
598 }
599 if let Some(p) = update.priority {
600 fields.insert("priority".into(), serde_json::json!({ "name": p }));
601 }
602 if let Some(comps) = update.components {
603 fields.insert("components".into(), name_object_array(comps));
604 }
605 if let Some(fvs) = update.fix_versions {
606 fields.insert("fixVersions".into(), name_object_array(fvs));
607 }
608 if let Some(lbls) = update.labels {
609 fields.insert("labels".into(), serde_json::json!(lbls));
610 }
611 if let Some(assignee_choice) = update.assignee {
612 let payload = match assignee_choice {
613 None => serde_json::Value::Null,
614 Some(id) => self.assignee_payload(id),
615 };
616 fields.insert("assignee".into(), payload);
617 }
618 for (k, value) in custom_fields {
619 fields.insert(k.clone(), value.clone());
620 }
621 if fields.is_empty() {
622 return Err(ApiError::InvalidInput(
623 "At least one field (--summary, --description, --priority, --components, --fix-versions, --labels, --assignee, or --field) is required"
624 .into(),
625 ));
626 }
627 self.put_empty_response(
628 &format!("issue/{key}"),
629 &serde_json::json!({ "fields": fields }),
630 )
631 .await
632 }
633
634 fn make_body(&self, text: &str) -> serde_json::Value {
639 if self.api_version >= 3 {
640 text_to_adf(text)
641 } else {
642 serde_json::Value::String(text.to_string())
643 }
644 }
645
646 pub async fn list_attachments(&self, key: &str) -> Result<Vec<Attachment>, ApiError> {
650 validate_issue_key(key)?;
651 #[derive(serde::Deserialize)]
652 struct Wrapper {
653 fields: AttachmentField,
654 }
655 #[derive(serde::Deserialize)]
656 struct AttachmentField {
657 #[serde(default)]
658 attachment: Vec<Attachment>,
659 }
660 let w: Wrapper = self.get(&format!("issue/{key}?fields=attachment")).await?;
661 Ok(w.fields.attachment)
662 }
663
664 pub async fn upload_attachments(
670 &self,
671 key: &str,
672 paths: &[PathBuf],
673 ) -> Result<Vec<Attachment>, ApiError> {
674 validate_issue_key(key)?;
675
676 let mut form = reqwest::multipart::Form::new();
677 for path in paths {
678 let file_name = path
679 .file_name()
680 .and_then(|name| name.to_str())
681 .ok_or_else(|| {
682 ApiError::InvalidInput(format!(
683 "'{}' does not name a file to upload",
684 path.display()
685 ))
686 })?
687 .to_string();
688 let content_type = mime_guess::from_path(&file_name).first_or_octet_stream();
689 let data = std::fs::read(path)
690 .map_err(|e| ApiError::Other(format!("cannot read {}: {e}", path.display())))?;
691 let part = reqwest::multipart::Part::bytes(data)
692 .file_name(file_name)
693 .mime_str(content_type.as_ref())
694 .map_err(|e| ApiError::Other(e.to_string()))?;
695 form = form.part("file", part);
696 }
697
698 let url = format!("{}/issue/{key}/attachments", self.base_url);
699 let request = self
700 .http
701 .post(&url)
702 .header("X-Atlassian-Token", "no-check")
703 .multipart(form);
704 let resp = self.send(request).await?;
705 resp.json::<Vec<Attachment>>().await.map_err(ApiError::Http)
706 }
707
708 pub async fn get_attachment(&self, id: &str) -> Result<Attachment, ApiError> {
710 validate_attachment_id(id)?;
711 self.get(&format!("attachment/{id}")).await
712 }
713
714 pub async fn download_attachment(&self, id: &str) -> Result<Vec<u8>, ApiError> {
719 validate_attachment_id(id)?;
720 let url = format!("{}/attachment/content/{id}", self.base_url);
721 let resp = self.send(self.http.get(&url)).await?;
722 let bytes = resp.bytes().await.map_err(ApiError::Http)?;
723 Ok(bytes.to_vec())
724 }
725
726 pub async fn delete_attachment(&self, id: &str) -> Result<(), ApiError> {
728 validate_attachment_id(id)?;
729 let url = format!("{}/attachment/{id}", self.base_url);
730 self.send(self.http.delete(&url)).await?;
731 Ok(())
732 }
733
734 pub async fn search_users(&self, query: &str) -> Result<Vec<User>, ApiError> {
740 let encoded = percent_encode(query);
741 let param = if self.api_version >= 3 {
742 "query"
743 } else {
744 "username"
745 };
746 let path = format!("user/search?{param}={encoded}&maxResults=50");
747 self.get::<Vec<User>>(&path).await
748 }
749
750 pub async fn get_link_types(&self) -> Result<Vec<IssueLinkType>, ApiError> {
754 #[derive(serde::Deserialize)]
755 struct Wrapper {
756 #[serde(rename = "issueLinkTypes")]
757 types: Vec<IssueLinkType>,
758 }
759 let w: Wrapper = self.get("issueLinkType").await?;
760 Ok(w.types)
761 }
762
763 pub async fn link_issues(
769 &self,
770 from_key: &str,
771 to_key: &str,
772 link_type: &str,
773 ) -> Result<(), ApiError> {
774 validate_issue_key(from_key)?;
775 validate_issue_key(to_key)?;
776 let payload = serde_json::json!({
777 "type": { "name": link_type },
778 "inwardIssue": { "key": from_key },
779 "outwardIssue": { "key": to_key },
780 });
781 let url = format!("{}/issueLink", self.base_url);
782 let resp = self.http.post(&url).json(&payload).send().await?;
783 let status = resp.status();
784 if !status.is_success() {
785 let body = resp.text().await.unwrap_or_default();
786 return Err(Self::map_status(status.as_u16(), body));
787 }
788 Ok(())
789 }
790
791 pub async fn unlink_issues(&self, link_id: &str) -> Result<(), ApiError> {
793 let url = format!("{}/issueLink/{link_id}", self.base_url);
794 let resp = self.http.delete(&url).send().await?;
795 let status = resp.status();
796 if !status.is_success() {
797 let body = resp.text().await.unwrap_or_default();
798 return Err(Self::map_status(status.as_u16(), body));
799 }
800 Ok(())
801 }
802
803 pub async fn list_boards(&self) -> Result<Vec<Board>, ApiError> {
807 let mut all = Vec::new();
808 let mut start_at = 0usize;
809 const PAGE: usize = 50;
810 loop {
811 let path = format!("board?startAt={start_at}&maxResults={PAGE}");
812 let page: BoardSearchResponse = self.agile_get(&path).await?;
813 let received = page.values.len();
814 all.extend(page.values);
815 if page.is_last || received == 0 {
816 break;
817 }
818 start_at += received;
819 }
820 Ok(all)
821 }
822
823 pub async fn list_sprints(
827 &self,
828 board_id: u64,
829 state: Option<&str>,
830 ) -> Result<Vec<Sprint>, ApiError> {
831 let mut all = Vec::new();
832 let mut start_at = 0usize;
833 const PAGE: usize = 50;
834 loop {
835 let state_param = state.map(|s| format!("&state={s}")).unwrap_or_default();
836 let path = format!(
837 "board/{board_id}/sprint?startAt={start_at}&maxResults={PAGE}{state_param}"
838 );
839 let page: SprintSearchResponse = self.agile_get(&path).await?;
840 let received = page.values.len();
841 all.extend(page.values);
842 if page.is_last || received == 0 {
843 break;
844 }
845 start_at += received;
846 }
847 Ok(all)
848 }
849
850 pub async fn list_projects(&self) -> Result<Vec<Project>, ApiError> {
858 if self.api_version < 3 {
859 return self.get::<Vec<Project>>("project").await;
860 }
861
862 let mut all: Vec<Project> = Vec::new();
863 let mut start_at: usize = 0;
864 const PAGE: usize = 50;
865
866 loop {
867 let path = format!("project/search?startAt={start_at}&maxResults={PAGE}&orderBy=key");
868 let page: ProjectSearchResponse = self.get(&path).await?;
869 let page_start = page.start_at;
870 let received = page.values.len();
871 let total = page.total;
872 all.extend(page.values);
873
874 if page.is_last || all.len() >= total {
875 break;
876 }
877
878 if received == 0 {
879 return Err(ApiError::Other(
880 "Project pagination returned an empty non-terminal page".into(),
881 ));
882 }
883
884 start_at = page_start.saturating_add(received);
885 }
886
887 Ok(all)
888 }
889
890 pub async fn get_project(&self, key: &str) -> Result<Project, ApiError> {
892 self.get(&format!("project/{key}")).await
893 }
894
895 pub async fn list_components(&self, project_key: &str) -> Result<Vec<Component>, ApiError> {
900 self.get::<Vec<Component>>(&format!("project/{project_key}/components"))
901 .await
902 }
903
904 pub async fn list_versions(&self, project_key: &str) -> Result<Vec<Version>, ApiError> {
909 self.get::<Vec<Version>>(&format!("project/{project_key}/versions"))
910 .await
911 }
912
913 pub async fn list_fields(&self) -> Result<Vec<Field>, ApiError> {
917 self.get::<Vec<Field>>("field").await
918 }
919
920 pub async fn move_issue_to_sprint(
924 &self,
925 issue_key: &str,
926 sprint_id: u64,
927 ) -> Result<(), ApiError> {
928 validate_issue_key(issue_key)?;
929 let url = format!("{}/sprint/{sprint_id}/issue", self.agile_base_url);
930 let payload = serde_json::json!({ "issues": [issue_key] });
931 let resp = self.http.post(&url).json(&payload).send().await?;
932 let status = resp.status();
933 if !status.is_success() {
934 let body = resp.text().await.unwrap_or_default();
935 return Err(Self::map_status(status.as_u16(), body));
936 }
937 Ok(())
938 }
939
940 pub async fn get_sprint(&self, sprint_id: u64) -> Result<Sprint, ApiError> {
942 self.agile_get::<Sprint>(&format!("sprint/{sprint_id}"))
943 .await
944 }
945
946 pub async fn resolve_sprint(&self, specifier: &str) -> Result<Sprint, ApiError> {
953 if let Ok(id) = specifier.parse::<u64>() {
954 return self.get_sprint(id).await;
955 }
956
957 let boards = self.list_boards().await?;
958 if boards.is_empty() {
959 return Err(ApiError::NotFound("No boards found".into()));
960 }
961
962 let target_state = if specifier.eq_ignore_ascii_case("active") {
963 Some("active")
964 } else {
965 None
966 };
967
968 for board in &boards {
969 let sprints = self.list_sprints(board.id, target_state).await?;
970 for sprint in sprints {
971 if specifier.eq_ignore_ascii_case("active") {
972 if sprint.state == "active" {
973 return Ok(sprint);
974 }
975 } else if sprint
976 .name
977 .to_lowercase()
978 .contains(&specifier.to_lowercase())
979 {
980 return Ok(sprint);
981 }
982 }
983 }
984
985 Err(ApiError::NotFound(format!(
986 "No sprint found matching '{specifier}'"
987 )))
988 }
989
990 pub async fn resolve_sprint_id(&self, specifier: &str) -> Result<u64, ApiError> {
994 if let Ok(id) = specifier.parse::<u64>() {
995 return Ok(id);
996 }
997 self.resolve_sprint(specifier).await.map(|s| s.id)
998 }
999}
1000
1001fn validate_issue_key(key: &str) -> Result<(), ApiError> {
1007 let mut parts = key.splitn(2, '-');
1008 let project = parts.next().unwrap_or("");
1009 let number = parts.next().unwrap_or("");
1010
1011 let valid = !project.is_empty()
1012 && !number.is_empty()
1013 && project
1014 .chars()
1015 .next()
1016 .is_some_and(|c| c.is_ascii_uppercase())
1017 && project
1018 .chars()
1019 .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit())
1020 && number.chars().all(|c| c.is_ascii_digit());
1021
1022 if valid {
1023 Ok(())
1024 } else {
1025 Err(ApiError::InvalidInput(format!(
1026 "Invalid issue key '{key}'. Expected format: PROJECT-123"
1027 )))
1028 }
1029}
1030
1031fn validate_attachment_id(id: &str) -> Result<(), ApiError> {
1033 if !id.is_empty() && id.chars().all(|c| c.is_ascii_digit()) {
1034 Ok(())
1035 } else {
1036 Err(ApiError::InvalidInput(format!(
1037 "Invalid attachment ID '{id}'. Expected a number."
1038 )))
1039 }
1040}
1041
1042fn percent_encode(s: &str) -> String {
1046 let mut encoded = String::with_capacity(s.len() * 2);
1047 for byte in s.bytes() {
1048 match byte {
1049 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1050 encoded.push(byte as char)
1051 }
1052 b => encoded.push_str(&format!("%{b:02X}")),
1053 }
1054 }
1055 encoded
1056}
1057
1058fn truncate_error_body(body: &str) -> String {
1060 const MAX: usize = 200;
1061 if body.chars().count() <= MAX {
1062 body.to_string()
1063 } else {
1064 let truncated: String = body.chars().take(MAX).collect();
1065 format!("{truncated}… (truncated)")
1066 }
1067}
1068
1069fn summarize_error_body(status: u16, body: &str) -> String {
1070 if should_include_raw_error_body() && !body.trim().is_empty() {
1071 return truncate_error_body(body);
1072 }
1073
1074 if let Some(message) = summarize_json_error_body(body) {
1075 return message;
1076 }
1077
1078 default_status_message(status)
1079}
1080
1081fn summarize_json_error_body(body: &str) -> Option<String> {
1082 let parsed: JiraErrorPayload = serde_json::from_str(body).ok()?;
1083 let mut parts = Vec::new();
1084
1085 if !parsed.error_messages.is_empty() {
1086 parts.push(format_error_messages(&parsed.error_messages));
1087 }
1088
1089 if !parsed.errors.is_empty() {
1090 let fields = parsed.errors.keys().take(5).cloned().collect::<Vec<_>>();
1091 parts.push(format!(
1092 "validation errors for fields: {}",
1093 fields.join(", ")
1094 ));
1095 }
1096
1097 if parts.is_empty() {
1098 None
1099 } else {
1100 Some(parts.join("; "))
1101 }
1102}
1103
1104const MAX_ERROR_MESSAGES_SHOWN: usize = 3;
1107
1108const MAX_ERROR_MESSAGE_LEN: usize = 240;
1111
1112fn format_error_messages(messages: &[String]) -> String {
1113 let shown: Vec<String> = messages
1114 .iter()
1115 .take(MAX_ERROR_MESSAGES_SHOWN)
1116 .map(|m| truncate_message(m.trim()))
1117 .collect();
1118 let joined = shown.join(" | ");
1119 let remaining = messages.len().saturating_sub(MAX_ERROR_MESSAGES_SHOWN);
1120 if remaining > 0 {
1121 format!("{joined} (+{remaining} more)")
1122 } else {
1123 joined
1124 }
1125}
1126
1127fn truncate_message(msg: &str) -> String {
1128 if msg.chars().count() <= MAX_ERROR_MESSAGE_LEN {
1129 msg.to_string()
1130 } else {
1131 let truncated: String = msg.chars().take(MAX_ERROR_MESSAGE_LEN).collect();
1132 format!("{truncated}…")
1133 }
1134}
1135
1136fn default_status_message(status: u16) -> String {
1137 match status {
1138 401 | 403 => "request unauthorized".into(),
1139 404 => "resource not found".into(),
1140 409 => "conflicts with the current state of the resource".into(),
1141 429 => "rate limited by Jira".into(),
1142 400..=499 => format!("request failed with status {status}"),
1143 _ => format!("Jira request failed with status {status}"),
1144 }
1145}
1146
1147fn should_include_raw_error_body() -> bool {
1148 std::env::var("JIRA_DEBUG_HTTP").is_ok_and(|value| crate::config::is_truthy(&value))
1149}
1150
1151#[derive(Debug, serde::Deserialize)]
1152#[serde(rename_all = "camelCase")]
1153struct JiraErrorPayload {
1154 #[serde(default)]
1155 error_messages: Vec<String>,
1156 #[serde(default)]
1157 errors: BTreeMap<String, String>,
1158}
1159
1160#[cfg(test)]
1161mod tests {
1162 use super::*;
1163
1164 #[test]
1165 fn percent_encode_spaces_use_percent_20() {
1166 assert_eq!(percent_encode("project = FOO"), "project%20%3D%20FOO");
1167 }
1168
1169 #[test]
1170 fn percent_encode_complex_jql() {
1171 let jql = r#"project = "MY PROJECT""#;
1172 let encoded = percent_encode(jql);
1173 assert!(encoded.contains("project"));
1174 assert!(!encoded.contains('"'));
1175 assert!(!encoded.contains(' '));
1176 }
1177
1178 #[test]
1179 fn validate_issue_key_valid() {
1180 assert!(validate_issue_key("PROJ-123").is_ok());
1181 assert!(validate_issue_key("ABC-1").is_ok());
1182 assert!(validate_issue_key("MYPROJECT-9999").is_ok());
1183 assert!(validate_issue_key("ABC2-123").is_ok());
1185 assert!(validate_issue_key("P1-1").is_ok());
1186 }
1187
1188 #[test]
1189 fn validate_issue_key_invalid() {
1190 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());
1194 assert!(validate_issue_key("").is_err());
1195 assert!(validate_issue_key("1PROJ-123").is_err()); }
1197
1198 #[test]
1199 fn truncate_error_body_short() {
1200 let body = "short error";
1201 assert_eq!(truncate_error_body(body), body);
1202 }
1203
1204 #[test]
1205 fn truncate_error_body_long() {
1206 let body = "x".repeat(300);
1207 let result = truncate_error_body(&body);
1208 assert!(result.len() < body.len());
1209 assert!(result.ends_with("(truncated)"));
1210 }
1211
1212 #[test]
1213 fn summarize_json_error_body_surfaces_messages_and_redacts_field_values() {
1214 let body = serde_json::json!({
1215 "errorMessages": ["JQL validation failed"],
1216 "errors": {
1217 "summary": "Summary must not contain secret project name",
1218 "description": "Description cannot include api token"
1219 }
1220 })
1221 .to_string();
1222
1223 let message = summarize_error_body(400, &body);
1224 assert!(message.contains("JQL validation failed"));
1226 assert!(message.contains("summary"));
1229 assert!(message.contains("description"));
1230 assert!(!message.contains("secret project name"));
1231 assert!(!message.contains("api token"));
1232 }
1233
1234 #[test]
1235 fn summarize_json_error_body_reports_retired_api() {
1236 let body = serde_json::json!({
1238 "errorMessages": [
1239 "The requested API has been removed. Please migrate to the /rest/api/3/search/jql API."
1240 ],
1241 "errors": {}
1242 })
1243 .to_string();
1244
1245 let message = summarize_error_body(410, &body);
1246 assert!(message.contains("The requested API has been removed"));
1247 assert!(message.contains("/rest/api/3/search/jql"));
1248 }
1249
1250 #[test]
1251 fn summarize_json_error_body_joins_multiple_messages() {
1252 let body = serde_json::json!({
1253 "errorMessages": ["first problem", "second problem"],
1254 "errors": {}
1255 })
1256 .to_string();
1257
1258 let message = summarize_error_body(400, &body);
1259 assert!(message.contains("first problem"));
1260 assert!(message.contains("second problem"));
1261 assert!(message.contains(" | "));
1262 }
1263
1264 #[test]
1265 fn summarize_json_error_body_collapses_overflow_messages() {
1266 let body = serde_json::json!({
1267 "errorMessages": ["a", "b", "c", "d", "e"],
1268 "errors": {}
1269 })
1270 .to_string();
1271
1272 let message = summarize_error_body(400, &body);
1273 assert!(message.contains("(+2 more)"));
1274 }
1275
1276 #[test]
1277 fn summarize_json_error_body_truncates_oversized_message() {
1278 let huge = "x".repeat(1000);
1279 let body = serde_json::json!({
1280 "errorMessages": [huge],
1281 "errors": {}
1282 })
1283 .to_string();
1284
1285 let message = summarize_error_body(400, &body);
1286 assert!(message.chars().count() < 500);
1287 assert!(message.contains('…'));
1288 }
1289
1290 #[test]
1291 fn browse_url_preserves_explicit_http_hosts() {
1292 let client = JiraClient::new(
1293 "http://localhost:8080",
1294 "me@example.com",
1295 "token",
1296 AuthType::Basic,
1297 3,
1298 )
1299 .unwrap();
1300 assert_eq!(
1301 client.browse_url("PROJ-1"),
1302 "http://localhost:8080/browse/PROJ-1"
1303 );
1304 }
1305
1306 #[test]
1307 fn new_with_pat_auth_does_not_require_email() {
1308 let client = JiraClient::new(
1309 "https://jira.example.com",
1310 "",
1311 "my-pat-token",
1312 AuthType::Pat,
1313 3,
1314 );
1315 assert!(client.is_ok());
1316 }
1317
1318 #[test]
1319 fn new_with_api_v2_uses_v2_base_url() {
1320 let client = JiraClient::new(
1321 "https://jira.example.com",
1322 "me@example.com",
1323 "token",
1324 AuthType::Basic,
1325 2,
1326 )
1327 .unwrap();
1328 assert_eq!(client.api_version(), 2);
1329 }
1330}