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
12mod hierarchy;
13mod metadata;
14mod sprints;
15
16pub struct JiraClient {
17 http: reqwest::Client,
18 base_url: String,
19 agile_base_url: String,
20 site_url: String,
21 host: String,
22 api_version: u8,
23 read_only: bool,
24 epic_lookup: std::sync::atomic::AtomicBool,
26 epic_link_fields: tokio::sync::OnceCell<Vec<String>>,
28}
29
30const SEARCH_FIELDS: [&str; 8] = [
31 "summary",
32 "status",
33 "assignee",
34 "priority",
35 "issuetype",
36 "parent",
37 "created",
38 "updated",
39];
40const ISSUE_DETAIL_FIELDS: [&str; 16] = [
41 "summary",
42 "status",
43 "assignee",
44 "reporter",
45 "priority",
46 "issuetype",
47 "parent",
48 "description",
49 "labels",
50 "components",
51 "fixVersions",
52 "versions",
53 "created",
54 "updated",
55 "comment",
56 "issuelinks",
57];
58const SEARCH_GET_JQL_LIMIT: usize = 1500;
59
60const SEARCH_JQL_MAX_PAGE: usize = 100;
64
65const SEARCH_JQL_SKIP_PAGE: usize = 1000;
68
69fn named_option_array(items: &[&str]) -> serde_json::Value {
71 serde_json::Value::Array(
72 items
73 .iter()
74 .map(|name| metadata::unresolved_option(name))
75 .collect(),
76 )
77}
78
79impl JiraClient {
80 pub fn new(
81 host: &str,
82 email: &str,
83 token: &str,
84 auth_type: AuthType,
85 api_version: u8,
86 ) -> Result<Self, ApiError> {
87 Self::new_with_cloud(host, email, token, auth_type, api_version, None, "classic")
88 }
89
90 pub fn new_with_cloud(
91 host: &str,
92 email: &str,
93 token: &str,
94 auth_type: AuthType,
95 api_version: u8,
96 cloud_id: Option<&str>,
97 token_kind: &str,
98 ) -> Result<Self, ApiError> {
99 let (scheme, domain) = if host.starts_with("http://") {
102 (
103 "http",
104 host.trim_start_matches("http://").trim_end_matches('/'),
105 )
106 } else {
107 (
108 "https",
109 host.trim_start_matches("https://").trim_end_matches('/'),
110 )
111 };
112
113 if domain.is_empty() {
114 return Err(ApiError::Other("Host cannot be empty".into()));
115 }
116
117 let auth_value = match auth_type {
118 AuthType::Basic => {
119 let credentials = BASE64.encode(format!("{email}:{token}"));
120 format!("Basic {credentials}")
121 }
122 AuthType::Pat => format!("Bearer {token}"),
123 };
124
125 let mut headers = HeaderMap::new();
126 headers.insert(
127 AUTHORIZATION,
128 HeaderValue::from_str(&auth_value).map_err(|e| ApiError::Other(e.to_string()))?,
129 );
130
131 let http = reqwest::Client::builder()
132 .default_headers(headers)
133 .timeout(std::time::Duration::from_secs(30))
134 .build()
135 .map_err(ApiError::Http)?;
136
137 let site_url = format!("{scheme}://{domain}");
138 let api_origin = if token_kind == "scoped" {
139 let cloud_id = cloud_id
140 .filter(|value| !value.trim().is_empty())
141 .ok_or_else(|| {
142 ApiError::InvalidInput("scoped token requires a Jira Cloud ID".into())
143 })?;
144 format!("https://api.atlassian.com/ex/jira/{cloud_id}")
145 } else {
146 site_url.clone()
147 };
148 let base_url = format!("{api_origin}/rest/api/{api_version}");
149 let agile_base_url = format!("{api_origin}/rest/agile/1.0");
150
151 Ok(Self {
152 http,
153 base_url,
154 agile_base_url,
155 site_url,
156 host: domain.to_string(),
157 api_version,
158 read_only: false,
159 epic_lookup: std::sync::atomic::AtomicBool::new(false),
160 epic_link_fields: tokio::sync::OnceCell::new(),
161 })
162 }
163
164 pub fn with_read_only(mut self, read_only: bool) -> Self {
166 self.read_only = read_only;
167 self
168 }
169
170 fn ensure_writable(&self) -> Result<(), ApiError> {
171 if self.read_only {
172 Err(ApiError::InvalidInput(
173 "read-only mode is enabled; Jira writes are blocked".into(),
174 ))
175 } else {
176 Ok(())
177 }
178 }
179
180 pub fn host(&self) -> &str {
181 &self.host
182 }
183
184 pub fn api_version(&self) -> u8 {
185 self.api_version
186 }
187
188 pub fn browse_base_url(&self) -> &str {
189 &self.site_url
190 }
191
192 pub fn browse_url(&self, issue_key: &str) -> String {
193 format!("{}/browse/{issue_key}", self.browse_base_url())
194 }
195
196 fn map_status(status: u16, body: String) -> ApiError {
197 let message = summarize_error_body(status, &body);
198 match status {
199 401 | 403 => ApiError::Auth(message),
200 404 => ApiError::NotFound(message),
201 409 => ApiError::Conflict(message),
202 429 => ApiError::RateLimit,
203 _ => ApiError::Api { status, message },
204 }
205 }
206
207 async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T, ApiError> {
208 let url = format!("{}/{path}", self.base_url);
209 self.send(self.http.get(&url))
210 .await?
211 .json()
212 .await
213 .map_err(ApiError::Http)
214 }
215
216 async fn agile_get<T: DeserializeOwned>(&self, path: &str) -> Result<T, ApiError> {
217 let url = format!("{}/{path}", self.agile_base_url);
218 self.send(self.http.get(&url))
219 .await?
220 .json()
221 .await
222 .map_err(ApiError::Http)
223 }
224
225 async fn post<T: DeserializeOwned>(
226 &self,
227 path: &str,
228 body: &serde_json::Value,
229 ) -> Result<T, ApiError> {
230 let url = format!("{}/{path}", self.base_url);
231 self.send(self.http.post(&url).json(body))
232 .await?
233 .json()
234 .await
235 .map_err(ApiError::Http)
236 }
237
238 async fn post_empty_response(
239 &self,
240 path: &str,
241 body: &serde_json::Value,
242 ) -> Result<(), ApiError> {
243 let url = format!("{}/{path}", self.base_url);
244 self.send(self.http.post(&url).json(body)).await?;
245 Ok(())
246 }
247
248 async fn put_empty_response(
249 &self,
250 path: &str,
251 body: &serde_json::Value,
252 ) -> Result<(), ApiError> {
253 let url = format!("{}/{path}", self.base_url);
254 self.send(self.http.put(&url).json(body)).await?;
255 Ok(())
256 }
257
258 async fn send(&self, builder: reqwest::RequestBuilder) -> Result<reqwest::Response, ApiError> {
261 let request = builder.build()?;
262 let method = request.method();
263 let read_post = method == reqwest::Method::POST
264 && ["search", "search/jql"].iter().any(|path| {
265 reqwest::Url::parse(&format!("{}/{path}", self.base_url))
268 .is_ok_and(|allowed| &allowed == request.url())
269 });
270 if !matches!(*method, reqwest::Method::GET | reqwest::Method::HEAD) && !read_post {
271 self.ensure_writable()?;
272 }
273 let resp = self.http.execute(request).await?;
274 let status = resp.status();
275 if !status.is_success() {
276 let body = resp.text().await.unwrap_or_default();
277 return Err(Self::map_status(status.as_u16(), body));
278 }
279 Ok(resp)
280 }
281
282 pub async fn search(
295 &self,
296 jql: &str,
297 max_results: usize,
298 start_at: usize,
299 ) -> Result<SearchResponse, ApiError> {
300 if self.api_version >= 3 {
301 self.search_jql_v3(jql, max_results, start_at).await
302 } else {
303 self.search_v2(jql, max_results, start_at).await
304 }
305 }
306
307 async fn search_v2(
308 &self,
309 jql: &str,
310 max_results: usize,
311 start_at: usize,
312 ) -> Result<SearchResponse, ApiError> {
313 let field_list = self.issue_fields(&SEARCH_FIELDS).await?;
314 let fields = field_list.join(",");
315 let encoded_jql = percent_encode(jql);
316 #[derive(serde::Deserialize)]
321 struct RawV2 {
322 issues: Vec<Issue>,
323 total: Option<usize>,
324 #[serde(rename = "startAt")]
325 start_at: Option<usize>,
326 #[serde(rename = "maxResults")]
327 max_results: Option<usize>,
328 }
329 let mut raw: RawV2 = if encoded_jql.len() <= SEARCH_GET_JQL_LIMIT {
330 let path = format!(
331 "search?jql={encoded_jql}&maxResults={max_results}&startAt={start_at}&fields={fields}"
332 );
333 self.get(&path).await?
334 } else {
335 self.post(
336 "search",
337 &serde_json::json!({
338 "jql": jql,
339 "maxResults": max_results,
340 "startAt": start_at,
341 "fields": field_list,
342 }),
343 )
344 .await?
345 };
346 self.fill_epics(&mut raw.issues).await?;
347 let echoed_start_at = raw.start_at.unwrap_or(start_at);
350 let is_last = match raw.total {
351 Some(total) => echoed_start_at + raw.issues.len() >= total,
352 None => raw.issues.len() < max_results,
355 };
356 Ok(SearchResponse {
357 issues: raw.issues,
358 total: raw.total,
359 start_at: echoed_start_at,
360 max_results: raw.max_results.unwrap_or(max_results),
361 is_last,
362 })
363 }
364
365 async fn search_jql_page(
371 &self,
372 jql: &str,
373 page_size: usize,
374 next_token: Option<&str>,
375 ) -> Result<SearchJqlPage, ApiError> {
376 let mut body = serde_json::json!({
377 "jql": jql,
378 "maxResults": page_size,
379 "fields": SEARCH_FIELDS,
380 });
381 if let Some(t) = next_token {
382 body["nextPageToken"] = serde_json::Value::String(t.to_string());
383 }
384 self.post("search/jql", &body).await
385 }
386
387 async fn search_jql_skip_page(
393 &self,
394 jql: &str,
395 page_size: usize,
396 next_token: Option<&str>,
397 ) -> Result<SearchJqlSkipPage, ApiError> {
398 let mut body = serde_json::json!({
399 "jql": jql,
400 "maxResults": page_size,
401 "fields": ["id"],
402 });
403 if let Some(t) = next_token {
404 body["nextPageToken"] = serde_json::Value::String(t.to_string());
405 }
406 self.post("search/jql", &body).await
407 }
408
409 async fn search_jql_v3(
410 &self,
411 jql: &str,
412 max_results: usize,
413 start_at: usize,
414 ) -> Result<SearchResponse, ApiError> {
415 let mut next_token: Option<String> = None;
420 let mut skipped = 0usize;
421 while skipped < start_at {
422 let want = (start_at - skipped).min(SEARCH_JQL_SKIP_PAGE);
423 let page = self
424 .search_jql_skip_page(jql, want, next_token.as_deref())
425 .await?;
426 let got = page.issues.len();
427 skipped += got;
428 if got == 0 || page.is_last {
429 return Ok(SearchResponse {
431 issues: Vec::new(),
432 total: None,
433 start_at,
434 max_results: 0,
435 is_last: true,
436 });
437 }
438 next_token = page.next_page_token;
439 if next_token.is_none() {
440 return Ok(SearchResponse {
443 issues: Vec::new(),
444 total: None,
445 start_at,
446 max_results: 0,
447 is_last: true,
448 });
449 }
450 }
451
452 let mut collected: Vec<Issue> = Vec::new();
455 let mut is_last = false;
456 while collected.len() < max_results {
457 let remaining = max_results - collected.len();
458 let want = remaining.min(SEARCH_JQL_MAX_PAGE);
459 let page = self
460 .search_jql_page(jql, want, next_token.as_deref())
461 .await?;
462 let got = page.issues.len();
463 collected.extend(page.issues);
464 if page.is_last || got == 0 {
465 is_last = true;
466 break;
467 }
468 next_token = page.next_page_token;
469 if next_token.is_none() {
470 is_last = true;
471 break;
472 }
473 }
474
475 self.fill_epics(&mut collected).await?;
476 let returned = collected.len();
477 Ok(SearchResponse {
478 issues: collected,
479 total: None,
481 start_at,
482 max_results: returned,
483 is_last,
484 })
485 }
486
487 pub async fn get_issue(&self, key: &str) -> Result<Issue, ApiError> {
493 validate_issue_key(key)?;
494 let fields = self.issue_fields(&ISSUE_DETAIL_FIELDS).await?.join(",");
495 let path = format!("issue/{key}?fields={fields}");
496 let mut issue: Issue = self.get(&path).await?;
497 self.fill_epics(std::slice::from_mut(&mut issue)).await?;
498
499 if let Some(ref mut comment_list) = issue.fields.comment
501 && comment_list.total > comment_list.comments.len()
502 {
503 let mut start_at = comment_list.comments.len();
504 while comment_list.comments.len() < comment_list.total {
505 let page: CommentList = self
506 .get(&format!(
507 "issue/{key}/comment?startAt={start_at}&maxResults=100"
508 ))
509 .await?;
510 if page.comments.is_empty() {
511 break;
512 }
513 start_at += page.comments.len();
514 comment_list.comments.extend(page.comments);
515 }
516 }
517
518 Ok(issue)
519 }
520
521 pub async fn create_issue(
523 &self,
524 draft: &IssueDraft<'_>,
525 custom_fields: &[(String, serde_json::Value)],
526 ) -> Result<CreateIssueResponse, ApiError> {
527 let (fields, meta) = self.prepare_create_payload(draft, custom_fields).await?;
528 self.post("issue", &serde_json::json!({ "fields": fields }))
529 .await
530 .map_err(|err| metadata::create_error(err, meta.as_ref(), draft))
531 }
532
533 pub async fn preview_create_issue(
535 &self,
536 draft: &IssueDraft<'_>,
537 custom_fields: &[(String, serde_json::Value)],
538 ) -> Result<serde_json::Value, ApiError> {
539 let (fields, meta) = self.prepare_create_payload(draft, custom_fields).await?;
540 if meta.is_none() {
541 let project = fields["project"]["key"]
542 .as_str()
543 .or_else(|| fields["project"]["id"].as_str())
544 .expect("validated project");
545 self.get_project(project).await?;
546 }
547 Ok(write_preview(
548 fields,
549 Some(meta.is_some()),
550 meta.as_ref().is_some_and(|m| m.fields.is_some()),
551 ))
552 }
553
554 async fn prepare_create_payload(
555 &self,
556 draft: &IssueDraft<'_>,
557 custom_fields: &[(String, serde_json::Value)],
558 ) -> Result<(serde_json::Value, Option<metadata::CreateMetadata>), ApiError> {
559 let mut fields = serde_json::json!({
560 "project": { "key": draft.project_key },
561 "issuetype": metadata::unresolved_option(draft.issue_type),
562 "summary": draft.summary,
563 });
564
565 if let Some(desc) = draft.description {
566 fields["description"] = self.make_body(desc);
567 }
568 if let Some(p) = draft.priority {
569 fields["priority"] = metadata::unresolved_option(p);
570 }
571 if let Some(lbls) = draft.labels
572 && !lbls.is_empty()
573 {
574 fields["labels"] = serde_json::json!(lbls);
575 }
576 if let Some(comps) = draft.components
577 && !comps.is_empty()
578 {
579 fields["components"] = named_option_array(comps);
580 }
581 if let Some(fvs) = draft.fix_versions
582 && !fvs.is_empty()
583 {
584 fields["fixVersions"] = named_option_array(fvs);
585 }
586 if let Some(assignee) = draft.assignee {
587 fields["assignee"] = assignee
588 .map(|id| self.assignee_payload(id))
589 .unwrap_or(serde_json::Value::Null);
590 }
591 for (key, value) in custom_fields {
592 fields[key] = value.clone();
593 }
594 let meta = self
595 .prepare_create(&mut fields, draft, custom_fields)
596 .await?;
597 Ok((fields, meta))
598 }
599
600 pub async fn log_work(
605 &self,
606 key: &str,
607 time_spent: &str,
608 comment: Option<&str>,
609 started: Option<&str>,
610 ) -> Result<WorklogEntry, ApiError> {
611 validate_issue_key(key)?;
612 let mut payload = serde_json::json!({ "timeSpent": time_spent });
613 if let Some(c) = comment {
614 payload["comment"] = self.make_body(c);
615 }
616 if let Some(s) = started {
617 payload["started"] = serde_json::Value::String(s.to_string());
618 }
619 self.post(&format!("issue/{key}/worklog"), &payload).await
620 }
621
622 pub async fn add_comment(&self, key: &str, body: &str) -> Result<Comment, ApiError> {
624 validate_issue_key(key)?;
625 let payload = serde_json::json!({ "body": self.make_body(body) });
626 self.post(&format!("issue/{key}/comment"), &payload).await
627 }
628
629 pub async fn get_transitions(&self, key: &str) -> Result<Vec<Transition>, ApiError> {
631 validate_issue_key(key)?;
632 let resp: TransitionsResponse = self.get(&format!("issue/{key}/transitions")).await?;
633 Ok(resp.transitions)
634 }
635
636 pub async fn do_transition(&self, key: &str, transition_id: &str) -> Result<(), ApiError> {
638 validate_issue_key(key)?;
639 let payload = serde_json::json!({ "transition": { "id": transition_id } });
640 self.post_empty_response(&format!("issue/{key}/transitions"), &payload)
641 .await
642 }
643
644 pub async fn assign_issue(&self, key: &str, account_id: Option<&str>) -> Result<(), ApiError> {
649 validate_issue_key(key)?;
650 let payload = match account_id {
651 Some(id) => self.assignee_payload(id),
652 None => {
653 if self.api_version >= 3 {
654 serde_json::json!({ "accountId": null })
655 } else {
656 serde_json::json!({ "name": null })
657 }
658 }
659 };
660 self.put_empty_response(&format!("issue/{key}/assignee"), &payload)
661 .await
662 }
663
664 fn assignee_payload(&self, id: &str) -> serde_json::Value {
668 if self.api_version >= 3 {
669 serde_json::json!({ "accountId": id })
670 } else {
671 serde_json::json!({ "name": id })
672 }
673 }
674
675 pub async fn get_myself(&self) -> Result<Myself, ApiError> {
677 self.get("myself").await
678 }
679
680 pub async fn update_issue(
687 &self,
688 key: &str,
689 update: &IssueUpdate<'_>,
690 custom_fields: &[(String, serde_json::Value)],
691 ) -> Result<(), ApiError> {
692 let (fields, meta) = self
693 .prepare_update_payload(key, update, custom_fields)
694 .await?;
695 self.put_empty_response(
696 &format!("issue/{key}"),
697 &serde_json::json!({"fields": fields}),
698 )
699 .await
700 .map_err(|err| {
701 let type_refused = update.issue_type.is_some()
704 && matches!(&err, ApiError::Api { status: 400, message } if message.contains("issuetype"));
705 if !type_refused {
706 return metadata::write_error(err, meta.as_ref(), update.priority, update.epic);
707 }
708 match metadata::write_error(err, meta.as_ref(), update.priority, None) {
709 ApiError::Api { status, mut message } => {
710 message.push_str("; Jira only changes an issue type in place when both types share a workflow and field configuration, otherwise use More > Move in the Jira web UI");
711 ApiError::Api { status, message }
712 }
713 err => err,
714 }
715 })
716 }
717
718 pub async fn preview_update_issue(
720 &self,
721 key: &str,
722 update: &IssueUpdate<'_>,
723 custom_fields: &[(String, serde_json::Value)],
724 ) -> Result<serde_json::Value, ApiError> {
725 let (fields, meta) = self
726 .prepare_update_payload(key, update, custom_fields)
727 .await?;
728 if meta.is_none() {
729 self.issue_type_for_link(key).await?;
730 }
731 Ok(write_preview(fields, None, meta.is_some()))
732 }
733
734 async fn prepare_update_payload(
735 &self,
736 key: &str,
737 update: &IssueUpdate<'_>,
738 custom_fields: &[(String, serde_json::Value)],
739 ) -> Result<(serde_json::Value, Option<metadata::Fields>), ApiError> {
740 validate_issue_key(key)?;
741 let mut fields = serde_json::Map::new();
742 if let Some(s) = update.summary {
743 fields.insert("summary".into(), serde_json::Value::String(s.into()));
744 }
745 if let Some(d) = update.description {
746 fields.insert("description".into(), self.make_body(d));
747 }
748 if let Some(p) = update.priority {
749 fields.insert("priority".into(), metadata::unresolved_option(p));
750 }
751 if let Some(comps) = update.components {
752 fields.insert("components".into(), named_option_array(comps));
753 }
754 if let Some(fvs) = update.fix_versions {
755 fields.insert("fixVersions".into(), named_option_array(fvs));
756 }
757 if let Some(lbls) = update.labels {
758 fields.insert("labels".into(), serde_json::json!(lbls));
759 }
760 if let Some(assignee_choice) = update.assignee {
761 let payload = match assignee_choice {
762 None => serde_json::Value::Null,
763 Some(id) => self.assignee_payload(id),
764 };
765 fields.insert("assignee".into(), payload);
766 }
767 for (k, value) in custom_fields {
768 fields.insert(k.clone(), value.clone());
769 }
770 if fields.is_empty()
771 && update.issue_type.is_none()
772 && update.epic.is_none()
773 && !update.clear_epic
774 {
775 return Err(ApiError::InvalidInput(
776 "At least one field (--summary, --description, --priority, --type, --epic, --clear-epic, --components, --fix-versions, --labels, --assignee, or --field) is required"
777 .into(),
778 ));
779 }
780 let mut fields = serde_json::Value::Object(fields);
781 let meta = self
782 .prepare_update(key, &mut fields, update, custom_fields)
783 .await?;
784 Ok((fields, meta))
785 }
786
787 fn make_body(&self, text: &str) -> serde_json::Value {
792 if self.api_version >= 3 {
793 text_to_adf(text)
794 } else {
795 serde_json::Value::String(text.to_string())
796 }
797 }
798
799 pub async fn list_attachments(&self, key: &str) -> Result<Vec<Attachment>, ApiError> {
803 validate_issue_key(key)?;
804 #[derive(serde::Deserialize)]
805 struct Wrapper {
806 fields: AttachmentField,
807 }
808 #[derive(serde::Deserialize)]
809 struct AttachmentField {
810 #[serde(default)]
811 attachment: Vec<Attachment>,
812 }
813 let w: Wrapper = self.get(&format!("issue/{key}?fields=attachment")).await?;
814 Ok(w.fields.attachment)
815 }
816
817 pub async fn upload_attachments(
823 &self,
824 key: &str,
825 paths: &[PathBuf],
826 ) -> Result<Vec<Attachment>, ApiError> {
827 self.ensure_writable()?;
828 validate_issue_key(key)?;
829
830 let mut form = reqwest::multipart::Form::new();
831 for path in paths {
832 let file_name = path
833 .file_name()
834 .and_then(|name| name.to_str())
835 .ok_or_else(|| {
836 ApiError::InvalidInput(format!(
837 "'{}' does not name a file to upload",
838 path.display()
839 ))
840 })?
841 .to_string();
842 let content_type = mime_guess::from_path(&file_name).first_or_octet_stream();
843 let data = std::fs::read(path)
844 .map_err(|e| ApiError::Other(format!("cannot read {}: {e}", path.display())))?;
845 let part = reqwest::multipart::Part::bytes(data)
846 .file_name(file_name)
847 .mime_str(content_type.as_ref())
848 .map_err(|e| ApiError::Other(e.to_string()))?;
849 form = form.part("file", part);
850 }
851
852 let url = format!("{}/issue/{key}/attachments", self.base_url);
853 let request = self
854 .http
855 .post(&url)
856 .header("X-Atlassian-Token", "no-check")
857 .multipart(form);
858 let resp = self.send(request).await?;
859 resp.json::<Vec<Attachment>>().await.map_err(ApiError::Http)
860 }
861
862 pub async fn get_attachment(&self, id: &str) -> Result<Attachment, ApiError> {
864 validate_attachment_id(id)?;
865 self.get(&format!("attachment/{id}")).await
866 }
867
868 pub async fn download_attachment(&self, id: &str) -> Result<Vec<u8>, ApiError> {
873 validate_attachment_id(id)?;
874 let url = format!("{}/attachment/content/{id}", self.base_url);
875 let resp = self.send(self.http.get(&url)).await?;
876 let bytes = resp.bytes().await.map_err(ApiError::Http)?;
877 Ok(bytes.to_vec())
878 }
879
880 pub async fn delete_attachment(&self, id: &str) -> Result<(), ApiError> {
882 self.ensure_writable()?;
883 validate_attachment_id(id)?;
884 let url = format!("{}/attachment/{id}", self.base_url);
885 self.send(self.http.delete(&url)).await?;
886 Ok(())
887 }
888
889 pub async fn search_users(&self, query: &str) -> Result<Vec<User>, ApiError> {
895 let encoded = percent_encode(query);
896 let param = if self.api_version >= 3 {
897 "query"
898 } else {
899 "username"
900 };
901 let path = format!("user/search?{param}={encoded}&maxResults=50");
902 self.get::<Vec<User>>(&path).await
903 }
904
905 pub async fn get_link_types(&self) -> Result<Vec<IssueLinkType>, ApiError> {
909 #[derive(serde::Deserialize)]
910 struct Wrapper {
911 #[serde(rename = "issueLinkTypes")]
912 types: Vec<IssueLinkType>,
913 }
914 let w: Wrapper = self.get("issueLinkType").await?;
915 Ok(w.types)
916 }
917
918 pub async fn link_issues(
924 &self,
925 from_key: &str,
926 to_key: &str,
927 link_type: &str,
928 ) -> Result<(), ApiError> {
929 self.ensure_writable()?;
930 validate_issue_key(from_key)?;
931 validate_issue_key(to_key)?;
932 let payload = serde_json::json!({
933 "type": { "name": link_type },
934 "inwardIssue": { "key": from_key },
935 "outwardIssue": { "key": to_key },
936 });
937 let url = format!("{}/issueLink", self.base_url);
938 self.send(self.http.post(&url).json(&payload)).await?;
939 Ok(())
940 }
941
942 pub async fn unlink_issues(&self, link_id: &str) -> Result<(), ApiError> {
944 self.ensure_writable()?;
945 let url = format!("{}/issueLink/{link_id}", self.base_url);
946 self.send(self.http.delete(&url)).await?;
947 Ok(())
948 }
949
950 pub async fn list_boards(&self) -> Result<Vec<Board>, ApiError> {
954 self.list_boards_for_project(None).await
955 }
956
957 pub async fn list_boards_for_project(
958 &self,
959 project: Option<&str>,
960 ) -> Result<Vec<Board>, ApiError> {
961 let mut all = Vec::new();
962 let mut start_at = 0usize;
963 const PAGE: usize = 50;
964 loop {
965 let project_param = project
966 .map(|p| format!("&projectKeyOrId={}", percent_encode(p)))
967 .unwrap_or_default();
968 let path = format!("board?startAt={start_at}&maxResults={PAGE}{project_param}");
969 let page: BoardSearchResponse = self.agile_get(&path).await?;
970 let received = page.values.len();
971 all.extend(page.values);
972 if page.is_last || received == 0 {
973 break;
974 }
975 start_at += received;
976 }
977 Ok(all)
978 }
979
980 pub async fn get_board(&self, id: u64) -> Result<Board, ApiError> {
982 self.agile_get(&format!("board/{id}")).await
983 }
984
985 pub(crate) async fn list_sprints_for_discovery(
988 &self,
989 board_id: u64,
990 state: Option<&str>,
991 ) -> Result<Option<Vec<Sprint>>, ApiError> {
992 match self.list_sprints(board_id, state).await {
993 Ok(sprints) => Ok(Some(sprints)),
994 Err(ApiError::Api {
995 status: 400,
996 ref message,
997 }) if message
998 .to_ascii_lowercase()
999 .contains("does not support sprints") =>
1000 {
1001 Ok(None)
1002 }
1003 Err(error) => Err(error),
1004 }
1005 }
1006
1007 pub async fn list_sprints(
1011 &self,
1012 board_id: u64,
1013 state: Option<&str>,
1014 ) -> Result<Vec<Sprint>, ApiError> {
1015 let mut all = Vec::new();
1016 let mut start_at = 0usize;
1017 const PAGE: usize = 50;
1018 loop {
1019 let state_param = state
1020 .map(|s| format!("&state={}", percent_encode(s)))
1021 .unwrap_or_default();
1022 let path = format!(
1023 "board/{board_id}/sprint?startAt={start_at}&maxResults={PAGE}{state_param}"
1024 );
1025 let page: SprintSearchResponse = self.agile_get(&path).await?;
1026 let received = page.values.len();
1027 all.extend(page.values);
1028 if page.is_last || received == 0 {
1029 break;
1030 }
1031 start_at += received;
1032 }
1033 Ok(all)
1034 }
1035
1036 pub async fn list_projects(&self) -> Result<Vec<Project>, ApiError> {
1044 if self.api_version < 3 {
1045 return self.get::<Vec<Project>>("project").await;
1046 }
1047
1048 let mut all: Vec<Project> = Vec::new();
1049 let mut start_at: usize = 0;
1050 const PAGE: usize = 50;
1051
1052 loop {
1053 let path = format!("project/search?startAt={start_at}&maxResults={PAGE}&orderBy=key");
1054 let page: ProjectSearchResponse = self.get(&path).await?;
1055 let page_start = page.start_at;
1056 let received = page.values.len();
1057 let total = page.total;
1058 all.extend(page.values);
1059
1060 if page.is_last || all.len() >= total {
1061 break;
1062 }
1063
1064 if received == 0 {
1065 return Err(ApiError::Other(
1066 "Project pagination returned an empty non-terminal page".into(),
1067 ));
1068 }
1069
1070 start_at = page_start.saturating_add(received);
1071 }
1072
1073 Ok(all)
1074 }
1075
1076 pub async fn get_project(&self, key: &str) -> Result<Project, ApiError> {
1078 self.get(&format!("project/{}", metadata::encode_segment(key)))
1079 .await
1080 }
1081
1082 pub async fn list_components(&self, project_key: &str) -> Result<Vec<Component>, ApiError> {
1087 self.get::<Vec<Component>>(&format!("project/{project_key}/components"))
1088 .await
1089 }
1090
1091 pub async fn list_versions(&self, project_key: &str) -> Result<Vec<Version>, ApiError> {
1096 self.get::<Vec<Version>>(&format!("project/{project_key}/versions"))
1097 .await
1098 }
1099
1100 pub async fn list_fields(&self) -> Result<Vec<Field>, ApiError> {
1104 self.get::<Vec<Field>>("field").await
1105 }
1106
1107 pub async fn move_issue_to_sprint(
1111 &self,
1112 issue_key: &str,
1113 sprint_id: u64,
1114 ) -> Result<(), ApiError> {
1115 self.ensure_writable()?;
1116 validate_issue_key(issue_key)?;
1117 let url = format!("{}/sprint/{sprint_id}/issue", self.agile_base_url);
1118 let payload = serde_json::json!({ "issues": [issue_key] });
1119 self.send(self.http.post(&url).json(&payload)).await?;
1120 Ok(())
1121 }
1122
1123 pub async fn get_sprint(&self, sprint_id: u64) -> Result<Sprint, ApiError> {
1125 self.agile_get::<Sprint>(&format!("sprint/{sprint_id}"))
1126 .await
1127 }
1128
1129 pub async fn resolve_sprint_id(&self, specifier: &str) -> Result<u64, ApiError> {
1133 if let Ok(id) = specifier.parse::<u64>() {
1134 return Ok(id);
1135 }
1136 self.resolve_sprint(specifier).await.map(|s| s.id)
1137 }
1138}
1139
1140fn validate_issue_key(key: &str) -> Result<(), ApiError> {
1146 let mut parts = key.splitn(2, '-');
1147 let project = parts.next().unwrap_or("");
1148 let number = parts.next().unwrap_or("");
1149
1150 let valid = !project.is_empty()
1151 && !number.is_empty()
1152 && project
1153 .chars()
1154 .next()
1155 .is_some_and(|c| c.is_ascii_uppercase())
1156 && project
1157 .chars()
1158 .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit())
1159 && number.chars().all(|c| c.is_ascii_digit());
1160
1161 if valid {
1162 Ok(())
1163 } else {
1164 Err(ApiError::InvalidInput(format!(
1165 "Invalid issue key '{key}'. Expected format: PROJECT-123"
1166 )))
1167 }
1168}
1169
1170fn validate_attachment_id(id: &str) -> Result<(), ApiError> {
1172 if !id.is_empty() && id.chars().all(|c| c.is_ascii_digit()) {
1173 Ok(())
1174 } else {
1175 Err(ApiError::InvalidInput(format!(
1176 "Invalid attachment ID '{id}'. Expected a number."
1177 )))
1178 }
1179}
1180
1181fn percent_encode(s: &str) -> String {
1185 let mut encoded = String::with_capacity(s.len() * 2);
1186 for byte in s.bytes() {
1187 match byte {
1188 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1189 encoded.push(byte as char)
1190 }
1191 b => encoded.push_str(&format!("%{b:02X}")),
1192 }
1193 }
1194 encoded
1195}
1196
1197fn truncate_error_body(body: &str) -> String {
1199 const MAX: usize = 200;
1200 if body.chars().count() <= MAX {
1201 body.to_string()
1202 } else {
1203 let truncated: String = body.chars().take(MAX).collect();
1204 format!("{truncated}… (truncated)")
1205 }
1206}
1207
1208fn summarize_error_body(status: u16, body: &str) -> String {
1209 if should_include_raw_error_body() && !body.trim().is_empty() {
1210 return truncate_error_body(body);
1211 }
1212
1213 if let Some(message) = summarize_json_error_body(body) {
1214 return message;
1215 }
1216
1217 default_status_message(status)
1218}
1219
1220fn summarize_json_error_body(body: &str) -> Option<String> {
1221 let parsed: JiraErrorPayload = serde_json::from_str(body).ok()?;
1222 let mut parts = Vec::new();
1223
1224 if !parsed.error_messages.is_empty() {
1225 parts.push(format_error_messages(&parsed.error_messages));
1226 }
1227
1228 if !parsed.errors.is_empty() {
1229 let fields = parsed.errors.keys().take(5).cloned().collect::<Vec<_>>();
1230 parts.push(format!(
1231 "validation errors for fields: {}",
1232 fields.join(", ")
1233 ));
1234 }
1235
1236 if parts.is_empty() {
1237 None
1238 } else {
1239 Some(parts.join("; "))
1240 }
1241}
1242
1243const MAX_ERROR_MESSAGES_SHOWN: usize = 3;
1246
1247const MAX_ERROR_MESSAGE_LEN: usize = 240;
1250
1251fn format_error_messages(messages: &[String]) -> String {
1252 let shown: Vec<String> = messages
1253 .iter()
1254 .take(MAX_ERROR_MESSAGES_SHOWN)
1255 .map(|m| truncate_message(m.trim()))
1256 .collect();
1257 let joined = shown.join(" | ");
1258 let remaining = messages.len().saturating_sub(MAX_ERROR_MESSAGES_SHOWN);
1259 if remaining > 0 {
1260 format!("{joined} (+{remaining} more)")
1261 } else {
1262 joined
1263 }
1264}
1265
1266fn truncate_message(msg: &str) -> String {
1267 if msg.chars().count() <= MAX_ERROR_MESSAGE_LEN {
1268 msg.to_string()
1269 } else {
1270 let truncated: String = msg.chars().take(MAX_ERROR_MESSAGE_LEN).collect();
1271 format!("{truncated}…")
1272 }
1273}
1274
1275fn default_status_message(status: u16) -> String {
1276 match status {
1277 401 | 403 => "request unauthorized".into(),
1278 404 => "resource not found".into(),
1279 409 => "conflicts with the current state of the resource".into(),
1280 429 => "rate limited by Jira".into(),
1281 400..=499 => format!("request failed with status {status}"),
1282 _ => format!("Jira request failed with status {status}"),
1283 }
1284}
1285
1286fn should_include_raw_error_body() -> bool {
1287 std::env::var("JIRA_DEBUG_HTTP").is_ok_and(|value| crate::config::is_truthy(&value))
1288}
1289
1290#[derive(Debug, serde::Deserialize)]
1291#[serde(rename_all = "camelCase")]
1292struct JiraErrorPayload {
1293 #[serde(default)]
1294 error_messages: Vec<String>,
1295 #[serde(default)]
1296 errors: BTreeMap<String, String>,
1297}
1298
1299fn write_preview(
1300 fields: serde_json::Value,
1301 issue_types: Option<bool>,
1302 field_metadata: bool,
1303) -> serde_json::Value {
1304 let mut warnings = vec![
1305 "Jira may apply additional workflow, permission, or plugin validators when the write is submitted.",
1306 ];
1307 if issue_types == Some(false) {
1308 warnings.push("Issue type metadata is unavailable; the issue type could not be normalized or validated.");
1309 }
1310 if !field_metadata {
1311 warnings.push("Field metadata is unavailable; required fields and allowed values could not be fully validated.");
1312 }
1313 serde_json::json!({"fields":fields, "metadata":{"issueTypes":issue_types, "fields":field_metadata}, "warnings":warnings})
1314}
1315
1316#[cfg(test)]
1317mod tests {
1318 use super::*;
1319
1320 #[test]
1321 fn percent_encode_spaces_use_percent_20() {
1322 assert_eq!(percent_encode("project = FOO"), "project%20%3D%20FOO");
1323 }
1324
1325 #[test]
1326 fn percent_encode_complex_jql() {
1327 let jql = r#"project = "MY PROJECT""#;
1328 let encoded = percent_encode(jql);
1329 assert!(encoded.contains("project"));
1330 assert!(!encoded.contains('"'));
1331 assert!(!encoded.contains(' '));
1332 }
1333
1334 #[test]
1335 fn validate_issue_key_valid() {
1336 assert!(validate_issue_key("PROJ-123").is_ok());
1337 assert!(validate_issue_key("ABC-1").is_ok());
1338 assert!(validate_issue_key("MYPROJECT-9999").is_ok());
1339 assert!(validate_issue_key("ABC2-123").is_ok());
1341 assert!(validate_issue_key("P1-1").is_ok());
1342 }
1343
1344 #[test]
1345 fn validate_issue_key_invalid() {
1346 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());
1350 assert!(validate_issue_key("").is_err());
1351 assert!(validate_issue_key("1PROJ-123").is_err()); }
1353
1354 #[test]
1355 fn truncate_error_body_short() {
1356 let body = "short error";
1357 assert_eq!(truncate_error_body(body), body);
1358 }
1359
1360 #[test]
1361 fn truncate_error_body_long() {
1362 let body = "x".repeat(300);
1363 let result = truncate_error_body(&body);
1364 assert!(result.len() < body.len());
1365 assert!(result.ends_with("(truncated)"));
1366 }
1367
1368 #[test]
1369 fn summarize_json_error_body_surfaces_messages_and_redacts_field_values() {
1370 let body = serde_json::json!({
1371 "errorMessages": ["JQL validation failed"],
1372 "errors": {
1373 "summary": "Summary must not contain secret project name",
1374 "description": "Description cannot include api token"
1375 }
1376 })
1377 .to_string();
1378
1379 let message = summarize_error_body(400, &body);
1380 assert!(message.contains("JQL validation failed"));
1382 assert!(message.contains("summary"));
1385 assert!(message.contains("description"));
1386 assert!(!message.contains("secret project name"));
1387 assert!(!message.contains("api token"));
1388 }
1389
1390 #[test]
1391 fn summarize_json_error_body_reports_retired_api() {
1392 let body = serde_json::json!({
1394 "errorMessages": [
1395 "The requested API has been removed. Please migrate to the /rest/api/3/search/jql API."
1396 ],
1397 "errors": {}
1398 })
1399 .to_string();
1400
1401 let message = summarize_error_body(410, &body);
1402 assert!(message.contains("The requested API has been removed"));
1403 assert!(message.contains("/rest/api/3/search/jql"));
1404 }
1405
1406 #[test]
1407 fn summarize_json_error_body_joins_multiple_messages() {
1408 let body = serde_json::json!({
1409 "errorMessages": ["first problem", "second problem"],
1410 "errors": {}
1411 })
1412 .to_string();
1413
1414 let message = summarize_error_body(400, &body);
1415 assert!(message.contains("first problem"));
1416 assert!(message.contains("second problem"));
1417 assert!(message.contains(" | "));
1418 }
1419
1420 #[test]
1421 fn summarize_json_error_body_collapses_overflow_messages() {
1422 let body = serde_json::json!({
1423 "errorMessages": ["a", "b", "c", "d", "e"],
1424 "errors": {}
1425 })
1426 .to_string();
1427
1428 let message = summarize_error_body(400, &body);
1429 assert!(message.contains("(+2 more)"));
1430 }
1431
1432 #[test]
1433 fn summarize_json_error_body_truncates_oversized_message() {
1434 let huge = "x".repeat(1000);
1435 let body = serde_json::json!({
1436 "errorMessages": [huge],
1437 "errors": {}
1438 })
1439 .to_string();
1440
1441 let message = summarize_error_body(400, &body);
1442 assert!(message.chars().count() < 500);
1443 assert!(message.contains('…'));
1444 }
1445
1446 #[test]
1447 fn browse_url_preserves_explicit_http_hosts() {
1448 let client = JiraClient::new(
1449 "http://localhost:8080",
1450 "me@example.com",
1451 "token",
1452 AuthType::Basic,
1453 3,
1454 )
1455 .unwrap();
1456 assert_eq!(
1457 client.browse_url("PROJ-1"),
1458 "http://localhost:8080/browse/PROJ-1"
1459 );
1460 }
1461
1462 #[test]
1463 fn new_with_pat_auth_does_not_require_email() {
1464 let client = JiraClient::new(
1465 "https://jira.example.com",
1466 "",
1467 "my-pat-token",
1468 AuthType::Pat,
1469 3,
1470 );
1471 assert!(client.is_ok());
1472 }
1473
1474 #[test]
1475 fn new_with_api_v2_uses_v2_base_url() {
1476 let client = JiraClient::new(
1477 "https://jira.example.com",
1478 "me@example.com",
1479 "token",
1480 AuthType::Basic,
1481 2,
1482 )
1483 .unwrap();
1484 assert_eq!(client.api_version(), 2);
1485 }
1486
1487 #[test]
1488 fn scoped_cloud_token_uses_gateway_but_keeps_site_links() {
1489 let client = JiraClient::new_with_cloud(
1490 "acme.atlassian.net",
1491 "me@example.com",
1492 "token",
1493 AuthType::Basic,
1494 3,
1495 Some("cloud-123"),
1496 "scoped",
1497 )
1498 .unwrap();
1499
1500 assert_eq!(
1501 client.base_url,
1502 "https://api.atlassian.com/ex/jira/cloud-123/rest/api/3"
1503 );
1504 assert_eq!(
1505 client.agile_base_url,
1506 "https://api.atlassian.com/ex/jira/cloud-123/rest/agile/1.0"
1507 );
1508 assert_eq!(
1509 client.browse_url("PROJ-1"),
1510 "https://acme.atlassian.net/browse/PROJ-1"
1511 );
1512 }
1513
1514 #[test]
1515 fn scoped_cloud_token_requires_cloud_id() {
1516 let error = JiraClient::new_with_cloud(
1517 "acme.atlassian.net",
1518 "me@example.com",
1519 "token",
1520 AuthType::Basic,
1521 3,
1522 None,
1523 "scoped",
1524 )
1525 .err()
1526 .expect("scoped credentials without a Cloud ID must fail");
1527 assert!(error.to_string().contains("Cloud ID"));
1528 }
1529}