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 Self::new_with_cloud(host, email, token, auth_type, api_version, None, "classic")
60 }
61
62 pub fn new_with_cloud(
63 host: &str,
64 email: &str,
65 token: &str,
66 auth_type: AuthType,
67 api_version: u8,
68 cloud_id: Option<&str>,
69 token_kind: &str,
70 ) -> Result<Self, ApiError> {
71 let (scheme, domain) = if host.starts_with("http://") {
74 (
75 "http",
76 host.trim_start_matches("http://").trim_end_matches('/'),
77 )
78 } else {
79 (
80 "https",
81 host.trim_start_matches("https://").trim_end_matches('/'),
82 )
83 };
84
85 if domain.is_empty() {
86 return Err(ApiError::Other("Host cannot be empty".into()));
87 }
88
89 let auth_value = match auth_type {
90 AuthType::Basic => {
91 let credentials = BASE64.encode(format!("{email}:{token}"));
92 format!("Basic {credentials}")
93 }
94 AuthType::Pat => format!("Bearer {token}"),
95 };
96
97 let mut headers = HeaderMap::new();
98 headers.insert(
99 AUTHORIZATION,
100 HeaderValue::from_str(&auth_value).map_err(|e| ApiError::Other(e.to_string()))?,
101 );
102
103 let http = reqwest::Client::builder()
104 .default_headers(headers)
105 .timeout(std::time::Duration::from_secs(30))
106 .build()
107 .map_err(ApiError::Http)?;
108
109 let site_url = format!("{scheme}://{domain}");
110 let api_origin = if token_kind == "scoped" {
111 let cloud_id = cloud_id
112 .filter(|value| !value.trim().is_empty())
113 .ok_or_else(|| {
114 ApiError::InvalidInput("scoped token requires a Jira Cloud ID".into())
115 })?;
116 format!("https://api.atlassian.com/ex/jira/{cloud_id}")
117 } else {
118 site_url.clone()
119 };
120 let base_url = format!("{api_origin}/rest/api/{api_version}");
121 let agile_base_url = format!("{api_origin}/rest/agile/1.0");
122
123 Ok(Self {
124 http,
125 base_url,
126 agile_base_url,
127 site_url,
128 host: domain.to_string(),
129 api_version,
130 })
131 }
132
133 pub fn host(&self) -> &str {
134 &self.host
135 }
136
137 pub fn api_version(&self) -> u8 {
138 self.api_version
139 }
140
141 pub fn browse_base_url(&self) -> &str {
142 &self.site_url
143 }
144
145 pub fn browse_url(&self, issue_key: &str) -> String {
146 format!("{}/browse/{issue_key}", self.browse_base_url())
147 }
148
149 fn map_status(status: u16, body: String) -> ApiError {
150 let message = summarize_error_body(status, &body);
151 match status {
152 401 | 403 => ApiError::Auth(message),
153 404 => ApiError::NotFound(message),
154 409 => ApiError::Conflict(message),
155 429 => ApiError::RateLimit,
156 _ => ApiError::Api { status, message },
157 }
158 }
159
160 async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T, ApiError> {
161 let url = format!("{}/{path}", self.base_url);
162 let resp = self.http.get(&url).send().await?;
163 let status = resp.status();
164 if !status.is_success() {
165 let body = resp.text().await.unwrap_or_default();
166 return Err(Self::map_status(status.as_u16(), body));
167 }
168 resp.json::<T>().await.map_err(ApiError::Http)
169 }
170
171 async fn agile_get<T: DeserializeOwned>(&self, path: &str) -> Result<T, ApiError> {
172 let url = format!("{}/{path}", self.agile_base_url);
173 let resp = self.http.get(&url).send().await?;
174 let status = resp.status();
175 if !status.is_success() {
176 let body = resp.text().await.unwrap_or_default();
177 return Err(Self::map_status(status.as_u16(), body));
178 }
179 resp.json::<T>().await.map_err(ApiError::Http)
180 }
181
182 async fn post<T: DeserializeOwned>(
183 &self,
184 path: &str,
185 body: &serde_json::Value,
186 ) -> Result<T, ApiError> {
187 let url = format!("{}/{path}", self.base_url);
188 let resp = self.http.post(&url).json(body).send().await?;
189 let status = resp.status();
190 if !status.is_success() {
191 let body_text = resp.text().await.unwrap_or_default();
192 return Err(Self::map_status(status.as_u16(), body_text));
193 }
194 resp.json::<T>().await.map_err(ApiError::Http)
195 }
196
197 async fn post_empty_response(
198 &self,
199 path: &str,
200 body: &serde_json::Value,
201 ) -> Result<(), ApiError> {
202 let url = format!("{}/{path}", self.base_url);
203 let resp = self.http.post(&url).json(body).send().await?;
204 let status = resp.status();
205 if !status.is_success() {
206 let body_text = resp.text().await.unwrap_or_default();
207 return Err(Self::map_status(status.as_u16(), body_text));
208 }
209 Ok(())
210 }
211
212 async fn put_empty_response(
213 &self,
214 path: &str,
215 body: &serde_json::Value,
216 ) -> Result<(), ApiError> {
217 let url = format!("{}/{path}", self.base_url);
218 let resp = self.http.put(&url).json(body).send().await?;
219 let status = resp.status();
220 if !status.is_success() {
221 let body_text = resp.text().await.unwrap_or_default();
222 return Err(Self::map_status(status.as_u16(), body_text));
223 }
224 Ok(())
225 }
226
227 async fn send(&self, request: reqwest::RequestBuilder) -> Result<reqwest::Response, ApiError> {
230 let resp = request.send().await?;
231 let status = resp.status();
232 if !status.is_success() {
233 let body = resp.text().await.unwrap_or_default();
234 return Err(Self::map_status(status.as_u16(), body));
235 }
236 Ok(resp)
237 }
238
239 pub async fn search(
252 &self,
253 jql: &str,
254 max_results: usize,
255 start_at: usize,
256 ) -> Result<SearchResponse, ApiError> {
257 if self.api_version >= 3 {
258 self.search_jql_v3(jql, max_results, start_at).await
259 } else {
260 self.search_v2(jql, max_results, start_at).await
261 }
262 }
263
264 async fn search_v2(
265 &self,
266 jql: &str,
267 max_results: usize,
268 start_at: usize,
269 ) -> Result<SearchResponse, ApiError> {
270 let fields = SEARCH_FIELDS.join(",");
271 let encoded_jql = percent_encode(jql);
272 #[derive(serde::Deserialize)]
277 struct RawV2 {
278 issues: Vec<Issue>,
279 total: Option<usize>,
280 #[serde(rename = "startAt")]
281 start_at: Option<usize>,
282 #[serde(rename = "maxResults")]
283 max_results: Option<usize>,
284 }
285 let raw: RawV2 = if encoded_jql.len() <= SEARCH_GET_JQL_LIMIT {
286 let path = format!(
287 "search?jql={encoded_jql}&maxResults={max_results}&startAt={start_at}&fields={fields}"
288 );
289 self.get(&path).await?
290 } else {
291 self.post(
292 "search",
293 &serde_json::json!({
294 "jql": jql,
295 "maxResults": max_results,
296 "startAt": start_at,
297 "fields": SEARCH_FIELDS,
298 }),
299 )
300 .await?
301 };
302 let echoed_start_at = raw.start_at.unwrap_or(start_at);
305 let is_last = match raw.total {
306 Some(total) => echoed_start_at + raw.issues.len() >= total,
307 None => raw.issues.len() < max_results,
310 };
311 Ok(SearchResponse {
312 issues: raw.issues,
313 total: raw.total,
314 start_at: echoed_start_at,
315 max_results: raw.max_results.unwrap_or(max_results),
316 is_last,
317 })
318 }
319
320 async fn search_jql_page(
326 &self,
327 jql: &str,
328 page_size: usize,
329 next_token: Option<&str>,
330 ) -> Result<SearchJqlPage, ApiError> {
331 let mut body = serde_json::json!({
332 "jql": jql,
333 "maxResults": page_size,
334 "fields": SEARCH_FIELDS,
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_skip_page(
348 &self,
349 jql: &str,
350 page_size: usize,
351 next_token: Option<&str>,
352 ) -> Result<SearchJqlSkipPage, ApiError> {
353 let mut body = serde_json::json!({
354 "jql": jql,
355 "maxResults": page_size,
356 "fields": ["id"],
357 });
358 if let Some(t) = next_token {
359 body["nextPageToken"] = serde_json::Value::String(t.to_string());
360 }
361 self.post("search/jql", &body).await
362 }
363
364 async fn search_jql_v3(
365 &self,
366 jql: &str,
367 max_results: usize,
368 start_at: usize,
369 ) -> Result<SearchResponse, ApiError> {
370 let mut next_token: Option<String> = None;
375 let mut skipped = 0usize;
376 while skipped < start_at {
377 let want = (start_at - skipped).min(SEARCH_JQL_SKIP_PAGE);
378 let page = self
379 .search_jql_skip_page(jql, want, next_token.as_deref())
380 .await?;
381 let got = page.issues.len();
382 skipped += got;
383 if got == 0 || page.is_last {
384 return Ok(SearchResponse {
386 issues: Vec::new(),
387 total: None,
388 start_at,
389 max_results: 0,
390 is_last: true,
391 });
392 }
393 next_token = page.next_page_token;
394 if next_token.is_none() {
395 return Ok(SearchResponse {
398 issues: Vec::new(),
399 total: None,
400 start_at,
401 max_results: 0,
402 is_last: true,
403 });
404 }
405 }
406
407 let mut collected: Vec<Issue> = Vec::new();
410 let mut is_last = false;
411 while collected.len() < max_results {
412 let remaining = max_results - collected.len();
413 let want = remaining.min(SEARCH_JQL_MAX_PAGE);
414 let page = self
415 .search_jql_page(jql, want, next_token.as_deref())
416 .await?;
417 let got = page.issues.len();
418 collected.extend(page.issues);
419 if page.is_last || got == 0 {
420 is_last = true;
421 break;
422 }
423 next_token = page.next_page_token;
424 if next_token.is_none() {
425 is_last = true;
426 break;
427 }
428 }
429
430 let returned = collected.len();
431 Ok(SearchResponse {
432 issues: collected,
433 total: None,
435 start_at,
436 max_results: returned,
437 is_last,
438 })
439 }
440
441 pub async fn get_issue(&self, key: &str) -> Result<Issue, ApiError> {
447 validate_issue_key(key)?;
448 let fields = "summary,status,assignee,reporter,priority,issuetype,description,labels,components,fixVersions,versions,created,updated,comment,issuelinks";
449 let path = format!("issue/{key}?fields={fields}");
450 let mut issue: Issue = self.get(&path).await?;
451
452 if let Some(ref mut comment_list) = issue.fields.comment
454 && comment_list.total > comment_list.comments.len()
455 {
456 let mut start_at = comment_list.comments.len();
457 while comment_list.comments.len() < comment_list.total {
458 let page: CommentList = self
459 .get(&format!(
460 "issue/{key}/comment?startAt={start_at}&maxResults=100"
461 ))
462 .await?;
463 if page.comments.is_empty() {
464 break;
465 }
466 start_at += page.comments.len();
467 comment_list.comments.extend(page.comments);
468 }
469 }
470
471 Ok(issue)
472 }
473
474 pub async fn create_issue(
476 &self,
477 draft: &IssueDraft<'_>,
478 custom_fields: &[(String, serde_json::Value)],
479 ) -> Result<CreateIssueResponse, ApiError> {
480 let mut fields = serde_json::json!({
481 "project": { "key": draft.project_key },
482 "issuetype": { "name": draft.issue_type },
483 "summary": draft.summary,
484 });
485
486 if let Some(desc) = draft.description {
487 fields["description"] = self.make_body(desc);
488 }
489 if let Some(p) = draft.priority {
490 fields["priority"] = serde_json::json!({ "name": p });
491 }
492 if let Some(lbls) = draft.labels
493 && !lbls.is_empty()
494 {
495 fields["labels"] = serde_json::json!(lbls);
496 }
497 if let Some(comps) = draft.components
498 && !comps.is_empty()
499 {
500 fields["components"] = name_object_array(comps);
501 }
502 if let Some(fvs) = draft.fix_versions
503 && !fvs.is_empty()
504 {
505 fields["fixVersions"] = name_object_array(fvs);
506 }
507 if let Some(id) = draft.assignee {
508 fields["assignee"] = self.assignee_payload(id);
509 }
510 if let Some(parent_key) = draft.parent {
511 fields["parent"] = serde_json::json!({ "key": parent_key });
512 }
513 for (key, value) in custom_fields {
514 fields[key] = value.clone();
515 }
516
517 self.post("issue", &serde_json::json!({ "fields": fields }))
518 .await
519 }
520
521 pub async fn log_work(
526 &self,
527 key: &str,
528 time_spent: &str,
529 comment: Option<&str>,
530 started: Option<&str>,
531 ) -> Result<WorklogEntry, ApiError> {
532 validate_issue_key(key)?;
533 let mut payload = serde_json::json!({ "timeSpent": time_spent });
534 if let Some(c) = comment {
535 payload["comment"] = self.make_body(c);
536 }
537 if let Some(s) = started {
538 payload["started"] = serde_json::Value::String(s.to_string());
539 }
540 self.post(&format!("issue/{key}/worklog"), &payload).await
541 }
542
543 pub async fn add_comment(&self, key: &str, body: &str) -> Result<Comment, ApiError> {
545 validate_issue_key(key)?;
546 let payload = serde_json::json!({ "body": self.make_body(body) });
547 self.post(&format!("issue/{key}/comment"), &payload).await
548 }
549
550 pub async fn get_transitions(&self, key: &str) -> Result<Vec<Transition>, ApiError> {
552 validate_issue_key(key)?;
553 let resp: TransitionsResponse = self.get(&format!("issue/{key}/transitions")).await?;
554 Ok(resp.transitions)
555 }
556
557 pub async fn do_transition(&self, key: &str, transition_id: &str) -> Result<(), ApiError> {
559 validate_issue_key(key)?;
560 let payload = serde_json::json!({ "transition": { "id": transition_id } });
561 self.post_empty_response(&format!("issue/{key}/transitions"), &payload)
562 .await
563 }
564
565 pub async fn assign_issue(&self, key: &str, account_id: Option<&str>) -> Result<(), ApiError> {
570 validate_issue_key(key)?;
571 let payload = match account_id {
572 Some(id) => self.assignee_payload(id),
573 None => {
574 if self.api_version >= 3 {
575 serde_json::json!({ "accountId": null })
576 } else {
577 serde_json::json!({ "name": null })
578 }
579 }
580 };
581 self.put_empty_response(&format!("issue/{key}/assignee"), &payload)
582 .await
583 }
584
585 fn assignee_payload(&self, id: &str) -> serde_json::Value {
589 if self.api_version >= 3 {
590 serde_json::json!({ "accountId": id })
591 } else {
592 serde_json::json!({ "name": id })
593 }
594 }
595
596 pub async fn get_myself(&self) -> Result<Myself, ApiError> {
598 self.get("myself").await
599 }
600
601 pub async fn update_issue(
608 &self,
609 key: &str,
610 update: &IssueUpdate<'_>,
611 custom_fields: &[(String, serde_json::Value)],
612 ) -> Result<(), ApiError> {
613 validate_issue_key(key)?;
614 let mut fields = serde_json::Map::new();
615 if let Some(s) = update.summary {
616 fields.insert("summary".into(), serde_json::Value::String(s.into()));
617 }
618 if let Some(d) = update.description {
619 fields.insert("description".into(), self.make_body(d));
620 }
621 if let Some(p) = update.priority {
622 fields.insert("priority".into(), serde_json::json!({ "name": p }));
623 }
624 if let Some(comps) = update.components {
625 fields.insert("components".into(), name_object_array(comps));
626 }
627 if let Some(fvs) = update.fix_versions {
628 fields.insert("fixVersions".into(), name_object_array(fvs));
629 }
630 if let Some(lbls) = update.labels {
631 fields.insert("labels".into(), serde_json::json!(lbls));
632 }
633 if let Some(assignee_choice) = update.assignee {
634 let payload = match assignee_choice {
635 None => serde_json::Value::Null,
636 Some(id) => self.assignee_payload(id),
637 };
638 fields.insert("assignee".into(), payload);
639 }
640 for (k, value) in custom_fields {
641 fields.insert(k.clone(), value.clone());
642 }
643 if fields.is_empty() {
644 return Err(ApiError::InvalidInput(
645 "At least one field (--summary, --description, --priority, --components, --fix-versions, --labels, --assignee, or --field) is required"
646 .into(),
647 ));
648 }
649 self.put_empty_response(
650 &format!("issue/{key}"),
651 &serde_json::json!({ "fields": fields }),
652 )
653 .await
654 }
655
656 fn make_body(&self, text: &str) -> serde_json::Value {
661 if self.api_version >= 3 {
662 text_to_adf(text)
663 } else {
664 serde_json::Value::String(text.to_string())
665 }
666 }
667
668 pub async fn list_attachments(&self, key: &str) -> Result<Vec<Attachment>, ApiError> {
672 validate_issue_key(key)?;
673 #[derive(serde::Deserialize)]
674 struct Wrapper {
675 fields: AttachmentField,
676 }
677 #[derive(serde::Deserialize)]
678 struct AttachmentField {
679 #[serde(default)]
680 attachment: Vec<Attachment>,
681 }
682 let w: Wrapper = self.get(&format!("issue/{key}?fields=attachment")).await?;
683 Ok(w.fields.attachment)
684 }
685
686 pub async fn upload_attachments(
692 &self,
693 key: &str,
694 paths: &[PathBuf],
695 ) -> Result<Vec<Attachment>, ApiError> {
696 validate_issue_key(key)?;
697
698 let mut form = reqwest::multipart::Form::new();
699 for path in paths {
700 let file_name = path
701 .file_name()
702 .and_then(|name| name.to_str())
703 .ok_or_else(|| {
704 ApiError::InvalidInput(format!(
705 "'{}' does not name a file to upload",
706 path.display()
707 ))
708 })?
709 .to_string();
710 let content_type = mime_guess::from_path(&file_name).first_or_octet_stream();
711 let data = std::fs::read(path)
712 .map_err(|e| ApiError::Other(format!("cannot read {}: {e}", path.display())))?;
713 let part = reqwest::multipart::Part::bytes(data)
714 .file_name(file_name)
715 .mime_str(content_type.as_ref())
716 .map_err(|e| ApiError::Other(e.to_string()))?;
717 form = form.part("file", part);
718 }
719
720 let url = format!("{}/issue/{key}/attachments", self.base_url);
721 let request = self
722 .http
723 .post(&url)
724 .header("X-Atlassian-Token", "no-check")
725 .multipart(form);
726 let resp = self.send(request).await?;
727 resp.json::<Vec<Attachment>>().await.map_err(ApiError::Http)
728 }
729
730 pub async fn get_attachment(&self, id: &str) -> Result<Attachment, ApiError> {
732 validate_attachment_id(id)?;
733 self.get(&format!("attachment/{id}")).await
734 }
735
736 pub async fn download_attachment(&self, id: &str) -> Result<Vec<u8>, ApiError> {
741 validate_attachment_id(id)?;
742 let url = format!("{}/attachment/content/{id}", self.base_url);
743 let resp = self.send(self.http.get(&url)).await?;
744 let bytes = resp.bytes().await.map_err(ApiError::Http)?;
745 Ok(bytes.to_vec())
746 }
747
748 pub async fn delete_attachment(&self, id: &str) -> Result<(), ApiError> {
750 validate_attachment_id(id)?;
751 let url = format!("{}/attachment/{id}", self.base_url);
752 self.send(self.http.delete(&url)).await?;
753 Ok(())
754 }
755
756 pub async fn search_users(&self, query: &str) -> Result<Vec<User>, ApiError> {
762 let encoded = percent_encode(query);
763 let param = if self.api_version >= 3 {
764 "query"
765 } else {
766 "username"
767 };
768 let path = format!("user/search?{param}={encoded}&maxResults=50");
769 self.get::<Vec<User>>(&path).await
770 }
771
772 pub async fn get_link_types(&self) -> Result<Vec<IssueLinkType>, ApiError> {
776 #[derive(serde::Deserialize)]
777 struct Wrapper {
778 #[serde(rename = "issueLinkTypes")]
779 types: Vec<IssueLinkType>,
780 }
781 let w: Wrapper = self.get("issueLinkType").await?;
782 Ok(w.types)
783 }
784
785 pub async fn link_issues(
791 &self,
792 from_key: &str,
793 to_key: &str,
794 link_type: &str,
795 ) -> Result<(), ApiError> {
796 validate_issue_key(from_key)?;
797 validate_issue_key(to_key)?;
798 let payload = serde_json::json!({
799 "type": { "name": link_type },
800 "inwardIssue": { "key": from_key },
801 "outwardIssue": { "key": to_key },
802 });
803 let url = format!("{}/issueLink", self.base_url);
804 let resp = self.http.post(&url).json(&payload).send().await?;
805 let status = resp.status();
806 if !status.is_success() {
807 let body = resp.text().await.unwrap_or_default();
808 return Err(Self::map_status(status.as_u16(), body));
809 }
810 Ok(())
811 }
812
813 pub async fn unlink_issues(&self, link_id: &str) -> Result<(), ApiError> {
815 let url = format!("{}/issueLink/{link_id}", self.base_url);
816 let resp = self.http.delete(&url).send().await?;
817 let status = resp.status();
818 if !status.is_success() {
819 let body = resp.text().await.unwrap_or_default();
820 return Err(Self::map_status(status.as_u16(), body));
821 }
822 Ok(())
823 }
824
825 pub async fn list_boards(&self) -> Result<Vec<Board>, ApiError> {
829 let mut all = Vec::new();
830 let mut start_at = 0usize;
831 const PAGE: usize = 50;
832 loop {
833 let path = format!("board?startAt={start_at}&maxResults={PAGE}");
834 let page: BoardSearchResponse = self.agile_get(&path).await?;
835 let received = page.values.len();
836 all.extend(page.values);
837 if page.is_last || received == 0 {
838 break;
839 }
840 start_at += received;
841 }
842 Ok(all)
843 }
844
845 pub async fn list_sprints(
849 &self,
850 board_id: u64,
851 state: Option<&str>,
852 ) -> Result<Vec<Sprint>, ApiError> {
853 let mut all = Vec::new();
854 let mut start_at = 0usize;
855 const PAGE: usize = 50;
856 loop {
857 let state_param = state.map(|s| format!("&state={s}")).unwrap_or_default();
858 let path = format!(
859 "board/{board_id}/sprint?startAt={start_at}&maxResults={PAGE}{state_param}"
860 );
861 let page: SprintSearchResponse = self.agile_get(&path).await?;
862 let received = page.values.len();
863 all.extend(page.values);
864 if page.is_last || received == 0 {
865 break;
866 }
867 start_at += received;
868 }
869 Ok(all)
870 }
871
872 pub async fn list_projects(&self) -> Result<Vec<Project>, ApiError> {
880 if self.api_version < 3 {
881 return self.get::<Vec<Project>>("project").await;
882 }
883
884 let mut all: Vec<Project> = Vec::new();
885 let mut start_at: usize = 0;
886 const PAGE: usize = 50;
887
888 loop {
889 let path = format!("project/search?startAt={start_at}&maxResults={PAGE}&orderBy=key");
890 let page: ProjectSearchResponse = self.get(&path).await?;
891 let page_start = page.start_at;
892 let received = page.values.len();
893 let total = page.total;
894 all.extend(page.values);
895
896 if page.is_last || all.len() >= total {
897 break;
898 }
899
900 if received == 0 {
901 return Err(ApiError::Other(
902 "Project pagination returned an empty non-terminal page".into(),
903 ));
904 }
905
906 start_at = page_start.saturating_add(received);
907 }
908
909 Ok(all)
910 }
911
912 pub async fn get_project(&self, key: &str) -> Result<Project, ApiError> {
914 self.get(&format!("project/{key}")).await
915 }
916
917 pub async fn list_components(&self, project_key: &str) -> Result<Vec<Component>, ApiError> {
922 self.get::<Vec<Component>>(&format!("project/{project_key}/components"))
923 .await
924 }
925
926 pub async fn list_versions(&self, project_key: &str) -> Result<Vec<Version>, ApiError> {
931 self.get::<Vec<Version>>(&format!("project/{project_key}/versions"))
932 .await
933 }
934
935 pub async fn list_fields(&self) -> Result<Vec<Field>, ApiError> {
939 self.get::<Vec<Field>>("field").await
940 }
941
942 pub async fn move_issue_to_sprint(
946 &self,
947 issue_key: &str,
948 sprint_id: u64,
949 ) -> Result<(), ApiError> {
950 validate_issue_key(issue_key)?;
951 let url = format!("{}/sprint/{sprint_id}/issue", self.agile_base_url);
952 let payload = serde_json::json!({ "issues": [issue_key] });
953 let resp = self.http.post(&url).json(&payload).send().await?;
954 let status = resp.status();
955 if !status.is_success() {
956 let body = resp.text().await.unwrap_or_default();
957 return Err(Self::map_status(status.as_u16(), body));
958 }
959 Ok(())
960 }
961
962 pub async fn get_sprint(&self, sprint_id: u64) -> Result<Sprint, ApiError> {
964 self.agile_get::<Sprint>(&format!("sprint/{sprint_id}"))
965 .await
966 }
967
968 pub async fn resolve_sprint(&self, specifier: &str) -> Result<Sprint, ApiError> {
975 if let Ok(id) = specifier.parse::<u64>() {
976 return self.get_sprint(id).await;
977 }
978
979 let boards = self.list_boards().await?;
980 if boards.is_empty() {
981 return Err(ApiError::NotFound("No boards found".into()));
982 }
983
984 let target_state = if specifier.eq_ignore_ascii_case("active") {
985 Some("active")
986 } else {
987 None
988 };
989
990 for board in &boards {
991 let sprints = self.list_sprints(board.id, target_state).await?;
992 for sprint in sprints {
993 if specifier.eq_ignore_ascii_case("active") {
994 if sprint.state == "active" {
995 return Ok(sprint);
996 }
997 } else if sprint
998 .name
999 .to_lowercase()
1000 .contains(&specifier.to_lowercase())
1001 {
1002 return Ok(sprint);
1003 }
1004 }
1005 }
1006
1007 Err(ApiError::NotFound(format!(
1008 "No sprint found matching '{specifier}'"
1009 )))
1010 }
1011
1012 pub async fn resolve_sprint_id(&self, specifier: &str) -> Result<u64, ApiError> {
1016 if let Ok(id) = specifier.parse::<u64>() {
1017 return Ok(id);
1018 }
1019 self.resolve_sprint(specifier).await.map(|s| s.id)
1020 }
1021}
1022
1023fn validate_issue_key(key: &str) -> Result<(), ApiError> {
1029 let mut parts = key.splitn(2, '-');
1030 let project = parts.next().unwrap_or("");
1031 let number = parts.next().unwrap_or("");
1032
1033 let valid = !project.is_empty()
1034 && !number.is_empty()
1035 && project
1036 .chars()
1037 .next()
1038 .is_some_and(|c| c.is_ascii_uppercase())
1039 && project
1040 .chars()
1041 .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit())
1042 && number.chars().all(|c| c.is_ascii_digit());
1043
1044 if valid {
1045 Ok(())
1046 } else {
1047 Err(ApiError::InvalidInput(format!(
1048 "Invalid issue key '{key}'. Expected format: PROJECT-123"
1049 )))
1050 }
1051}
1052
1053fn validate_attachment_id(id: &str) -> Result<(), ApiError> {
1055 if !id.is_empty() && id.chars().all(|c| c.is_ascii_digit()) {
1056 Ok(())
1057 } else {
1058 Err(ApiError::InvalidInput(format!(
1059 "Invalid attachment ID '{id}'. Expected a number."
1060 )))
1061 }
1062}
1063
1064fn percent_encode(s: &str) -> String {
1068 let mut encoded = String::with_capacity(s.len() * 2);
1069 for byte in s.bytes() {
1070 match byte {
1071 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1072 encoded.push(byte as char)
1073 }
1074 b => encoded.push_str(&format!("%{b:02X}")),
1075 }
1076 }
1077 encoded
1078}
1079
1080fn truncate_error_body(body: &str) -> String {
1082 const MAX: usize = 200;
1083 if body.chars().count() <= MAX {
1084 body.to_string()
1085 } else {
1086 let truncated: String = body.chars().take(MAX).collect();
1087 format!("{truncated}… (truncated)")
1088 }
1089}
1090
1091fn summarize_error_body(status: u16, body: &str) -> String {
1092 if should_include_raw_error_body() && !body.trim().is_empty() {
1093 return truncate_error_body(body);
1094 }
1095
1096 if let Some(message) = summarize_json_error_body(body) {
1097 return message;
1098 }
1099
1100 default_status_message(status)
1101}
1102
1103fn summarize_json_error_body(body: &str) -> Option<String> {
1104 let parsed: JiraErrorPayload = serde_json::from_str(body).ok()?;
1105 let mut parts = Vec::new();
1106
1107 if !parsed.error_messages.is_empty() {
1108 parts.push(format_error_messages(&parsed.error_messages));
1109 }
1110
1111 if !parsed.errors.is_empty() {
1112 let fields = parsed.errors.keys().take(5).cloned().collect::<Vec<_>>();
1113 parts.push(format!(
1114 "validation errors for fields: {}",
1115 fields.join(", ")
1116 ));
1117 }
1118
1119 if parts.is_empty() {
1120 None
1121 } else {
1122 Some(parts.join("; "))
1123 }
1124}
1125
1126const MAX_ERROR_MESSAGES_SHOWN: usize = 3;
1129
1130const MAX_ERROR_MESSAGE_LEN: usize = 240;
1133
1134fn format_error_messages(messages: &[String]) -> String {
1135 let shown: Vec<String> = messages
1136 .iter()
1137 .take(MAX_ERROR_MESSAGES_SHOWN)
1138 .map(|m| truncate_message(m.trim()))
1139 .collect();
1140 let joined = shown.join(" | ");
1141 let remaining = messages.len().saturating_sub(MAX_ERROR_MESSAGES_SHOWN);
1142 if remaining > 0 {
1143 format!("{joined} (+{remaining} more)")
1144 } else {
1145 joined
1146 }
1147}
1148
1149fn truncate_message(msg: &str) -> String {
1150 if msg.chars().count() <= MAX_ERROR_MESSAGE_LEN {
1151 msg.to_string()
1152 } else {
1153 let truncated: String = msg.chars().take(MAX_ERROR_MESSAGE_LEN).collect();
1154 format!("{truncated}…")
1155 }
1156}
1157
1158fn default_status_message(status: u16) -> String {
1159 match status {
1160 401 | 403 => "request unauthorized".into(),
1161 404 => "resource not found".into(),
1162 409 => "conflicts with the current state of the resource".into(),
1163 429 => "rate limited by Jira".into(),
1164 400..=499 => format!("request failed with status {status}"),
1165 _ => format!("Jira request failed with status {status}"),
1166 }
1167}
1168
1169fn should_include_raw_error_body() -> bool {
1170 std::env::var("JIRA_DEBUG_HTTP").is_ok_and(|value| crate::config::is_truthy(&value))
1171}
1172
1173#[derive(Debug, serde::Deserialize)]
1174#[serde(rename_all = "camelCase")]
1175struct JiraErrorPayload {
1176 #[serde(default)]
1177 error_messages: Vec<String>,
1178 #[serde(default)]
1179 errors: BTreeMap<String, String>,
1180}
1181
1182#[cfg(test)]
1183mod tests {
1184 use super::*;
1185
1186 #[test]
1187 fn percent_encode_spaces_use_percent_20() {
1188 assert_eq!(percent_encode("project = FOO"), "project%20%3D%20FOO");
1189 }
1190
1191 #[test]
1192 fn percent_encode_complex_jql() {
1193 let jql = r#"project = "MY PROJECT""#;
1194 let encoded = percent_encode(jql);
1195 assert!(encoded.contains("project"));
1196 assert!(!encoded.contains('"'));
1197 assert!(!encoded.contains(' '));
1198 }
1199
1200 #[test]
1201 fn validate_issue_key_valid() {
1202 assert!(validate_issue_key("PROJ-123").is_ok());
1203 assert!(validate_issue_key("ABC-1").is_ok());
1204 assert!(validate_issue_key("MYPROJECT-9999").is_ok());
1205 assert!(validate_issue_key("ABC2-123").is_ok());
1207 assert!(validate_issue_key("P1-1").is_ok());
1208 }
1209
1210 #[test]
1211 fn validate_issue_key_invalid() {
1212 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());
1216 assert!(validate_issue_key("").is_err());
1217 assert!(validate_issue_key("1PROJ-123").is_err()); }
1219
1220 #[test]
1221 fn truncate_error_body_short() {
1222 let body = "short error";
1223 assert_eq!(truncate_error_body(body), body);
1224 }
1225
1226 #[test]
1227 fn truncate_error_body_long() {
1228 let body = "x".repeat(300);
1229 let result = truncate_error_body(&body);
1230 assert!(result.len() < body.len());
1231 assert!(result.ends_with("(truncated)"));
1232 }
1233
1234 #[test]
1235 fn summarize_json_error_body_surfaces_messages_and_redacts_field_values() {
1236 let body = serde_json::json!({
1237 "errorMessages": ["JQL validation failed"],
1238 "errors": {
1239 "summary": "Summary must not contain secret project name",
1240 "description": "Description cannot include api token"
1241 }
1242 })
1243 .to_string();
1244
1245 let message = summarize_error_body(400, &body);
1246 assert!(message.contains("JQL validation failed"));
1248 assert!(message.contains("summary"));
1251 assert!(message.contains("description"));
1252 assert!(!message.contains("secret project name"));
1253 assert!(!message.contains("api token"));
1254 }
1255
1256 #[test]
1257 fn summarize_json_error_body_reports_retired_api() {
1258 let body = serde_json::json!({
1260 "errorMessages": [
1261 "The requested API has been removed. Please migrate to the /rest/api/3/search/jql API."
1262 ],
1263 "errors": {}
1264 })
1265 .to_string();
1266
1267 let message = summarize_error_body(410, &body);
1268 assert!(message.contains("The requested API has been removed"));
1269 assert!(message.contains("/rest/api/3/search/jql"));
1270 }
1271
1272 #[test]
1273 fn summarize_json_error_body_joins_multiple_messages() {
1274 let body = serde_json::json!({
1275 "errorMessages": ["first problem", "second problem"],
1276 "errors": {}
1277 })
1278 .to_string();
1279
1280 let message = summarize_error_body(400, &body);
1281 assert!(message.contains("first problem"));
1282 assert!(message.contains("second problem"));
1283 assert!(message.contains(" | "));
1284 }
1285
1286 #[test]
1287 fn summarize_json_error_body_collapses_overflow_messages() {
1288 let body = serde_json::json!({
1289 "errorMessages": ["a", "b", "c", "d", "e"],
1290 "errors": {}
1291 })
1292 .to_string();
1293
1294 let message = summarize_error_body(400, &body);
1295 assert!(message.contains("(+2 more)"));
1296 }
1297
1298 #[test]
1299 fn summarize_json_error_body_truncates_oversized_message() {
1300 let huge = "x".repeat(1000);
1301 let body = serde_json::json!({
1302 "errorMessages": [huge],
1303 "errors": {}
1304 })
1305 .to_string();
1306
1307 let message = summarize_error_body(400, &body);
1308 assert!(message.chars().count() < 500);
1309 assert!(message.contains('…'));
1310 }
1311
1312 #[test]
1313 fn browse_url_preserves_explicit_http_hosts() {
1314 let client = JiraClient::new(
1315 "http://localhost:8080",
1316 "me@example.com",
1317 "token",
1318 AuthType::Basic,
1319 3,
1320 )
1321 .unwrap();
1322 assert_eq!(
1323 client.browse_url("PROJ-1"),
1324 "http://localhost:8080/browse/PROJ-1"
1325 );
1326 }
1327
1328 #[test]
1329 fn new_with_pat_auth_does_not_require_email() {
1330 let client = JiraClient::new(
1331 "https://jira.example.com",
1332 "",
1333 "my-pat-token",
1334 AuthType::Pat,
1335 3,
1336 );
1337 assert!(client.is_ok());
1338 }
1339
1340 #[test]
1341 fn new_with_api_v2_uses_v2_base_url() {
1342 let client = JiraClient::new(
1343 "https://jira.example.com",
1344 "me@example.com",
1345 "token",
1346 AuthType::Basic,
1347 2,
1348 )
1349 .unwrap();
1350 assert_eq!(client.api_version(), 2);
1351 }
1352
1353 #[test]
1354 fn scoped_cloud_token_uses_gateway_but_keeps_site_links() {
1355 let client = JiraClient::new_with_cloud(
1356 "acme.atlassian.net",
1357 "me@example.com",
1358 "token",
1359 AuthType::Basic,
1360 3,
1361 Some("cloud-123"),
1362 "scoped",
1363 )
1364 .unwrap();
1365
1366 assert_eq!(
1367 client.base_url,
1368 "https://api.atlassian.com/ex/jira/cloud-123/rest/api/3"
1369 );
1370 assert_eq!(
1371 client.agile_base_url,
1372 "https://api.atlassian.com/ex/jira/cloud-123/rest/agile/1.0"
1373 );
1374 assert_eq!(
1375 client.browse_url("PROJ-1"),
1376 "https://acme.atlassian.net/browse/PROJ-1"
1377 );
1378 }
1379
1380 #[test]
1381 fn scoped_cloud_token_requires_cloud_id() {
1382 let error = JiraClient::new_with_cloud(
1383 "acme.atlassian.net",
1384 "me@example.com",
1385 "token",
1386 AuthType::Basic,
1387 3,
1388 None,
1389 "scoped",
1390 )
1391 .err()
1392 .expect("scoped credentials without a Cloud ID must fail");
1393 assert!(error.to_string().contains("Cloud ID"));
1394 }
1395}