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 create_sprint(
1010 &self,
1011 board_id: u64,
1012 name: &str,
1013 start_date: Option<&str>,
1014 end_date: Option<&str>,
1015 goal: Option<&str>,
1016 ) -> Result<Sprint> {
1017 let body = json!({
1018 "name": name,
1019 "originBoardId": board_id,
1020 "startDate": start_date,
1021 "endDate": end_date,
1022 "goal": goal,
1023 });
1024 let value = self
1025 .raw_request("POST", "/rest/agile/1.0/sprint", Some(body))
1026 .await?
1027 .ok_or_else(|| JiraError::Api {
1028 status: 0,
1029 message: "Empty response when creating sprint".into(),
1030 })?;
1031 self.parse_sprint_value(&value, Some(board_id))
1032 .ok_or_else(|| JiraError::Api {
1033 status: 0,
1034 message: "Failed to parse created sprint".into(),
1035 })
1036 }
1037
1038 pub async fn update_sprint(&self, sprint_id: u64, body: Value) -> Result<Sprint> {
1040 let value = self
1041 .raw_request(
1042 "PUT",
1043 &format!("/rest/agile/1.0/sprint/{sprint_id}"),
1044 Some(body),
1045 )
1046 .await?
1047 .ok_or_else(|| JiraError::Api {
1048 status: 0,
1049 message: "Empty response when updating sprint".into(),
1050 })?;
1051 self.parse_sprint_value(&value, None)
1052 .ok_or_else(|| JiraError::Api {
1053 status: 0,
1054 message: "Failed to parse updated sprint".into(),
1055 })
1056 }
1057
1058 pub async fn delete_sprint(&self, sprint_id: u64) -> Result<()> {
1060 self.raw_request(
1061 "DELETE",
1062 &format!("/rest/agile/1.0/sprint/{sprint_id}"),
1063 None,
1064 )
1065 .await?;
1066 Ok(())
1067 }
1068
1069 pub async fn add_issue_to_sprint(&self, sprint_id: u64, issue_key: &str) -> Result<()> {
1071 let path = format!("/rest/agile/1.0/sprint/{sprint_id}/issue");
1072 let body = json!({ "issues": [issue_key] });
1073 self.raw_request("POST", &path, Some(body)).await?;
1074 Ok(())
1075 }
1076
1077 pub async fn add_comment_adf(&self, issue_key: &str, adf: Value) -> Result<Comment> {
1079 let headers = self.auth_headers()?;
1080 let url = self.platform_url(&format!("/issue/{issue_key}/comment"));
1081 let payload = json!({ "body": adf });
1082 let http = &self.http;
1083 let raw: Value = self
1084 .request(|| http.post(&url).headers(headers.clone()).json(&payload))
1085 .await?;
1086 Comment::from_value(&raw, issue_key).ok_or_else(|| JiraError::Api {
1087 status: 0,
1088 message: "Failed to parse comment".into(),
1089 })
1090 }
1091
1092 pub async fn upload_attachment(
1094 &self,
1095 issue_key: &str,
1096 file_path: &std::path::Path,
1097 ) -> Result<Vec<Attachment>> {
1098 let file_name = file_path
1099 .file_name()
1100 .and_then(|n| n.to_str())
1101 .unwrap_or("attachment")
1102 .to_string();
1103 let bytes = std::fs::read(file_path)?;
1104 let mime = mime_guess::from_path(file_path)
1105 .first_or_octet_stream()
1106 .to_string();
1107
1108 self.upload_attachment_bytes(issue_key, &file_name, bytes, Some(&mime))
1109 .await
1110 }
1111
1112 pub async fn upload_attachment_bytes(
1114 &self,
1115 issue_key: &str,
1116 file_name: &str,
1117 bytes: Vec<u8>,
1118 media_type: Option<&str>,
1119 ) -> Result<Vec<Attachment>> {
1120 use reqwest::{header::HeaderValue, multipart};
1121
1122 let headers = self.auth_headers_no_content_type()?;
1123 let url = self.platform_url(&format!("/issue/{issue_key}/attachments"));
1124 let mime = media_type
1125 .map(|value| value.to_string())
1126 .or_else(|| {
1127 mime_guess::from_path(file_name)
1128 .first_raw()
1129 .map(str::to_string)
1130 })
1131 .unwrap_or_else(|| "application/octet-stream".to_string());
1132
1133 let http = &self.http;
1134 let raw_attachments: Vec<Value> = self
1135 .request_multipart(|| {
1136 let part = multipart::Part::bytes(bytes.clone())
1137 .file_name(file_name.to_string())
1138 .mime_str(&mime)
1139 .expect("invalid mime type");
1140 let form = multipart::Form::new().part("file", part);
1141
1142 let mut req_headers = headers.clone();
1143 req_headers.insert("X-Atlassian-Token", HeaderValue::from_static("no-check"));
1144
1145 http.post(&url).headers(req_headers).multipart(form)
1146 })
1147 .await?;
1148
1149 Ok(raw_attachments
1150 .iter()
1151 .filter_map(Attachment::from_value)
1152 .collect())
1153 }
1154
1155 pub async fn create_issue_v2(&self, req: CreateIssueRequestV2) -> Result<Issue> {
1157 let headers = self.auth_headers()?;
1158 let url = self.platform_url("/issue");
1159
1160 let description_adf = req
1161 .description_adf
1162 .or_else(|| req.description.as_deref().map(markdown_to_adf));
1163
1164 let mut fields = json!({
1165 "project": { "key": req.project_key },
1166 "summary": req.summary,
1167 "issuetype": { "name": req.issue_type }
1168 });
1169
1170 if let Some(adf) = description_adf {
1171 fields["description"] = adf;
1172 }
1173 if let Some(assignee) = &req.assignee {
1174 let account_id = self.resolve_assignee_account_id(assignee).await?;
1175 fields["assignee"] = json!({ "accountId": account_id });
1176 }
1177 if let Some(priority) = &req.priority {
1178 fields["priority"] = json!({ "name": priority });
1179 }
1180 if !req.labels.is_empty() {
1181 fields["labels"] = json!(req.labels);
1182 }
1183 if !req.components.is_empty() {
1184 fields["components"] = json!(req
1185 .components
1186 .iter()
1187 .map(|c| json!({"name": c}))
1188 .collect::<Vec<_>>());
1189 }
1190 if let Some(parent) = &req.parent {
1191 fields["parent"] = json!({ "key": parent });
1192 }
1193 if !req.fix_versions.is_empty() {
1194 fields["fixVersions"] = json!(req
1195 .fix_versions
1196 .iter()
1197 .map(|v| json!({"name": v}))
1198 .collect::<Vec<_>>());
1199 }
1200 for (field_id, value) in &req.custom_fields {
1201 fields[field_id] = value.to_api_json();
1202 }
1203
1204 let body = json!({ "fields": fields });
1205
1206 #[derive(serde::Deserialize)]
1207 struct CreateResponse {
1208 key: String,
1209 }
1210
1211 let http = &self.http;
1212 let resp: CreateResponse = self
1213 .request(|| http.post(&url).headers(headers.clone()).json(&body))
1214 .await?;
1215
1216 self.get_issue(&resp.key).await
1217 }
1218
1219 pub async fn get_comments(&self, issue_key: &str) -> Result<Vec<Comment>> {
1223 let headers = self.auth_headers()?;
1224 let url = self.platform_url(&format!("/issue/{issue_key}/comment"));
1225
1226 #[derive(serde::Deserialize)]
1227 struct CommentResponse {
1228 comments: Vec<Value>,
1229 }
1230
1231 let http = &self.http;
1232 let resp: CommentResponse = self
1233 .request(|| http.get(&url).headers(headers.clone()))
1234 .await?;
1235
1236 Ok(resp
1237 .comments
1238 .iter()
1239 .filter_map(|v| Comment::from_value(v, issue_key))
1240 .collect())
1241 }
1242
1243 pub async fn add_comment(&self, issue_key: &str, body: &str) -> Result<Comment> {
1245 let headers = self.auth_headers()?;
1246 let url = self.platform_url(&format!("/issue/{issue_key}/comment"));
1247
1248 let payload = json!({
1249 "body": markdown_to_adf(body)
1250 });
1251
1252 let http = &self.http;
1253 let raw: Value = self
1254 .request(|| http.post(&url).headers(headers.clone()).json(&payload))
1255 .await?;
1256
1257 Comment::from_value(&raw, issue_key).ok_or_else(|| JiraError::Api {
1258 status: 0,
1259 message: "Failed to parse comment".into(),
1260 })
1261 }
1262
1263 pub async fn get_worklogs(&self, issue_key: &str) -> Result<Vec<Worklog>> {
1267 let headers = self.auth_headers()?;
1268 let url = self.platform_url(&format!("/issue/{issue_key}/worklog"));
1269
1270 #[derive(serde::Deserialize)]
1271 struct WorklogResponse {
1272 worklogs: Vec<Value>,
1273 }
1274
1275 let http = &self.http;
1276 let resp: WorklogResponse = self
1277 .request(|| http.get(&url).headers(headers.clone()))
1278 .await?;
1279
1280 Ok(resp
1281 .worklogs
1282 .iter()
1283 .filter_map(|v| Worklog::from_value(v, issue_key))
1284 .collect())
1285 }
1286
1287 pub async fn add_worklog(
1291 &self,
1292 issue_key: &str,
1293 time_spent: &str,
1294 comment: Option<&str>,
1295 started: Option<&str>,
1296 ) -> Result<Worklog> {
1297 let headers = self.auth_headers()?;
1298 let url = self.platform_url(&format!("/issue/{issue_key}/worklog"));
1299
1300 let started_str = started
1302 .map(|s| s.to_string())
1303 .unwrap_or_else(current_jira_timestamp);
1304
1305 let mut body = json!({
1306 "timeSpent": time_spent,
1307 "started": started_str,
1308 });
1309
1310 if let Some(c) = comment {
1311 body["comment"] = markdown_to_adf(c);
1312 }
1313
1314 let http = &self.http;
1315 let raw: Value = self
1316 .request(|| http.post(&url).headers(headers.clone()).json(&body))
1317 .await?;
1318
1319 Worklog::from_value(&raw, issue_key).ok_or_else(|| JiraError::Api {
1320 status: 0,
1321 message: "Failed to parse worklog".into(),
1322 })
1323 }
1324
1325 pub async fn delete_worklog(&self, issue_key: &str, worklog_id: &str) -> Result<()> {
1327 let headers = self.auth_headers()?;
1328 let url = self.platform_url(&format!("/issue/{issue_key}/worklog/{worklog_id}"));
1329
1330 let http = &self.http;
1331 self.request_no_body(|| http.delete(&url).headers(headers.clone()))
1332 .await
1333 }
1334
1335 pub async fn delete_comment(&self, issue_key: &str, comment_id: &str) -> Result<()> {
1337 let headers = self.auth_headers()?;
1338 let url = self.platform_url(&format!("/issue/{issue_key}/comment/{comment_id}"));
1339
1340 let http = &self.http;
1341 self.request_no_body(|| http.delete(&url).headers(headers.clone()))
1342 .await
1343 }
1344
1345 pub async fn delete_attachment(&self, attachment_id: &str) -> Result<()> {
1347 let headers = self.auth_headers()?;
1348 let url = self.platform_url(&format!("/attachment/{attachment_id}"));
1349
1350 let http = &self.http;
1351 self.request_no_body(|| http.delete(&url).headers(headers.clone()))
1352 .await
1353 }
1354
1355 pub async fn get_remote_links(&self, issue_key: &str) -> Result<Vec<RemoteLink>> {
1357 let headers = self.auth_headers()?;
1358 let url = self.platform_url(&format!("/issue/{issue_key}/remotelink"));
1359
1360 let http = &self.http;
1361 self.request(|| http.get(&url).headers(headers.clone()))
1362 .await
1363 }
1364
1365 pub async fn add_remote_link(
1367 &self,
1368 issue_key: &str,
1369 url_str: &str,
1370 title: &str,
1371 ) -> Result<Value> {
1372 let headers = self.auth_headers()?;
1373 let url = self.platform_url(&format!("/issue/{issue_key}/remotelink"));
1374
1375 let payload = json!({
1376 "object": {
1377 "url": url_str,
1378 "title": title,
1379 }
1380 });
1381
1382 let http = &self.http;
1383 self.request(|| http.post(&url).headers(headers.clone()).json(&payload))
1384 .await
1385 }
1386
1387 pub async fn delete_remote_link(&self, issue_key: &str, link_id: &str) -> Result<()> {
1389 let headers = self.auth_headers()?;
1390 let url = self.platform_url(&format!("/issue/{issue_key}/remotelink/{link_id}"));
1391
1392 let http = &self.http;
1393 self.request_no_body(|| http.delete(&url).headers(headers.clone()))
1394 .await
1395 }
1396
1397 pub async fn get_all_issues(&self, jql: &str) -> Result<Vec<Issue>> {
1402 let mut all_issues = Vec::new();
1403 let mut next_page_token: Option<String> = None;
1404 let mut iterations = 0u32;
1405 const MAX_ITERATIONS: u32 = 500;
1406
1407 loop {
1408 iterations += 1;
1409 if iterations > MAX_ITERATIONS {
1410 break;
1411 }
1412
1413 let result = self
1414 .search_issues(jql, next_page_token.as_deref(), Some(100))
1415 .await?;
1416
1417 all_issues.extend(result.issues);
1418
1419 match result.next_page_token {
1420 Some(token) => next_page_token = Some(token),
1421 None => break,
1422 }
1423 }
1424
1425 Ok(all_issues)
1426 }
1427
1428 pub async fn archive_issues(&self, issue_keys: &[String]) -> Result<()> {
1430 if issue_keys.is_empty() {
1431 return Ok(());
1432 }
1433 let headers = self.auth_headers()?;
1434 let url = self.platform_url("/issue/archive");
1435
1436 for chunk in issue_keys.chunks(1000) {
1438 let body = json!({ "issueIdsOrKeys": chunk });
1439 let http = &self.http;
1440 let _: Value = self
1442 .request(|| http.put(&url).headers(headers.clone()).json(&body))
1443 .await?;
1444 }
1445
1446 Ok(())
1447 }
1448
1449 pub async fn move_issue(
1451 &self,
1452 issue_key: &str,
1453 target_project_key: &str,
1454 target_issue_type_id: &str,
1455 target_parent: Option<&str>,
1456 ) -> Result<Issue> {
1457 let mapping_key = match target_parent {
1458 Some(parent) => format!("{target_project_key},{target_issue_type_id},{parent}"),
1459 None => format!("{target_project_key},{target_issue_type_id}"),
1460 };
1461
1462 let body = json!({
1463 "sendBulkNotification": true,
1464 "targetToSourcesMapping": {
1465 mapping_key: {
1466 "inferClassificationDefaults": true,
1467 "inferFieldDefaults": true,
1468 "inferStatusDefaults": true,
1469 "inferSubtaskTypeDefault": true,
1470 "issueIdsOrKeys": [issue_key]
1471 }
1472 }
1473 });
1474
1475 let bulk_move_path = self.platform_path("/bulk/issues/move");
1476 let submitted = self
1477 .raw_request("POST", &bulk_move_path, Some(body))
1478 .await?
1479 .ok_or_else(|| JiraError::Api {
1480 status: 0,
1481 message: "Bulk move returned an empty response".into(),
1482 })?;
1483
1484 let task_id = submitted
1485 .get("taskId")
1486 .and_then(|v| v.as_str())
1487 .ok_or_else(|| JiraError::Api {
1488 status: 0,
1489 message: "Bulk move response did not include a taskId".into(),
1490 })?;
1491
1492 const MAX_POLLS: usize = 60;
1493 for _ in 0..MAX_POLLS {
1494 tokio::time::sleep(Duration::from_secs(2)).await;
1495
1496 let progress_path = self.platform_path(&format!("/bulk/queue/{task_id}"));
1497 let progress = self
1498 .raw_request("GET", &progress_path, None)
1499 .await?
1500 .ok_or_else(|| JiraError::Api {
1501 status: 0,
1502 message: "Bulk move progress returned an empty response".into(),
1503 })?;
1504
1505 match progress
1506 .get("status")
1507 .and_then(|v| v.as_str())
1508 .unwrap_or("")
1509 {
1510 "COMPLETE" => return self.get_issue(issue_key).await,
1511 "FAILED" | "DEAD" | "CANCELLED" => {
1512 return Err(JiraError::Api {
1513 status: 0,
1514 message: progress.to_string(),
1515 });
1516 }
1517 _ => {}
1518 }
1519 }
1520
1521 Err(JiraError::Api {
1522 status: 0,
1523 message: format!("Timed out waiting for Jira bulk move task {task_id}"),
1524 })
1525 }
1526
1527 pub async fn raw_request(
1533 &self,
1534 method: &str,
1535 path: &str,
1536 body: Option<Value>,
1537 ) -> Result<Option<Value>> {
1538 let headers = self.auth_headers()?;
1539 let url = format!("{}{}", self.config.base_url.trim_end_matches('/'), path);
1540
1541 let http = &self.http;
1542 let response = self
1543 .execute_with_retry(|| {
1544 let req = match method.to_uppercase().as_str() {
1545 "GET" => http.get(&url),
1546 "POST" => http.post(&url),
1547 "PUT" => http.put(&url),
1548 "DELETE" => http.delete(&url),
1549 "PATCH" => http.patch(&url),
1550 _ => http.get(&url),
1551 };
1552 let req = req.headers(headers.clone());
1553 if let Some(b) = &body {
1554 req.json(b)
1555 } else {
1556 req
1557 }
1558 })
1559 .await?;
1560
1561 let status = response.status();
1562
1563 if status == StatusCode::NO_CONTENT {
1565 return Ok(None);
1566 }
1567
1568 if status.is_success() {
1569 let value: Value = response.json().await?;
1570 return Ok(Some(value));
1571 }
1572
1573 let body_text = response.text().await.unwrap_or_default();
1574 Err(match status {
1575 StatusCode::NOT_FOUND => JiraError::NotFound(body_text),
1576 StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => {
1577 JiraError::Auth(format!("HTTP {status}: {body_text}"))
1578 }
1579 _ => JiraError::Api {
1580 status: status.as_u16(),
1581 message: body_text,
1582 },
1583 })
1584 }
1585
1586 pub async fn is_premium(&self) -> bool {
1590 match self.get_server_info().await {
1591 Ok(info) => {
1592 let license = info
1593 .get("deploymentType")
1594 .and_then(|v| v.as_str())
1595 .unwrap_or("");
1596 let _ = license;
1598 let headers = match self.auth_headers() {
1600 Ok(h) => h,
1601 Err(_) => return false,
1602 };
1603 let url = self.platform_url("/plans/plan");
1604 let http = &self.http;
1605 matches!(
1606 http.get(&url).headers(headers).send().await,
1607 Ok(r) if r.status().is_success()
1608 )
1609 }
1610 Err(_) => false,
1611 }
1612 }
1613
1614 pub async fn get_plans(&self) -> Result<Vec<Value>> {
1616 let headers = self.auth_headers()?;
1617 let url = self.platform_url("/plans/plan");
1618
1619 #[derive(serde::Deserialize)]
1620 struct PlansResponse {
1621 values: Vec<Value>,
1622 }
1623
1624 let http = &self.http;
1625 let resp: PlansResponse = self
1626 .request(|| http.get(&url).headers(headers.clone()))
1627 .await?;
1628
1629 Ok(resp.values)
1630 }
1631
1632 pub async fn list_issue_link_types(&self) -> Result<Vec<IssueLinkType>> {
1636 let headers = self.auth_headers()?;
1637 let url = self.platform_url("/issueLinkType");
1638
1639 #[derive(serde::Deserialize)]
1640 struct LinkTypesResponse {
1641 #[serde(rename = "issueLinkTypes")]
1642 issue_link_types: Vec<IssueLinkType>,
1643 }
1644
1645 let http = &self.http;
1646 let resp: LinkTypesResponse = self
1647 .request(|| http.get(&url).headers(headers.clone()))
1648 .await?;
1649
1650 Ok(resp.issue_link_types)
1651 }
1652
1653 pub async fn link_issues(
1658 &self,
1659 outward_key: &str,
1660 inward_key: &str,
1661 type_name: &str,
1662 comment: Option<&str>,
1663 ) -> Result<()> {
1664 let headers = self.auth_headers()?;
1665 let url = self.platform_url("/issueLink");
1666
1667 let mut payload = json!({
1668 "type": { "name": type_name },
1669 "inwardIssue": { "key": inward_key },
1670 "outwardIssue": { "key": outward_key }
1671 });
1672
1673 if let Some(c) = comment {
1674 payload["comment"] = json!({
1675 "body": crate::adf::markdown_to_adf(c)
1676 });
1677 }
1678
1679 let http = &self.http;
1680 self.request_no_body(|| http.post(&url).headers(headers.clone()).json(&payload))
1681 .await
1682 }
1683
1684 pub async fn get_issue_link(&self, link_id: &str) -> Result<IssueLink> {
1686 let headers = self.auth_headers()?;
1687 let url = self.platform_url(&format!("/issueLink/{link_id}"));
1688
1689 let http = &self.http;
1690 self.request(|| http.get(&url).headers(headers.clone()))
1691 .await
1692 }
1693
1694 pub async fn delete_issue_link(&self, link_id: &str) -> Result<()> {
1696 let headers = self.auth_headers()?;
1697 let url = self.platform_url(&format!("/issueLink/{link_id}"));
1698
1699 let http = &self.http;
1700 self.request_no_body(|| http.delete(&url).headers(headers.clone()))
1701 .await
1702 }
1703}
1704
1705async fn handle_response<T>(response: Response) -> Result<T>
1706where
1707 T: serde::de::DeserializeOwned,
1708{
1709 let status = response.status();
1710
1711 if status.is_success() {
1712 if status == StatusCode::NO_CONTENT || status == StatusCode::RESET_CONTENT {
1715 return serde_json::from_value(serde_json::Value::Null).map_err(|_| JiraError::Api {
1716 status: status.as_u16(),
1717 message: "Unexpected empty response body".into(),
1718 });
1719 }
1720 let value: T = response.json().await?;
1721 return Ok(value);
1722 }
1723
1724 let body = response.text().await.unwrap_or_default();
1725
1726 match status {
1727 StatusCode::NOT_FOUND => Err(JiraError::NotFound(body)),
1728 StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => {
1729 Err(JiraError::Auth(format!("HTTP {status}: {body}")))
1730 }
1731 _ => Err(JiraError::Api {
1732 status: status.as_u16(),
1733 message: body,
1734 }),
1735 }
1736}
1737
1738fn current_jira_timestamp() -> String {
1740 chrono::Utc::now()
1741 .format("%Y-%m-%dT%H:%M:%S%.3f%z")
1742 .to_string()
1743}
1744
1745fn base64_encode(input: &str) -> String {
1746 use std::fmt::Write;
1747 const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
1748 let bytes = input.as_bytes();
1749 let mut result = String::new();
1750 let mut i = 0;
1751 while i < bytes.len() {
1752 let b0 = bytes[i] as u32;
1753 let b1 = if i + 1 < bytes.len() {
1754 bytes[i + 1] as u32
1755 } else {
1756 0
1757 };
1758 let b2 = if i + 2 < bytes.len() {
1759 bytes[i + 2] as u32
1760 } else {
1761 0
1762 };
1763
1764 let _ = write!(result, "{}", CHARS[((b0 >> 2) & 0x3F) as usize] as char);
1765 let _ = write!(
1766 result,
1767 "{}",
1768 CHARS[(((b0 & 0x3) << 4) | ((b1 >> 4) & 0xF)) as usize] as char
1769 );
1770 if i + 1 < bytes.len() {
1771 let _ = write!(
1772 result,
1773 "{}",
1774 CHARS[(((b1 & 0xF) << 2) | ((b2 >> 6) & 0x3)) as usize] as char
1775 );
1776 } else {
1777 result.push('=');
1778 }
1779 if i + 2 < bytes.len() {
1780 let _ = write!(result, "{}", CHARS[(b2 & 0x3F) as usize] as char);
1781 } else {
1782 result.push('=');
1783 }
1784 i += 3;
1785 }
1786 result
1787}
1788
1789#[cfg(test)]
1790mod tests {
1791 use super::*;
1792 use crate::{
1793 config::{JiraAuthType, JiraDeployment},
1794 model::FieldValue,
1795 };
1796 use wiremock::{
1797 matchers::{body_json, header, method, path, query_param},
1798 Mock, MockServer, ResponseTemplate,
1799 };
1800
1801 fn cloud_client(server: &MockServer) -> JiraClient {
1802 JiraClient::new(JiraConfig {
1803 profile_name: Some("cloud-main".into()),
1804 base_url: server.uri(),
1805 email: "dev@example.com".into(),
1806 token: Some("cloud-token".into()),
1807 project: None,
1808 timeout_secs: 30,
1809 deployment: JiraDeployment::Cloud,
1810 auth_type: JiraAuthType::CloudApiToken,
1811 api_version: 3,
1812 })
1813 }
1814
1815 fn cloud_auth() -> String {
1816 format!("Basic {}", base64_encode("dev@example.com:cloud-token"))
1817 }
1818
1819 #[tokio::test]
1820 async fn data_center_pat_uses_bearer_and_api_v2() {
1821 let server = MockServer::start().await;
1822
1823 Mock::given(method("GET"))
1824 .and(path("/rest/api/2/serverInfo"))
1825 .and(header("authorization", "Bearer dc-token"))
1826 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1827 "deploymentType": "Data Center",
1828 "version": "10.0.0"
1829 })))
1830 .mount(&server)
1831 .await;
1832
1833 let client = JiraClient::new(JiraConfig {
1834 profile_name: Some("dc-main".into()),
1835 base_url: server.uri(),
1836 email: String::new(),
1837 token: Some("dc-token".into()),
1838 project: None,
1839 timeout_secs: 30,
1840 deployment: JiraDeployment::DataCenter,
1841 auth_type: JiraAuthType::DataCenterPat,
1842 api_version: 2,
1843 });
1844
1845 let info = client.get_server_info().await.expect("server info");
1846 assert_eq!(info["deploymentType"], Value::String("Data Center".into()));
1847 }
1848
1849 #[tokio::test]
1850 async fn cloud_auth_uses_basic_and_api_v3() {
1851 let server = MockServer::start().await;
1852 let expected = format!("Basic {}", base64_encode("dev@example.com:cloud-token"));
1853
1854 Mock::given(method("GET"))
1855 .and(path("/rest/api/3/serverInfo"))
1856 .and(header("authorization", expected.as_str()))
1857 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1858 "deploymentType": "Cloud",
1859 "version": "1001.0.0"
1860 })))
1861 .mount(&server)
1862 .await;
1863
1864 let client = JiraClient::new(JiraConfig {
1865 profile_name: Some("cloud-main".into()),
1866 base_url: server.uri(),
1867 email: "dev@example.com".into(),
1868 token: Some("cloud-token".into()),
1869 project: None,
1870 timeout_secs: 30,
1871 deployment: JiraDeployment::Cloud,
1872 auth_type: JiraAuthType::CloudApiToken,
1873 api_version: 3,
1874 });
1875
1876 let info = client.get_server_info().await.expect("server info");
1877 assert_eq!(info["deploymentType"], Value::String("Cloud".into()));
1878 }
1879
1880 #[tokio::test]
1881 async fn get_fields_for_issue_type_supports_map_response() {
1882 let server = MockServer::start().await;
1883 let expected = format!("Basic {}", base64_encode("dev@example.com:cloud-token"));
1884
1885 Mock::given(method("GET"))
1886 .and(path("/rest/api/3/issue/createmeta/TEST/issuetypes/10001"))
1887 .and(header("authorization", expected.as_str()))
1888 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1889 "fields": {
1890 "summary": {
1891 "name": "Summary",
1892 "required": true,
1893 "schema": { "type": "string" }
1894 }
1895 }
1896 })))
1897 .mount(&server)
1898 .await;
1899
1900 let client = JiraClient::new(JiraConfig {
1901 profile_name: Some("cloud-main".into()),
1902 base_url: server.uri(),
1903 email: "dev@example.com".into(),
1904 token: Some("cloud-token".into()),
1905 project: None,
1906 timeout_secs: 30,
1907 deployment: JiraDeployment::Cloud,
1908 auth_type: JiraAuthType::CloudApiToken,
1909 api_version: 3,
1910 });
1911
1912 let fields = client
1913 .get_fields_for_issue_type("TEST", "10001")
1914 .await
1915 .expect("map response should parse");
1916
1917 assert_eq!(fields.len(), 1);
1918 assert_eq!(fields[0].id, "summary");
1919 assert_eq!(fields[0].name, "Summary");
1920 assert!(fields[0].required);
1921 assert_eq!(fields[0].field_type, "string");
1922 }
1923
1924 #[tokio::test]
1925 async fn get_fields_for_issue_type_supports_list_response() {
1926 let server = MockServer::start().await;
1927 let expected = format!("Basic {}", base64_encode("dev@example.com:cloud-token"));
1928
1929 Mock::given(method("GET"))
1930 .and(path("/rest/api/3/issue/createmeta/TEST/issuetypes/10002"))
1931 .and(header("authorization", expected.as_str()))
1932 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1933 "fields": [
1934 {
1935 "fieldId": "customfield_10553",
1936 "key": "customfield_10553",
1937 "name": "Labels (OSS)",
1938 "required": true,
1939 "schema": {
1940 "custom": "com.atlassian.jira.plugin.system.customfieldtypes:labels",
1941 "items": "string",
1942 "type": "array"
1943 },
1944 "allowedValues": []
1945 }
1946 ]
1947 })))
1948 .mount(&server)
1949 .await;
1950
1951 let client = JiraClient::new(JiraConfig {
1952 profile_name: Some("cloud-main".into()),
1953 base_url: server.uri(),
1954 email: "dev@example.com".into(),
1955 token: Some("cloud-token".into()),
1956 project: None,
1957 timeout_secs: 30,
1958 deployment: JiraDeployment::Cloud,
1959 auth_type: JiraAuthType::CloudApiToken,
1960 api_version: 3,
1961 });
1962
1963 let fields = client
1964 .get_fields_for_issue_type("TEST", "10002")
1965 .await
1966 .expect("list response should parse");
1967
1968 assert_eq!(fields.len(), 1);
1969 assert_eq!(fields[0].id, "customfield_10553");
1970 assert_eq!(fields[0].name, "Labels (OSS)");
1971 assert!(fields[0].required);
1972 assert_eq!(fields[0].field_type, "array");
1973 }
1974
1975 #[tokio::test]
1976 async fn search_users_supports_empty_query_and_no_results() {
1977 let server = MockServer::start().await;
1978 let expected_auth = cloud_auth();
1979
1980 Mock::given(method("GET"))
1981 .and(path("/rest/api/3/user/search"))
1982 .and(header("authorization", expected_auth.as_str()))
1983 .and(query_param("query", ""))
1984 .and(query_param("maxResults", "20"))
1985 .respond_with(ResponseTemplate::new(200).set_body_json(json!([])))
1986 .mount(&server)
1987 .await;
1988
1989 let client = cloud_client(&server);
1990 let users = client
1991 .search_users("")
1992 .await
1993 .expect("empty query should work");
1994
1995 assert!(users.is_empty());
1996 }
1997
1998 #[tokio::test]
1999 async fn search_users_preserves_users_without_email() {
2000 let server = MockServer::start().await;
2001 let expected_auth = cloud_auth();
2002
2003 Mock::given(method("GET"))
2004 .and(path("/rest/api/3/user/search"))
2005 .and(header("authorization", expected_auth.as_str()))
2006 .and(query_param("query", "alice"))
2007 .and(query_param("maxResults", "20"))
2008 .respond_with(ResponseTemplate::new(200).set_body_json(json!([
2009 {
2010 "accountId": "acct-1",
2011 "displayName": "Alice Example"
2012 }
2013 ])))
2014 .mount(&server)
2015 .await;
2016
2017 let client = cloud_client(&server);
2018 let users = client
2019 .search_users("alice")
2020 .await
2021 .expect("search should parse");
2022
2023 assert_eq!(users.len(), 1);
2024 assert_eq!(users[0].account_id, "acct-1");
2025 assert_eq!(users[0].display_name.as_deref(), Some("Alice Example"));
2026 assert!(users[0].email_address.is_none());
2027 }
2028
2029 #[tokio::test]
2030 async fn add_issue_to_sprint_posts_expected_payload() {
2031 let server = MockServer::start().await;
2032 let expected_auth = cloud_auth();
2033
2034 Mock::given(method("POST"))
2035 .and(path("/rest/agile/1.0/sprint/42/issue"))
2036 .and(header("authorization", expected_auth.as_str()))
2037 .and(body_json(json!({ "issues": ["TEST-123"] })))
2038 .respond_with(ResponseTemplate::new(200).set_body_json(json!({})))
2039 .mount(&server)
2040 .await;
2041
2042 let client = cloud_client(&server);
2043 client
2044 .add_issue_to_sprint(42, "TEST-123")
2045 .await
2046 .expect("add to sprint should succeed");
2047 }
2048
2049 #[tokio::test]
2050 async fn add_issue_to_sprint_propagates_non_success_response() {
2051 let server = MockServer::start().await;
2052 let expected_auth = cloud_auth();
2053
2054 Mock::given(method("POST"))
2055 .and(path("/rest/agile/1.0/sprint/42/issue"))
2056 .and(header("authorization", expected_auth.as_str()))
2057 .respond_with(ResponseTemplate::new(400).set_body_string("bad sprint request"))
2058 .mount(&server)
2059 .await;
2060
2061 let client = cloud_client(&server);
2062 let err = client
2063 .add_issue_to_sprint(42, "TEST-123")
2064 .await
2065 .expect_err("non-success should return error");
2066
2067 match err {
2068 JiraError::Api { status, message } => {
2069 assert_eq!(status, 400);
2070 assert!(message.contains("bad sprint request"));
2071 }
2072 other => panic!("expected JiraError::Api, got {other:?}"),
2073 }
2074 }
2075
2076 #[tokio::test]
2077 async fn list_sprints_for_project_returns_empty_when_no_boards_exist() {
2078 let server = MockServer::start().await;
2079 let expected_auth = cloud_auth();
2080
2081 Mock::given(method("GET"))
2082 .and(path("/rest/agile/1.0/board"))
2083 .and(header("authorization", expected_auth.as_str()))
2084 .and(query_param("projectKeyOrId", "TEST"))
2085 .and(query_param("maxResults", "100"))
2086 .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "values": [] })))
2087 .mount(&server)
2088 .await;
2089
2090 let client = cloud_client(&server);
2091 let sprints = client
2092 .list_sprints_for_project("TEST")
2093 .await
2094 .expect("no boards should not error");
2095
2096 assert!(sprints.is_empty());
2097 }
2098
2099 #[tokio::test]
2100 async fn list_sprints_for_project_dedups_sorts_and_skips_missing_board_sprints() {
2101 let server = MockServer::start().await;
2102 let expected_auth = cloud_auth();
2103
2104 Mock::given(method("GET"))
2105 .and(path("/rest/agile/1.0/board"))
2106 .and(header("authorization", expected_auth.as_str()))
2107 .and(query_param("projectKeyOrId", "TEST"))
2108 .and(query_param("maxResults", "100"))
2109 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2110 "values": [
2111 { "id": 1 },
2112 { "id": 2 },
2113 { "id": 3 }
2114 ]
2115 })))
2116 .mount(&server)
2117 .await;
2118
2119 Mock::given(method("GET"))
2120 .and(path("/rest/agile/1.0/board/1/sprint"))
2121 .and(header("authorization", expected_auth.as_str()))
2122 .and(query_param("state", "active,future"))
2123 .and(query_param("maxResults", "200"))
2124 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2125 "values": [
2126 { "id": 20, "name": "Future Sprint", "state": "future" },
2127 { "id": 10, "name": "Active Sprint", "state": "active" }
2128 ]
2129 })))
2130 .mount(&server)
2131 .await;
2132
2133 Mock::given(method("GET"))
2134 .and(path("/rest/agile/1.0/board/2/sprint"))
2135 .and(header("authorization", expected_auth.as_str()))
2136 .and(query_param("state", "active,future"))
2137 .and(query_param("maxResults", "200"))
2138 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2139 "values": [
2140 { "id": 10, "name": "Active Sprint", "state": "active" },
2141 { "id": 30, "name": "Alpha Future", "state": "future" }
2142 ]
2143 })))
2144 .mount(&server)
2145 .await;
2146
2147 Mock::given(method("GET"))
2148 .and(path("/rest/agile/1.0/board/3/sprint"))
2149 .and(header("authorization", expected_auth.as_str()))
2150 .and(query_param("state", "active,future"))
2151 .and(query_param("maxResults", "200"))
2152 .respond_with(ResponseTemplate::new(404).set_body_string("board not found"))
2153 .mount(&server)
2154 .await;
2155
2156 let client = cloud_client(&server);
2157 let sprints = client
2158 .list_sprints_for_project("TEST")
2159 .await
2160 .expect("404 on one board should be skipped");
2161
2162 assert_eq!(sprints.len(), 3);
2163 assert_eq!(sprints[0].id, 10);
2164 assert_eq!(sprints[0].state, "active");
2165 assert_eq!(sprints[1].id, 30);
2166 assert_eq!(sprints[1].state, "future");
2167 assert_eq!(sprints[2].id, 20);
2168 assert_eq!(sprints[2].state, "future");
2169 }
2170
2171 #[tokio::test]
2172 async fn list_sprints_for_project_handles_large_sprint_pages() {
2173 let server = MockServer::start().await;
2174 let expected_auth = cloud_auth();
2175
2176 let sprint_values: Vec<Value> = (1..=60)
2177 .map(|id| {
2178 json!({
2179 "id": id,
2180 "name": format!("Sprint {id:02}"),
2181 "state": if id == 1 { "active" } else { "future" }
2182 })
2183 })
2184 .collect();
2185
2186 Mock::given(method("GET"))
2187 .and(path("/rest/agile/1.0/board"))
2188 .and(header("authorization", expected_auth.as_str()))
2189 .and(query_param("projectKeyOrId", "TEST"))
2190 .and(query_param("maxResults", "100"))
2191 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2192 "values": [{ "id": 9 }]
2193 })))
2194 .mount(&server)
2195 .await;
2196
2197 Mock::given(method("GET"))
2198 .and(path("/rest/agile/1.0/board/9/sprint"))
2199 .and(header("authorization", expected_auth.as_str()))
2200 .and(query_param("state", "active,future"))
2201 .and(query_param("maxResults", "200"))
2202 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2203 "values": sprint_values
2204 })))
2205 .mount(&server)
2206 .await;
2207
2208 let client = cloud_client(&server);
2209 let sprints = client
2210 .list_sprints_for_project("TEST")
2211 .await
2212 .expect(">50 sprints in one response should parse");
2213
2214 assert_eq!(sprints.len(), 60);
2215 assert_eq!(sprints[0].id, 1);
2216 assert_eq!(sprints[0].state, "active");
2217 assert_eq!(sprints[59].id, 60);
2218 }
2219
2220 #[tokio::test]
2221 async fn list_sprints_for_project_paginates_boards_and_sprints() {
2222 let server = MockServer::start().await;
2223 let expected_auth = cloud_auth();
2224
2225 Mock::given(method("GET"))
2226 .and(path("/rest/agile/1.0/board"))
2227 .and(header("authorization", expected_auth.as_str()))
2228 .and(query_param("projectKeyOrId", "TEST"))
2229 .and(query_param("maxResults", "100"))
2230 .and(query_param("startAt", "0"))
2231 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2232 "values": [{ "id": 1 }],
2233 "isLast": false
2234 })))
2235 .mount(&server)
2236 .await;
2237
2238 Mock::given(method("GET"))
2239 .and(path("/rest/agile/1.0/board"))
2240 .and(header("authorization", expected_auth.as_str()))
2241 .and(query_param("projectKeyOrId", "TEST"))
2242 .and(query_param("maxResults", "100"))
2243 .and(query_param("startAt", "1"))
2244 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2245 "values": [{ "id": 2 }],
2246 "isLast": true
2247 })))
2248 .mount(&server)
2249 .await;
2250
2251 Mock::given(method("GET"))
2252 .and(path("/rest/agile/1.0/board/1/sprint"))
2253 .and(header("authorization", expected_auth.as_str()))
2254 .and(query_param("state", "active,future"))
2255 .and(query_param("maxResults", "200"))
2256 .and(query_param("startAt", "0"))
2257 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2258 "values": [{
2259 "id": 10,
2260 "name": "Sprint 10",
2261 "state": "active"
2262 }],
2263 "isLast": false
2264 })))
2265 .mount(&server)
2266 .await;
2267
2268 Mock::given(method("GET"))
2269 .and(path("/rest/agile/1.0/board/1/sprint"))
2270 .and(header("authorization", expected_auth.as_str()))
2271 .and(query_param("state", "active,future"))
2272 .and(query_param("maxResults", "200"))
2273 .and(query_param("startAt", "1"))
2274 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2275 "values": [{
2276 "id": 11,
2277 "name": "Sprint 11",
2278 "state": "future"
2279 }],
2280 "isLast": true
2281 })))
2282 .mount(&server)
2283 .await;
2284
2285 Mock::given(method("GET"))
2286 .and(path("/rest/agile/1.0/board/2/sprint"))
2287 .and(header("authorization", expected_auth.as_str()))
2288 .and(query_param("state", "active,future"))
2289 .and(query_param("maxResults", "200"))
2290 .and(query_param("startAt", "0"))
2291 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2292 "values": [{
2293 "id": 12,
2294 "name": "Sprint 12",
2295 "state": "future"
2296 }],
2297 "isLast": true
2298 })))
2299 .mount(&server)
2300 .await;
2301
2302 let client = cloud_client(&server);
2303 let sprints = client
2304 .list_sprints_for_project("TEST")
2305 .await
2306 .expect("pagination should collect all pages");
2307
2308 assert_eq!(sprints.len(), 3);
2309 assert_eq!(sprints[0].id, 10);
2310 assert_eq!(sprints[1].id, 11);
2311 assert_eq!(sprints[2].id, 12);
2312 }
2313
2314 #[tokio::test]
2315 async fn list_sprints_for_project_propagates_non_not_found_board_errors() {
2316 let server = MockServer::start().await;
2317 let expected_auth = cloud_auth();
2318
2319 Mock::given(method("GET"))
2320 .and(path("/rest/agile/1.0/board"))
2321 .and(header("authorization", expected_auth.as_str()))
2322 .and(query_param("projectKeyOrId", "TEST"))
2323 .and(query_param("maxResults", "100"))
2324 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2325 "values": [{ "id": 9 }]
2326 })))
2327 .mount(&server)
2328 .await;
2329
2330 Mock::given(method("GET"))
2331 .and(path("/rest/agile/1.0/board/9/sprint"))
2332 .and(header("authorization", expected_auth.as_str()))
2333 .and(query_param("state", "active,future"))
2334 .and(query_param("maxResults", "200"))
2335 .respond_with(ResponseTemplate::new(500).set_body_string("jira exploded"))
2336 .mount(&server)
2337 .await;
2338
2339 let client = cloud_client(&server);
2340 let err = client
2341 .list_sprints_for_project("TEST")
2342 .await
2343 .expect_err("500 should propagate");
2344
2345 match err {
2346 JiraError::Api { status, message } => {
2347 assert_eq!(status, 500);
2348 assert!(message.contains("jira exploded"));
2349 }
2350 other => panic!("expected JiraError::Api, got {other:?}"),
2351 }
2352 }
2353
2354 #[tokio::test]
2355 async fn create_sprint_posts_expected_payload() {
2356 let server = MockServer::start().await;
2357 let expected_auth = cloud_auth();
2358
2359 Mock::given(method("POST"))
2360 .and(path("/rest/agile/1.0/sprint"))
2361 .and(header("authorization", expected_auth.as_str()))
2362 .and(body_json(json!({
2363 "name": "Sprint 42",
2364 "originBoardId": 7,
2365 "startDate": "2026-05-20T00:00:00.000Z",
2366 "endDate": "2026-05-27T00:00:00.000Z",
2367 "goal": "Ship sprint lifecycle support"
2368 })))
2369 .respond_with(ResponseTemplate::new(201).set_body_json(json!({
2370 "id": 42,
2371 "name": "Sprint 42",
2372 "state": "future",
2373 "goal": "Ship sprint lifecycle support",
2374 "startDate": "2026-05-20T00:00:00.000Z",
2375 "endDate": "2026-05-27T00:00:00.000Z"
2376 })))
2377 .mount(&server)
2378 .await;
2379
2380 let client = cloud_client(&server);
2381 let sprint = client
2382 .create_sprint(
2383 7,
2384 "Sprint 42",
2385 Some("2026-05-20T00:00:00.000Z"),
2386 Some("2026-05-27T00:00:00.000Z"),
2387 Some("Ship sprint lifecycle support"),
2388 )
2389 .await
2390 .expect("create sprint should succeed");
2391
2392 assert_eq!(sprint.id, 42);
2393 assert_eq!(sprint.board_id, Some(7));
2394 assert_eq!(sprint.state, "future");
2395 assert_eq!(
2396 sprint.goal.as_deref(),
2397 Some("Ship sprint lifecycle support")
2398 );
2399 }
2400
2401 #[tokio::test]
2402 async fn update_sprint_puts_expected_payload() {
2403 let server = MockServer::start().await;
2404 let expected_auth = cloud_auth();
2405
2406 Mock::given(method("PUT"))
2407 .and(path("/rest/agile/1.0/sprint/42"))
2408 .and(header("authorization", expected_auth.as_str()))
2409 .and(body_json(json!({
2410 "state": "active",
2411 "startDate": "2026-05-20T00:00:00.000Z",
2412 "endDate": "2026-05-27T00:00:00.000Z"
2413 })))
2414 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2415 "id": 42,
2416 "name": "Sprint 42",
2417 "state": "active",
2418 "startDate": "2026-05-20T00:00:00.000Z",
2419 "endDate": "2026-05-27T00:00:00.000Z"
2420 })))
2421 .mount(&server)
2422 .await;
2423
2424 let client = cloud_client(&server);
2425 let sprint = client
2426 .update_sprint(
2427 42,
2428 json!({
2429 "state": "active",
2430 "startDate": "2026-05-20T00:00:00.000Z",
2431 "endDate": "2026-05-27T00:00:00.000Z"
2432 }),
2433 )
2434 .await
2435 .expect("update sprint should succeed");
2436
2437 assert_eq!(sprint.id, 42);
2438 assert_eq!(sprint.state, "active");
2439 }
2440
2441 #[tokio::test]
2442 async fn delete_sprint_uses_delete_endpoint() {
2443 let server = MockServer::start().await;
2444 let expected_auth = cloud_auth();
2445
2446 Mock::given(method("DELETE"))
2447 .and(path("/rest/agile/1.0/sprint/42"))
2448 .and(header("authorization", expected_auth.as_str()))
2449 .respond_with(ResponseTemplate::new(204))
2450 .mount(&server)
2451 .await;
2452
2453 let client = cloud_client(&server);
2454 client
2455 .delete_sprint(42)
2456 .await
2457 .expect("delete sprint should succeed");
2458 }
2459
2460 #[tokio::test]
2461 async fn create_issue_v2_prefers_adf_and_builds_extended_fields() {
2462 let server = MockServer::start().await;
2463 let expected_auth = cloud_auth();
2464 let description_adf = json!({
2465 "type": "doc",
2466 "version": 1,
2467 "content": [{
2468 "type": "paragraph",
2469 "content": [{ "type": "text", "text": "ADF body" }]
2470 }]
2471 });
2472
2473 Mock::given(method("GET"))
2474 .and(path("/rest/api/3/user/search"))
2475 .and(header("authorization", expected_auth.as_str()))
2476 .and(query_param("query", "alice@example.com"))
2477 .and(query_param("maxResults", "20"))
2478 .respond_with(ResponseTemplate::new(200).set_body_json(json!([
2479 {
2480 "accountId": "acct-1",
2481 "emailAddress": "alice@example.com",
2482 "displayName": "Alice"
2483 }
2484 ])))
2485 .mount(&server)
2486 .await;
2487
2488 Mock::given(method("POST"))
2489 .and(path("/rest/api/3/issue"))
2490 .and(header("authorization", expected_auth.as_str()))
2491 .and(body_json(json!({
2492 "fields": {
2493 "project": { "key": "TEST" },
2494 "summary": "Ship feature",
2495 "issuetype": { "name": "Task" },
2496 "description": description_adf,
2497 "assignee": { "accountId": "acct-1" },
2498 "priority": { "name": "High" },
2499 "labels": ["backend", "urgent"],
2500 "components": [{ "name": "api" }, { "name": "worker" }],
2501 "parent": { "key": "TEST-1" },
2502 "fixVersions": [{ "name": "v1.0" }, { "name": "v1.1" }],
2503 "customfield_10010": "hello",
2504 "customfield_10011": { "value": "Blue" },
2505 "customfield_10012": ["triage"]
2506 }
2507 })))
2508 .respond_with(ResponseTemplate::new(201).set_body_json(json!({ "key": "TEST-123" })))
2509 .mount(&server)
2510 .await;
2511
2512 Mock::given(method("GET"))
2513 .and(path("/rest/api/3/issue/TEST-123"))
2514 .and(header("authorization", expected_auth.as_str()))
2515 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2516 "id": "10001",
2517 "key": "TEST-123",
2518 "fields": {
2519 "summary": "Ship feature",
2520 "description": description_adf,
2521 "status": { "name": "To Do" },
2522 "issuetype": { "name": "Task" },
2523 "project": { "key": "TEST" },
2524 "created": "2023-01-01T00:00:00.000+0000",
2525 "updated": "2023-01-01T00:00:00.000+0000"
2526 }
2527 })))
2528 .mount(&server)
2529 .await;
2530
2531 let client = cloud_client(&server);
2532 let mut custom_fields = std::collections::HashMap::new();
2533 custom_fields.insert(
2534 "customfield_10010".to_string(),
2535 FieldValue::Text("hello".into()),
2536 );
2537 custom_fields.insert(
2538 "customfield_10011".to_string(),
2539 FieldValue::SelectName("Blue".into()),
2540 );
2541 custom_fields.insert(
2542 "customfield_10012".to_string(),
2543 FieldValue::Labels(vec!["triage".into()]),
2544 );
2545
2546 let issue = client
2547 .create_issue_v2(CreateIssueRequestV2 {
2548 project_key: "TEST".into(),
2549 summary: "Ship feature".into(),
2550 description: Some("markdown body should be ignored".into()),
2551 description_adf: Some(description_adf.clone()),
2552 issue_type: "Task".into(),
2553 assignee: Some("alice@example.com".into()),
2554 priority: Some("High".into()),
2555 labels: vec!["backend".into(), "urgent".into()],
2556 components: vec!["api".into(), "worker".into()],
2557 parent: Some("TEST-1".into()),
2558 fix_versions: vec!["v1.0".into(), "v1.1".into()],
2559 custom_fields,
2560 })
2561 .await
2562 .expect("create issue v2 should succeed");
2563
2564 assert_eq!(issue.key, "TEST-123");
2565 assert_eq!(issue.summary, "Ship feature");
2566 assert_eq!(issue.description, Some(description_adf));
2567 }
2568
2569 #[tokio::test]
2570 async fn create_issue_v2_uses_markdown_description_when_adf_missing() {
2571 let server = MockServer::start().await;
2572 let expected_auth = cloud_auth();
2573 let markdown_adf = markdown_to_adf("hello world");
2574
2575 Mock::given(method("POST"))
2576 .and(path("/rest/api/3/issue"))
2577 .and(header("authorization", expected_auth.as_str()))
2578 .and(body_json(json!({
2579 "fields": {
2580 "project": { "key": "TEST" },
2581 "summary": "Plain issue",
2582 "issuetype": { "name": "Task" },
2583 "description": markdown_adf
2584 }
2585 })))
2586 .respond_with(ResponseTemplate::new(201).set_body_json(json!({ "key": "TEST-124" })))
2587 .mount(&server)
2588 .await;
2589
2590 Mock::given(method("GET"))
2591 .and(path("/rest/api/3/issue/TEST-124"))
2592 .and(header("authorization", expected_auth.as_str()))
2593 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2594 "id": "10002",
2595 "key": "TEST-124",
2596 "fields": {
2597 "summary": "Plain issue",
2598 "description": markdown_to_adf("hello world"),
2599 "status": { "name": "To Do" },
2600 "issuetype": { "name": "Task" },
2601 "project": { "key": "TEST" },
2602 "created": "2023-01-01T00:00:00.000+0000",
2603 "updated": "2023-01-01T00:00:00.000+0000"
2604 }
2605 })))
2606 .mount(&server)
2607 .await;
2608
2609 let client = cloud_client(&server);
2610 let issue = client
2611 .create_issue_v2(CreateIssueRequestV2 {
2612 project_key: "TEST".into(),
2613 summary: "Plain issue".into(),
2614 description: Some("hello world".into()),
2615 description_adf: None,
2616 issue_type: "Task".into(),
2617 assignee: None,
2618 priority: None,
2619 labels: vec![],
2620 components: vec![],
2621 parent: None,
2622 fix_versions: vec![],
2623 custom_fields: std::collections::HashMap::new(),
2624 })
2625 .await
2626 .expect("markdown fallback should succeed");
2627
2628 assert_eq!(issue.key, "TEST-124");
2629 assert_eq!(issue.summary, "Plain issue");
2630 assert_eq!(issue.description, Some(markdown_to_adf("hello world")));
2631 }
2632
2633 #[tokio::test]
2634 async fn get_comments_parses_comment_text_and_mentions() {
2635 let server = MockServer::start().await;
2636 let expected_auth = cloud_auth();
2637
2638 Mock::given(method("GET"))
2639 .and(path("/rest/api/3/issue/TEST-1/comment"))
2640 .and(header("authorization", expected_auth.as_str()))
2641 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2642 "comments": [
2643 {
2644 "id": "10001",
2645 "author": {
2646 "displayName": "Alice",
2647 "accountId": "acct-1"
2648 },
2649 "body": {
2650 "type": "doc",
2651 "version": 1,
2652 "content": [{
2653 "type": "paragraph",
2654 "content": [
2655 { "type": "text", "text": "Hi " },
2656 { "type": "mention", "attrs": { "id": "acct-2", "text": "@Bob" } }
2657 ]
2658 }]
2659 },
2660 "created": "2023-01-01T00:00:00.000+0000",
2661 "updated": "2023-01-01T00:00:00.000+0000"
2662 }
2663 ]
2664 })))
2665 .mount(&server)
2666 .await;
2667
2668 let client = cloud_client(&server);
2669 let comments = client
2670 .get_comments("TEST-1")
2671 .await
2672 .expect("comments should parse");
2673
2674 assert_eq!(comments.len(), 1);
2675 assert_eq!(comments[0].id, "10001");
2676 assert_eq!(comments[0].author.as_deref(), Some("Alice"));
2677 assert_eq!(comments[0].author_account_id.as_deref(), Some("acct-1"));
2678 assert_eq!(comments[0].body.as_deref(), Some("Hi @Bob"));
2679 assert_eq!(comments[0].mentions, vec!["acct-2"]);
2680 }
2681
2682 #[tokio::test]
2683 async fn add_comment_adf_posts_prebuilt_adf_payload() {
2684 let server = MockServer::start().await;
2685 let expected_auth = cloud_auth();
2686 let adf = json!({
2687 "type": "doc",
2688 "version": 1,
2689 "content": [{
2690 "type": "paragraph",
2691 "content": [
2692 { "type": "text", "text": "Hi " },
2693 { "type": "mention", "attrs": { "id": "acct-2", "text": "@Bob" } }
2694 ]
2695 }]
2696 });
2697
2698 Mock::given(method("POST"))
2699 .and(path("/rest/api/3/issue/TEST-1/comment"))
2700 .and(header("authorization", expected_auth.as_str()))
2701 .and(body_json(json!({ "body": adf.clone() })))
2702 .respond_with(ResponseTemplate::new(201).set_body_json(json!({
2703 "id": "10002",
2704 "author": {
2705 "displayName": "Alice",
2706 "accountId": "acct-1"
2707 },
2708 "body": adf,
2709 "created": "2023-01-01T00:00:00.000+0000",
2710 "updated": "2023-01-01T00:00:00.000+0000"
2711 })))
2712 .mount(&server)
2713 .await;
2714
2715 let client = cloud_client(&server);
2716 let comment = client
2717 .add_comment_adf(
2718 "TEST-1",
2719 json!({
2720 "type": "doc",
2721 "version": 1,
2722 "content": [{
2723 "type": "paragraph",
2724 "content": [
2725 { "type": "text", "text": "Hi " },
2726 { "type": "mention", "attrs": { "id": "acct-2", "text": "@Bob" } }
2727 ]
2728 }]
2729 }),
2730 )
2731 .await
2732 .expect("add comment adf should succeed");
2733
2734 assert_eq!(comment.id, "10002");
2735 assert_eq!(comment.body.as_deref(), Some("Hi @Bob"));
2736 assert_eq!(comment.mentions, vec!["acct-2"]);
2737 }
2738
2739 #[tokio::test]
2740 async fn search_users_retries_after_429_then_succeeds() {
2741 let server = MockServer::start().await;
2742 let expected_auth = cloud_auth();
2743
2744 Mock::given(method("GET"))
2745 .and(path("/rest/api/3/user/search"))
2746 .and(header("authorization", expected_auth.as_str()))
2747 .and(query_param("query", "alice"))
2748 .and(query_param("maxResults", "20"))
2749 .respond_with(ResponseTemplate::new(429).insert_header("Retry-After", "0"))
2750 .up_to_n_times(1)
2751 .mount(&server)
2752 .await;
2753
2754 Mock::given(method("GET"))
2755 .and(path("/rest/api/3/user/search"))
2756 .and(header("authorization", expected_auth.as_str()))
2757 .and(query_param("query", "alice"))
2758 .and(query_param("maxResults", "20"))
2759 .respond_with(ResponseTemplate::new(200).set_body_json(json!([
2760 {
2761 "accountId": "acct-1",
2762 "displayName": "Alice Example"
2763 }
2764 ])))
2765 .mount(&server)
2766 .await;
2767
2768 let client = cloud_client(&server);
2769 let users = client
2770 .search_users("alice")
2771 .await
2772 .expect("request should retry and succeed");
2773
2774 assert_eq!(users.len(), 1);
2775 assert_eq!(users[0].account_id, "acct-1");
2776 }
2777
2778 #[tokio::test]
2779 async fn search_users_returns_rate_limit_after_max_retries() {
2780 let server = MockServer::start().await;
2781 let expected_auth = cloud_auth();
2782
2783 Mock::given(method("GET"))
2784 .and(path("/rest/api/3/user/search"))
2785 .and(header("authorization", expected_auth.as_str()))
2786 .and(query_param("query", "alice"))
2787 .and(query_param("maxResults", "20"))
2788 .respond_with(ResponseTemplate::new(429).insert_header("Retry-After", "0"))
2789 .mount(&server)
2790 .await;
2791
2792 let client = cloud_client(&server);
2793 let err = client
2794 .search_users("alice")
2795 .await
2796 .expect_err("request should fail after max retries");
2797
2798 match err {
2799 JiraError::RateLimit { retry_after } => assert_eq!(retry_after, 0),
2800 other => panic!("expected JiraError::RateLimit, got {other:?}"),
2801 }
2802 }
2803
2804 #[tokio::test]
2805 async fn move_issue_submits_bulk_move_and_fetches_issue_on_complete() {
2806 let server = MockServer::start().await;
2807 let expected_auth = cloud_auth();
2808
2809 Mock::given(method("POST"))
2810 .and(path("/rest/api/3/bulk/issues/move"))
2811 .and(header("authorization", expected_auth.as_str()))
2812 .and(body_json(json!({
2813 "sendBulkNotification": true,
2814 "targetToSourcesMapping": {
2815 "NEW,10002": {
2816 "inferClassificationDefaults": true,
2817 "inferFieldDefaults": true,
2818 "inferStatusDefaults": true,
2819 "inferSubtaskTypeDefault": true,
2820 "issueIdsOrKeys": ["TEST-1"]
2821 }
2822 }
2823 })))
2824 .respond_with(ResponseTemplate::new(201).set_body_json(json!({ "taskId": "task-1" })))
2825 .mount(&server)
2826 .await;
2827
2828 Mock::given(method("GET"))
2829 .and(path("/rest/api/3/bulk/queue/task-1"))
2830 .and(header("authorization", expected_auth.as_str()))
2831 .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "status": "COMPLETE" })))
2832 .mount(&server)
2833 .await;
2834
2835 Mock::given(method("GET"))
2836 .and(path("/rest/api/3/issue/TEST-1"))
2837 .and(header("authorization", expected_auth.as_str()))
2838 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2839 "id": "10001",
2840 "key": "TEST-1",
2841 "fields": {
2842 "summary": "Moved issue",
2843 "status": { "name": "To Do" },
2844 "issuetype": { "name": "Task" },
2845 "project": { "key": "NEW" },
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 .move_issue("TEST-1", "NEW", "10002", None)
2856 .await
2857 .expect("move issue should complete");
2858
2859 assert_eq!(issue.key, "TEST-1");
2860 assert_eq!(issue.project_key, "NEW");
2861 }
2862
2863 #[tokio::test]
2864 async fn move_issue_returns_api_error_when_bulk_task_fails() {
2865 let server = MockServer::start().await;
2866 let expected_auth = cloud_auth();
2867
2868 Mock::given(method("POST"))
2869 .and(path("/rest/api/3/bulk/issues/move"))
2870 .and(header("authorization", expected_auth.as_str()))
2871 .respond_with(ResponseTemplate::new(201).set_body_json(json!({ "taskId": "task-2" })))
2872 .mount(&server)
2873 .await;
2874
2875 Mock::given(method("GET"))
2876 .and(path("/rest/api/3/bulk/queue/task-2"))
2877 .and(header("authorization", expected_auth.as_str()))
2878 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2879 "status": "FAILED",
2880 "errors": ["cannot move issue"]
2881 })))
2882 .mount(&server)
2883 .await;
2884
2885 let client = cloud_client(&server);
2886 let err = client
2887 .move_issue("TEST-1", "NEW", "10002", None)
2888 .await
2889 .expect_err("failed bulk task should return error");
2890
2891 match err {
2892 JiraError::Api { message, .. } => assert!(message.contains("FAILED")),
2893 other => panic!("expected JiraError::Api, got {other:?}"),
2894 }
2895 }
2896
2897 #[tokio::test]
2898 async fn move_issue_uses_configured_api_version_for_data_center() {
2899 let server = MockServer::start().await;
2900
2901 Mock::given(method("POST"))
2902 .and(path("/rest/api/2/bulk/issues/move"))
2903 .and(header("authorization", "Bearer dc-token"))
2904 .respond_with(ResponseTemplate::new(201).set_body_json(json!({ "taskId": "task-dc" })))
2905 .mount(&server)
2906 .await;
2907
2908 Mock::given(method("GET"))
2909 .and(path("/rest/api/2/bulk/queue/task-dc"))
2910 .and(header("authorization", "Bearer dc-token"))
2911 .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "status": "COMPLETE" })))
2912 .mount(&server)
2913 .await;
2914
2915 Mock::given(method("GET"))
2916 .and(path("/rest/api/2/issue/DC-1"))
2917 .and(header("authorization", "Bearer dc-token"))
2918 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2919 "id": "20001",
2920 "key": "DC-1",
2921 "fields": {
2922 "summary": "Moved issue",
2923 "status": { "name": "Done" },
2924 "issuetype": { "name": "Task" },
2925 "project": { "key": "OPS" },
2926 "created": "2023-01-01T00:00:00.000+0000",
2927 "updated": "2023-01-01T00:00:00.000+0000"
2928 }
2929 })))
2930 .mount(&server)
2931 .await;
2932
2933 let client = JiraClient::new(JiraConfig {
2934 profile_name: Some("dc-main".into()),
2935 base_url: server.uri(),
2936 email: String::new(),
2937 token: Some("dc-token".into()),
2938 project: None,
2939 timeout_secs: 30,
2940 deployment: JiraDeployment::DataCenter,
2941 auth_type: JiraAuthType::DataCenterPat,
2942 api_version: 2,
2943 });
2944
2945 let issue = client
2946 .move_issue("DC-1", "OPS", "10002", None)
2947 .await
2948 .expect("data center move should use api v2");
2949
2950 assert_eq!(issue.key, "DC-1");
2951 assert_eq!(issue.project_key, "OPS");
2952 }
2953
2954 #[tokio::test]
2955 async fn issue_link_integration() {
2956 let server = MockServer::start().await;
2957 let expected_auth = format!("Basic {}", base64_encode("dev@example.com:cloud-token"));
2958
2959 Mock::given(method("GET"))
2961 .and(path("/rest/api/3/issueLinkType"))
2962 .and(header("authorization", expected_auth.as_str()))
2963 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2964 "issueLinkTypes": [
2965 {
2966 "id": "10000",
2967 "name": "Blocks",
2968 "inward": "is blocked by",
2969 "outward": "blocks"
2970 }
2971 ]
2972 })))
2973 .mount(&server)
2974 .await;
2975
2976 Mock::given(method("POST"))
2978 .and(path("/rest/api/3/issueLink"))
2979 .and(header("authorization", expected_auth.as_str()))
2980 .respond_with(ResponseTemplate::new(201))
2981 .mount(&server)
2982 .await;
2983
2984 let client = JiraClient::new(JiraConfig {
2985 profile_name: Some("cloud-main".into()),
2986 base_url: server.uri(),
2987 email: "dev@example.com".into(),
2988 token: Some("cloud-token".into()),
2989 project: None,
2990 timeout_secs: 30,
2991 deployment: JiraDeployment::Cloud,
2992 auth_type: JiraAuthType::CloudApiToken,
2993 api_version: 3,
2994 });
2995
2996 let link_types = client.list_issue_link_types().await.expect("list types");
2998 assert_eq!(link_types.len(), 1);
2999 assert_eq!(link_types[0].name, "Blocks");
3000
3001 client
3003 .link_issues("TEST-1", "TEST-2", "Blocks", Some("Adding dependency"))
3004 .await
3005 .expect("link issues");
3006 }
3007
3008 #[tokio::test]
3009 async fn get_issue_parses_links() {
3010 let server = MockServer::start().await;
3011 let expected_auth = format!("Basic {}", base64_encode("dev@example.com:cloud-token"));
3012
3013 Mock::given(method("GET"))
3014 .and(path("/rest/api/3/issue/TEST-1"))
3015 .and(header("authorization", expected_auth.as_str()))
3016 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
3017 "id": "10001",
3018 "key": "TEST-1",
3019 "fields": {
3020 "summary": "Main Issue",
3021 "status": { "name": "To Do" },
3022 "issuetype": { "name": "Task" },
3023 "project": { "key": "TEST" },
3024 "created": "2023-01-01T00:00:00.000+0000",
3025 "updated": "2023-01-01T00:00:00.000+0000",
3026 "issuelinks": [
3027 {
3028 "id": "20000",
3029 "type": {
3030 "id": "10000",
3031 "name": "Blocks",
3032 "inward": "is blocked by",
3033 "outward": "blocks"
3034 },
3035 "outwardIssue": {
3036 "id": "10002",
3037 "key": "TEST-2",
3038 "fields": {
3039 "summary": "Blocked Issue",
3040 "status": { "name": "Open" },
3041 "priority": { "name": "High" },
3042 "issuetype": { "name": "Bug" }
3043 }
3044 }
3045 }
3046 ]
3047 }
3048 })))
3049 .mount(&server)
3050 .await;
3051
3052 let client = JiraClient::new(JiraConfig {
3053 profile_name: Some("cloud-main".into()),
3054 base_url: server.uri(),
3055 email: "dev@example.com".into(),
3056 token: Some("cloud-token".into()),
3057 project: None,
3058 timeout_secs: 30,
3059 deployment: JiraDeployment::Cloud,
3060 auth_type: JiraAuthType::CloudApiToken,
3061 api_version: 3,
3062 });
3063
3064 let issue = client.get_issue("TEST-1").await.expect("get issue");
3065 assert_eq!(issue.links.len(), 1);
3066 let link = &issue.links[0];
3067 assert_eq!(link.link_type.name, "Blocks");
3068 assert!(link.outward_issue.is_some());
3069 assert_eq!(link.outward_issue.as_ref().unwrap().key, "TEST-2");
3070 assert_eq!(
3071 link.outward_issue.as_ref().unwrap().summary,
3072 "Blocked Issue"
3073 );
3074 }
3075
3076 #[tokio::test]
3077 async fn get_remote_links_parses_object_title_and_url() {
3078 let server = MockServer::start().await;
3079
3080 Mock::given(method("GET"))
3081 .and(path("/rest/api/3/issue/TEST-1/remotelink"))
3082 .and(header("authorization", cloud_auth().as_str()))
3083 .respond_with(ResponseTemplate::new(200).set_body_json(json!([
3084 {
3085 "id": 10001,
3086 "self": "https://jira.example.com/rest/api/3/issue/TEST-1/remotelink/10001",
3087 "globalId": "system=https://docs.example.com&id=42",
3088 "relationship": "Wiki Page",
3089 "object": {
3090 "title": "Design Doc",
3091 "url": "https://docs.example.com/design",
3092 "summary": "Architecture overview"
3093 }
3094 },
3095 {
3096 "id": 10002,
3097 "object": {
3098 "title": "Tracking ticket",
3099 "url": "https://tracker.example.com/4242"
3100 }
3101 }
3102 ])))
3103 .mount(&server)
3104 .await;
3105
3106 let client = cloud_client(&server);
3107 let links = client
3108 .get_remote_links("TEST-1")
3109 .await
3110 .expect("remote links should parse");
3111
3112 assert_eq!(links.len(), 2);
3113 assert_eq!(links[0].id, 10001);
3114 assert_eq!(
3115 links[0].global_id.as_deref(),
3116 Some("system=https://docs.example.com&id=42")
3117 );
3118 assert_eq!(links[0].object.title, "Design Doc");
3119 assert_eq!(links[0].object.url, "https://docs.example.com/design");
3120 assert_eq!(
3121 links[0].object.summary.as_deref(),
3122 Some("Architecture overview")
3123 );
3124 assert_eq!(links[1].id, 10002);
3125 assert!(links[1].global_id.is_none());
3126 assert!(links[1].object.summary.is_none());
3127 }
3128
3129 #[tokio::test]
3130 async fn get_remote_links_returns_empty_when_no_links() {
3131 let server = MockServer::start().await;
3132
3133 Mock::given(method("GET"))
3134 .and(path("/rest/api/3/issue/TEST-1/remotelink"))
3135 .respond_with(ResponseTemplate::new(200).set_body_json(json!([])))
3136 .mount(&server)
3137 .await;
3138
3139 let client = cloud_client(&server);
3140 let links = client.get_remote_links("TEST-1").await.expect("parse");
3141 assert!(links.is_empty());
3142 }
3143
3144 #[tokio::test]
3145 async fn get_project_components_parses_id_and_name() {
3146 let server = MockServer::start().await;
3147
3148 Mock::given(method("GET"))
3149 .and(path("/rest/api/3/project/PROJ/components"))
3150 .and(header("authorization", cloud_auth().as_str()))
3151 .respond_with(ResponseTemplate::new(200).set_body_json(json!([
3152 {
3153 "id": "10100",
3154 "name": "Backend",
3155 "description": "Server-side code",
3156 "self": "https://jira.example.com/rest/api/3/component/10100"
3157 },
3158 {
3159 "id": "10101",
3160 "name": "Frontend"
3161 }
3162 ])))
3163 .mount(&server)
3164 .await;
3165
3166 let client = cloud_client(&server);
3167 let components = client
3168 .get_project_components("PROJ")
3169 .await
3170 .expect("components should parse");
3171
3172 assert_eq!(components.len(), 2);
3173 assert_eq!(components[0].id, "10100");
3174 assert_eq!(components[0].name, "Backend");
3175 assert_eq!(
3176 components[0].description.as_deref(),
3177 Some("Server-side code")
3178 );
3179 assert!(components[0].self_url.is_some());
3180 assert_eq!(components[1].name, "Frontend");
3181 assert!(components[1].description.is_none());
3182 }
3183
3184 #[tokio::test]
3185 async fn get_transitions_parses_id_name_and_preserves_extra_fields() {
3186 let server = MockServer::start().await;
3187
3188 Mock::given(method("GET"))
3189 .and(path("/rest/api/3/issue/TEST-1/transitions"))
3190 .and(header("authorization", cloud_auth().as_str()))
3191 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
3192 "expand": "transitions",
3193 "transitions": [
3194 {
3195 "id": "21",
3196 "name": "In Progress",
3197 "hasScreen": false,
3198 "isGlobal": false,
3199 "isInitial": false,
3200 "isAvailable": true,
3201 "to": {
3202 "id": "3",
3203 "name": "In Progress",
3204 "statusCategory": { "key": "indeterminate" }
3205 }
3206 },
3207 {
3208 "id": "31",
3209 "name": "Done",
3210 "to": { "id": "10001", "name": "Done" }
3211 }
3212 ]
3213 })))
3214 .mount(&server)
3215 .await;
3216
3217 let client = cloud_client(&server);
3218 let transitions = client
3219 .get_transitions("TEST-1")
3220 .await
3221 .expect("transitions should parse");
3222
3223 assert_eq!(transitions.len(), 2);
3224 assert_eq!(transitions[0].id, "21");
3225 assert_eq!(transitions[0].name, "In Progress");
3226 assert_eq!(
3227 transitions[0].to.as_ref().and_then(|t| t.name.as_deref()),
3228 Some("In Progress")
3229 );
3230
3231 let reserialized = serde_json::to_value(&transitions[0]).expect("serialize");
3234 assert_eq!(reserialized["hasScreen"], json!(false));
3235 assert_eq!(reserialized["isAvailable"], json!(true));
3236
3237 assert_eq!(transitions[1].id, "31");
3238 assert!(transitions[1].to.is_some());
3239 }
3240
3241 #[tokio::test]
3242 async fn get_transitions_returns_empty_when_no_transitions_available() {
3243 let server = MockServer::start().await;
3244
3245 Mock::given(method("GET"))
3246 .and(path("/rest/api/3/issue/TEST-1/transitions"))
3247 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
3248 "transitions": []
3249 })))
3250 .mount(&server)
3251 .await;
3252
3253 let client = cloud_client(&server);
3254 let transitions = client.get_transitions("TEST-1").await.expect("parse");
3255 assert!(transitions.is_empty());
3256 }
3257}