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