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)]
251 struct RawV2 {
252 issues: Vec<Issue>,
253 #[serde(default)]
254 total: usize,
255 #[serde(rename = "startAt", default)]
256 start_at: usize,
257 #[serde(rename = "maxResults", default)]
258 max_results: usize,
259 }
260 let raw: RawV2 = if encoded_jql.len() <= SEARCH_GET_JQL_LIMIT {
261 let path = format!(
262 "search?jql={encoded_jql}&maxResults={max_results}&startAt={start_at}&fields={fields}"
263 );
264 self.get(&path).await?
265 } else {
266 self.post(
267 "search",
268 &serde_json::json!({
269 "jql": jql,
270 "maxResults": max_results,
271 "startAt": start_at,
272 "fields": SEARCH_FIELDS,
273 }),
274 )
275 .await?
276 };
277 let is_last = raw.start_at + raw.issues.len() >= raw.total;
278 Ok(SearchResponse {
279 issues: raw.issues,
280 total: Some(raw.total),
281 start_at: raw.start_at,
282 max_results: raw.max_results,
283 is_last,
284 })
285 }
286
287 async fn search_jql_page(
293 &self,
294 jql: &str,
295 page_size: usize,
296 next_token: Option<&str>,
297 ) -> Result<SearchJqlPage, ApiError> {
298 let mut body = serde_json::json!({
299 "jql": jql,
300 "maxResults": page_size,
301 "fields": SEARCH_FIELDS,
302 });
303 if let Some(t) = next_token {
304 body["nextPageToken"] = serde_json::Value::String(t.to_string());
305 }
306 self.post("search/jql", &body).await
307 }
308
309 async fn search_jql_skip_page(
315 &self,
316 jql: &str,
317 page_size: usize,
318 next_token: Option<&str>,
319 ) -> Result<SearchJqlSkipPage, ApiError> {
320 let mut body = serde_json::json!({
321 "jql": jql,
322 "maxResults": page_size,
323 "fields": ["id"],
324 });
325 if let Some(t) = next_token {
326 body["nextPageToken"] = serde_json::Value::String(t.to_string());
327 }
328 self.post("search/jql", &body).await
329 }
330
331 async fn search_jql_v3(
332 &self,
333 jql: &str,
334 max_results: usize,
335 start_at: usize,
336 ) -> Result<SearchResponse, ApiError> {
337 let mut next_token: Option<String> = None;
342 let mut skipped = 0usize;
343 while skipped < start_at {
344 let want = (start_at - skipped).min(SEARCH_JQL_SKIP_PAGE);
345 let page = self
346 .search_jql_skip_page(jql, want, next_token.as_deref())
347 .await?;
348 let got = page.issues.len();
349 skipped += got;
350 if got == 0 || page.is_last {
351 return Ok(SearchResponse {
353 issues: Vec::new(),
354 total: None,
355 start_at,
356 max_results: 0,
357 is_last: true,
358 });
359 }
360 next_token = page.next_page_token;
361 if next_token.is_none() {
362 return Ok(SearchResponse {
365 issues: Vec::new(),
366 total: None,
367 start_at,
368 max_results: 0,
369 is_last: true,
370 });
371 }
372 }
373
374 let mut collected: Vec<Issue> = Vec::new();
377 let mut is_last = false;
378 while collected.len() < max_results {
379 let remaining = max_results - collected.len();
380 let want = remaining.min(SEARCH_JQL_MAX_PAGE);
381 let page = self
382 .search_jql_page(jql, want, next_token.as_deref())
383 .await?;
384 let got = page.issues.len();
385 collected.extend(page.issues);
386 if page.is_last || got == 0 {
387 is_last = true;
388 break;
389 }
390 next_token = page.next_page_token;
391 if next_token.is_none() {
392 is_last = true;
393 break;
394 }
395 }
396
397 let returned = collected.len();
398 Ok(SearchResponse {
399 issues: collected,
400 total: None,
402 start_at,
403 max_results: returned,
404 is_last,
405 })
406 }
407
408 pub async fn get_issue(&self, key: &str) -> Result<Issue, ApiError> {
414 validate_issue_key(key)?;
415 let fields = "summary,status,assignee,reporter,priority,issuetype,description,labels,components,fixVersions,versions,created,updated,comment,issuelinks";
416 let path = format!("issue/{key}?fields={fields}");
417 let mut issue: Issue = self.get(&path).await?;
418
419 if let Some(ref mut comment_list) = issue.fields.comment
421 && comment_list.total > comment_list.comments.len()
422 {
423 let mut start_at = comment_list.comments.len();
424 while comment_list.comments.len() < comment_list.total {
425 let page: CommentList = self
426 .get(&format!(
427 "issue/{key}/comment?startAt={start_at}&maxResults=100"
428 ))
429 .await?;
430 if page.comments.is_empty() {
431 break;
432 }
433 start_at += page.comments.len();
434 comment_list.comments.extend(page.comments);
435 }
436 }
437
438 Ok(issue)
439 }
440
441 pub async fn create_issue(
443 &self,
444 draft: &IssueDraft<'_>,
445 custom_fields: &[(String, serde_json::Value)],
446 ) -> Result<CreateIssueResponse, ApiError> {
447 let mut fields = serde_json::json!({
448 "project": { "key": draft.project_key },
449 "issuetype": { "name": draft.issue_type },
450 "summary": draft.summary,
451 });
452
453 if let Some(desc) = draft.description {
454 fields["description"] = self.make_body(desc);
455 }
456 if let Some(p) = draft.priority {
457 fields["priority"] = serde_json::json!({ "name": p });
458 }
459 if let Some(lbls) = draft.labels
460 && !lbls.is_empty()
461 {
462 fields["labels"] = serde_json::json!(lbls);
463 }
464 if let Some(comps) = draft.components
465 && !comps.is_empty()
466 {
467 fields["components"] = name_object_array(comps);
468 }
469 if let Some(fvs) = draft.fix_versions
470 && !fvs.is_empty()
471 {
472 fields["fixVersions"] = name_object_array(fvs);
473 }
474 if let Some(id) = draft.assignee {
475 fields["assignee"] = self.assignee_payload(id);
476 }
477 if let Some(parent_key) = draft.parent {
478 fields["parent"] = serde_json::json!({ "key": parent_key });
479 }
480 for (key, value) in custom_fields {
481 fields[key] = value.clone();
482 }
483
484 self.post("issue", &serde_json::json!({ "fields": fields }))
485 .await
486 }
487
488 pub async fn log_work(
493 &self,
494 key: &str,
495 time_spent: &str,
496 comment: Option<&str>,
497 started: Option<&str>,
498 ) -> Result<WorklogEntry, ApiError> {
499 validate_issue_key(key)?;
500 let mut payload = serde_json::json!({ "timeSpent": time_spent });
501 if let Some(c) = comment {
502 payload["comment"] = self.make_body(c);
503 }
504 if let Some(s) = started {
505 payload["started"] = serde_json::Value::String(s.to_string());
506 }
507 self.post(&format!("issue/{key}/worklog"), &payload).await
508 }
509
510 pub async fn add_comment(&self, key: &str, body: &str) -> Result<Comment, ApiError> {
512 validate_issue_key(key)?;
513 let payload = serde_json::json!({ "body": self.make_body(body) });
514 self.post(&format!("issue/{key}/comment"), &payload).await
515 }
516
517 pub async fn get_transitions(&self, key: &str) -> Result<Vec<Transition>, ApiError> {
519 validate_issue_key(key)?;
520 let resp: TransitionsResponse = self.get(&format!("issue/{key}/transitions")).await?;
521 Ok(resp.transitions)
522 }
523
524 pub async fn do_transition(&self, key: &str, transition_id: &str) -> Result<(), ApiError> {
526 validate_issue_key(key)?;
527 let payload = serde_json::json!({ "transition": { "id": transition_id } });
528 self.post_empty_response(&format!("issue/{key}/transitions"), &payload)
529 .await
530 }
531
532 pub async fn assign_issue(&self, key: &str, account_id: Option<&str>) -> Result<(), ApiError> {
537 validate_issue_key(key)?;
538 let payload = match account_id {
539 Some(id) => self.assignee_payload(id),
540 None => {
541 if self.api_version >= 3 {
542 serde_json::json!({ "accountId": null })
543 } else {
544 serde_json::json!({ "name": null })
545 }
546 }
547 };
548 self.put_empty_response(&format!("issue/{key}/assignee"), &payload)
549 .await
550 }
551
552 fn assignee_payload(&self, id: &str) -> serde_json::Value {
556 if self.api_version >= 3 {
557 serde_json::json!({ "accountId": id })
558 } else {
559 serde_json::json!({ "name": id })
560 }
561 }
562
563 pub async fn get_myself(&self) -> Result<Myself, ApiError> {
565 self.get("myself").await
566 }
567
568 pub async fn update_issue(
575 &self,
576 key: &str,
577 update: &IssueUpdate<'_>,
578 custom_fields: &[(String, serde_json::Value)],
579 ) -> Result<(), ApiError> {
580 validate_issue_key(key)?;
581 let mut fields = serde_json::Map::new();
582 if let Some(s) = update.summary {
583 fields.insert("summary".into(), serde_json::Value::String(s.into()));
584 }
585 if let Some(d) = update.description {
586 fields.insert("description".into(), self.make_body(d));
587 }
588 if let Some(p) = update.priority {
589 fields.insert("priority".into(), serde_json::json!({ "name": p }));
590 }
591 if let Some(comps) = update.components {
592 fields.insert("components".into(), name_object_array(comps));
593 }
594 if let Some(fvs) = update.fix_versions {
595 fields.insert("fixVersions".into(), name_object_array(fvs));
596 }
597 if let Some(lbls) = update.labels {
598 fields.insert("labels".into(), serde_json::json!(lbls));
599 }
600 if let Some(assignee_choice) = update.assignee {
601 let payload = match assignee_choice {
602 None => serde_json::Value::Null,
603 Some(id) => self.assignee_payload(id),
604 };
605 fields.insert("assignee".into(), payload);
606 }
607 for (k, value) in custom_fields {
608 fields.insert(k.clone(), value.clone());
609 }
610 if fields.is_empty() {
611 return Err(ApiError::InvalidInput(
612 "At least one field (--summary, --description, --priority, --components, --fix-versions, --labels, --assignee, or --field) is required"
613 .into(),
614 ));
615 }
616 self.put_empty_response(
617 &format!("issue/{key}"),
618 &serde_json::json!({ "fields": fields }),
619 )
620 .await
621 }
622
623 fn make_body(&self, text: &str) -> serde_json::Value {
628 if self.api_version >= 3 {
629 text_to_adf(text)
630 } else {
631 serde_json::Value::String(text.to_string())
632 }
633 }
634
635 pub async fn list_attachments(&self, key: &str) -> Result<Vec<Attachment>, ApiError> {
639 validate_issue_key(key)?;
640 #[derive(serde::Deserialize)]
641 struct Wrapper {
642 fields: AttachmentField,
643 }
644 #[derive(serde::Deserialize)]
645 struct AttachmentField {
646 #[serde(default)]
647 attachment: Vec<Attachment>,
648 }
649 let w: Wrapper = self.get(&format!("issue/{key}?fields=attachment")).await?;
650 Ok(w.fields.attachment)
651 }
652
653 pub async fn upload_attachments(
659 &self,
660 key: &str,
661 paths: &[PathBuf],
662 ) -> Result<Vec<Attachment>, ApiError> {
663 validate_issue_key(key)?;
664
665 let mut form = reqwest::multipart::Form::new();
666 for path in paths {
667 let file_name = path
668 .file_name()
669 .and_then(|name| name.to_str())
670 .ok_or_else(|| {
671 ApiError::InvalidInput(format!(
672 "'{}' does not name a file to upload",
673 path.display()
674 ))
675 })?
676 .to_string();
677 let content_type = mime_guess::from_path(&file_name).first_or_octet_stream();
678 let data = std::fs::read(path)
679 .map_err(|e| ApiError::Other(format!("cannot read {}: {e}", path.display())))?;
680 let part = reqwest::multipart::Part::bytes(data)
681 .file_name(file_name)
682 .mime_str(content_type.as_ref())
683 .map_err(|e| ApiError::Other(e.to_string()))?;
684 form = form.part("file", part);
685 }
686
687 let url = format!("{}/issue/{key}/attachments", self.base_url);
688 let request = self
689 .http
690 .post(&url)
691 .header("X-Atlassian-Token", "no-check")
692 .multipart(form);
693 let resp = self.send(request).await?;
694 resp.json::<Vec<Attachment>>().await.map_err(ApiError::Http)
695 }
696
697 pub async fn get_attachment(&self, id: &str) -> Result<Attachment, ApiError> {
699 validate_attachment_id(id)?;
700 self.get(&format!("attachment/{id}")).await
701 }
702
703 pub async fn download_attachment(&self, id: &str) -> Result<Vec<u8>, ApiError> {
708 validate_attachment_id(id)?;
709 let url = format!("{}/attachment/content/{id}", self.base_url);
710 let resp = self.send(self.http.get(&url)).await?;
711 let bytes = resp.bytes().await.map_err(ApiError::Http)?;
712 Ok(bytes.to_vec())
713 }
714
715 pub async fn delete_attachment(&self, id: &str) -> Result<(), ApiError> {
717 validate_attachment_id(id)?;
718 let url = format!("{}/attachment/{id}", self.base_url);
719 self.send(self.http.delete(&url)).await?;
720 Ok(())
721 }
722
723 pub async fn search_users(&self, query: &str) -> Result<Vec<User>, ApiError> {
729 let encoded = percent_encode(query);
730 let param = if self.api_version >= 3 {
731 "query"
732 } else {
733 "username"
734 };
735 let path = format!("user/search?{param}={encoded}&maxResults=50");
736 self.get::<Vec<User>>(&path).await
737 }
738
739 pub async fn get_link_types(&self) -> Result<Vec<IssueLinkType>, ApiError> {
743 #[derive(serde::Deserialize)]
744 struct Wrapper {
745 #[serde(rename = "issueLinkTypes")]
746 types: Vec<IssueLinkType>,
747 }
748 let w: Wrapper = self.get("issueLinkType").await?;
749 Ok(w.types)
750 }
751
752 pub async fn link_issues(
758 &self,
759 from_key: &str,
760 to_key: &str,
761 link_type: &str,
762 ) -> Result<(), ApiError> {
763 validate_issue_key(from_key)?;
764 validate_issue_key(to_key)?;
765 let payload = serde_json::json!({
766 "type": { "name": link_type },
767 "inwardIssue": { "key": from_key },
768 "outwardIssue": { "key": to_key },
769 });
770 let url = format!("{}/issueLink", self.base_url);
771 let resp = self.http.post(&url).json(&payload).send().await?;
772 let status = resp.status();
773 if !status.is_success() {
774 let body = resp.text().await.unwrap_or_default();
775 return Err(Self::map_status(status.as_u16(), body));
776 }
777 Ok(())
778 }
779
780 pub async fn unlink_issues(&self, link_id: &str) -> Result<(), ApiError> {
782 let url = format!("{}/issueLink/{link_id}", self.base_url);
783 let resp = self.http.delete(&url).send().await?;
784 let status = resp.status();
785 if !status.is_success() {
786 let body = resp.text().await.unwrap_or_default();
787 return Err(Self::map_status(status.as_u16(), body));
788 }
789 Ok(())
790 }
791
792 pub async fn list_boards(&self) -> Result<Vec<Board>, ApiError> {
796 let mut all = Vec::new();
797 let mut start_at = 0usize;
798 const PAGE: usize = 50;
799 loop {
800 let path = format!("board?startAt={start_at}&maxResults={PAGE}");
801 let page: BoardSearchResponse = self.agile_get(&path).await?;
802 let received = page.values.len();
803 all.extend(page.values);
804 if page.is_last || received == 0 {
805 break;
806 }
807 start_at += received;
808 }
809 Ok(all)
810 }
811
812 pub async fn list_sprints(
816 &self,
817 board_id: u64,
818 state: Option<&str>,
819 ) -> Result<Vec<Sprint>, ApiError> {
820 let mut all = Vec::new();
821 let mut start_at = 0usize;
822 const PAGE: usize = 50;
823 loop {
824 let state_param = state.map(|s| format!("&state={s}")).unwrap_or_default();
825 let path = format!(
826 "board/{board_id}/sprint?startAt={start_at}&maxResults={PAGE}{state_param}"
827 );
828 let page: SprintSearchResponse = self.agile_get(&path).await?;
829 let received = page.values.len();
830 all.extend(page.values);
831 if page.is_last || received == 0 {
832 break;
833 }
834 start_at += received;
835 }
836 Ok(all)
837 }
838
839 pub async fn list_projects(&self) -> Result<Vec<Project>, ApiError> {
847 if self.api_version < 3 {
848 return self.get::<Vec<Project>>("project").await;
849 }
850
851 let mut all: Vec<Project> = Vec::new();
852 let mut start_at: usize = 0;
853 const PAGE: usize = 50;
854
855 loop {
856 let path = format!("project/search?startAt={start_at}&maxResults={PAGE}&orderBy=key");
857 let page: ProjectSearchResponse = self.get(&path).await?;
858 let page_start = page.start_at;
859 let received = page.values.len();
860 let total = page.total;
861 all.extend(page.values);
862
863 if page.is_last || all.len() >= total {
864 break;
865 }
866
867 if received == 0 {
868 return Err(ApiError::Other(
869 "Project pagination returned an empty non-terminal page".into(),
870 ));
871 }
872
873 start_at = page_start.saturating_add(received);
874 }
875
876 Ok(all)
877 }
878
879 pub async fn get_project(&self, key: &str) -> Result<Project, ApiError> {
881 self.get(&format!("project/{key}")).await
882 }
883
884 pub async fn list_components(&self, project_key: &str) -> Result<Vec<Component>, ApiError> {
889 self.get::<Vec<Component>>(&format!("project/{project_key}/components"))
890 .await
891 }
892
893 pub async fn list_versions(&self, project_key: &str) -> Result<Vec<Version>, ApiError> {
898 self.get::<Vec<Version>>(&format!("project/{project_key}/versions"))
899 .await
900 }
901
902 pub async fn list_fields(&self) -> Result<Vec<Field>, ApiError> {
906 self.get::<Vec<Field>>("field").await
907 }
908
909 pub async fn move_issue_to_sprint(
913 &self,
914 issue_key: &str,
915 sprint_id: u64,
916 ) -> Result<(), ApiError> {
917 validate_issue_key(issue_key)?;
918 let url = format!("{}/sprint/{sprint_id}/issue", self.agile_base_url);
919 let payload = serde_json::json!({ "issues": [issue_key] });
920 let resp = self.http.post(&url).json(&payload).send().await?;
921 let status = resp.status();
922 if !status.is_success() {
923 let body = resp.text().await.unwrap_or_default();
924 return Err(Self::map_status(status.as_u16(), body));
925 }
926 Ok(())
927 }
928
929 pub async fn get_sprint(&self, sprint_id: u64) -> Result<Sprint, ApiError> {
931 self.agile_get::<Sprint>(&format!("sprint/{sprint_id}"))
932 .await
933 }
934
935 pub async fn resolve_sprint(&self, specifier: &str) -> Result<Sprint, ApiError> {
942 if let Ok(id) = specifier.parse::<u64>() {
943 return self.get_sprint(id).await;
944 }
945
946 let boards = self.list_boards().await?;
947 if boards.is_empty() {
948 return Err(ApiError::NotFound("No boards found".into()));
949 }
950
951 let target_state = if specifier.eq_ignore_ascii_case("active") {
952 Some("active")
953 } else {
954 None
955 };
956
957 for board in &boards {
958 let sprints = self.list_sprints(board.id, target_state).await?;
959 for sprint in sprints {
960 if specifier.eq_ignore_ascii_case("active") {
961 if sprint.state == "active" {
962 return Ok(sprint);
963 }
964 } else if sprint
965 .name
966 .to_lowercase()
967 .contains(&specifier.to_lowercase())
968 {
969 return Ok(sprint);
970 }
971 }
972 }
973
974 Err(ApiError::NotFound(format!(
975 "No sprint found matching '{specifier}'"
976 )))
977 }
978
979 pub async fn resolve_sprint_id(&self, specifier: &str) -> Result<u64, ApiError> {
983 if let Ok(id) = specifier.parse::<u64>() {
984 return Ok(id);
985 }
986 self.resolve_sprint(specifier).await.map(|s| s.id)
987 }
988}
989
990fn validate_issue_key(key: &str) -> Result<(), ApiError> {
996 let mut parts = key.splitn(2, '-');
997 let project = parts.next().unwrap_or("");
998 let number = parts.next().unwrap_or("");
999
1000 let valid = !project.is_empty()
1001 && !number.is_empty()
1002 && project
1003 .chars()
1004 .next()
1005 .is_some_and(|c| c.is_ascii_uppercase())
1006 && project
1007 .chars()
1008 .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit())
1009 && number.chars().all(|c| c.is_ascii_digit());
1010
1011 if valid {
1012 Ok(())
1013 } else {
1014 Err(ApiError::InvalidInput(format!(
1015 "Invalid issue key '{key}'. Expected format: PROJECT-123"
1016 )))
1017 }
1018}
1019
1020fn validate_attachment_id(id: &str) -> Result<(), ApiError> {
1022 if !id.is_empty() && id.chars().all(|c| c.is_ascii_digit()) {
1023 Ok(())
1024 } else {
1025 Err(ApiError::InvalidInput(format!(
1026 "Invalid attachment ID '{id}'. Expected a number."
1027 )))
1028 }
1029}
1030
1031fn percent_encode(s: &str) -> String {
1035 let mut encoded = String::with_capacity(s.len() * 2);
1036 for byte in s.bytes() {
1037 match byte {
1038 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1039 encoded.push(byte as char)
1040 }
1041 b => encoded.push_str(&format!("%{b:02X}")),
1042 }
1043 }
1044 encoded
1045}
1046
1047fn truncate_error_body(body: &str) -> String {
1049 const MAX: usize = 200;
1050 if body.chars().count() <= MAX {
1051 body.to_string()
1052 } else {
1053 let truncated: String = body.chars().take(MAX).collect();
1054 format!("{truncated}… (truncated)")
1055 }
1056}
1057
1058fn summarize_error_body(status: u16, body: &str) -> String {
1059 if should_include_raw_error_body() && !body.trim().is_empty() {
1060 return truncate_error_body(body);
1061 }
1062
1063 if let Some(message) = summarize_json_error_body(body) {
1064 return message;
1065 }
1066
1067 default_status_message(status)
1068}
1069
1070fn summarize_json_error_body(body: &str) -> Option<String> {
1071 let parsed: JiraErrorPayload = serde_json::from_str(body).ok()?;
1072 let mut parts = Vec::new();
1073
1074 if !parsed.error_messages.is_empty() {
1075 parts.push(format_error_messages(&parsed.error_messages));
1076 }
1077
1078 if !parsed.errors.is_empty() {
1079 let fields = parsed.errors.keys().take(5).cloned().collect::<Vec<_>>();
1080 parts.push(format!(
1081 "validation errors for fields: {}",
1082 fields.join(", ")
1083 ));
1084 }
1085
1086 if parts.is_empty() {
1087 None
1088 } else {
1089 Some(parts.join("; "))
1090 }
1091}
1092
1093const MAX_ERROR_MESSAGES_SHOWN: usize = 3;
1096
1097const MAX_ERROR_MESSAGE_LEN: usize = 240;
1100
1101fn format_error_messages(messages: &[String]) -> String {
1102 let shown: Vec<String> = messages
1103 .iter()
1104 .take(MAX_ERROR_MESSAGES_SHOWN)
1105 .map(|m| truncate_message(m.trim()))
1106 .collect();
1107 let joined = shown.join(" | ");
1108 let remaining = messages.len().saturating_sub(MAX_ERROR_MESSAGES_SHOWN);
1109 if remaining > 0 {
1110 format!("{joined} (+{remaining} more)")
1111 } else {
1112 joined
1113 }
1114}
1115
1116fn truncate_message(msg: &str) -> String {
1117 if msg.chars().count() <= MAX_ERROR_MESSAGE_LEN {
1118 msg.to_string()
1119 } else {
1120 let truncated: String = msg.chars().take(MAX_ERROR_MESSAGE_LEN).collect();
1121 format!("{truncated}…")
1122 }
1123}
1124
1125fn default_status_message(status: u16) -> String {
1126 match status {
1127 401 | 403 => "request unauthorized".into(),
1128 404 => "resource not found".into(),
1129 409 => "conflicts with the current state of the resource".into(),
1130 429 => "rate limited by Jira".into(),
1131 400..=499 => format!("request failed with status {status}"),
1132 _ => format!("Jira request failed with status {status}"),
1133 }
1134}
1135
1136fn should_include_raw_error_body() -> bool {
1137 std::env::var("JIRA_DEBUG_HTTP").is_ok_and(|value| crate::config::is_truthy(&value))
1138}
1139
1140#[derive(Debug, serde::Deserialize)]
1141#[serde(rename_all = "camelCase")]
1142struct JiraErrorPayload {
1143 #[serde(default)]
1144 error_messages: Vec<String>,
1145 #[serde(default)]
1146 errors: BTreeMap<String, String>,
1147}
1148
1149#[cfg(test)]
1150mod tests {
1151 use super::*;
1152
1153 #[test]
1154 fn percent_encode_spaces_use_percent_20() {
1155 assert_eq!(percent_encode("project = FOO"), "project%20%3D%20FOO");
1156 }
1157
1158 #[test]
1159 fn percent_encode_complex_jql() {
1160 let jql = r#"project = "MY PROJECT""#;
1161 let encoded = percent_encode(jql);
1162 assert!(encoded.contains("project"));
1163 assert!(!encoded.contains('"'));
1164 assert!(!encoded.contains(' '));
1165 }
1166
1167 #[test]
1168 fn validate_issue_key_valid() {
1169 assert!(validate_issue_key("PROJ-123").is_ok());
1170 assert!(validate_issue_key("ABC-1").is_ok());
1171 assert!(validate_issue_key("MYPROJECT-9999").is_ok());
1172 assert!(validate_issue_key("ABC2-123").is_ok());
1174 assert!(validate_issue_key("P1-1").is_ok());
1175 }
1176
1177 #[test]
1178 fn validate_issue_key_invalid() {
1179 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());
1183 assert!(validate_issue_key("").is_err());
1184 assert!(validate_issue_key("1PROJ-123").is_err()); }
1186
1187 #[test]
1188 fn truncate_error_body_short() {
1189 let body = "short error";
1190 assert_eq!(truncate_error_body(body), body);
1191 }
1192
1193 #[test]
1194 fn truncate_error_body_long() {
1195 let body = "x".repeat(300);
1196 let result = truncate_error_body(&body);
1197 assert!(result.len() < body.len());
1198 assert!(result.ends_with("(truncated)"));
1199 }
1200
1201 #[test]
1202 fn summarize_json_error_body_surfaces_messages_and_redacts_field_values() {
1203 let body = serde_json::json!({
1204 "errorMessages": ["JQL validation failed"],
1205 "errors": {
1206 "summary": "Summary must not contain secret project name",
1207 "description": "Description cannot include api token"
1208 }
1209 })
1210 .to_string();
1211
1212 let message = summarize_error_body(400, &body);
1213 assert!(message.contains("JQL validation failed"));
1215 assert!(message.contains("summary"));
1218 assert!(message.contains("description"));
1219 assert!(!message.contains("secret project name"));
1220 assert!(!message.contains("api token"));
1221 }
1222
1223 #[test]
1224 fn summarize_json_error_body_reports_retired_api() {
1225 let body = serde_json::json!({
1227 "errorMessages": [
1228 "The requested API has been removed. Please migrate to the /rest/api/3/search/jql API."
1229 ],
1230 "errors": {}
1231 })
1232 .to_string();
1233
1234 let message = summarize_error_body(410, &body);
1235 assert!(message.contains("The requested API has been removed"));
1236 assert!(message.contains("/rest/api/3/search/jql"));
1237 }
1238
1239 #[test]
1240 fn summarize_json_error_body_joins_multiple_messages() {
1241 let body = serde_json::json!({
1242 "errorMessages": ["first problem", "second problem"],
1243 "errors": {}
1244 })
1245 .to_string();
1246
1247 let message = summarize_error_body(400, &body);
1248 assert!(message.contains("first problem"));
1249 assert!(message.contains("second problem"));
1250 assert!(message.contains(" | "));
1251 }
1252
1253 #[test]
1254 fn summarize_json_error_body_collapses_overflow_messages() {
1255 let body = serde_json::json!({
1256 "errorMessages": ["a", "b", "c", "d", "e"],
1257 "errors": {}
1258 })
1259 .to_string();
1260
1261 let message = summarize_error_body(400, &body);
1262 assert!(message.contains("(+2 more)"));
1263 }
1264
1265 #[test]
1266 fn summarize_json_error_body_truncates_oversized_message() {
1267 let huge = "x".repeat(1000);
1268 let body = serde_json::json!({
1269 "errorMessages": [huge],
1270 "errors": {}
1271 })
1272 .to_string();
1273
1274 let message = summarize_error_body(400, &body);
1275 assert!(message.chars().count() < 500);
1276 assert!(message.contains('…'));
1277 }
1278
1279 #[test]
1280 fn browse_url_preserves_explicit_http_hosts() {
1281 let client = JiraClient::new(
1282 "http://localhost:8080",
1283 "me@example.com",
1284 "token",
1285 AuthType::Basic,
1286 3,
1287 )
1288 .unwrap();
1289 assert_eq!(
1290 client.browse_url("PROJ-1"),
1291 "http://localhost:8080/browse/PROJ-1"
1292 );
1293 }
1294
1295 #[test]
1296 fn new_with_pat_auth_does_not_require_email() {
1297 let client = JiraClient::new(
1298 "https://jira.example.com",
1299 "",
1300 "my-pat-token",
1301 AuthType::Pat,
1302 3,
1303 );
1304 assert!(client.is_ok());
1305 }
1306
1307 #[test]
1308 fn new_with_api_v2_uses_v2_base_url() {
1309 let client = JiraClient::new(
1310 "https://jira.example.com",
1311 "me@example.com",
1312 "token",
1313 AuthType::Basic,
1314 2,
1315 )
1316 .unwrap();
1317 assert_eq!(client.api_version(), 2);
1318 }
1319}