1use std::time::Duration;
2
3use reqwest::{
4 header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE},
5 Client, Response, StatusCode,
6};
7use serde_json::{json, Value};
8use tracing::{debug, warn};
9
10use crate::{
11 adf::markdown_to_adf,
12 config::{JiraAuthType, JiraConfig},
13 error::{JiraError, Result},
14 model::{
15 attachment::Attachment,
16 comment::Comment,
17 component::Component,
18 field::Field,
19 issue::{
20 CreateIssueRequest, CreateIssueRequestV2, Issue, RawIssue, RawSearchResponse,
21 SearchResult, UpdateIssueRequest,
22 },
23 issue_type::IssueType,
24 link::{IssueLink, IssueLinkType},
25 remote_link::RemoteLink,
26 sprint::Sprint,
27 transition::Transition,
28 user::JiraUser,
29 version::{CreateProjectVersionRequest, ProjectVersion, UpdateProjectVersionRequest},
30 watcher::Watchers,
31 worklog::Worklog,
32 },
33};
34
35const MAX_RETRIES: u32 = 3;
36
37#[derive(serde::Deserialize)]
38struct MyselfResponse {
39 #[serde(rename = "accountId")]
40 account_id: Option<String>,
41 #[serde(rename = "timeZone")]
42 time_zone: Option<String>,
43}
44
45#[derive(Clone)]
46pub struct JiraClient {
47 http: Client,
48 config: JiraConfig,
49}
50
51impl JiraClient {
52 pub fn new(config: JiraConfig) -> Self {
53 let http = Client::builder()
54 .timeout(Duration::from_secs(config.timeout_secs))
55 .build()
56 .expect("Failed to build HTTP client");
57
58 Self { http, config }
59 }
60
61 pub fn base_url(&self) -> &str {
62 &self.config.base_url
63 }
64
65 fn platform_url(&self, path: &str) -> String {
66 format!(
67 "{}/rest/api/{}{}",
68 self.config.base_url.trim_end_matches('/'),
69 self.config.api_version,
70 path
71 )
72 }
73
74 fn platform_path(&self, path: &str) -> String {
75 format!("/rest/api/{}{}", self.config.api_version, path)
76 }
77
78 fn auth_headers(&self) -> Result<HeaderMap> {
79 self.build_auth_headers(true)
80 }
81
82 fn auth_headers_no_content_type(&self) -> Result<HeaderMap> {
84 self.build_auth_headers(false)
85 }
86
87 fn build_auth_headers(&self, include_content_type: bool) -> Result<HeaderMap> {
88 let token = self.config.token.as_deref().ok_or_else(|| {
89 JiraError::Auth("No token configured. Run `jirac auth login` first.".into())
90 })?;
91
92 let auth_value = match self.config.auth_type {
93 JiraAuthType::CloudApiToken | JiraAuthType::DataCenterBasic => {
94 let credentials = base64_encode(&format!("{}:{}", self.config.email, token));
95 format!("Basic {credentials}")
96 }
97 JiraAuthType::DataCenterPat => format!("Bearer {token}"),
98 };
99
100 let mut headers = HeaderMap::new();
101 headers.insert(
102 AUTHORIZATION,
103 HeaderValue::from_str(&auth_value)
104 .map_err(|e| JiraError::Auth(format!("Invalid auth header: {e}")))?,
105 );
106 if include_content_type {
107 headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
108 }
109 Ok(headers)
110 }
111
112 async fn get_myself_info(&self) -> Result<MyselfResponse> {
113 let headers = self.auth_headers()?;
114 let url = self.platform_url("/myself");
115
116 let http = &self.http;
117 self.request(|| http.get(&url).headers(headers.clone()))
118 .await
119 }
120
121 pub async fn get_myself(&self) -> Result<String> {
123 let me = self.get_myself_info().await?;
124 me.account_id.ok_or_else(|| JiraError::Api {
125 status: 0,
126 message: "Could not get accountId from /myself".into(),
127 })
128 }
129
130 pub async fn get_myself_timezone(&self) -> Result<Option<String>> {
132 Ok(self.get_myself_info().await?.time_zone)
133 }
134
135 async fn resolve_assignee_account_id(&self, s: &str) -> Result<String> {
141 if s == "me" {
142 return self.get_myself().await;
143 }
144 if !s.contains('@') {
145 return Ok(s.to_string());
146 }
147 let users = self.search_users(s).await?;
149 users
150 .iter()
151 .find(|u| {
152 u.email_address
153 .as_deref()
154 .map(|e| e.eq_ignore_ascii_case(s))
155 .unwrap_or(false)
156 })
157 .or_else(|| users.first())
158 .map(|u| u.account_id.clone())
159 .ok_or_else(|| JiraError::Api {
160 status: 0,
161 message: format!("User not found: {s}"),
162 })
163 }
164
165 async fn execute_with_retry(
168 &self,
169 builder_fn: impl Fn() -> reqwest::RequestBuilder,
170 ) -> Result<reqwest::Response> {
171 let mut attempt = 0u32;
172 loop {
173 attempt += 1;
174 let response = builder_fn().send().await?;
175
176 if response.status() != StatusCode::TOO_MANY_REQUESTS {
177 return Ok(response);
178 }
179
180 let retry_after = response
181 .headers()
182 .get("Retry-After")
183 .and_then(|v| v.to_str().ok())
184 .and_then(|v| v.parse::<u64>().ok())
185 .unwrap_or(60);
186
187 warn!("Rate limited. Retrying after {}s", retry_after);
188
189 if attempt >= MAX_RETRIES {
190 return Err(JiraError::RateLimit { retry_after });
191 }
192
193 tokio::time::sleep(Duration::from_secs(retry_after)).await;
194 }
195 }
196
197 async fn request<T>(&self, builder_fn: impl Fn() -> reqwest::RequestBuilder) -> Result<T>
199 where
200 T: serde::de::DeserializeOwned,
201 {
202 let response = self.execute_with_retry(builder_fn).await?;
203 handle_response(response).await
204 }
205
206 async fn request_no_body(
208 &self,
209 builder_fn: impl Fn() -> reqwest::RequestBuilder,
210 ) -> Result<()> {
211 let response = self.execute_with_retry(builder_fn).await?;
212 let status = response.status();
213 if status.is_success() {
214 return Ok(());
215 }
216 let body = response
217 .text()
218 .await
219 .unwrap_or_else(|e| format!("<failed to read response body: {e}>"));
220 if status == StatusCode::NOT_FOUND {
221 return Err(JiraError::NotFound(body));
222 }
223 Err(JiraError::Api {
224 status: status.as_u16(),
225 message: body,
226 })
227 }
228
229 async fn request_multipart<T>(
231 &self,
232 builder_fn: impl Fn() -> reqwest::RequestBuilder,
233 ) -> Result<T>
234 where
235 T: serde::de::DeserializeOwned,
236 {
237 let response = self.execute_with_retry(builder_fn).await?;
238 handle_response(response).await
239 }
240
241 pub async fn search_issues(
243 &self,
244 jql: &str,
245 next_page_token: Option<&str>,
246 max_results: Option<u32>,
247 ) -> Result<SearchResult> {
248 const DEFAULT_FIELDS: &[&str] = &[
249 "summary",
250 "status",
251 "assignee",
252 "reporter",
253 "priority",
254 "issuetype",
255 "project",
256 "created",
257 "updated",
258 "description",
259 ];
260 self.search_issues_with_fields(jql, next_page_token, max_results, DEFAULT_FIELDS)
261 .await
262 }
263
264 pub async fn search_issues_with_fields(
267 &self,
268 jql: &str,
269 next_page_token: Option<&str>,
270 max_results: Option<u32>,
271 fields: &[&str],
272 ) -> Result<SearchResult> {
273 let headers = self.auth_headers()?;
274 let url = self.platform_url("/search/jql");
275
276 let mut body = json!({
277 "jql": jql,
278 "maxResults": max_results.unwrap_or(50),
279 "fields": fields,
280 });
281
282 if let Some(token) = next_page_token {
283 body["nextPageToken"] = json!(token);
284 }
285
286 debug!("Searching JQL: {}", jql);
287
288 let http = &self.http;
289 let raw: RawSearchResponse = self
290 .request(|| http.post(&url).headers(headers.clone()).json(&body))
291 .await?;
292
293 Ok(SearchResult {
294 issues: raw.issues.into_iter().map(|r| r.into_issue()).collect(),
295 next_page_token: raw.next_page_token,
296 total: raw.total,
297 })
298 }
299
300 pub async fn list_fields(&self) -> Result<Vec<Field>> {
303 let headers = self.auth_headers()?;
304 let url = self.platform_url("/field");
305
306 #[derive(serde::Deserialize)]
307 struct FieldEntry {
308 id: String,
309 name: String,
310 #[serde(default)]
311 schema: Option<Value>,
312 }
313
314 let http = &self.http;
315 let raw: Vec<FieldEntry> = self
316 .request(|| http.get(&url).headers(headers.clone()))
317 .await?;
318
319 Ok(raw
320 .into_iter()
321 .map(|f| {
322 let field_type = f
323 .schema
324 .as_ref()
325 .and_then(|s| s.get("type"))
326 .and_then(|v| v.as_str())
327 .unwrap_or("")
328 .to_string();
329 Field {
330 id: f.id,
331 name: f.name,
332 field_type,
333 required: false,
334 schema: f.schema,
335 allowed_values: None,
336 }
337 })
338 .collect())
339 }
340
341 pub async fn get_issue(&self, key: &str) -> Result<Issue> {
343 let headers = self.auth_headers()?;
344 let url = self.platform_url(&format!("/issue/{key}"));
345
346 let http = &self.http;
347 let raw: RawIssue = self
348 .request(|| http.get(&url).headers(headers.clone()))
349 .await?;
350
351 Ok(raw.into_issue())
352 }
353
354 #[deprecated(
361 since = "0.40.0",
362 note = "Use `create_issue_v2` — supports custom fields, labels, components, parent, fix versions"
363 )]
364 pub async fn create_issue(&self, req: CreateIssueRequest) -> Result<Issue> {
365 let headers = self.auth_headers()?;
366 let url = self.platform_url("/issue");
367
368 let description_adf = req.description.as_deref().map(markdown_to_adf);
369
370 let mut fields = json!({
371 "project": { "key": req.project_key },
372 "summary": req.summary,
373 "issuetype": { "name": req.issue_type }
374 });
375
376 if let Some(adf) = description_adf {
377 fields["description"] = adf;
378 }
379
380 if let Some(assignee) = &req.assignee {
381 let account_id = self.resolve_assignee_account_id(assignee).await?;
382 fields["assignee"] = json!({ "accountId": account_id });
383 }
384
385 if let Some(priority) = &req.priority {
386 fields["priority"] = json!({ "name": priority });
387 }
388
389 let body = json!({ "fields": fields });
390
391 #[derive(serde::Deserialize)]
392 struct CreateResponse {
393 key: String,
394 }
395
396 let http = &self.http;
397 let resp: CreateResponse = self
398 .request(|| http.post(&url).headers(headers.clone()).json(&body))
399 .await?;
400
401 self.get_issue(&resp.key).await
403 }
404
405 pub async fn update_issue(&self, key: &str, req: UpdateIssueRequest) -> Result<()> {
407 let headers = self.auth_headers()?;
408 let url = self.platform_url(&format!("/issue/{key}"));
409
410 let mut fields = json!({});
411
412 if let Some(summary) = &req.summary {
413 fields["summary"] = json!(summary);
414 }
415 if let Some(adf) = &req.description_adf {
416 fields["description"] = adf.clone();
417 } else if let Some(description) = &req.description {
418 fields["description"] = markdown_to_adf(description);
419 }
420 if let Some(assignee) = &req.assignee {
421 let account_id = self.resolve_assignee_account_id(assignee).await?;
422 fields["assignee"] = json!({ "accountId": account_id });
423 }
424 if let Some(priority) = &req.priority {
425 fields["priority"] = json!({ "name": priority });
426 }
427 if let Some(labels) = &req.labels {
428 fields["labels"] = json!(labels);
429 }
430 if let Some(components) = &req.components {
431 fields["components"] = json!(components
432 .iter()
433 .map(|c| json!({"name": c}))
434 .collect::<Vec<_>>());
435 }
436 if let Some(fix_versions) = &req.fix_versions {
437 fields["fixVersions"] = json!(fix_versions
438 .iter()
439 .map(|v| json!({"name": v}))
440 .collect::<Vec<_>>());
441 }
442 if let Some(parent) = &req.parent {
443 fields["parent"] = json!({ "key": parent });
444 }
445 for (field_id, value) in &req.custom_fields {
446 fields[field_id] = value.to_api_json();
447 }
448
449 let body = json!({ "fields": fields });
450
451 let http = &self.http;
452 self.request_no_body(|| http.put(&url).headers(headers.clone()).json(&body))
453 .await
454 }
455
456 pub async fn delete_issue(&self, key: &str) -> Result<()> {
458 let headers = self.auth_headers()?;
459 let url = self.platform_url(&format!("/issue/{key}"));
460
461 let http = &self.http;
462 self.request_no_body(|| http.delete(&url).headers(headers.clone()))
463 .await
464 }
465
466 pub async fn get_project_fields(&self, project_key: &str) -> Result<Vec<Field>> {
472 let issue_types = self.get_issue_types(project_key).await?;
473
474 let mut fields: Vec<Field> = Vec::new();
475 let mut seen = std::collections::HashSet::new();
476
477 for it in issue_types {
478 for field in self.get_fields_for_issue_type(project_key, &it.id).await? {
482 if seen.insert(field.id.clone()) {
483 fields.push(field);
484 }
485 }
486 }
487
488 Ok(fields)
489 }
490
491 pub async fn get_server_info(&self) -> Result<Value> {
493 let headers = self.auth_headers()?;
494 let url = self.platform_url("/serverInfo");
495
496 let http = &self.http;
497 self.request(|| http.get(&url).headers(headers.clone()))
498 .await
499 }
500
501 pub async fn transition_issue(&self, key: &str, transition_id: &str) -> Result<()> {
503 let headers = self.auth_headers()?;
504 let url = self.platform_url(&format!("/issue/{key}/transitions"));
505
506 let body = json!({
507 "transition": { "id": transition_id }
508 });
509
510 let http = &self.http;
511 self.request_no_body(|| http.post(&url).headers(headers.clone()).json(&body))
512 .await
513 }
514
515 pub async fn get_transitions(&self, key: &str) -> Result<Vec<Transition>> {
517 let headers = self.auth_headers()?;
518 let url = self.platform_url(&format!("/issue/{key}/transitions"));
519
520 #[derive(serde::Deserialize)]
521 struct TransitionsResponse {
522 transitions: Vec<Transition>,
523 }
524
525 let http = &self.http;
526 let resp: TransitionsResponse = self
527 .request(|| http.get(&url).headers(headers.clone()))
528 .await?;
529
530 Ok(resp.transitions)
531 }
532
533 pub async fn add_watcher(&self, key: &str, account_id: &str) -> Result<()> {
536 let headers = self.auth_headers()?;
537 let url = self.platform_url(&format!("/issue/{key}/watchers"));
538 let body = Value::String(account_id.to_string());
539
540 let http = &self.http;
541 self.request_no_body(|| http.post(&url).headers(headers.clone()).json(&body))
542 .await
543 }
544
545 pub async fn remove_watcher(&self, key: &str, account_id: &str) -> Result<()> {
547 let headers = self.auth_headers()?;
548 let url = self.platform_url(&format!("/issue/{key}/watchers"));
549
550 let http = &self.http;
551 self.request_no_body(|| {
552 http.delete(&url)
553 .headers(headers.clone())
554 .query(&[("accountId", account_id)])
555 })
556 .await
557 }
558
559 pub async fn list_watchers(&self, key: &str) -> Result<Watchers> {
561 let headers = self.auth_headers()?;
562 let url = self.platform_url(&format!("/issue/{key}/watchers"));
563
564 let http = &self.http;
565 let raw: Value = self
566 .request(|| http.get(&url).headers(headers.clone()))
567 .await?;
568 Watchers::from_value(&raw, key).ok_or_else(|| JiraError::Api {
569 status: 0,
570 message: "Failed to parse watchers".into(),
571 })
572 }
573
574 pub async fn get_issue_types(&self, project_key: &str) -> Result<Vec<IssueType>> {
576 let headers = self.auth_headers()?;
577 let url = self.platform_url(&format!("/issue/createmeta/{project_key}/issuetypes"));
578
579 #[derive(serde::Deserialize)]
580 struct MetaResponse {
581 #[serde(rename = "issueTypes")]
582 issue_types: Vec<IssueType>,
583 }
584
585 let http = &self.http;
586 let resp: MetaResponse = self
587 .request(|| http.get(&url).headers(headers.clone()))
588 .await?;
589
590 Ok(resp.issue_types)
591 }
592
593 pub async fn get_issue_type_by_name(
595 &self,
596 project_key: &str,
597 issue_type_name: &str,
598 ) -> Result<IssueType> {
599 let issue_types = self.get_issue_types(project_key).await?;
600
601 issue_types
602 .iter()
603 .find(|it| it.name == issue_type_name)
604 .or_else(|| {
605 issue_types
606 .iter()
607 .find(|it| it.name.eq_ignore_ascii_case(issue_type_name))
608 })
609 .cloned()
610 .ok_or_else(|| JiraError::Api {
611 status: 0,
612 message: format!(
613 "Issue type '{}' not found in project {}",
614 issue_type_name, project_key
615 ),
616 })
617 }
618
619 pub async fn get_fields_for_issue_type(
621 &self,
622 project_key: &str,
623 issue_type_id: &str,
624 ) -> Result<Vec<Field>> {
625 let headers = self.auth_headers()?;
626 let url = self.platform_url(&format!(
627 "/issue/createmeta/{project_key}/issuetypes/{issue_type_id}"
628 ));
629
630 #[derive(serde::Deserialize)]
631 struct FieldMetaResponse {
632 fields: FieldCollection,
633 }
634
635 #[derive(serde::Deserialize)]
636 #[serde(untagged)]
637 enum FieldCollection {
638 Map(std::collections::HashMap<String, FieldMetaMap>),
639 List(Vec<FieldMetaEntry>),
640 }
641
642 #[derive(serde::Deserialize)]
643 struct FieldMetaMap {
644 name: String,
645 required: bool,
646 schema: Option<Value>,
647 #[serde(rename = "allowedValues")]
648 allowed_values: Option<Vec<Value>>,
649 }
650
651 #[derive(serde::Deserialize)]
652 struct FieldMetaEntry {
653 #[serde(rename = "fieldId")]
654 field_id: Option<String>,
655 key: Option<String>,
656 name: String,
657 required: bool,
658 schema: Option<Value>,
659 #[serde(rename = "allowedValues")]
660 allowed_values: Option<Vec<Value>>,
661 }
662
663 let http = &self.http;
664 let resp: FieldMetaResponse = self
665 .request(|| http.get(&url).headers(headers.clone()))
666 .await?;
667
668 let fields = match resp.fields {
669 FieldCollection::Map(fields) => fields
670 .into_iter()
671 .map(|(id, meta)| {
672 let field_type = meta
673 .schema
674 .as_ref()
675 .and_then(|s| s.get("type"))
676 .and_then(|v| v.as_str())
677 .unwrap_or("unknown")
678 .to_string();
679
680 Field {
681 id,
682 name: meta.name,
683 field_type,
684 required: meta.required,
685 schema: meta.schema,
686 allowed_values: meta.allowed_values,
687 }
688 })
689 .collect(),
690 FieldCollection::List(fields) => fields
691 .into_iter()
692 .map(|meta| {
693 let field_type = meta
694 .schema
695 .as_ref()
696 .and_then(|s| s.get("type"))
697 .and_then(|v| v.as_str())
698 .unwrap_or("unknown")
699 .to_string();
700
701 let id = meta.field_id.or(meta.key).unwrap_or_default();
702
703 Field {
704 id,
705 name: meta.name,
706 field_type,
707 required: meta.required,
708 schema: meta.schema,
709 allowed_values: meta.allowed_values,
710 }
711 })
712 .collect(),
713 };
714
715 Ok(fields)
716 }
717
718 pub async fn search_users(&self, query: &str) -> Result<Vec<JiraUser>> {
720 let headers = self.auth_headers()?;
721 let url = self.platform_url("/user/search");
722
723 let http = &self.http;
724 self.request(|| {
725 http.get(&url)
726 .headers(headers.clone())
727 .query(&[("query", query), ("maxResults", "20")])
728 })
729 .await
730 }
731
732 pub async fn get_project_components(&self, project_key: &str) -> Result<Vec<Component>> {
734 let headers = self.auth_headers()?;
735 let url = self.platform_url(&format!("/project/{project_key}/components"));
736
737 let http = &self.http;
738 self.request(|| http.get(&url).headers(headers.clone()))
739 .await
740 }
741
742 pub async fn get_project_versions(&self, project_key: &str) -> Result<Vec<ProjectVersion>> {
744 let headers = self.auth_headers()?;
745 let url = self.platform_url(&format!("/project/{project_key}/versions"));
746
747 let http = &self.http;
748 let versions: Vec<ProjectVersion> = self
749 .request(|| http.get(&url).headers(headers.clone()))
750 .await?;
751
752 Ok(versions)
753 }
754
755 pub async fn create_project_version(
757 &self,
758 request: &CreateProjectVersionRequest,
759 ) -> Result<ProjectVersion> {
760 let path = format!("/rest/api/{}/version", self.config.api_version);
761 let body = serde_json::to_value(request)?;
762 let value = self
763 .raw_request("POST", &path, Some(body))
764 .await?
765 .ok_or_else(|| JiraError::Api {
766 status: 0,
767 message: "Empty response when creating project version".into(),
768 })?;
769 let version: ProjectVersion =
770 serde_json::from_value(value).map_err(|e| JiraError::Api {
771 status: 0,
772 message: format!("Failed to parse created project version: {e}"),
773 })?;
774 Ok(version)
775 }
776
777 pub async fn update_project_version(
779 &self,
780 version_id: &str,
781 request: &UpdateProjectVersionRequest,
782 ) -> Result<ProjectVersion> {
783 let path = format!(
784 "/rest/api/{}/version/{}",
785 self.config.api_version, version_id
786 );
787 let body = serde_json::to_value(request)?;
788 let value = self
789 .raw_request("PUT", &path, Some(body))
790 .await?
791 .ok_or_else(|| JiraError::Api {
792 status: 0,
793 message: "Empty response when updating project version".into(),
794 })?;
795 let version: ProjectVersion =
796 serde_json::from_value(value).map_err(|e| JiraError::Api {
797 status: 0,
798 message: format!("Failed to parse updated project version: {e}"),
799 })?;
800 Ok(version)
801 }
802
803 fn parse_sprint_value(&self, value: &Value, board_id: Option<u64>) -> Option<Sprint> {
804 let id = value.get("id").and_then(|v| v.as_u64())?;
805 Some(Sprint {
806 id,
807 name: value
808 .get("name")
809 .and_then(|v| v.as_str())
810 .unwrap_or("")
811 .to_string(),
812 state: value
813 .get("state")
814 .and_then(|v| v.as_str())
815 .unwrap_or("")
816 .to_string(),
817 board_id,
818 goal: value
819 .get("goal")
820 .and_then(|v| v.as_str())
821 .map(str::to_owned),
822 start_date: value
823 .get("startDate")
824 .and_then(|v| v.as_str())
825 .map(str::to_owned),
826 end_date: value
827 .get("endDate")
828 .and_then(|v| v.as_str())
829 .map(str::to_owned),
830 complete_date: value
831 .get("completeDate")
832 .and_then(|v| v.as_str())
833 .map(str::to_owned),
834 })
835 }
836
837 fn paged_values(response: &Value) -> &[Value] {
838 response
839 .get("values")
840 .and_then(|v| v.as_array())
841 .map(Vec::as_slice)
842 .unwrap_or(&[])
843 }
844
845 fn page_is_last(response: &Value, fetched_count: usize) -> bool {
846 if fetched_count == 0 {
849 return true;
850 }
851 if let Some(is_last) = response.get("isLast").and_then(|v| v.as_bool()) {
852 return is_last;
853 }
854 if let Some(total) = response.get("total").and_then(|v| v.as_u64()) {
855 let start_at = response
856 .get("startAt")
857 .and_then(|v| v.as_u64())
858 .unwrap_or(0);
859 return start_at + fetched_count as u64 >= total;
860 }
861 if let Some(max_results) = response.get("maxResults").and_then(|v| v.as_u64()) {
862 return fetched_count < max_results as usize;
863 }
864 true
867 }
868
869 pub async fn list_sprints_for_project_with_states(
871 &self,
872 project_key: &str,
873 states: &[&str],
874 ) -> Result<Vec<Sprint>> {
875 const MAX_PAGES: u32 = 500;
876
877 let mut board_ids = Vec::new();
878 let mut board_start_at = 0u64;
879 let mut iterations = 0u32;
880
881 loop {
882 iterations += 1;
883 if iterations > MAX_PAGES {
884 return Err(JiraError::Api {
885 status: 500,
886 message: "board pagination exceeded MAX_PAGES safeguard".to_string(),
887 });
888 }
889 let boards_path = format!(
890 "/rest/agile/1.0/board?projectKeyOrId={project_key}&maxResults=100&startAt={board_start_at}"
891 );
892 let boards_resp = self
893 .raw_request("GET", &boards_path, None)
894 .await?
895 .unwrap_or_default();
896 let boards = Self::paged_values(&boards_resp);
897
898 board_ids.extend(boards.iter().filter_map(|b| {
902 let is_scrum = b
903 .get("type")
904 .and_then(|t| t.as_str())
905 .map(|t| t.eq_ignore_ascii_case("scrum"))
906 .unwrap_or(true);
907 is_scrum
908 .then(|| b.get("id").and_then(|id| id.as_u64()))
909 .flatten()
910 }));
911
912 if Self::page_is_last(&boards_resp, boards.len()) {
913 break;
914 }
915 board_start_at += boards.len() as u64;
916 }
917
918 let mut seen: std::collections::HashSet<u64> = std::collections::HashSet::new();
919 let mut sprints: Vec<Sprint> = Vec::new();
920 let state_filter = states.join(",");
921
922 for board_id in board_ids {
923 let mut sprint_start_at = 0u64;
924 let mut sprint_iterations = 0u32;
925 loop {
926 sprint_iterations += 1;
927 if sprint_iterations > MAX_PAGES {
928 return Err(JiraError::Api {
929 status: 500,
930 message: format!(
931 "sprint pagination exceeded MAX_PAGES safeguard for board {board_id}"
932 ),
933 });
934 }
935 let sprint_path = format!(
936 "/rest/agile/1.0/board/{board_id}/sprint?state={state_filter}&maxResults=200&startAt={sprint_start_at}"
937 );
938 let resp = match self.raw_request("GET", &sprint_path, None).await {
939 Ok(Some(resp)) => resp,
940 Ok(None) => break,
941 Err(JiraError::NotFound(_)) => break,
942 Err(err) => return Err(err),
943 };
944 let values = Self::paged_values(&resp);
945 for value in values {
946 let Some(sprint) = self.parse_sprint_value(value, Some(board_id)) else {
947 continue;
948 };
949 if !seen.insert(sprint.id) {
950 continue;
951 }
952 sprints.push(sprint);
953 }
954
955 if Self::page_is_last(&resp, values.len()) {
956 break;
957 }
958 sprint_start_at += values.len() as u64;
959 }
960 }
961
962 sprints.sort_by(|a, b| {
963 let order = |s: &str| match s {
964 "active" => 0u8,
965 "future" => 1u8,
966 _ => 2u8,
967 };
968 order(&a.state)
969 .cmp(&order(&b.state))
970 .then(a.name.cmp(&b.name))
971 });
972
973 Ok(sprints)
974 }
975
976 pub async fn list_sprints_for_project(&self, project_key: &str) -> Result<Vec<Sprint>> {
978 self.list_sprints_for_project_with_states(project_key, &["active", "future"])
979 .await
980 }
981
982 pub async fn list_boards(
984 &self,
985 project_key: Option<&str>,
986 board_type: Option<&str>,
987 ) -> Result<Vec<crate::model::Board>> {
988 const MAX_PAGES: u32 = 500;
989 let mut boards: Vec<crate::model::Board> = Vec::new();
990 let mut start_at = 0u64;
991 let mut iterations = 0u32;
992
993 loop {
994 iterations += 1;
995 if iterations > MAX_PAGES {
996 return Err(JiraError::Api {
997 status: 500,
998 message: "board pagination exceeded MAX_PAGES safeguard".into(),
999 });
1000 }
1001 let mut query = format!("maxResults=50&startAt={start_at}");
1002 if let Some(p) = project_key {
1003 query.push_str(&format!("&projectKeyOrId={p}"));
1004 }
1005 if let Some(t) = board_type {
1006 query.push_str(&format!("&type={t}"));
1007 }
1008 let path = format!("/rest/agile/1.0/board?{query}");
1009 let resp = self
1010 .raw_request("GET", &path, None)
1011 .await?
1012 .unwrap_or_default();
1013 let values = Self::paged_values(&resp);
1014 for v in values {
1015 if let Some(b) = crate::model::Board::from_value(v) {
1016 boards.push(b);
1017 }
1018 }
1019 if Self::page_is_last(&resp, values.len()) {
1020 break;
1021 }
1022 start_at += values.len() as u64;
1023 }
1024
1025 Ok(boards)
1026 }
1027
1028 pub async fn get_board(&self, board_id: u64) -> Result<crate::model::Board> {
1030 let path = format!("/rest/agile/1.0/board/{board_id}");
1031 let resp = self
1032 .raw_request("GET", &path, None)
1033 .await?
1034 .ok_or_else(|| JiraError::NotFound(format!("board {board_id}")))?;
1035 crate::model::Board::from_value(&resp).ok_or_else(|| JiraError::Api {
1036 status: 500,
1037 message: "could not parse board response".into(),
1038 })
1039 }
1040
1041 async fn board_issue_list(
1042 &self,
1043 board_id: u64,
1044 endpoint: &str,
1045 jql: Option<&str>,
1046 max_results: Option<u32>,
1047 ) -> Result<Vec<Issue>> {
1048 let max = max_results.unwrap_or(50).min(1000);
1049 let mut path =
1050 format!("/rest/agile/1.0/board/{board_id}/{endpoint}?maxResults={max}&startAt=0");
1051 if let Some(j) = jql {
1052 path.push_str(&format!("&jql={}", url_encode_component(j)));
1053 }
1054 let resp = self
1055 .raw_request("GET", &path, None)
1056 .await?
1057 .unwrap_or_default();
1058 let issues = resp
1059 .get("issues")
1060 .and_then(|v| v.as_array())
1061 .cloned()
1062 .unwrap_or_default();
1063 Ok(issues
1064 .into_iter()
1065 .filter_map(|v| serde_json::from_value::<RawIssue>(v).ok())
1066 .map(|r| r.into_issue())
1067 .collect())
1068 }
1069
1070 pub async fn board_issues(
1072 &self,
1073 board_id: u64,
1074 jql: Option<&str>,
1075 max_results: Option<u32>,
1076 ) -> Result<Vec<Issue>> {
1077 self.board_issue_list(board_id, "issue", jql, max_results)
1078 .await
1079 }
1080
1081 pub async fn board_backlog(
1083 &self,
1084 board_id: u64,
1085 jql: Option<&str>,
1086 max_results: Option<u32>,
1087 ) -> Result<Vec<Issue>> {
1088 self.board_issue_list(board_id, "backlog", jql, max_results)
1089 .await
1090 }
1091
1092 pub async fn create_sprint(
1094 &self,
1095 board_id: u64,
1096 name: &str,
1097 start_date: Option<&str>,
1098 end_date: Option<&str>,
1099 goal: Option<&str>,
1100 ) -> Result<Sprint> {
1101 let body = json!({
1102 "name": name,
1103 "originBoardId": board_id,
1104 "startDate": start_date,
1105 "endDate": end_date,
1106 "goal": goal,
1107 });
1108 let value = self
1109 .raw_request("POST", "/rest/agile/1.0/sprint", Some(body))
1110 .await?
1111 .ok_or_else(|| JiraError::Api {
1112 status: 0,
1113 message: "Empty response when creating sprint".into(),
1114 })?;
1115 self.parse_sprint_value(&value, Some(board_id))
1116 .ok_or_else(|| JiraError::Api {
1117 status: 0,
1118 message: "Failed to parse created sprint".into(),
1119 })
1120 }
1121
1122 pub async fn update_sprint(&self, sprint_id: u64, body: Value) -> Result<Sprint> {
1124 let value = self
1125 .raw_request(
1126 "PUT",
1127 &format!("/rest/agile/1.0/sprint/{sprint_id}"),
1128 Some(body),
1129 )
1130 .await?
1131 .ok_or_else(|| JiraError::Api {
1132 status: 0,
1133 message: "Empty response when updating sprint".into(),
1134 })?;
1135 self.parse_sprint_value(&value, None)
1136 .ok_or_else(|| JiraError::Api {
1137 status: 0,
1138 message: "Failed to parse updated sprint".into(),
1139 })
1140 }
1141
1142 pub async fn delete_sprint(&self, sprint_id: u64) -> Result<()> {
1144 self.raw_request(
1145 "DELETE",
1146 &format!("/rest/agile/1.0/sprint/{sprint_id}"),
1147 None,
1148 )
1149 .await?;
1150 Ok(())
1151 }
1152
1153 pub async fn add_issue_to_sprint(&self, sprint_id: u64, issue_key: &str) -> Result<()> {
1155 let path = format!("/rest/agile/1.0/sprint/{sprint_id}/issue");
1156 let body = json!({ "issues": [issue_key] });
1157 self.raw_request("POST", &path, Some(body)).await?;
1158 Ok(())
1159 }
1160
1161 pub async fn add_comment_adf(&self, issue_key: &str, adf: Value) -> Result<Comment> {
1163 let headers = self.auth_headers()?;
1164 let url = self.platform_url(&format!("/issue/{issue_key}/comment"));
1165 let payload = json!({ "body": adf });
1166 let http = &self.http;
1167 let raw: Value = self
1168 .request(|| http.post(&url).headers(headers.clone()).json(&payload))
1169 .await?;
1170 Comment::from_value(&raw, issue_key).ok_or_else(|| JiraError::Api {
1171 status: 0,
1172 message: "Failed to parse comment".into(),
1173 })
1174 }
1175
1176 pub async fn upload_attachment(
1178 &self,
1179 issue_key: &str,
1180 file_path: &std::path::Path,
1181 ) -> Result<Vec<Attachment>> {
1182 let file_name = file_path
1183 .file_name()
1184 .and_then(|n| n.to_str())
1185 .unwrap_or("attachment")
1186 .to_string();
1187 let bytes = std::fs::read(file_path)?;
1188 let mime = mime_guess::from_path(file_path)
1189 .first_or_octet_stream()
1190 .to_string();
1191
1192 self.upload_attachment_bytes(issue_key, &file_name, bytes, Some(&mime))
1193 .await
1194 }
1195
1196 pub async fn upload_attachment_bytes(
1198 &self,
1199 issue_key: &str,
1200 file_name: &str,
1201 bytes: Vec<u8>,
1202 media_type: Option<&str>,
1203 ) -> Result<Vec<Attachment>> {
1204 use reqwest::{header::HeaderValue, multipart};
1205
1206 let headers = self.auth_headers_no_content_type()?;
1207 let url = self.platform_url(&format!("/issue/{issue_key}/attachments"));
1208 let mime = media_type
1209 .map(|value| value.to_string())
1210 .or_else(|| {
1211 mime_guess::from_path(file_name)
1212 .first_raw()
1213 .map(str::to_string)
1214 })
1215 .unwrap_or_else(|| "application/octet-stream".to_string());
1216
1217 let http = &self.http;
1218 let raw_attachments: Vec<Value> = self
1219 .request_multipart(|| {
1220 let part = multipart::Part::bytes(bytes.clone())
1221 .file_name(file_name.to_string())
1222 .mime_str(&mime)
1223 .expect("invalid mime type");
1224 let form = multipart::Form::new().part("file", part);
1225
1226 let mut req_headers = headers.clone();
1227 req_headers.insert("X-Atlassian-Token", HeaderValue::from_static("no-check"));
1228
1229 http.post(&url).headers(req_headers).multipart(form)
1230 })
1231 .await?;
1232
1233 Ok(raw_attachments
1234 .iter()
1235 .filter_map(Attachment::from_value)
1236 .collect())
1237 }
1238
1239 pub async fn create_issue_v2(&self, req: CreateIssueRequestV2) -> Result<Issue> {
1241 let headers = self.auth_headers()?;
1242 let url = self.platform_url("/issue");
1243
1244 let description_adf = req
1245 .description_adf
1246 .or_else(|| req.description.as_deref().map(markdown_to_adf));
1247
1248 let mut fields = json!({
1249 "project": { "key": req.project_key },
1250 "summary": req.summary,
1251 "issuetype": { "name": req.issue_type }
1252 });
1253
1254 if let Some(adf) = description_adf {
1255 fields["description"] = adf;
1256 }
1257 if let Some(assignee) = &req.assignee {
1258 let account_id = self.resolve_assignee_account_id(assignee).await?;
1259 fields["assignee"] = json!({ "accountId": account_id });
1260 }
1261 if let Some(priority) = &req.priority {
1262 fields["priority"] = json!({ "name": priority });
1263 }
1264 if !req.labels.is_empty() {
1265 fields["labels"] = json!(req.labels);
1266 }
1267 if !req.components.is_empty() {
1268 fields["components"] = json!(req
1269 .components
1270 .iter()
1271 .map(|c| json!({"name": c}))
1272 .collect::<Vec<_>>());
1273 }
1274 if let Some(parent) = &req.parent {
1275 fields["parent"] = json!({ "key": parent });
1276 }
1277 if !req.fix_versions.is_empty() {
1278 fields["fixVersions"] = json!(req
1279 .fix_versions
1280 .iter()
1281 .map(|v| json!({"name": v}))
1282 .collect::<Vec<_>>());
1283 }
1284 for (field_id, value) in &req.custom_fields {
1285 fields[field_id] = value.to_api_json();
1286 }
1287
1288 let body = json!({ "fields": fields });
1289
1290 #[derive(serde::Deserialize)]
1291 struct CreateResponse {
1292 key: String,
1293 }
1294
1295 let http = &self.http;
1296 let resp: CreateResponse = self
1297 .request(|| http.post(&url).headers(headers.clone()).json(&body))
1298 .await?;
1299
1300 self.get_issue(&resp.key).await
1301 }
1302
1303 pub async fn get_comments(&self, issue_key: &str) -> Result<Vec<Comment>> {
1307 let headers = self.auth_headers()?;
1308 let url = self.platform_url(&format!("/issue/{issue_key}/comment"));
1309
1310 #[derive(serde::Deserialize)]
1311 struct CommentResponse {
1312 comments: Vec<Value>,
1313 }
1314
1315 let http = &self.http;
1316 let resp: CommentResponse = self
1317 .request(|| http.get(&url).headers(headers.clone()))
1318 .await?;
1319
1320 Ok(resp
1321 .comments
1322 .iter()
1323 .filter_map(|v| Comment::from_value(v, issue_key))
1324 .collect())
1325 }
1326
1327 pub async fn add_comment(&self, issue_key: &str, body: &str) -> Result<Comment> {
1329 let headers = self.auth_headers()?;
1330 let url = self.platform_url(&format!("/issue/{issue_key}/comment"));
1331
1332 let payload = json!({
1333 "body": markdown_to_adf(body)
1334 });
1335
1336 let http = &self.http;
1337 let raw: Value = self
1338 .request(|| http.post(&url).headers(headers.clone()).json(&payload))
1339 .await?;
1340
1341 Comment::from_value(&raw, issue_key).ok_or_else(|| JiraError::Api {
1342 status: 0,
1343 message: "Failed to parse comment".into(),
1344 })
1345 }
1346
1347 pub async fn get_worklogs(&self, issue_key: &str) -> Result<Vec<Worklog>> {
1351 let headers = self.auth_headers()?;
1352 let url = self.platform_url(&format!("/issue/{issue_key}/worklog"));
1353
1354 #[derive(serde::Deserialize)]
1355 struct WorklogResponse {
1356 worklogs: Vec<Value>,
1357 }
1358
1359 let http = &self.http;
1360 let resp: WorklogResponse = self
1361 .request(|| http.get(&url).headers(headers.clone()))
1362 .await?;
1363
1364 Ok(resp
1365 .worklogs
1366 .iter()
1367 .filter_map(|v| Worklog::from_value(v, issue_key))
1368 .collect())
1369 }
1370
1371 pub async fn add_worklog(
1375 &self,
1376 issue_key: &str,
1377 time_spent: &str,
1378 comment: Option<&str>,
1379 started: Option<&str>,
1380 ) -> Result<Worklog> {
1381 let headers = self.auth_headers()?;
1382 let url = self.platform_url(&format!("/issue/{issue_key}/worklog"));
1383
1384 let started_str = started
1386 .map(|s| s.to_string())
1387 .unwrap_or_else(current_jira_timestamp);
1388
1389 let mut body = json!({
1390 "timeSpent": time_spent,
1391 "started": started_str,
1392 });
1393
1394 if let Some(c) = comment {
1395 body["comment"] = markdown_to_adf(c);
1396 }
1397
1398 let http = &self.http;
1399 let raw: Value = self
1400 .request(|| http.post(&url).headers(headers.clone()).json(&body))
1401 .await?;
1402
1403 Worklog::from_value(&raw, issue_key).ok_or_else(|| JiraError::Api {
1404 status: 0,
1405 message: "Failed to parse worklog".into(),
1406 })
1407 }
1408
1409 pub async fn delete_worklog(&self, issue_key: &str, worklog_id: &str) -> Result<()> {
1411 let headers = self.auth_headers()?;
1412 let url = self.platform_url(&format!("/issue/{issue_key}/worklog/{worklog_id}"));
1413
1414 let http = &self.http;
1415 self.request_no_body(|| http.delete(&url).headers(headers.clone()))
1416 .await
1417 }
1418
1419 pub async fn delete_comment(&self, issue_key: &str, comment_id: &str) -> Result<()> {
1421 let headers = self.auth_headers()?;
1422 let url = self.platform_url(&format!("/issue/{issue_key}/comment/{comment_id}"));
1423
1424 let http = &self.http;
1425 self.request_no_body(|| http.delete(&url).headers(headers.clone()))
1426 .await
1427 }
1428
1429 pub async fn list_attachments(&self, issue_key: &str) -> Result<Vec<Attachment>> {
1431 let headers = self.auth_headers()?;
1432 let url = self.platform_url(&format!("/issue/{issue_key}?fields=attachment"));
1433
1434 let http = &self.http;
1435 let raw: Value = self
1436 .request(|| http.get(&url).headers(headers.clone()))
1437 .await?;
1438
1439 let arr = raw
1440 .get("fields")
1441 .and_then(|f| f.get("attachment"))
1442 .and_then(|a| a.as_array())
1443 .cloned()
1444 .unwrap_or_default();
1445
1446 Ok(arr
1447 .iter()
1448 .filter_map(crate::model::attachment::Attachment::from_value)
1449 .collect())
1450 }
1451
1452 pub async fn download_attachment(
1458 &self,
1459 attachment_id: &str,
1460 ) -> Result<(String, Vec<u8>, String)> {
1461 let headers = self.auth_headers_no_content_type()?;
1462 let url = self.platform_url(&format!("/attachment/content/{attachment_id}"));
1463
1464 let http = &self.http;
1465 let response = self
1466 .execute_with_retry(|| http.get(&url).headers(headers.clone()))
1467 .await?;
1468
1469 let status = response.status();
1470 if !status.is_success() {
1471 let body = response
1472 .text()
1473 .await
1474 .unwrap_or_else(|e| format!("<failed to read response body: {e}>"));
1475 if status == StatusCode::NOT_FOUND {
1476 return Err(JiraError::NotFound(body));
1477 }
1478 return Err(JiraError::Api {
1479 status: status.as_u16(),
1480 message: body,
1481 });
1482 }
1483
1484 let mime_type = response
1485 .headers()
1486 .get(reqwest::header::CONTENT_TYPE)
1487 .and_then(|v| v.to_str().ok())
1488 .unwrap_or("application/octet-stream")
1489 .to_string();
1490
1491 let filename = response
1492 .headers()
1493 .get(reqwest::header::CONTENT_DISPOSITION)
1494 .and_then(|v| v.to_str().ok())
1495 .and_then(parse_content_disposition_filename)
1496 .or_else(|| {
1497 response
1498 .url()
1499 .path_segments()
1500 .and_then(|mut s| s.next_back().map(|s| s.to_string()))
1501 .filter(|s| !s.is_empty())
1502 })
1503 .unwrap_or_else(|| format!("attachment-{attachment_id}"));
1504
1505 let bytes = response.bytes().await?.to_vec();
1506 Ok((filename, bytes, mime_type))
1507 }
1508
1509 pub async fn delete_attachment(&self, attachment_id: &str) -> Result<()> {
1511 let headers = self.auth_headers()?;
1512 let url = self.platform_url(&format!("/attachment/{attachment_id}"));
1513
1514 let http = &self.http;
1515 self.request_no_body(|| http.delete(&url).headers(headers.clone()))
1516 .await
1517 }
1518
1519 pub async fn get_remote_links(&self, issue_key: &str) -> Result<Vec<RemoteLink>> {
1521 let headers = self.auth_headers()?;
1522 let url = self.platform_url(&format!("/issue/{issue_key}/remotelink"));
1523
1524 let http = &self.http;
1525 self.request(|| http.get(&url).headers(headers.clone()))
1526 .await
1527 }
1528
1529 pub async fn add_remote_link(
1531 &self,
1532 issue_key: &str,
1533 url_str: &str,
1534 title: &str,
1535 ) -> Result<Value> {
1536 let headers = self.auth_headers()?;
1537 let url = self.platform_url(&format!("/issue/{issue_key}/remotelink"));
1538
1539 let payload = json!({
1540 "object": {
1541 "url": url_str,
1542 "title": title,
1543 }
1544 });
1545
1546 let http = &self.http;
1547 self.request(|| http.post(&url).headers(headers.clone()).json(&payload))
1548 .await
1549 }
1550
1551 pub async fn delete_remote_link(&self, issue_key: &str, link_id: &str) -> Result<()> {
1553 let headers = self.auth_headers()?;
1554 let url = self.platform_url(&format!("/issue/{issue_key}/remotelink/{link_id}"));
1555
1556 let http = &self.http;
1557 self.request_no_body(|| http.delete(&url).headers(headers.clone()))
1558 .await
1559 }
1560
1561 pub async fn get_all_issues(&self, jql: &str) -> Result<Vec<Issue>> {
1566 let mut all_issues = Vec::new();
1567 let mut next_page_token: Option<String> = None;
1568 let mut iterations = 0u32;
1569 const MAX_ITERATIONS: u32 = 500;
1570
1571 loop {
1572 iterations += 1;
1573 if iterations > MAX_ITERATIONS {
1574 break;
1575 }
1576
1577 let result = self
1578 .search_issues(jql, next_page_token.as_deref(), Some(100))
1579 .await?;
1580
1581 all_issues.extend(result.issues);
1582
1583 match result.next_page_token {
1584 Some(token) => next_page_token = Some(token),
1585 None => break,
1586 }
1587 }
1588
1589 Ok(all_issues)
1590 }
1591
1592 pub async fn archive_issues(&self, issue_keys: &[String]) -> Result<()> {
1594 if issue_keys.is_empty() {
1595 return Ok(());
1596 }
1597 let headers = self.auth_headers()?;
1598 let url = self.platform_url("/issue/archive");
1599
1600 for chunk in issue_keys.chunks(1000) {
1602 let body = json!({ "issueIdsOrKeys": chunk });
1603 let http = &self.http;
1604 let _: Value = self
1606 .request(|| http.put(&url).headers(headers.clone()).json(&body))
1607 .await?;
1608 }
1609
1610 Ok(())
1611 }
1612
1613 pub async fn move_issue(
1615 &self,
1616 issue_key: &str,
1617 target_project_key: &str,
1618 target_issue_type_id: &str,
1619 target_parent: Option<&str>,
1620 ) -> Result<Issue> {
1621 let mapping_key = match target_parent {
1622 Some(parent) => format!("{target_project_key},{target_issue_type_id},{parent}"),
1623 None => format!("{target_project_key},{target_issue_type_id}"),
1624 };
1625
1626 let body = json!({
1627 "sendBulkNotification": true,
1628 "targetToSourcesMapping": {
1629 mapping_key: {
1630 "inferClassificationDefaults": true,
1631 "inferFieldDefaults": true,
1632 "inferStatusDefaults": true,
1633 "inferSubtaskTypeDefault": true,
1634 "issueIdsOrKeys": [issue_key]
1635 }
1636 }
1637 });
1638
1639 let bulk_move_path = self.platform_path("/bulk/issues/move");
1640 let submitted = self
1641 .raw_request("POST", &bulk_move_path, Some(body))
1642 .await?
1643 .ok_or_else(|| JiraError::Api {
1644 status: 0,
1645 message: "Bulk move returned an empty response".into(),
1646 })?;
1647
1648 let task_id = submitted
1649 .get("taskId")
1650 .and_then(|v| v.as_str())
1651 .ok_or_else(|| JiraError::Api {
1652 status: 0,
1653 message: "Bulk move response did not include a taskId".into(),
1654 })?;
1655
1656 const MAX_POLLS: usize = 60;
1657 for _ in 0..MAX_POLLS {
1658 tokio::time::sleep(Duration::from_secs(2)).await;
1659
1660 let progress_path = self.platform_path(&format!("/bulk/queue/{task_id}"));
1661 let progress = self
1662 .raw_request("GET", &progress_path, None)
1663 .await?
1664 .ok_or_else(|| JiraError::Api {
1665 status: 0,
1666 message: "Bulk move progress returned an empty response".into(),
1667 })?;
1668
1669 match progress
1670 .get("status")
1671 .and_then(|v| v.as_str())
1672 .unwrap_or("")
1673 {
1674 "COMPLETE" => return self.get_issue(issue_key).await,
1675 "FAILED" | "DEAD" | "CANCELLED" => {
1676 return Err(JiraError::Api {
1677 status: 0,
1678 message: progress.to_string(),
1679 });
1680 }
1681 _ => {}
1682 }
1683 }
1684
1685 Err(JiraError::Api {
1686 status: 0,
1687 message: format!("Timed out waiting for Jira bulk move task {task_id}"),
1688 })
1689 }
1690
1691 pub async fn raw_request(
1697 &self,
1698 method: &str,
1699 path: &str,
1700 body: Option<Value>,
1701 ) -> Result<Option<Value>> {
1702 let headers = self.auth_headers()?;
1703 let url = format!("{}{}", self.config.base_url.trim_end_matches('/'), path);
1704
1705 let http = &self.http;
1706 let response = self
1707 .execute_with_retry(|| {
1708 let req = match method.to_uppercase().as_str() {
1709 "GET" => http.get(&url),
1710 "POST" => http.post(&url),
1711 "PUT" => http.put(&url),
1712 "DELETE" => http.delete(&url),
1713 "PATCH" => http.patch(&url),
1714 _ => http.get(&url),
1715 };
1716 let req = req.headers(headers.clone());
1717 if let Some(b) = &body {
1718 req.json(b)
1719 } else {
1720 req
1721 }
1722 })
1723 .await?;
1724
1725 let status = response.status();
1726
1727 if status == StatusCode::NO_CONTENT {
1729 return Ok(None);
1730 }
1731
1732 if status.is_success() {
1733 let value: Value = response.json().await?;
1734 return Ok(Some(value));
1735 }
1736
1737 let body_text = response.text().await.unwrap_or_default();
1738 Err(match status {
1739 StatusCode::NOT_FOUND => JiraError::NotFound(body_text),
1740 StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => {
1741 JiraError::Auth(format!("HTTP {status}: {body_text}"))
1742 }
1743 _ => JiraError::Api {
1744 status: status.as_u16(),
1745 message: body_text,
1746 },
1747 })
1748 }
1749
1750 pub async fn is_premium(&self) -> bool {
1754 match self.get_server_info().await {
1755 Ok(info) => {
1756 let license = info
1757 .get("deploymentType")
1758 .and_then(|v| v.as_str())
1759 .unwrap_or("");
1760 let _ = license;
1762 let headers = match self.auth_headers() {
1764 Ok(h) => h,
1765 Err(_) => return false,
1766 };
1767 let url = self.platform_url("/plans/plan");
1768 let http = &self.http;
1769 matches!(
1770 http.get(&url).headers(headers).send().await,
1771 Ok(r) if r.status().is_success()
1772 )
1773 }
1774 Err(_) => false,
1775 }
1776 }
1777
1778 pub async fn get_plans(&self) -> Result<Vec<Value>> {
1780 let headers = self.auth_headers()?;
1781 let url = self.platform_url("/plans/plan");
1782
1783 #[derive(serde::Deserialize)]
1784 struct PlansResponse {
1785 values: Vec<Value>,
1786 }
1787
1788 let http = &self.http;
1789 let resp: PlansResponse = self
1790 .request(|| http.get(&url).headers(headers.clone()))
1791 .await?;
1792
1793 Ok(resp.values)
1794 }
1795
1796 pub async fn list_issue_link_types(&self) -> Result<Vec<IssueLinkType>> {
1800 let headers = self.auth_headers()?;
1801 let url = self.platform_url("/issueLinkType");
1802
1803 #[derive(serde::Deserialize)]
1804 struct LinkTypesResponse {
1805 #[serde(rename = "issueLinkTypes")]
1806 issue_link_types: Vec<IssueLinkType>,
1807 }
1808
1809 let http = &self.http;
1810 let resp: LinkTypesResponse = self
1811 .request(|| http.get(&url).headers(headers.clone()))
1812 .await?;
1813
1814 Ok(resp.issue_link_types)
1815 }
1816
1817 pub async fn link_issues(
1822 &self,
1823 outward_key: &str,
1824 inward_key: &str,
1825 type_name: &str,
1826 comment: Option<&str>,
1827 ) -> Result<()> {
1828 let headers = self.auth_headers()?;
1829 let url = self.platform_url("/issueLink");
1830
1831 let mut payload = json!({
1832 "type": { "name": type_name },
1833 "inwardIssue": { "key": inward_key },
1834 "outwardIssue": { "key": outward_key }
1835 });
1836
1837 if let Some(c) = comment {
1838 payload["comment"] = json!({
1839 "body": crate::adf::markdown_to_adf(c)
1840 });
1841 }
1842
1843 let http = &self.http;
1844 self.request_no_body(|| http.post(&url).headers(headers.clone()).json(&payload))
1845 .await
1846 }
1847
1848 pub async fn get_issue_link(&self, link_id: &str) -> Result<IssueLink> {
1850 let headers = self.auth_headers()?;
1851 let url = self.platform_url(&format!("/issueLink/{link_id}"));
1852
1853 let http = &self.http;
1854 self.request(|| http.get(&url).headers(headers.clone()))
1855 .await
1856 }
1857
1858 pub async fn delete_issue_link(&self, link_id: &str) -> Result<()> {
1860 let headers = self.auth_headers()?;
1861 let url = self.platform_url(&format!("/issueLink/{link_id}"));
1862
1863 let http = &self.http;
1864 self.request_no_body(|| http.delete(&url).headers(headers.clone()))
1865 .await
1866 }
1867}
1868
1869async fn handle_response<T>(response: Response) -> Result<T>
1870where
1871 T: serde::de::DeserializeOwned,
1872{
1873 let status = response.status();
1874
1875 if status.is_success() {
1876 if status == StatusCode::NO_CONTENT || status == StatusCode::RESET_CONTENT {
1879 return serde_json::from_value(serde_json::Value::Null).map_err(|_| JiraError::Api {
1880 status: status.as_u16(),
1881 message: "Unexpected empty response body".into(),
1882 });
1883 }
1884 let value: T = response.json().await?;
1885 return Ok(value);
1886 }
1887
1888 let body = response.text().await.unwrap_or_default();
1889
1890 match status {
1891 StatusCode::NOT_FOUND => Err(JiraError::NotFound(body)),
1892 StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => {
1893 Err(JiraError::Auth(format!("HTTP {status}: {body}")))
1894 }
1895 _ => Err(JiraError::Api {
1896 status: status.as_u16(),
1897 message: body,
1898 }),
1899 }
1900}
1901
1902fn parse_content_disposition_filename(value: &str) -> Option<String> {
1904 for part in value.split(';') {
1905 let part = part.trim();
1906 if let Some(rest) = part.strip_prefix("filename*=") {
1907 let payload = rest.splitn(3, '\'').nth(2)?;
1909 return Some(percent_decode(payload));
1910 }
1911 if let Some(rest) = part.strip_prefix("filename=") {
1912 let trimmed = rest.trim_matches('"').to_string();
1913 if !trimmed.is_empty() {
1914 return Some(trimmed);
1915 }
1916 }
1917 }
1918 None
1919}
1920
1921fn percent_decode(s: &str) -> String {
1922 let bytes = s.as_bytes();
1923 let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
1924 let mut i = 0;
1925 while i < bytes.len() {
1926 if bytes[i] == b'%' && i + 2 < bytes.len() {
1927 if let (Some(h), Some(l)) = (
1928 (bytes[i + 1] as char).to_digit(16),
1929 (bytes[i + 2] as char).to_digit(16),
1930 ) {
1931 out.push(((h << 4) | l) as u8);
1932 i += 3;
1933 continue;
1934 }
1935 }
1936 out.push(bytes[i]);
1937 i += 1;
1938 }
1939 String::from_utf8(out).unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned())
1940}
1941
1942fn url_encode_component(s: &str) -> String {
1944 let mut out = String::with_capacity(s.len());
1945 for b in s.as_bytes() {
1946 match *b {
1947 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1948 out.push(*b as char);
1949 }
1950 _ => out.push_str(&format!("%{:02X}", b)),
1951 }
1952 }
1953 out
1954}
1955
1956fn current_jira_timestamp() -> String {
1958 chrono::Utc::now()
1959 .format("%Y-%m-%dT%H:%M:%S%.3f%z")
1960 .to_string()
1961}
1962
1963fn base64_encode(input: &str) -> String {
1964 use std::fmt::Write;
1965 const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
1966 let bytes = input.as_bytes();
1967 let mut result = String::new();
1968 let mut i = 0;
1969 while i < bytes.len() {
1970 let b0 = bytes[i] as u32;
1971 let b1 = if i + 1 < bytes.len() {
1972 bytes[i + 1] as u32
1973 } else {
1974 0
1975 };
1976 let b2 = if i + 2 < bytes.len() {
1977 bytes[i + 2] as u32
1978 } else {
1979 0
1980 };
1981
1982 let _ = write!(result, "{}", CHARS[((b0 >> 2) & 0x3F) as usize] as char);
1983 let _ = write!(
1984 result,
1985 "{}",
1986 CHARS[(((b0 & 0x3) << 4) | ((b1 >> 4) & 0xF)) as usize] as char
1987 );
1988 if i + 1 < bytes.len() {
1989 let _ = write!(
1990 result,
1991 "{}",
1992 CHARS[(((b1 & 0xF) << 2) | ((b2 >> 6) & 0x3)) as usize] as char
1993 );
1994 } else {
1995 result.push('=');
1996 }
1997 if i + 2 < bytes.len() {
1998 let _ = write!(result, "{}", CHARS[(b2 & 0x3F) as usize] as char);
1999 } else {
2000 result.push('=');
2001 }
2002 i += 3;
2003 }
2004 result
2005}
2006
2007#[cfg(test)]
2008mod tests {
2009 use super::*;
2010 use crate::{
2011 config::{JiraAuthType, JiraDeployment},
2012 model::FieldValue,
2013 };
2014 use wiremock::{
2015 matchers::{body_json, header, method, path, query_param},
2016 Mock, MockServer, ResponseTemplate,
2017 };
2018
2019 fn cloud_client(server: &MockServer) -> JiraClient {
2020 JiraClient::new(JiraConfig {
2021 profile_name: Some("cloud-main".into()),
2022 base_url: server.uri(),
2023 email: "dev@example.com".into(),
2024 token: Some("cloud-token".into()),
2025 project: None,
2026 timeout_secs: 30,
2027 deployment: JiraDeployment::Cloud,
2028 auth_type: JiraAuthType::CloudApiToken,
2029 api_version: 3,
2030 })
2031 }
2032
2033 fn cloud_auth() -> String {
2034 format!("Basic {}", base64_encode("dev@example.com:cloud-token"))
2035 }
2036
2037 #[tokio::test]
2038 async fn data_center_pat_uses_bearer_and_api_v2() {
2039 let server = MockServer::start().await;
2040
2041 Mock::given(method("GET"))
2042 .and(path("/rest/api/2/serverInfo"))
2043 .and(header("authorization", "Bearer dc-token"))
2044 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2045 "deploymentType": "Data Center",
2046 "version": "10.0.0"
2047 })))
2048 .mount(&server)
2049 .await;
2050
2051 let client = JiraClient::new(JiraConfig {
2052 profile_name: Some("dc-main".into()),
2053 base_url: server.uri(),
2054 email: String::new(),
2055 token: Some("dc-token".into()),
2056 project: None,
2057 timeout_secs: 30,
2058 deployment: JiraDeployment::DataCenter,
2059 auth_type: JiraAuthType::DataCenterPat,
2060 api_version: 2,
2061 });
2062
2063 let info = client.get_server_info().await.expect("server info");
2064 assert_eq!(info["deploymentType"], Value::String("Data Center".into()));
2065 }
2066
2067 #[tokio::test]
2068 async fn cloud_auth_uses_basic_and_api_v3() {
2069 let server = MockServer::start().await;
2070 let expected = format!("Basic {}", base64_encode("dev@example.com:cloud-token"));
2071
2072 Mock::given(method("GET"))
2073 .and(path("/rest/api/3/serverInfo"))
2074 .and(header("authorization", expected.as_str()))
2075 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2076 "deploymentType": "Cloud",
2077 "version": "1001.0.0"
2078 })))
2079 .mount(&server)
2080 .await;
2081
2082 let client = JiraClient::new(JiraConfig {
2083 profile_name: Some("cloud-main".into()),
2084 base_url: server.uri(),
2085 email: "dev@example.com".into(),
2086 token: Some("cloud-token".into()),
2087 project: None,
2088 timeout_secs: 30,
2089 deployment: JiraDeployment::Cloud,
2090 auth_type: JiraAuthType::CloudApiToken,
2091 api_version: 3,
2092 });
2093
2094 let info = client.get_server_info().await.expect("server info");
2095 assert_eq!(info["deploymentType"], Value::String("Cloud".into()));
2096 }
2097
2098 #[tokio::test]
2099 async fn get_fields_for_issue_type_supports_map_response() {
2100 let server = MockServer::start().await;
2101 let expected = format!("Basic {}", base64_encode("dev@example.com:cloud-token"));
2102
2103 Mock::given(method("GET"))
2104 .and(path("/rest/api/3/issue/createmeta/TEST/issuetypes/10001"))
2105 .and(header("authorization", expected.as_str()))
2106 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2107 "fields": {
2108 "summary": {
2109 "name": "Summary",
2110 "required": true,
2111 "schema": { "type": "string" }
2112 }
2113 }
2114 })))
2115 .mount(&server)
2116 .await;
2117
2118 let client = JiraClient::new(JiraConfig {
2119 profile_name: Some("cloud-main".into()),
2120 base_url: server.uri(),
2121 email: "dev@example.com".into(),
2122 token: Some("cloud-token".into()),
2123 project: None,
2124 timeout_secs: 30,
2125 deployment: JiraDeployment::Cloud,
2126 auth_type: JiraAuthType::CloudApiToken,
2127 api_version: 3,
2128 });
2129
2130 let fields = client
2131 .get_fields_for_issue_type("TEST", "10001")
2132 .await
2133 .expect("map response should parse");
2134
2135 assert_eq!(fields.len(), 1);
2136 assert_eq!(fields[0].id, "summary");
2137 assert_eq!(fields[0].name, "Summary");
2138 assert!(fields[0].required);
2139 assert_eq!(fields[0].field_type, "string");
2140 }
2141
2142 #[tokio::test]
2143 async fn get_fields_for_issue_type_supports_list_response() {
2144 let server = MockServer::start().await;
2145 let expected = format!("Basic {}", base64_encode("dev@example.com:cloud-token"));
2146
2147 Mock::given(method("GET"))
2148 .and(path("/rest/api/3/issue/createmeta/TEST/issuetypes/10002"))
2149 .and(header("authorization", expected.as_str()))
2150 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2151 "fields": [
2152 {
2153 "fieldId": "customfield_10553",
2154 "key": "customfield_10553",
2155 "name": "Labels (OSS)",
2156 "required": true,
2157 "schema": {
2158 "custom": "com.atlassian.jira.plugin.system.customfieldtypes:labels",
2159 "items": "string",
2160 "type": "array"
2161 },
2162 "allowedValues": []
2163 }
2164 ]
2165 })))
2166 .mount(&server)
2167 .await;
2168
2169 let client = JiraClient::new(JiraConfig {
2170 profile_name: Some("cloud-main".into()),
2171 base_url: server.uri(),
2172 email: "dev@example.com".into(),
2173 token: Some("cloud-token".into()),
2174 project: None,
2175 timeout_secs: 30,
2176 deployment: JiraDeployment::Cloud,
2177 auth_type: JiraAuthType::CloudApiToken,
2178 api_version: 3,
2179 });
2180
2181 let fields = client
2182 .get_fields_for_issue_type("TEST", "10002")
2183 .await
2184 .expect("list response should parse");
2185
2186 assert_eq!(fields.len(), 1);
2187 assert_eq!(fields[0].id, "customfield_10553");
2188 assert_eq!(fields[0].name, "Labels (OSS)");
2189 assert!(fields[0].required);
2190 assert_eq!(fields[0].field_type, "array");
2191 }
2192
2193 #[tokio::test]
2194 async fn search_users_supports_empty_query_and_no_results() {
2195 let server = MockServer::start().await;
2196 let expected_auth = cloud_auth();
2197
2198 Mock::given(method("GET"))
2199 .and(path("/rest/api/3/user/search"))
2200 .and(header("authorization", expected_auth.as_str()))
2201 .and(query_param("query", ""))
2202 .and(query_param("maxResults", "20"))
2203 .respond_with(ResponseTemplate::new(200).set_body_json(json!([])))
2204 .mount(&server)
2205 .await;
2206
2207 let client = cloud_client(&server);
2208 let users = client
2209 .search_users("")
2210 .await
2211 .expect("empty query should work");
2212
2213 assert!(users.is_empty());
2214 }
2215
2216 #[tokio::test]
2217 async fn search_users_preserves_users_without_email() {
2218 let server = MockServer::start().await;
2219 let expected_auth = cloud_auth();
2220
2221 Mock::given(method("GET"))
2222 .and(path("/rest/api/3/user/search"))
2223 .and(header("authorization", expected_auth.as_str()))
2224 .and(query_param("query", "alice"))
2225 .and(query_param("maxResults", "20"))
2226 .respond_with(ResponseTemplate::new(200).set_body_json(json!([
2227 {
2228 "accountId": "acct-1",
2229 "displayName": "Alice Example"
2230 }
2231 ])))
2232 .mount(&server)
2233 .await;
2234
2235 let client = cloud_client(&server);
2236 let users = client
2237 .search_users("alice")
2238 .await
2239 .expect("search should parse");
2240
2241 assert_eq!(users.len(), 1);
2242 assert_eq!(users[0].account_id, "acct-1");
2243 assert_eq!(users[0].display_name.as_deref(), Some("Alice Example"));
2244 assert!(users[0].email_address.is_none());
2245 }
2246
2247 #[tokio::test]
2248 async fn add_issue_to_sprint_posts_expected_payload() {
2249 let server = MockServer::start().await;
2250 let expected_auth = cloud_auth();
2251
2252 Mock::given(method("POST"))
2253 .and(path("/rest/agile/1.0/sprint/42/issue"))
2254 .and(header("authorization", expected_auth.as_str()))
2255 .and(body_json(json!({ "issues": ["TEST-123"] })))
2256 .respond_with(ResponseTemplate::new(200).set_body_json(json!({})))
2257 .mount(&server)
2258 .await;
2259
2260 let client = cloud_client(&server);
2261 client
2262 .add_issue_to_sprint(42, "TEST-123")
2263 .await
2264 .expect("add to sprint should succeed");
2265 }
2266
2267 #[tokio::test]
2268 async fn add_issue_to_sprint_propagates_non_success_response() {
2269 let server = MockServer::start().await;
2270 let expected_auth = cloud_auth();
2271
2272 Mock::given(method("POST"))
2273 .and(path("/rest/agile/1.0/sprint/42/issue"))
2274 .and(header("authorization", expected_auth.as_str()))
2275 .respond_with(ResponseTemplate::new(400).set_body_string("bad sprint request"))
2276 .mount(&server)
2277 .await;
2278
2279 let client = cloud_client(&server);
2280 let err = client
2281 .add_issue_to_sprint(42, "TEST-123")
2282 .await
2283 .expect_err("non-success should return error");
2284
2285 match err {
2286 JiraError::Api { status, message } => {
2287 assert_eq!(status, 400);
2288 assert!(message.contains("bad sprint request"));
2289 }
2290 other => panic!("expected JiraError::Api, got {other:?}"),
2291 }
2292 }
2293
2294 #[tokio::test]
2295 async fn list_sprints_for_project_returns_empty_when_no_boards_exist() {
2296 let server = MockServer::start().await;
2297 let expected_auth = cloud_auth();
2298
2299 Mock::given(method("GET"))
2300 .and(path("/rest/agile/1.0/board"))
2301 .and(header("authorization", expected_auth.as_str()))
2302 .and(query_param("projectKeyOrId", "TEST"))
2303 .and(query_param("maxResults", "100"))
2304 .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "values": [] })))
2305 .mount(&server)
2306 .await;
2307
2308 let client = cloud_client(&server);
2309 let sprints = client
2310 .list_sprints_for_project("TEST")
2311 .await
2312 .expect("no boards should not error");
2313
2314 assert!(sprints.is_empty());
2315 }
2316
2317 #[tokio::test]
2318 async fn list_sprints_for_project_dedups_sorts_and_skips_missing_board_sprints() {
2319 let server = MockServer::start().await;
2320 let expected_auth = cloud_auth();
2321
2322 Mock::given(method("GET"))
2323 .and(path("/rest/agile/1.0/board"))
2324 .and(header("authorization", expected_auth.as_str()))
2325 .and(query_param("projectKeyOrId", "TEST"))
2326 .and(query_param("maxResults", "100"))
2327 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2328 "values": [
2329 { "id": 1 },
2330 { "id": 2 },
2331 { "id": 3 }
2332 ]
2333 })))
2334 .mount(&server)
2335 .await;
2336
2337 Mock::given(method("GET"))
2338 .and(path("/rest/agile/1.0/board/1/sprint"))
2339 .and(header("authorization", expected_auth.as_str()))
2340 .and(query_param("state", "active,future"))
2341 .and(query_param("maxResults", "200"))
2342 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2343 "values": [
2344 { "id": 20, "name": "Future Sprint", "state": "future" },
2345 { "id": 10, "name": "Active Sprint", "state": "active" }
2346 ]
2347 })))
2348 .mount(&server)
2349 .await;
2350
2351 Mock::given(method("GET"))
2352 .and(path("/rest/agile/1.0/board/2/sprint"))
2353 .and(header("authorization", expected_auth.as_str()))
2354 .and(query_param("state", "active,future"))
2355 .and(query_param("maxResults", "200"))
2356 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2357 "values": [
2358 { "id": 10, "name": "Active Sprint", "state": "active" },
2359 { "id": 30, "name": "Alpha Future", "state": "future" }
2360 ]
2361 })))
2362 .mount(&server)
2363 .await;
2364
2365 Mock::given(method("GET"))
2366 .and(path("/rest/agile/1.0/board/3/sprint"))
2367 .and(header("authorization", expected_auth.as_str()))
2368 .and(query_param("state", "active,future"))
2369 .and(query_param("maxResults", "200"))
2370 .respond_with(ResponseTemplate::new(404).set_body_string("board not found"))
2371 .mount(&server)
2372 .await;
2373
2374 let client = cloud_client(&server);
2375 let sprints = client
2376 .list_sprints_for_project("TEST")
2377 .await
2378 .expect("404 on one board should be skipped");
2379
2380 assert_eq!(sprints.len(), 3);
2381 assert_eq!(sprints[0].id, 10);
2382 assert_eq!(sprints[0].state, "active");
2383 assert_eq!(sprints[1].id, 30);
2384 assert_eq!(sprints[1].state, "future");
2385 assert_eq!(sprints[2].id, 20);
2386 assert_eq!(sprints[2].state, "future");
2387 }
2388
2389 #[tokio::test]
2390 async fn list_sprints_for_project_skips_kanban_boards() {
2391 let server = MockServer::start().await;
2392 let expected_auth = cloud_auth();
2393
2394 Mock::given(method("GET"))
2395 .and(path("/rest/agile/1.0/board"))
2396 .and(header("authorization", expected_auth.as_str()))
2397 .and(query_param("projectKeyOrId", "TEST"))
2398 .and(query_param("maxResults", "100"))
2399 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2400 "values": [
2401 { "id": 1, "type": "scrum" },
2402 { "id": 2, "type": "kanban" }
2403 ]
2404 })))
2405 .mount(&server)
2406 .await;
2407
2408 Mock::given(method("GET"))
2409 .and(path("/rest/agile/1.0/board/1/sprint"))
2410 .and(header("authorization", expected_auth.as_str()))
2411 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2412 "values": [
2413 { "id": 10, "name": "Active Sprint", "state": "active" }
2414 ]
2415 })))
2416 .mount(&server)
2417 .await;
2418
2419 Mock::given(method("GET"))
2422 .and(path("/rest/agile/1.0/board/2/sprint"))
2423 .and(header("authorization", expected_auth.as_str()))
2424 .respond_with(ResponseTemplate::new(500).set_body_string("kanban has no sprints"))
2425 .mount(&server)
2426 .await;
2427
2428 let client = cloud_client(&server);
2429 let sprints = client
2430 .list_sprints_for_project("TEST")
2431 .await
2432 .expect("kanban board should be skipped, not error");
2433
2434 assert_eq!(sprints.len(), 1);
2435 assert_eq!(sprints[0].id, 10);
2436 }
2437
2438 #[tokio::test]
2439 async fn get_project_fields_aggregates_across_issue_types() {
2440 let server = MockServer::start().await;
2441 let expected_auth = cloud_auth();
2442
2443 Mock::given(method("GET"))
2444 .and(path("/rest/api/3/issue/createmeta/TEST/issuetypes"))
2445 .and(header("authorization", expected_auth.as_str()))
2446 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2447 "issueTypes": [
2448 { "id": "10001", "name": "Story" },
2449 { "id": "10002", "name": "Task" }
2450 ]
2451 })))
2452 .mount(&server)
2453 .await;
2454
2455 Mock::given(method("GET"))
2456 .and(path("/rest/api/3/issue/createmeta/TEST/issuetypes/10001"))
2457 .and(header("authorization", expected_auth.as_str()))
2458 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2459 "fields": {
2460 "summary": { "name": "Summary", "required": true, "schema": { "type": "string" } },
2461 "customfield_1": { "name": "Story Points", "required": false, "schema": { "type": "number" } }
2462 }
2463 })))
2464 .mount(&server)
2465 .await;
2466
2467 Mock::given(method("GET"))
2468 .and(path("/rest/api/3/issue/createmeta/TEST/issuetypes/10002"))
2469 .and(header("authorization", expected_auth.as_str()))
2470 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2471 "fields": {
2472 "summary": { "name": "Summary", "required": true, "schema": { "type": "string" } },
2473 "customfield_2": { "name": "Component", "required": false, "schema": { "type": "string" } }
2474 }
2475 })))
2476 .mount(&server)
2477 .await;
2478
2479 let client = cloud_client(&server);
2480 let fields = client
2481 .get_project_fields("TEST")
2482 .await
2483 .expect("project fields should resolve per issue type");
2484
2485 let mut ids: Vec<&str> = fields.iter().map(|f| f.id.as_str()).collect();
2486 ids.sort_unstable();
2487 assert_eq!(ids, vec!["customfield_1", "customfield_2", "summary"]);
2488 }
2489
2490 #[tokio::test]
2491 async fn list_sprints_for_project_handles_large_sprint_pages() {
2492 let server = MockServer::start().await;
2493 let expected_auth = cloud_auth();
2494
2495 let sprint_values: Vec<Value> = (1..=60)
2496 .map(|id| {
2497 json!({
2498 "id": id,
2499 "name": format!("Sprint {id:02}"),
2500 "state": if id == 1 { "active" } else { "future" }
2501 })
2502 })
2503 .collect();
2504
2505 Mock::given(method("GET"))
2506 .and(path("/rest/agile/1.0/board"))
2507 .and(header("authorization", expected_auth.as_str()))
2508 .and(query_param("projectKeyOrId", "TEST"))
2509 .and(query_param("maxResults", "100"))
2510 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2511 "values": [{ "id": 9 }]
2512 })))
2513 .mount(&server)
2514 .await;
2515
2516 Mock::given(method("GET"))
2517 .and(path("/rest/agile/1.0/board/9/sprint"))
2518 .and(header("authorization", expected_auth.as_str()))
2519 .and(query_param("state", "active,future"))
2520 .and(query_param("maxResults", "200"))
2521 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2522 "values": sprint_values
2523 })))
2524 .mount(&server)
2525 .await;
2526
2527 let client = cloud_client(&server);
2528 let sprints = client
2529 .list_sprints_for_project("TEST")
2530 .await
2531 .expect(">50 sprints in one response should parse");
2532
2533 assert_eq!(sprints.len(), 60);
2534 assert_eq!(sprints[0].id, 1);
2535 assert_eq!(sprints[0].state, "active");
2536 assert_eq!(sprints[59].id, 60);
2537 }
2538
2539 #[tokio::test]
2540 async fn list_sprints_for_project_paginates_boards_and_sprints() {
2541 let server = MockServer::start().await;
2542 let expected_auth = cloud_auth();
2543
2544 Mock::given(method("GET"))
2545 .and(path("/rest/agile/1.0/board"))
2546 .and(header("authorization", expected_auth.as_str()))
2547 .and(query_param("projectKeyOrId", "TEST"))
2548 .and(query_param("maxResults", "100"))
2549 .and(query_param("startAt", "0"))
2550 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2551 "values": [{ "id": 1 }],
2552 "isLast": false
2553 })))
2554 .mount(&server)
2555 .await;
2556
2557 Mock::given(method("GET"))
2558 .and(path("/rest/agile/1.0/board"))
2559 .and(header("authorization", expected_auth.as_str()))
2560 .and(query_param("projectKeyOrId", "TEST"))
2561 .and(query_param("maxResults", "100"))
2562 .and(query_param("startAt", "1"))
2563 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2564 "values": [{ "id": 2 }],
2565 "isLast": true
2566 })))
2567 .mount(&server)
2568 .await;
2569
2570 Mock::given(method("GET"))
2571 .and(path("/rest/agile/1.0/board/1/sprint"))
2572 .and(header("authorization", expected_auth.as_str()))
2573 .and(query_param("state", "active,future"))
2574 .and(query_param("maxResults", "200"))
2575 .and(query_param("startAt", "0"))
2576 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2577 "values": [{
2578 "id": 10,
2579 "name": "Sprint 10",
2580 "state": "active"
2581 }],
2582 "isLast": false
2583 })))
2584 .mount(&server)
2585 .await;
2586
2587 Mock::given(method("GET"))
2588 .and(path("/rest/agile/1.0/board/1/sprint"))
2589 .and(header("authorization", expected_auth.as_str()))
2590 .and(query_param("state", "active,future"))
2591 .and(query_param("maxResults", "200"))
2592 .and(query_param("startAt", "1"))
2593 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2594 "values": [{
2595 "id": 11,
2596 "name": "Sprint 11",
2597 "state": "future"
2598 }],
2599 "isLast": true
2600 })))
2601 .mount(&server)
2602 .await;
2603
2604 Mock::given(method("GET"))
2605 .and(path("/rest/agile/1.0/board/2/sprint"))
2606 .and(header("authorization", expected_auth.as_str()))
2607 .and(query_param("state", "active,future"))
2608 .and(query_param("maxResults", "200"))
2609 .and(query_param("startAt", "0"))
2610 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2611 "values": [{
2612 "id": 12,
2613 "name": "Sprint 12",
2614 "state": "future"
2615 }],
2616 "isLast": true
2617 })))
2618 .mount(&server)
2619 .await;
2620
2621 let client = cloud_client(&server);
2622 let sprints = client
2623 .list_sprints_for_project("TEST")
2624 .await
2625 .expect("pagination should collect all pages");
2626
2627 assert_eq!(sprints.len(), 3);
2628 assert_eq!(sprints[0].id, 10);
2629 assert_eq!(sprints[1].id, 11);
2630 assert_eq!(sprints[2].id, 12);
2631 }
2632
2633 #[tokio::test]
2634 async fn list_sprints_for_project_propagates_non_not_found_board_errors() {
2635 let server = MockServer::start().await;
2636 let expected_auth = cloud_auth();
2637
2638 Mock::given(method("GET"))
2639 .and(path("/rest/agile/1.0/board"))
2640 .and(header("authorization", expected_auth.as_str()))
2641 .and(query_param("projectKeyOrId", "TEST"))
2642 .and(query_param("maxResults", "100"))
2643 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2644 "values": [{ "id": 9 }]
2645 })))
2646 .mount(&server)
2647 .await;
2648
2649 Mock::given(method("GET"))
2650 .and(path("/rest/agile/1.0/board/9/sprint"))
2651 .and(header("authorization", expected_auth.as_str()))
2652 .and(query_param("state", "active,future"))
2653 .and(query_param("maxResults", "200"))
2654 .respond_with(ResponseTemplate::new(500).set_body_string("jira exploded"))
2655 .mount(&server)
2656 .await;
2657
2658 let client = cloud_client(&server);
2659 let err = client
2660 .list_sprints_for_project("TEST")
2661 .await
2662 .expect_err("500 should propagate");
2663
2664 match err {
2665 JiraError::Api { status, message } => {
2666 assert_eq!(status, 500);
2667 assert!(message.contains("jira exploded"));
2668 }
2669 other => panic!("expected JiraError::Api, got {other:?}"),
2670 }
2671 }
2672
2673 #[tokio::test]
2674 async fn create_sprint_posts_expected_payload() {
2675 let server = MockServer::start().await;
2676 let expected_auth = cloud_auth();
2677
2678 Mock::given(method("POST"))
2679 .and(path("/rest/agile/1.0/sprint"))
2680 .and(header("authorization", expected_auth.as_str()))
2681 .and(body_json(json!({
2682 "name": "Sprint 42",
2683 "originBoardId": 7,
2684 "startDate": "2026-05-20T00:00:00.000Z",
2685 "endDate": "2026-05-27T00:00:00.000Z",
2686 "goal": "Ship sprint lifecycle support"
2687 })))
2688 .respond_with(ResponseTemplate::new(201).set_body_json(json!({
2689 "id": 42,
2690 "name": "Sprint 42",
2691 "state": "future",
2692 "goal": "Ship sprint lifecycle support",
2693 "startDate": "2026-05-20T00:00:00.000Z",
2694 "endDate": "2026-05-27T00:00:00.000Z"
2695 })))
2696 .mount(&server)
2697 .await;
2698
2699 let client = cloud_client(&server);
2700 let sprint = client
2701 .create_sprint(
2702 7,
2703 "Sprint 42",
2704 Some("2026-05-20T00:00:00.000Z"),
2705 Some("2026-05-27T00:00:00.000Z"),
2706 Some("Ship sprint lifecycle support"),
2707 )
2708 .await
2709 .expect("create sprint should succeed");
2710
2711 assert_eq!(sprint.id, 42);
2712 assert_eq!(sprint.board_id, Some(7));
2713 assert_eq!(sprint.state, "future");
2714 assert_eq!(
2715 sprint.goal.as_deref(),
2716 Some("Ship sprint lifecycle support")
2717 );
2718 }
2719
2720 #[tokio::test]
2721 async fn update_sprint_puts_expected_payload() {
2722 let server = MockServer::start().await;
2723 let expected_auth = cloud_auth();
2724
2725 Mock::given(method("PUT"))
2726 .and(path("/rest/agile/1.0/sprint/42"))
2727 .and(header("authorization", expected_auth.as_str()))
2728 .and(body_json(json!({
2729 "state": "active",
2730 "startDate": "2026-05-20T00:00:00.000Z",
2731 "endDate": "2026-05-27T00:00:00.000Z"
2732 })))
2733 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2734 "id": 42,
2735 "name": "Sprint 42",
2736 "state": "active",
2737 "startDate": "2026-05-20T00:00:00.000Z",
2738 "endDate": "2026-05-27T00:00:00.000Z"
2739 })))
2740 .mount(&server)
2741 .await;
2742
2743 let client = cloud_client(&server);
2744 let sprint = client
2745 .update_sprint(
2746 42,
2747 json!({
2748 "state": "active",
2749 "startDate": "2026-05-20T00:00:00.000Z",
2750 "endDate": "2026-05-27T00:00:00.000Z"
2751 }),
2752 )
2753 .await
2754 .expect("update sprint should succeed");
2755
2756 assert_eq!(sprint.id, 42);
2757 assert_eq!(sprint.state, "active");
2758 }
2759
2760 #[tokio::test]
2761 async fn delete_sprint_uses_delete_endpoint() {
2762 let server = MockServer::start().await;
2763 let expected_auth = cloud_auth();
2764
2765 Mock::given(method("DELETE"))
2766 .and(path("/rest/agile/1.0/sprint/42"))
2767 .and(header("authorization", expected_auth.as_str()))
2768 .respond_with(ResponseTemplate::new(204))
2769 .mount(&server)
2770 .await;
2771
2772 let client = cloud_client(&server);
2773 client
2774 .delete_sprint(42)
2775 .await
2776 .expect("delete sprint should succeed");
2777 }
2778
2779 #[tokio::test]
2780 async fn create_issue_v2_prefers_adf_and_builds_extended_fields() {
2781 let server = MockServer::start().await;
2782 let expected_auth = cloud_auth();
2783 let description_adf = json!({
2784 "type": "doc",
2785 "version": 1,
2786 "content": [{
2787 "type": "paragraph",
2788 "content": [{ "type": "text", "text": "ADF body" }]
2789 }]
2790 });
2791
2792 Mock::given(method("GET"))
2793 .and(path("/rest/api/3/user/search"))
2794 .and(header("authorization", expected_auth.as_str()))
2795 .and(query_param("query", "alice@example.com"))
2796 .and(query_param("maxResults", "20"))
2797 .respond_with(ResponseTemplate::new(200).set_body_json(json!([
2798 {
2799 "accountId": "acct-1",
2800 "emailAddress": "alice@example.com",
2801 "displayName": "Alice"
2802 }
2803 ])))
2804 .mount(&server)
2805 .await;
2806
2807 Mock::given(method("POST"))
2808 .and(path("/rest/api/3/issue"))
2809 .and(header("authorization", expected_auth.as_str()))
2810 .and(body_json(json!({
2811 "fields": {
2812 "project": { "key": "TEST" },
2813 "summary": "Ship feature",
2814 "issuetype": { "name": "Task" },
2815 "description": description_adf,
2816 "assignee": { "accountId": "acct-1" },
2817 "priority": { "name": "High" },
2818 "labels": ["backend", "urgent"],
2819 "components": [{ "name": "api" }, { "name": "worker" }],
2820 "parent": { "key": "TEST-1" },
2821 "fixVersions": [{ "name": "v1.0" }, { "name": "v1.1" }],
2822 "customfield_10010": "hello",
2823 "customfield_10011": { "value": "Blue" },
2824 "customfield_10012": ["triage"]
2825 }
2826 })))
2827 .respond_with(ResponseTemplate::new(201).set_body_json(json!({ "key": "TEST-123" })))
2828 .mount(&server)
2829 .await;
2830
2831 Mock::given(method("GET"))
2832 .and(path("/rest/api/3/issue/TEST-123"))
2833 .and(header("authorization", expected_auth.as_str()))
2834 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2835 "id": "10001",
2836 "key": "TEST-123",
2837 "fields": {
2838 "summary": "Ship feature",
2839 "description": description_adf,
2840 "status": { "name": "To Do" },
2841 "issuetype": { "name": "Task" },
2842 "project": { "key": "TEST" },
2843 "created": "2023-01-01T00:00:00.000+0000",
2844 "updated": "2023-01-01T00:00:00.000+0000"
2845 }
2846 })))
2847 .mount(&server)
2848 .await;
2849
2850 let client = cloud_client(&server);
2851 let mut custom_fields = std::collections::HashMap::new();
2852 custom_fields.insert(
2853 "customfield_10010".to_string(),
2854 FieldValue::Text("hello".into()),
2855 );
2856 custom_fields.insert(
2857 "customfield_10011".to_string(),
2858 FieldValue::SelectName("Blue".into()),
2859 );
2860 custom_fields.insert(
2861 "customfield_10012".to_string(),
2862 FieldValue::Labels(vec!["triage".into()]),
2863 );
2864
2865 let issue = client
2866 .create_issue_v2(CreateIssueRequestV2 {
2867 project_key: "TEST".into(),
2868 summary: "Ship feature".into(),
2869 description: Some("markdown body should be ignored".into()),
2870 description_adf: Some(description_adf.clone()),
2871 issue_type: "Task".into(),
2872 assignee: Some("alice@example.com".into()),
2873 priority: Some("High".into()),
2874 labels: vec!["backend".into(), "urgent".into()],
2875 components: vec!["api".into(), "worker".into()],
2876 parent: Some("TEST-1".into()),
2877 fix_versions: vec!["v1.0".into(), "v1.1".into()],
2878 custom_fields,
2879 })
2880 .await
2881 .expect("create issue v2 should succeed");
2882
2883 assert_eq!(issue.key, "TEST-123");
2884 assert_eq!(issue.summary, "Ship feature");
2885 assert_eq!(issue.description, Some(description_adf));
2886 }
2887
2888 #[tokio::test]
2889 async fn create_issue_v2_uses_markdown_description_when_adf_missing() {
2890 let server = MockServer::start().await;
2891 let expected_auth = cloud_auth();
2892 let markdown_adf = markdown_to_adf("hello world");
2893
2894 Mock::given(method("POST"))
2895 .and(path("/rest/api/3/issue"))
2896 .and(header("authorization", expected_auth.as_str()))
2897 .and(body_json(json!({
2898 "fields": {
2899 "project": { "key": "TEST" },
2900 "summary": "Plain issue",
2901 "issuetype": { "name": "Task" },
2902 "description": markdown_adf
2903 }
2904 })))
2905 .respond_with(ResponseTemplate::new(201).set_body_json(json!({ "key": "TEST-124" })))
2906 .mount(&server)
2907 .await;
2908
2909 Mock::given(method("GET"))
2910 .and(path("/rest/api/3/issue/TEST-124"))
2911 .and(header("authorization", expected_auth.as_str()))
2912 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2913 "id": "10002",
2914 "key": "TEST-124",
2915 "fields": {
2916 "summary": "Plain issue",
2917 "description": markdown_to_adf("hello world"),
2918 "status": { "name": "To Do" },
2919 "issuetype": { "name": "Task" },
2920 "project": { "key": "TEST" },
2921 "created": "2023-01-01T00:00:00.000+0000",
2922 "updated": "2023-01-01T00:00:00.000+0000"
2923 }
2924 })))
2925 .mount(&server)
2926 .await;
2927
2928 let client = cloud_client(&server);
2929 let issue = client
2930 .create_issue_v2(CreateIssueRequestV2 {
2931 project_key: "TEST".into(),
2932 summary: "Plain issue".into(),
2933 description: Some("hello world".into()),
2934 description_adf: None,
2935 issue_type: "Task".into(),
2936 assignee: None,
2937 priority: None,
2938 labels: vec![],
2939 components: vec![],
2940 parent: None,
2941 fix_versions: vec![],
2942 custom_fields: std::collections::HashMap::new(),
2943 })
2944 .await
2945 .expect("markdown fallback should succeed");
2946
2947 assert_eq!(issue.key, "TEST-124");
2948 assert_eq!(issue.summary, "Plain issue");
2949 assert_eq!(issue.description, Some(markdown_to_adf("hello world")));
2950 }
2951
2952 #[tokio::test]
2953 async fn get_comments_parses_comment_text_and_mentions() {
2954 let server = MockServer::start().await;
2955 let expected_auth = cloud_auth();
2956
2957 Mock::given(method("GET"))
2958 .and(path("/rest/api/3/issue/TEST-1/comment"))
2959 .and(header("authorization", expected_auth.as_str()))
2960 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2961 "comments": [
2962 {
2963 "id": "10001",
2964 "author": {
2965 "displayName": "Alice",
2966 "accountId": "acct-1"
2967 },
2968 "body": {
2969 "type": "doc",
2970 "version": 1,
2971 "content": [{
2972 "type": "paragraph",
2973 "content": [
2974 { "type": "text", "text": "Hi " },
2975 { "type": "mention", "attrs": { "id": "acct-2", "text": "@Bob" } }
2976 ]
2977 }]
2978 },
2979 "created": "2023-01-01T00:00:00.000+0000",
2980 "updated": "2023-01-01T00:00:00.000+0000"
2981 }
2982 ]
2983 })))
2984 .mount(&server)
2985 .await;
2986
2987 let client = cloud_client(&server);
2988 let comments = client
2989 .get_comments("TEST-1")
2990 .await
2991 .expect("comments should parse");
2992
2993 assert_eq!(comments.len(), 1);
2994 assert_eq!(comments[0].id, "10001");
2995 assert_eq!(comments[0].author.as_deref(), Some("Alice"));
2996 assert_eq!(comments[0].author_account_id.as_deref(), Some("acct-1"));
2997 assert_eq!(comments[0].body.as_deref(), Some("Hi @Bob"));
2998 assert_eq!(comments[0].mentions, vec!["acct-2"]);
2999 }
3000
3001 #[tokio::test]
3002 async fn add_comment_adf_posts_prebuilt_adf_payload() {
3003 let server = MockServer::start().await;
3004 let expected_auth = cloud_auth();
3005 let adf = json!({
3006 "type": "doc",
3007 "version": 1,
3008 "content": [{
3009 "type": "paragraph",
3010 "content": [
3011 { "type": "text", "text": "Hi " },
3012 { "type": "mention", "attrs": { "id": "acct-2", "text": "@Bob" } }
3013 ]
3014 }]
3015 });
3016
3017 Mock::given(method("POST"))
3018 .and(path("/rest/api/3/issue/TEST-1/comment"))
3019 .and(header("authorization", expected_auth.as_str()))
3020 .and(body_json(json!({ "body": adf.clone() })))
3021 .respond_with(ResponseTemplate::new(201).set_body_json(json!({
3022 "id": "10002",
3023 "author": {
3024 "displayName": "Alice",
3025 "accountId": "acct-1"
3026 },
3027 "body": adf,
3028 "created": "2023-01-01T00:00:00.000+0000",
3029 "updated": "2023-01-01T00:00:00.000+0000"
3030 })))
3031 .mount(&server)
3032 .await;
3033
3034 let client = cloud_client(&server);
3035 let comment = client
3036 .add_comment_adf(
3037 "TEST-1",
3038 json!({
3039 "type": "doc",
3040 "version": 1,
3041 "content": [{
3042 "type": "paragraph",
3043 "content": [
3044 { "type": "text", "text": "Hi " },
3045 { "type": "mention", "attrs": { "id": "acct-2", "text": "@Bob" } }
3046 ]
3047 }]
3048 }),
3049 )
3050 .await
3051 .expect("add comment adf should succeed");
3052
3053 assert_eq!(comment.id, "10002");
3054 assert_eq!(comment.body.as_deref(), Some("Hi @Bob"));
3055 assert_eq!(comment.mentions, vec!["acct-2"]);
3056 }
3057
3058 #[tokio::test]
3059 async fn search_users_retries_after_429_then_succeeds() {
3060 let server = MockServer::start().await;
3061 let expected_auth = cloud_auth();
3062
3063 Mock::given(method("GET"))
3064 .and(path("/rest/api/3/user/search"))
3065 .and(header("authorization", expected_auth.as_str()))
3066 .and(query_param("query", "alice"))
3067 .and(query_param("maxResults", "20"))
3068 .respond_with(ResponseTemplate::new(429).insert_header("Retry-After", "0"))
3069 .up_to_n_times(1)
3070 .mount(&server)
3071 .await;
3072
3073 Mock::given(method("GET"))
3074 .and(path("/rest/api/3/user/search"))
3075 .and(header("authorization", expected_auth.as_str()))
3076 .and(query_param("query", "alice"))
3077 .and(query_param("maxResults", "20"))
3078 .respond_with(ResponseTemplate::new(200).set_body_json(json!([
3079 {
3080 "accountId": "acct-1",
3081 "displayName": "Alice Example"
3082 }
3083 ])))
3084 .mount(&server)
3085 .await;
3086
3087 let client = cloud_client(&server);
3088 let users = client
3089 .search_users("alice")
3090 .await
3091 .expect("request should retry and succeed");
3092
3093 assert_eq!(users.len(), 1);
3094 assert_eq!(users[0].account_id, "acct-1");
3095 }
3096
3097 #[tokio::test]
3098 async fn search_users_returns_rate_limit_after_max_retries() {
3099 let server = MockServer::start().await;
3100 let expected_auth = cloud_auth();
3101
3102 Mock::given(method("GET"))
3103 .and(path("/rest/api/3/user/search"))
3104 .and(header("authorization", expected_auth.as_str()))
3105 .and(query_param("query", "alice"))
3106 .and(query_param("maxResults", "20"))
3107 .respond_with(ResponseTemplate::new(429).insert_header("Retry-After", "0"))
3108 .mount(&server)
3109 .await;
3110
3111 let client = cloud_client(&server);
3112 let err = client
3113 .search_users("alice")
3114 .await
3115 .expect_err("request should fail after max retries");
3116
3117 match err {
3118 JiraError::RateLimit { retry_after } => assert_eq!(retry_after, 0),
3119 other => panic!("expected JiraError::RateLimit, got {other:?}"),
3120 }
3121 }
3122
3123 #[tokio::test]
3124 async fn move_issue_submits_bulk_move_and_fetches_issue_on_complete() {
3125 let server = MockServer::start().await;
3126 let expected_auth = cloud_auth();
3127
3128 Mock::given(method("POST"))
3129 .and(path("/rest/api/3/bulk/issues/move"))
3130 .and(header("authorization", expected_auth.as_str()))
3131 .and(body_json(json!({
3132 "sendBulkNotification": true,
3133 "targetToSourcesMapping": {
3134 "NEW,10002": {
3135 "inferClassificationDefaults": true,
3136 "inferFieldDefaults": true,
3137 "inferStatusDefaults": true,
3138 "inferSubtaskTypeDefault": true,
3139 "issueIdsOrKeys": ["TEST-1"]
3140 }
3141 }
3142 })))
3143 .respond_with(ResponseTemplate::new(201).set_body_json(json!({ "taskId": "task-1" })))
3144 .mount(&server)
3145 .await;
3146
3147 Mock::given(method("GET"))
3148 .and(path("/rest/api/3/bulk/queue/task-1"))
3149 .and(header("authorization", expected_auth.as_str()))
3150 .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "status": "COMPLETE" })))
3151 .mount(&server)
3152 .await;
3153
3154 Mock::given(method("GET"))
3155 .and(path("/rest/api/3/issue/TEST-1"))
3156 .and(header("authorization", expected_auth.as_str()))
3157 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
3158 "id": "10001",
3159 "key": "TEST-1",
3160 "fields": {
3161 "summary": "Moved issue",
3162 "status": { "name": "To Do" },
3163 "issuetype": { "name": "Task" },
3164 "project": { "key": "NEW" },
3165 "created": "2023-01-01T00:00:00.000+0000",
3166 "updated": "2023-01-01T00:00:00.000+0000"
3167 }
3168 })))
3169 .mount(&server)
3170 .await;
3171
3172 let client = cloud_client(&server);
3173 let issue = client
3174 .move_issue("TEST-1", "NEW", "10002", None)
3175 .await
3176 .expect("move issue should complete");
3177
3178 assert_eq!(issue.key, "TEST-1");
3179 assert_eq!(issue.project_key, "NEW");
3180 }
3181
3182 #[tokio::test]
3183 async fn move_issue_returns_api_error_when_bulk_task_fails() {
3184 let server = MockServer::start().await;
3185 let expected_auth = cloud_auth();
3186
3187 Mock::given(method("POST"))
3188 .and(path("/rest/api/3/bulk/issues/move"))
3189 .and(header("authorization", expected_auth.as_str()))
3190 .respond_with(ResponseTemplate::new(201).set_body_json(json!({ "taskId": "task-2" })))
3191 .mount(&server)
3192 .await;
3193
3194 Mock::given(method("GET"))
3195 .and(path("/rest/api/3/bulk/queue/task-2"))
3196 .and(header("authorization", expected_auth.as_str()))
3197 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
3198 "status": "FAILED",
3199 "errors": ["cannot move issue"]
3200 })))
3201 .mount(&server)
3202 .await;
3203
3204 let client = cloud_client(&server);
3205 let err = client
3206 .move_issue("TEST-1", "NEW", "10002", None)
3207 .await
3208 .expect_err("failed bulk task should return error");
3209
3210 match err {
3211 JiraError::Api { message, .. } => assert!(message.contains("FAILED")),
3212 other => panic!("expected JiraError::Api, got {other:?}"),
3213 }
3214 }
3215
3216 #[tokio::test]
3217 async fn move_issue_uses_configured_api_version_for_data_center() {
3218 let server = MockServer::start().await;
3219
3220 Mock::given(method("POST"))
3221 .and(path("/rest/api/2/bulk/issues/move"))
3222 .and(header("authorization", "Bearer dc-token"))
3223 .respond_with(ResponseTemplate::new(201).set_body_json(json!({ "taskId": "task-dc" })))
3224 .mount(&server)
3225 .await;
3226
3227 Mock::given(method("GET"))
3228 .and(path("/rest/api/2/bulk/queue/task-dc"))
3229 .and(header("authorization", "Bearer dc-token"))
3230 .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "status": "COMPLETE" })))
3231 .mount(&server)
3232 .await;
3233
3234 Mock::given(method("GET"))
3235 .and(path("/rest/api/2/issue/DC-1"))
3236 .and(header("authorization", "Bearer dc-token"))
3237 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
3238 "id": "20001",
3239 "key": "DC-1",
3240 "fields": {
3241 "summary": "Moved issue",
3242 "status": { "name": "Done" },
3243 "issuetype": { "name": "Task" },
3244 "project": { "key": "OPS" },
3245 "created": "2023-01-01T00:00:00.000+0000",
3246 "updated": "2023-01-01T00:00:00.000+0000"
3247 }
3248 })))
3249 .mount(&server)
3250 .await;
3251
3252 let client = JiraClient::new(JiraConfig {
3253 profile_name: Some("dc-main".into()),
3254 base_url: server.uri(),
3255 email: String::new(),
3256 token: Some("dc-token".into()),
3257 project: None,
3258 timeout_secs: 30,
3259 deployment: JiraDeployment::DataCenter,
3260 auth_type: JiraAuthType::DataCenterPat,
3261 api_version: 2,
3262 });
3263
3264 let issue = client
3265 .move_issue("DC-1", "OPS", "10002", None)
3266 .await
3267 .expect("data center move should use api v2");
3268
3269 assert_eq!(issue.key, "DC-1");
3270 assert_eq!(issue.project_key, "OPS");
3271 }
3272
3273 #[tokio::test]
3274 async fn issue_link_integration() {
3275 let server = MockServer::start().await;
3276 let expected_auth = format!("Basic {}", base64_encode("dev@example.com:cloud-token"));
3277
3278 Mock::given(method("GET"))
3280 .and(path("/rest/api/3/issueLinkType"))
3281 .and(header("authorization", expected_auth.as_str()))
3282 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
3283 "issueLinkTypes": [
3284 {
3285 "id": "10000",
3286 "name": "Blocks",
3287 "inward": "is blocked by",
3288 "outward": "blocks"
3289 }
3290 ]
3291 })))
3292 .mount(&server)
3293 .await;
3294
3295 Mock::given(method("POST"))
3297 .and(path("/rest/api/3/issueLink"))
3298 .and(header("authorization", expected_auth.as_str()))
3299 .respond_with(ResponseTemplate::new(201))
3300 .mount(&server)
3301 .await;
3302
3303 let client = JiraClient::new(JiraConfig {
3304 profile_name: Some("cloud-main".into()),
3305 base_url: server.uri(),
3306 email: "dev@example.com".into(),
3307 token: Some("cloud-token".into()),
3308 project: None,
3309 timeout_secs: 30,
3310 deployment: JiraDeployment::Cloud,
3311 auth_type: JiraAuthType::CloudApiToken,
3312 api_version: 3,
3313 });
3314
3315 let link_types = client.list_issue_link_types().await.expect("list types");
3317 assert_eq!(link_types.len(), 1);
3318 assert_eq!(link_types[0].name, "Blocks");
3319
3320 client
3322 .link_issues("TEST-1", "TEST-2", "Blocks", Some("Adding dependency"))
3323 .await
3324 .expect("link issues");
3325 }
3326
3327 #[tokio::test]
3328 async fn get_issue_parses_links() {
3329 let server = MockServer::start().await;
3330 let expected_auth = format!("Basic {}", base64_encode("dev@example.com:cloud-token"));
3331
3332 Mock::given(method("GET"))
3333 .and(path("/rest/api/3/issue/TEST-1"))
3334 .and(header("authorization", expected_auth.as_str()))
3335 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
3336 "id": "10001",
3337 "key": "TEST-1",
3338 "fields": {
3339 "summary": "Main Issue",
3340 "status": { "name": "To Do" },
3341 "issuetype": { "name": "Task" },
3342 "project": { "key": "TEST" },
3343 "created": "2023-01-01T00:00:00.000+0000",
3344 "updated": "2023-01-01T00:00:00.000+0000",
3345 "issuelinks": [
3346 {
3347 "id": "20000",
3348 "type": {
3349 "id": "10000",
3350 "name": "Blocks",
3351 "inward": "is blocked by",
3352 "outward": "blocks"
3353 },
3354 "outwardIssue": {
3355 "id": "10002",
3356 "key": "TEST-2",
3357 "fields": {
3358 "summary": "Blocked Issue",
3359 "status": { "name": "Open" },
3360 "priority": { "name": "High" },
3361 "issuetype": { "name": "Bug" }
3362 }
3363 }
3364 }
3365 ]
3366 }
3367 })))
3368 .mount(&server)
3369 .await;
3370
3371 let client = JiraClient::new(JiraConfig {
3372 profile_name: Some("cloud-main".into()),
3373 base_url: server.uri(),
3374 email: "dev@example.com".into(),
3375 token: Some("cloud-token".into()),
3376 project: None,
3377 timeout_secs: 30,
3378 deployment: JiraDeployment::Cloud,
3379 auth_type: JiraAuthType::CloudApiToken,
3380 api_version: 3,
3381 });
3382
3383 let issue = client.get_issue("TEST-1").await.expect("get issue");
3384 assert_eq!(issue.links.len(), 1);
3385 let link = &issue.links[0];
3386 assert_eq!(link.link_type.name, "Blocks");
3387 assert!(link.outward_issue.is_some());
3388 assert_eq!(link.outward_issue.as_ref().unwrap().key, "TEST-2");
3389 assert_eq!(
3390 link.outward_issue.as_ref().unwrap().summary,
3391 "Blocked Issue"
3392 );
3393 }
3394
3395 #[tokio::test]
3396 async fn get_remote_links_parses_object_title_and_url() {
3397 let server = MockServer::start().await;
3398
3399 Mock::given(method("GET"))
3400 .and(path("/rest/api/3/issue/TEST-1/remotelink"))
3401 .and(header("authorization", cloud_auth().as_str()))
3402 .respond_with(ResponseTemplate::new(200).set_body_json(json!([
3403 {
3404 "id": 10001,
3405 "self": "https://jira.example.com/rest/api/3/issue/TEST-1/remotelink/10001",
3406 "globalId": "system=https://docs.example.com&id=42",
3407 "relationship": "Wiki Page",
3408 "object": {
3409 "title": "Design Doc",
3410 "url": "https://docs.example.com/design",
3411 "summary": "Architecture overview"
3412 }
3413 },
3414 {
3415 "id": 10002,
3416 "object": {
3417 "title": "Tracking ticket",
3418 "url": "https://tracker.example.com/4242"
3419 }
3420 }
3421 ])))
3422 .mount(&server)
3423 .await;
3424
3425 let client = cloud_client(&server);
3426 let links = client
3427 .get_remote_links("TEST-1")
3428 .await
3429 .expect("remote links should parse");
3430
3431 assert_eq!(links.len(), 2);
3432 assert_eq!(links[0].id, 10001);
3433 assert_eq!(
3434 links[0].global_id.as_deref(),
3435 Some("system=https://docs.example.com&id=42")
3436 );
3437 assert_eq!(links[0].object.title, "Design Doc");
3438 assert_eq!(links[0].object.url, "https://docs.example.com/design");
3439 assert_eq!(
3440 links[0].object.summary.as_deref(),
3441 Some("Architecture overview")
3442 );
3443 assert_eq!(links[1].id, 10002);
3444 assert!(links[1].global_id.is_none());
3445 assert!(links[1].object.summary.is_none());
3446 }
3447
3448 #[tokio::test]
3449 async fn get_remote_links_returns_empty_when_no_links() {
3450 let server = MockServer::start().await;
3451
3452 Mock::given(method("GET"))
3453 .and(path("/rest/api/3/issue/TEST-1/remotelink"))
3454 .respond_with(ResponseTemplate::new(200).set_body_json(json!([])))
3455 .mount(&server)
3456 .await;
3457
3458 let client = cloud_client(&server);
3459 let links = client.get_remote_links("TEST-1").await.expect("parse");
3460 assert!(links.is_empty());
3461 }
3462
3463 #[tokio::test]
3464 async fn get_project_components_parses_id_and_name() {
3465 let server = MockServer::start().await;
3466
3467 Mock::given(method("GET"))
3468 .and(path("/rest/api/3/project/PROJ/components"))
3469 .and(header("authorization", cloud_auth().as_str()))
3470 .respond_with(ResponseTemplate::new(200).set_body_json(json!([
3471 {
3472 "id": "10100",
3473 "name": "Backend",
3474 "description": "Server-side code",
3475 "self": "https://jira.example.com/rest/api/3/component/10100"
3476 },
3477 {
3478 "id": "10101",
3479 "name": "Frontend"
3480 }
3481 ])))
3482 .mount(&server)
3483 .await;
3484
3485 let client = cloud_client(&server);
3486 let components = client
3487 .get_project_components("PROJ")
3488 .await
3489 .expect("components should parse");
3490
3491 assert_eq!(components.len(), 2);
3492 assert_eq!(components[0].id, "10100");
3493 assert_eq!(components[0].name, "Backend");
3494 assert_eq!(
3495 components[0].description.as_deref(),
3496 Some("Server-side code")
3497 );
3498 assert!(components[0].self_url.is_some());
3499 assert_eq!(components[1].name, "Frontend");
3500 assert!(components[1].description.is_none());
3501 }
3502
3503 #[tokio::test]
3504 async fn get_transitions_parses_id_name_and_preserves_extra_fields() {
3505 let server = MockServer::start().await;
3506
3507 Mock::given(method("GET"))
3508 .and(path("/rest/api/3/issue/TEST-1/transitions"))
3509 .and(header("authorization", cloud_auth().as_str()))
3510 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
3511 "expand": "transitions",
3512 "transitions": [
3513 {
3514 "id": "21",
3515 "name": "In Progress",
3516 "hasScreen": false,
3517 "isGlobal": false,
3518 "isInitial": false,
3519 "isAvailable": true,
3520 "to": {
3521 "id": "3",
3522 "name": "In Progress",
3523 "statusCategory": { "key": "indeterminate" }
3524 }
3525 },
3526 {
3527 "id": "31",
3528 "name": "Done",
3529 "to": { "id": "10001", "name": "Done" }
3530 }
3531 ]
3532 })))
3533 .mount(&server)
3534 .await;
3535
3536 let client = cloud_client(&server);
3537 let transitions = client
3538 .get_transitions("TEST-1")
3539 .await
3540 .expect("transitions should parse");
3541
3542 assert_eq!(transitions.len(), 2);
3543 assert_eq!(transitions[0].id, "21");
3544 assert_eq!(transitions[0].name, "In Progress");
3545 assert_eq!(
3546 transitions[0].to.as_ref().and_then(|t| t.name.as_deref()),
3547 Some("In Progress")
3548 );
3549
3550 let reserialized = serde_json::to_value(&transitions[0]).expect("serialize");
3553 assert_eq!(reserialized["hasScreen"], json!(false));
3554 assert_eq!(reserialized["isAvailable"], json!(true));
3555
3556 assert_eq!(transitions[1].id, "31");
3557 assert!(transitions[1].to.is_some());
3558 }
3559
3560 #[test]
3561 fn parses_content_disposition_plain() {
3562 assert_eq!(
3563 parse_content_disposition_filename("attachment; filename=\"report.pdf\""),
3564 Some("report.pdf".to_string())
3565 );
3566 assert_eq!(
3567 parse_content_disposition_filename("attachment; filename=plain.txt"),
3568 Some("plain.txt".to_string())
3569 );
3570 }
3571
3572 #[test]
3573 fn parses_content_disposition_rfc5987() {
3574 assert_eq!(
3575 parse_content_disposition_filename(
3576 "attachment; filename*=UTF-8''na%C3%AFve%20file.txt"
3577 ),
3578 Some("naïve file.txt".to_string())
3579 );
3580 }
3581
3582 #[tokio::test]
3583 async fn list_attachments_extracts_fields() {
3584 let server = MockServer::start().await;
3585 Mock::given(method("GET"))
3586 .and(path("/rest/api/3/issue/TEST-1"))
3587 .and(query_param("fields", "attachment"))
3588 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
3589 "fields": {
3590 "attachment": [
3591 {
3592 "id": "10001",
3593 "filename": "a.txt",
3594 "size": 12,
3595 "mimeType": "text/plain",
3596 "content": "https://example/c/10001",
3597 "created": "2026-06-14T00:00:00.000+0000"
3598 }
3599 ]
3600 }
3601 })))
3602 .mount(&server)
3603 .await;
3604 let client = cloud_client(&server);
3605 let items = client.list_attachments("TEST-1").await.expect("ok");
3606 assert_eq!(items.len(), 1);
3607 assert_eq!(items[0].id, "10001");
3608 assert_eq!(items[0].filename, "a.txt");
3609 }
3610
3611 #[tokio::test]
3612 async fn download_attachment_returns_bytes_and_filename() {
3613 let server = MockServer::start().await;
3614 Mock::given(method("GET"))
3615 .and(path("/rest/api/3/attachment/content/42"))
3616 .respond_with(
3617 ResponseTemplate::new(200)
3618 .insert_header("Content-Type", "application/pdf")
3619 .insert_header("Content-Disposition", "attachment; filename=\"file.pdf\"")
3620 .set_body_bytes(vec![0x25u8, 0x50, 0x44, 0x46]),
3621 )
3622 .mount(&server)
3623 .await;
3624 let client = cloud_client(&server);
3625 let (name, bytes, mime) = client.download_attachment("42").await.expect("ok");
3626 assert_eq!(name, "file.pdf");
3627 assert_eq!(mime, "application/pdf");
3628 assert_eq!(bytes, vec![0x25, 0x50, 0x44, 0x46]);
3629 }
3630
3631 #[tokio::test]
3632 async fn download_attachment_404_returns_not_found() {
3633 let server = MockServer::start().await;
3634 Mock::given(method("GET"))
3635 .and(path("/rest/api/3/attachment/content/99"))
3636 .respond_with(ResponseTemplate::new(404).set_body_string("missing"))
3637 .mount(&server)
3638 .await;
3639 let client = cloud_client(&server);
3640 let err = client.download_attachment("99").await.expect_err("err");
3641 assert!(matches!(err, JiraError::NotFound(_)));
3642 }
3643
3644 #[tokio::test]
3645 async fn list_boards_paginates() {
3646 let server = MockServer::start().await;
3647 Mock::given(method("GET"))
3648 .and(path("/rest/agile/1.0/board"))
3649 .and(query_param("startAt", "0"))
3650 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
3651 "maxResults": 50, "startAt": 0, "isLast": false,
3652 "values": [
3653 { "id": 1, "name": "B1", "type": "scrum" },
3654 { "id": 2, "name": "B2", "type": "kanban",
3655 "location": { "projectKey": "ABC", "projectId": 10 } }
3656 ]
3657 })))
3658 .mount(&server)
3659 .await;
3660 Mock::given(method("GET"))
3661 .and(path("/rest/agile/1.0/board"))
3662 .and(query_param("startAt", "2"))
3663 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
3664 "maxResults": 50, "startAt": 2, "isLast": true,
3665 "values": []
3666 })))
3667 .mount(&server)
3668 .await;
3669 let client = cloud_client(&server);
3670 let boards = client.list_boards(None, None).await.expect("ok");
3671 assert_eq!(boards.len(), 2);
3672 assert_eq!(boards[1].project_key.as_deref(), Some("ABC"));
3673 }
3674
3675 #[tokio::test]
3676 async fn get_board_parses_single() {
3677 let server = MockServer::start().await;
3678 Mock::given(method("GET"))
3679 .and(path("/rest/agile/1.0/board/9"))
3680 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
3681 "id": 9, "name": "X", "type": "scrum"
3682 })))
3683 .mount(&server)
3684 .await;
3685 let client = cloud_client(&server);
3686 let b = client.get_board(9).await.expect("ok");
3687 assert_eq!(b.id, 9);
3688 assert_eq!(b.name, "X");
3689 }
3690
3691 #[tokio::test]
3692 async fn board_issues_returns_issues() {
3693 let server = MockServer::start().await;
3694 Mock::given(method("GET"))
3695 .and(path("/rest/agile/1.0/board/5/issue"))
3696 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
3697 "issues": [
3698 { "id": "1", "key": "ABC-1", "fields": { "summary": "s",
3699 "status": { "name": "To Do" }, "issuetype": { "name": "Task" },
3700 "project": { "key": "ABC" }, "created": "", "updated": "" } }
3701 ]
3702 })))
3703 .mount(&server)
3704 .await;
3705 let client = cloud_client(&server);
3706 let issues = client.board_issues(5, None, Some(50)).await.expect("ok");
3707 assert_eq!(issues.len(), 1);
3708 assert_eq!(issues[0].key, "ABC-1");
3709 }
3710
3711 #[tokio::test]
3712 async fn board_backlog_passes_jql() {
3713 let server = MockServer::start().await;
3714 Mock::given(method("GET"))
3715 .and(path("/rest/agile/1.0/board/5/backlog"))
3716 .and(query_param("jql", "labels = \"x\""))
3717 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
3718 "issues": []
3719 })))
3720 .mount(&server)
3721 .await;
3722 let client = cloud_client(&server);
3723 let issues = client
3724 .board_backlog(5, Some("labels = \"x\""), None)
3725 .await
3726 .expect("ok");
3727 assert!(issues.is_empty());
3728 }
3729
3730 #[tokio::test]
3731 async fn get_transitions_returns_empty_when_no_transitions_available() {
3732 let server = MockServer::start().await;
3733
3734 Mock::given(method("GET"))
3735 .and(path("/rest/api/3/issue/TEST-1/transitions"))
3736 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
3737 "transitions": []
3738 })))
3739 .mount(&server)
3740 .await;
3741
3742 let client = cloud_client(&server);
3743 let transitions = client.get_transitions("TEST-1").await.expect("parse");
3744 assert!(transitions.is_empty());
3745 }
3746}