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 worklog::Worklog,
31 },
32};
33
34const AGILE_BASE: &str = "/rest/agile/1.0";
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 #[allow(dead_code)]
75 fn agile_url(&self, path: &str) -> String {
76 format!(
77 "{}{}{}",
78 self.config.base_url.trim_end_matches('/'),
79 AGILE_BASE,
80 path
81 )
82 }
83
84 fn auth_headers(&self) -> Result<HeaderMap> {
85 self.build_auth_headers(true)
86 }
87
88 fn auth_headers_no_content_type(&self) -> Result<HeaderMap> {
90 self.build_auth_headers(false)
91 }
92
93 fn build_auth_headers(&self, include_content_type: bool) -> Result<HeaderMap> {
94 let token = self.config.token.as_deref().ok_or_else(|| {
95 JiraError::Auth("No token configured. Run `jirac auth login` first.".into())
96 })?;
97
98 let auth_value = match self.config.auth_type {
99 JiraAuthType::CloudApiToken | JiraAuthType::DataCenterBasic => {
100 let credentials = base64_encode(&format!("{}:{}", self.config.email, token));
101 format!("Basic {credentials}")
102 }
103 JiraAuthType::DataCenterPat => format!("Bearer {token}"),
104 };
105
106 let mut headers = HeaderMap::new();
107 headers.insert(
108 AUTHORIZATION,
109 HeaderValue::from_str(&auth_value)
110 .map_err(|e| JiraError::Auth(format!("Invalid auth header: {e}")))?,
111 );
112 if include_content_type {
113 headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
114 }
115 Ok(headers)
116 }
117
118 async fn get_myself_info(&self) -> Result<MyselfResponse> {
119 let headers = self.auth_headers()?;
120 let url = self.platform_url("/myself");
121
122 let http = &self.http;
123 self.request(|| http.get(&url).headers(headers.clone()))
124 .await
125 }
126
127 pub async fn get_myself(&self) -> Result<String> {
129 let me = self.get_myself_info().await?;
130 me.account_id.ok_or_else(|| JiraError::Api {
131 status: 0,
132 message: "Could not get accountId from /myself".into(),
133 })
134 }
135
136 pub async fn get_myself_timezone(&self) -> Result<Option<String>> {
138 Ok(self.get_myself_info().await?.time_zone)
139 }
140
141 async fn resolve_assignee_account_id(&self, s: &str) -> Result<String> {
147 if s == "me" {
148 return self.get_myself().await;
149 }
150 if !s.contains('@') {
151 return Ok(s.to_string());
152 }
153 let users = self.search_users(s).await?;
155 users
156 .iter()
157 .find(|u| {
158 u.email_address
159 .as_deref()
160 .map(|e| e.eq_ignore_ascii_case(s))
161 .unwrap_or(false)
162 })
163 .or_else(|| users.first())
164 .map(|u| u.account_id.clone())
165 .ok_or_else(|| JiraError::Api {
166 status: 0,
167 message: format!("User not found: {s}"),
168 })
169 }
170
171 async fn execute_with_retry(
174 &self,
175 builder_fn: impl Fn() -> reqwest::RequestBuilder,
176 ) -> Result<reqwest::Response> {
177 let mut attempt = 0u32;
178 loop {
179 attempt += 1;
180 let response = builder_fn().send().await?;
181
182 if response.status() != StatusCode::TOO_MANY_REQUESTS {
183 return Ok(response);
184 }
185
186 let retry_after = response
187 .headers()
188 .get("Retry-After")
189 .and_then(|v| v.to_str().ok())
190 .and_then(|v| v.parse::<u64>().ok())
191 .unwrap_or(60);
192
193 warn!("Rate limited. Retrying after {}s", retry_after);
194
195 if attempt >= MAX_RETRIES {
196 return Err(JiraError::RateLimit { retry_after });
197 }
198
199 tokio::time::sleep(Duration::from_secs(retry_after)).await;
200 }
201 }
202
203 async fn request<T>(&self, builder_fn: impl Fn() -> reqwest::RequestBuilder) -> Result<T>
205 where
206 T: serde::de::DeserializeOwned,
207 {
208 let response = self.execute_with_retry(builder_fn).await?;
209 handle_response(response).await
210 }
211
212 async fn request_no_body(
214 &self,
215 builder_fn: impl Fn() -> reqwest::RequestBuilder,
216 ) -> Result<()> {
217 let response = self.execute_with_retry(builder_fn).await?;
218 let status = response.status();
219 if status.is_success() {
220 return Ok(());
221 }
222 let body = response.text().await.unwrap_or_default();
223 if status == StatusCode::NOT_FOUND {
224 return Err(JiraError::NotFound(body));
225 }
226 Err(JiraError::Api {
227 status: status.as_u16(),
228 message: body,
229 })
230 }
231
232 async fn request_multipart<T>(
234 &self,
235 builder_fn: impl Fn() -> reqwest::RequestBuilder,
236 ) -> Result<T>
237 where
238 T: serde::de::DeserializeOwned,
239 {
240 let response = self.execute_with_retry(builder_fn).await?;
241 handle_response(response).await
242 }
243
244 pub async fn search_issues(
246 &self,
247 jql: &str,
248 next_page_token: Option<&str>,
249 max_results: Option<u32>,
250 ) -> Result<SearchResult> {
251 const DEFAULT_FIELDS: &[&str] = &[
252 "summary",
253 "status",
254 "assignee",
255 "reporter",
256 "priority",
257 "issuetype",
258 "project",
259 "created",
260 "updated",
261 "description",
262 ];
263 self.search_issues_with_fields(jql, next_page_token, max_results, DEFAULT_FIELDS)
264 .await
265 }
266
267 pub async fn search_issues_with_fields(
270 &self,
271 jql: &str,
272 next_page_token: Option<&str>,
273 max_results: Option<u32>,
274 fields: &[&str],
275 ) -> Result<SearchResult> {
276 let headers = self.auth_headers()?;
277 let url = self.platform_url("/search/jql");
278
279 let mut body = json!({
280 "jql": jql,
281 "maxResults": max_results.unwrap_or(50),
282 "fields": fields,
283 });
284
285 if let Some(token) = next_page_token {
286 body["nextPageToken"] = json!(token);
287 }
288
289 debug!("Searching JQL: {}", jql);
290
291 let http = &self.http;
292 let raw: RawSearchResponse = self
293 .request(|| http.post(&url).headers(headers.clone()).json(&body))
294 .await?;
295
296 Ok(SearchResult {
297 issues: raw.issues.into_iter().map(|r| r.into_issue()).collect(),
298 next_page_token: raw.next_page_token,
299 total: raw.total,
300 })
301 }
302
303 pub async fn list_fields(&self) -> Result<Vec<Field>> {
306 let headers = self.auth_headers()?;
307 let url = self.platform_url("/field");
308
309 #[derive(serde::Deserialize)]
310 struct FieldEntry {
311 id: String,
312 name: String,
313 #[serde(default)]
314 schema: Option<Value>,
315 }
316
317 let http = &self.http;
318 let raw: Vec<FieldEntry> = self
319 .request(|| http.get(&url).headers(headers.clone()))
320 .await?;
321
322 Ok(raw
323 .into_iter()
324 .map(|f| {
325 let field_type = f
326 .schema
327 .as_ref()
328 .and_then(|s| s.get("type"))
329 .and_then(|v| v.as_str())
330 .unwrap_or("")
331 .to_string();
332 Field {
333 id: f.id,
334 name: f.name,
335 field_type,
336 required: false,
337 schema: f.schema,
338 allowed_values: None,
339 }
340 })
341 .collect())
342 }
343
344 pub async fn get_issue(&self, key: &str) -> Result<Issue> {
346 let headers = self.auth_headers()?;
347 let url = self.platform_url(&format!("/issue/{key}"));
348
349 let http = &self.http;
350 let raw: RawIssue = self
351 .request(|| http.get(&url).headers(headers.clone()))
352 .await?;
353
354 Ok(raw.into_issue())
355 }
356
357 #[deprecated(
364 since = "0.40.0",
365 note = "Use `create_issue_v2` — supports custom fields, labels, components, parent, fix versions"
366 )]
367 pub async fn create_issue(&self, req: CreateIssueRequest) -> Result<Issue> {
368 let headers = self.auth_headers()?;
369 let url = self.platform_url("/issue");
370
371 let description_adf = req.description.as_deref().map(markdown_to_adf);
372
373 let mut fields = json!({
374 "project": { "key": req.project_key },
375 "summary": req.summary,
376 "issuetype": { "name": req.issue_type }
377 });
378
379 if let Some(adf) = description_adf {
380 fields["description"] = adf;
381 }
382
383 if let Some(assignee) = &req.assignee {
384 let account_id = self.resolve_assignee_account_id(assignee).await?;
385 fields["assignee"] = json!({ "accountId": account_id });
386 }
387
388 if let Some(priority) = &req.priority {
389 fields["priority"] = json!({ "name": priority });
390 }
391
392 let body = json!({ "fields": fields });
393
394 #[derive(serde::Deserialize)]
395 struct CreateResponse {
396 key: String,
397 }
398
399 let http = &self.http;
400 let resp: CreateResponse = self
401 .request(|| http.post(&url).headers(headers.clone()).json(&body))
402 .await?;
403
404 self.get_issue(&resp.key).await
406 }
407
408 pub async fn update_issue(&self, key: &str, req: UpdateIssueRequest) -> Result<()> {
410 let headers = self.auth_headers()?;
411 let url = self.platform_url(&format!("/issue/{key}"));
412
413 let mut fields = json!({});
414
415 if let Some(summary) = &req.summary {
416 fields["summary"] = json!(summary);
417 }
418 if let Some(adf) = &req.description_adf {
419 fields["description"] = adf.clone();
420 } else if let Some(description) = &req.description {
421 fields["description"] = markdown_to_adf(description);
422 }
423 if let Some(assignee) = &req.assignee {
424 let account_id = self.resolve_assignee_account_id(assignee).await?;
425 fields["assignee"] = json!({ "accountId": account_id });
426 }
427 if let Some(priority) = &req.priority {
428 fields["priority"] = json!({ "name": priority });
429 }
430 if let Some(labels) = &req.labels {
431 fields["labels"] = json!(labels);
432 }
433 if let Some(components) = &req.components {
434 fields["components"] = json!(components
435 .iter()
436 .map(|c| json!({"name": c}))
437 .collect::<Vec<_>>());
438 }
439 if let Some(fix_versions) = &req.fix_versions {
440 fields["fixVersions"] = json!(fix_versions
441 .iter()
442 .map(|v| json!({"name": v}))
443 .collect::<Vec<_>>());
444 }
445 if let Some(parent) = &req.parent {
446 fields["parent"] = json!({ "key": parent });
447 }
448 for (field_id, value) in &req.custom_fields {
449 fields[field_id] = value.to_api_json();
450 }
451
452 let body = json!({ "fields": fields });
453
454 let http = &self.http;
455 self.request_no_body(|| http.put(&url).headers(headers.clone()).json(&body))
456 .await
457 }
458
459 pub async fn delete_issue(&self, key: &str) -> Result<()> {
461 let headers = self.auth_headers()?;
462 let url = self.platform_url(&format!("/issue/{key}"));
463
464 let http = &self.http;
465 self.request_no_body(|| http.delete(&url).headers(headers.clone()))
466 .await
467 }
468
469 pub async fn get_project_fields(&self, project_key: &str) -> Result<Vec<Field>> {
471 let headers = self.auth_headers()?;
472 let url = self.platform_url(&format!("/issue/createmeta/{project_key}/issuetypes"));
473
474 #[derive(serde::Deserialize)]
475 struct IssueTypeMeta {
476 #[serde(rename = "issueTypes")]
477 issue_types: Vec<IssueTypeDetail>,
478 }
479
480 #[derive(serde::Deserialize)]
481 struct IssueTypeDetail {
482 fields: Option<std::collections::HashMap<String, FieldMeta>>,
483 }
484
485 #[derive(serde::Deserialize)]
486 struct FieldMeta {
487 name: String,
488 required: bool,
489 schema: Option<Value>,
490 }
491
492 let http = &self.http;
493 let meta: IssueTypeMeta = self
494 .request(|| http.get(&url).headers(headers.clone()))
495 .await?;
496
497 let mut fields: Vec<Field> = Vec::new();
498 let mut seen = std::collections::HashSet::new();
499
500 for it in meta.issue_types {
501 if let Some(field_map) = it.fields {
502 for (id, meta) in field_map {
503 if seen.insert(id.clone()) {
504 let field_type = meta
505 .schema
506 .as_ref()
507 .and_then(|s| s.get("type"))
508 .and_then(|v| v.as_str())
509 .unwrap_or("unknown")
510 .to_string();
511
512 fields.push(Field {
513 id,
514 name: meta.name,
515 field_type,
516 required: meta.required,
517 schema: meta.schema,
518 allowed_values: None,
519 });
520 }
521 }
522 }
523 }
524
525 Ok(fields)
526 }
527
528 pub async fn get_server_info(&self) -> Result<Value> {
530 let headers = self.auth_headers()?;
531 let url = self.platform_url("/serverInfo");
532
533 let http = &self.http;
534 self.request(|| http.get(&url).headers(headers.clone()))
535 .await
536 }
537
538 pub async fn transition_issue(&self, key: &str, transition_id: &str) -> Result<()> {
540 let headers = self.auth_headers()?;
541 let url = self.platform_url(&format!("/issue/{key}/transitions"));
542
543 let body = json!({
544 "transition": { "id": transition_id }
545 });
546
547 let http = &self.http;
548 self.request_no_body(|| http.post(&url).headers(headers.clone()).json(&body))
549 .await
550 }
551
552 pub async fn get_transitions(&self, key: &str) -> Result<Vec<Transition>> {
554 let headers = self.auth_headers()?;
555 let url = self.platform_url(&format!("/issue/{key}/transitions"));
556
557 #[derive(serde::Deserialize)]
558 struct TransitionsResponse {
559 transitions: Vec<Transition>,
560 }
561
562 let http = &self.http;
563 let resp: TransitionsResponse = self
564 .request(|| http.get(&url).headers(headers.clone()))
565 .await?;
566
567 Ok(resp.transitions)
568 }
569
570 pub async fn get_issue_types(&self, project_key: &str) -> Result<Vec<IssueType>> {
572 let headers = self.auth_headers()?;
573 let url = self.platform_url(&format!("/issue/createmeta/{project_key}/issuetypes"));
574
575 #[derive(serde::Deserialize)]
576 struct MetaResponse {
577 #[serde(rename = "issueTypes")]
578 issue_types: Vec<IssueType>,
579 }
580
581 let http = &self.http;
582 let resp: MetaResponse = self
583 .request(|| http.get(&url).headers(headers.clone()))
584 .await?;
585
586 Ok(resp.issue_types)
587 }
588
589 pub async fn get_issue_type_by_name(
591 &self,
592 project_key: &str,
593 issue_type_name: &str,
594 ) -> Result<IssueType> {
595 let issue_types = self.get_issue_types(project_key).await?;
596
597 issue_types
598 .iter()
599 .find(|it| it.name == issue_type_name)
600 .or_else(|| {
601 issue_types
602 .iter()
603 .find(|it| it.name.eq_ignore_ascii_case(issue_type_name))
604 })
605 .cloned()
606 .ok_or_else(|| JiraError::Api {
607 status: 0,
608 message: format!(
609 "Issue type '{}' not found in project {}",
610 issue_type_name, project_key
611 ),
612 })
613 }
614
615 pub async fn get_fields_for_issue_type(
617 &self,
618 project_key: &str,
619 issue_type_id: &str,
620 ) -> Result<Vec<Field>> {
621 let headers = self.auth_headers()?;
622 let url = self.platform_url(&format!(
623 "/issue/createmeta/{project_key}/issuetypes/{issue_type_id}"
624 ));
625
626 #[derive(serde::Deserialize)]
627 struct FieldMetaResponse {
628 fields: FieldCollection,
629 }
630
631 #[derive(serde::Deserialize)]
632 #[serde(untagged)]
633 enum FieldCollection {
634 Map(std::collections::HashMap<String, FieldMetaMap>),
635 List(Vec<FieldMetaEntry>),
636 }
637
638 #[derive(serde::Deserialize)]
639 struct FieldMetaMap {
640 name: String,
641 required: bool,
642 schema: Option<Value>,
643 #[serde(rename = "allowedValues")]
644 allowed_values: Option<Vec<Value>>,
645 }
646
647 #[derive(serde::Deserialize)]
648 struct FieldMetaEntry {
649 #[serde(rename = "fieldId")]
650 field_id: Option<String>,
651 key: Option<String>,
652 name: String,
653 required: bool,
654 schema: Option<Value>,
655 #[serde(rename = "allowedValues")]
656 allowed_values: Option<Vec<Value>>,
657 }
658
659 let http = &self.http;
660 let resp: FieldMetaResponse = self
661 .request(|| http.get(&url).headers(headers.clone()))
662 .await?;
663
664 let fields = match resp.fields {
665 FieldCollection::Map(fields) => fields
666 .into_iter()
667 .map(|(id, meta)| {
668 let field_type = meta
669 .schema
670 .as_ref()
671 .and_then(|s| s.get("type"))
672 .and_then(|v| v.as_str())
673 .unwrap_or("unknown")
674 .to_string();
675
676 Field {
677 id,
678 name: meta.name,
679 field_type,
680 required: meta.required,
681 schema: meta.schema,
682 allowed_values: meta.allowed_values,
683 }
684 })
685 .collect(),
686 FieldCollection::List(fields) => fields
687 .into_iter()
688 .map(|meta| {
689 let field_type = meta
690 .schema
691 .as_ref()
692 .and_then(|s| s.get("type"))
693 .and_then(|v| v.as_str())
694 .unwrap_or("unknown")
695 .to_string();
696
697 let id = meta.field_id.or(meta.key).unwrap_or_default();
698
699 Field {
700 id,
701 name: meta.name,
702 field_type,
703 required: meta.required,
704 schema: meta.schema,
705 allowed_values: meta.allowed_values,
706 }
707 })
708 .collect(),
709 };
710
711 Ok(fields)
712 }
713
714 pub async fn search_users(&self, query: &str) -> Result<Vec<JiraUser>> {
716 let headers = self.auth_headers()?;
717 let url = self.platform_url("/user/search");
718
719 let http = &self.http;
720 self.request(|| {
721 http.get(&url)
722 .headers(headers.clone())
723 .query(&[("query", query), ("maxResults", "20")])
724 })
725 .await
726 }
727
728 pub async fn get_project_components(&self, project_key: &str) -> Result<Vec<Component>> {
730 let headers = self.auth_headers()?;
731 let url = self.platform_url(&format!("/project/{project_key}/components"));
732
733 let http = &self.http;
734 self.request(|| http.get(&url).headers(headers.clone()))
735 .await
736 }
737
738 pub async fn get_project_versions(&self, project_key: &str) -> Result<Vec<ProjectVersion>> {
740 let headers = self.auth_headers()?;
741 let url = self.platform_url(&format!("/project/{project_key}/versions"));
742
743 let http = &self.http;
744 let versions: Vec<ProjectVersion> = self
745 .request(|| http.get(&url).headers(headers.clone()))
746 .await?;
747
748 Ok(versions)
749 }
750
751 pub async fn create_project_version(
753 &self,
754 request: &CreateProjectVersionRequest,
755 ) -> Result<ProjectVersion> {
756 let path = format!("/rest/api/{}/version", self.config.api_version);
757 let body = serde_json::to_value(request)?;
758 let value = self
759 .raw_request("POST", &path, Some(body))
760 .await?
761 .ok_or_else(|| JiraError::Api {
762 status: 0,
763 message: "Empty response when creating project version".into(),
764 })?;
765 let version: ProjectVersion =
766 serde_json::from_value(value).map_err(|e| JiraError::Api {
767 status: 0,
768 message: format!("Failed to parse created project version: {e}"),
769 })?;
770 Ok(version)
771 }
772
773 pub async fn update_project_version(
775 &self,
776 version_id: &str,
777 request: &UpdateProjectVersionRequest,
778 ) -> Result<ProjectVersion> {
779 let path = format!(
780 "/rest/api/{}/version/{}",
781 self.config.api_version, version_id
782 );
783 let body = serde_json::to_value(request)?;
784 let value = self
785 .raw_request("PUT", &path, Some(body))
786 .await?
787 .ok_or_else(|| JiraError::Api {
788 status: 0,
789 message: "Empty response when updating project version".into(),
790 })?;
791 let version: ProjectVersion =
792 serde_json::from_value(value).map_err(|e| JiraError::Api {
793 status: 0,
794 message: format!("Failed to parse updated project version: {e}"),
795 })?;
796 Ok(version)
797 }
798
799 fn parse_sprint_value(&self, value: &Value, board_id: Option<u64>) -> Option<Sprint> {
800 let id = value.get("id").and_then(|v| v.as_u64())?;
801 Some(Sprint {
802 id,
803 name: value
804 .get("name")
805 .and_then(|v| v.as_str())
806 .unwrap_or("")
807 .to_string(),
808 state: value
809 .get("state")
810 .and_then(|v| v.as_str())
811 .unwrap_or("")
812 .to_string(),
813 board_id,
814 goal: value
815 .get("goal")
816 .and_then(|v| v.as_str())
817 .map(str::to_owned),
818 start_date: value
819 .get("startDate")
820 .and_then(|v| v.as_str())
821 .map(str::to_owned),
822 end_date: value
823 .get("endDate")
824 .and_then(|v| v.as_str())
825 .map(str::to_owned),
826 complete_date: value
827 .get("completeDate")
828 .and_then(|v| v.as_str())
829 .map(str::to_owned),
830 })
831 }
832
833 pub async fn list_sprints_for_project_with_states(
835 &self,
836 project_key: &str,
837 states: &[&str],
838 ) -> Result<Vec<Sprint>> {
839 let boards_path =
840 format!("/rest/agile/1.0/board?projectKeyOrId={project_key}&maxResults=100");
841 let boards_resp = self
842 .raw_request("GET", &boards_path, None)
843 .await?
844 .unwrap_or_default();
845
846 let board_ids: Vec<u64> = boards_resp
847 .get("values")
848 .and_then(|v| v.as_array())
849 .map(|boards| {
850 boards
851 .iter()
852 .filter_map(|b| b.get("id").and_then(|id| id.as_u64()))
853 .collect()
854 })
855 .unwrap_or_default();
856
857 let mut seen: std::collections::HashSet<u64> = std::collections::HashSet::new();
858 let mut sprints: Vec<Sprint> = Vec::new();
859 let state_filter = states.join(",");
860
861 for board_id in board_ids {
862 let sprint_path = format!(
863 "/rest/agile/1.0/board/{board_id}/sprint?state={state_filter}&maxResults=200"
864 );
865 let Ok(Some(resp)) = self.raw_request("GET", &sprint_path, None).await else {
866 continue;
867 };
868 if let Some(values) = resp.get("values").and_then(|v| v.as_array()) {
869 for value in values {
870 let Some(sprint) = self.parse_sprint_value(value, Some(board_id)) else {
871 continue;
872 };
873 if !seen.insert(sprint.id) {
874 continue;
875 }
876 sprints.push(sprint);
877 }
878 }
879 }
880
881 sprints.sort_by(|a, b| {
882 let order = |s: &str| match s {
883 "active" => 0u8,
884 "future" => 1u8,
885 _ => 2u8,
886 };
887 order(&a.state)
888 .cmp(&order(&b.state))
889 .then(a.name.cmp(&b.name))
890 });
891
892 Ok(sprints)
893 }
894
895 pub async fn list_sprints_for_project(&self, project_key: &str) -> Result<Vec<Sprint>> {
897 self.list_sprints_for_project_with_states(project_key, &["active", "future"])
898 .await
899 }
900
901 pub async fn create_sprint(
903 &self,
904 board_id: u64,
905 name: &str,
906 start_date: Option<&str>,
907 end_date: Option<&str>,
908 goal: Option<&str>,
909 ) -> Result<Sprint> {
910 let body = json!({
911 "name": name,
912 "originBoardId": board_id,
913 "startDate": start_date,
914 "endDate": end_date,
915 "goal": goal,
916 });
917 let value = self
918 .raw_request("POST", "/rest/agile/1.0/sprint", Some(body))
919 .await?
920 .ok_or_else(|| JiraError::Api {
921 status: 0,
922 message: "Empty response when creating sprint".into(),
923 })?;
924 self.parse_sprint_value(&value, Some(board_id))
925 .ok_or_else(|| JiraError::Api {
926 status: 0,
927 message: "Failed to parse created sprint".into(),
928 })
929 }
930
931 pub async fn update_sprint(&self, sprint_id: u64, body: Value) -> Result<Sprint> {
933 let value = self
934 .raw_request(
935 "PUT",
936 &format!("/rest/agile/1.0/sprint/{sprint_id}"),
937 Some(body),
938 )
939 .await?
940 .ok_or_else(|| JiraError::Api {
941 status: 0,
942 message: "Empty response when updating sprint".into(),
943 })?;
944 self.parse_sprint_value(&value, None)
945 .ok_or_else(|| JiraError::Api {
946 status: 0,
947 message: "Failed to parse updated sprint".into(),
948 })
949 }
950
951 pub async fn delete_sprint(&self, sprint_id: u64) -> Result<()> {
953 self.raw_request(
954 "DELETE",
955 &format!("/rest/agile/1.0/sprint/{sprint_id}"),
956 None,
957 )
958 .await?;
959 Ok(())
960 }
961
962 pub async fn add_issue_to_sprint(&self, sprint_id: u64, issue_key: &str) -> Result<()> {
964 let path = format!("/rest/agile/1.0/sprint/{sprint_id}/issue");
965 let body = json!({ "issues": [issue_key] });
966 self.raw_request("POST", &path, Some(body)).await?;
967 Ok(())
968 }
969
970 pub async fn add_comment_adf(&self, issue_key: &str, adf: Value) -> Result<Comment> {
972 let headers = self.auth_headers()?;
973 let url = self.platform_url(&format!("/issue/{issue_key}/comment"));
974 let payload = json!({ "body": adf });
975 let http = &self.http;
976 let raw: Value = self
977 .request(|| http.post(&url).headers(headers.clone()).json(&payload))
978 .await?;
979 Comment::from_value(&raw, issue_key).ok_or_else(|| JiraError::Api {
980 status: 0,
981 message: "Failed to parse comment".into(),
982 })
983 }
984
985 pub async fn upload_attachment(
987 &self,
988 issue_key: &str,
989 file_path: &std::path::Path,
990 ) -> Result<Vec<Attachment>> {
991 let file_name = file_path
992 .file_name()
993 .and_then(|n| n.to_str())
994 .unwrap_or("attachment")
995 .to_string();
996 let bytes = std::fs::read(file_path)?;
997 let mime = mime_guess::from_path(file_path)
998 .first_or_octet_stream()
999 .to_string();
1000
1001 self.upload_attachment_bytes(issue_key, &file_name, bytes, Some(&mime))
1002 .await
1003 }
1004
1005 pub async fn upload_attachment_bytes(
1007 &self,
1008 issue_key: &str,
1009 file_name: &str,
1010 bytes: Vec<u8>,
1011 media_type: Option<&str>,
1012 ) -> Result<Vec<Attachment>> {
1013 use reqwest::{header::HeaderValue, multipart};
1014
1015 let headers = self.auth_headers_no_content_type()?;
1016 let url = self.platform_url(&format!("/issue/{issue_key}/attachments"));
1017 let mime = media_type
1018 .map(|value| value.to_string())
1019 .or_else(|| {
1020 mime_guess::from_path(file_name)
1021 .first_raw()
1022 .map(str::to_string)
1023 })
1024 .unwrap_or_else(|| "application/octet-stream".to_string());
1025
1026 let http = &self.http;
1027 let raw_attachments: Vec<Value> = self
1028 .request_multipart(|| {
1029 let part = multipart::Part::bytes(bytes.clone())
1030 .file_name(file_name.to_string())
1031 .mime_str(&mime)
1032 .expect("invalid mime type");
1033 let form = multipart::Form::new().part("file", part);
1034
1035 let mut req_headers = headers.clone();
1036 req_headers.insert("X-Atlassian-Token", HeaderValue::from_static("no-check"));
1037
1038 http.post(&url).headers(req_headers).multipart(form)
1039 })
1040 .await?;
1041
1042 Ok(raw_attachments
1043 .iter()
1044 .filter_map(Attachment::from_value)
1045 .collect())
1046 }
1047
1048 pub async fn create_issue_v2(&self, req: CreateIssueRequestV2) -> Result<Issue> {
1050 let headers = self.auth_headers()?;
1051 let url = self.platform_url("/issue");
1052
1053 let description_adf = req
1054 .description_adf
1055 .or_else(|| req.description.as_deref().map(markdown_to_adf));
1056
1057 let mut fields = json!({
1058 "project": { "key": req.project_key },
1059 "summary": req.summary,
1060 "issuetype": { "name": req.issue_type }
1061 });
1062
1063 if let Some(adf) = description_adf {
1064 fields["description"] = adf;
1065 }
1066 if let Some(assignee) = &req.assignee {
1067 let account_id = self.resolve_assignee_account_id(assignee).await?;
1068 fields["assignee"] = json!({ "accountId": account_id });
1069 }
1070 if let Some(priority) = &req.priority {
1071 fields["priority"] = json!({ "name": priority });
1072 }
1073 if !req.labels.is_empty() {
1074 fields["labels"] = json!(req.labels);
1075 }
1076 if !req.components.is_empty() {
1077 fields["components"] = json!(req
1078 .components
1079 .iter()
1080 .map(|c| json!({"name": c}))
1081 .collect::<Vec<_>>());
1082 }
1083 if let Some(parent) = &req.parent {
1084 fields["parent"] = json!({ "key": parent });
1085 }
1086 if !req.fix_versions.is_empty() {
1087 fields["fixVersions"] = json!(req
1088 .fix_versions
1089 .iter()
1090 .map(|v| json!({"name": v}))
1091 .collect::<Vec<_>>());
1092 }
1093 for (field_id, value) in &req.custom_fields {
1094 fields[field_id] = value.to_api_json();
1095 }
1096
1097 let body = json!({ "fields": fields });
1098
1099 #[derive(serde::Deserialize)]
1100 struct CreateResponse {
1101 key: String,
1102 }
1103
1104 let http = &self.http;
1105 let resp: CreateResponse = self
1106 .request(|| http.post(&url).headers(headers.clone()).json(&body))
1107 .await?;
1108
1109 self.get_issue(&resp.key).await
1110 }
1111
1112 pub async fn get_comments(&self, issue_key: &str) -> Result<Vec<Comment>> {
1116 let headers = self.auth_headers()?;
1117 let url = self.platform_url(&format!("/issue/{issue_key}/comment"));
1118
1119 #[derive(serde::Deserialize)]
1120 struct CommentResponse {
1121 comments: Vec<Value>,
1122 }
1123
1124 let http = &self.http;
1125 let resp: CommentResponse = self
1126 .request(|| http.get(&url).headers(headers.clone()))
1127 .await?;
1128
1129 Ok(resp
1130 .comments
1131 .iter()
1132 .filter_map(|v| Comment::from_value(v, issue_key))
1133 .collect())
1134 }
1135
1136 pub async fn add_comment(&self, issue_key: &str, body: &str) -> Result<Comment> {
1138 let headers = self.auth_headers()?;
1139 let url = self.platform_url(&format!("/issue/{issue_key}/comment"));
1140
1141 let payload = json!({
1142 "body": markdown_to_adf(body)
1143 });
1144
1145 let http = &self.http;
1146 let raw: Value = self
1147 .request(|| http.post(&url).headers(headers.clone()).json(&payload))
1148 .await?;
1149
1150 Comment::from_value(&raw, issue_key).ok_or_else(|| JiraError::Api {
1151 status: 0,
1152 message: "Failed to parse comment".into(),
1153 })
1154 }
1155
1156 pub async fn get_worklogs(&self, issue_key: &str) -> Result<Vec<Worklog>> {
1160 let headers = self.auth_headers()?;
1161 let url = self.platform_url(&format!("/issue/{issue_key}/worklog"));
1162
1163 #[derive(serde::Deserialize)]
1164 struct WorklogResponse {
1165 worklogs: Vec<Value>,
1166 }
1167
1168 let http = &self.http;
1169 let resp: WorklogResponse = self
1170 .request(|| http.get(&url).headers(headers.clone()))
1171 .await?;
1172
1173 Ok(resp
1174 .worklogs
1175 .iter()
1176 .filter_map(|v| Worklog::from_value(v, issue_key))
1177 .collect())
1178 }
1179
1180 pub async fn add_worklog(
1184 &self,
1185 issue_key: &str,
1186 time_spent: &str,
1187 comment: Option<&str>,
1188 started: Option<&str>,
1189 ) -> Result<Worklog> {
1190 let headers = self.auth_headers()?;
1191 let url = self.platform_url(&format!("/issue/{issue_key}/worklog"));
1192
1193 let started_str = started
1195 .map(|s| s.to_string())
1196 .unwrap_or_else(current_jira_timestamp);
1197
1198 let mut body = json!({
1199 "timeSpent": time_spent,
1200 "started": started_str,
1201 });
1202
1203 if let Some(c) = comment {
1204 body["comment"] = markdown_to_adf(c);
1205 }
1206
1207 let http = &self.http;
1208 let raw: Value = self
1209 .request(|| http.post(&url).headers(headers.clone()).json(&body))
1210 .await?;
1211
1212 Worklog::from_value(&raw, issue_key).ok_or_else(|| JiraError::Api {
1213 status: 0,
1214 message: "Failed to parse worklog".into(),
1215 })
1216 }
1217
1218 pub async fn delete_worklog(&self, issue_key: &str, worklog_id: &str) -> Result<()> {
1220 let headers = self.auth_headers()?;
1221 let url = self.platform_url(&format!("/issue/{issue_key}/worklog/{worklog_id}"));
1222
1223 let http = &self.http;
1224 self.request_no_body(|| http.delete(&url).headers(headers.clone()))
1225 .await
1226 }
1227
1228 pub async fn delete_comment(&self, issue_key: &str, comment_id: &str) -> Result<()> {
1230 let headers = self.auth_headers()?;
1231 let url = self.platform_url(&format!("/issue/{issue_key}/comment/{comment_id}"));
1232
1233 let http = &self.http;
1234 self.request_no_body(|| http.delete(&url).headers(headers.clone()))
1235 .await
1236 }
1237
1238 pub async fn delete_attachment(&self, attachment_id: &str) -> Result<()> {
1240 let headers = self.auth_headers()?;
1241 let url = self.platform_url(&format!("/attachment/{attachment_id}"));
1242
1243 let http = &self.http;
1244 self.request_no_body(|| http.delete(&url).headers(headers.clone()))
1245 .await
1246 }
1247
1248 pub async fn get_remote_links(&self, issue_key: &str) -> Result<Vec<RemoteLink>> {
1250 let headers = self.auth_headers()?;
1251 let url = self.platform_url(&format!("/issue/{issue_key}/remotelink"));
1252
1253 let http = &self.http;
1254 self.request(|| http.get(&url).headers(headers.clone()))
1255 .await
1256 }
1257
1258 pub async fn add_remote_link(
1260 &self,
1261 issue_key: &str,
1262 url_str: &str,
1263 title: &str,
1264 ) -> Result<Value> {
1265 let headers = self.auth_headers()?;
1266 let url = self.platform_url(&format!("/issue/{issue_key}/remotelink"));
1267
1268 let payload = json!({
1269 "object": {
1270 "url": url_str,
1271 "title": title,
1272 }
1273 });
1274
1275 let http = &self.http;
1276 self.request(|| http.post(&url).headers(headers.clone()).json(&payload))
1277 .await
1278 }
1279
1280 pub async fn delete_remote_link(&self, issue_key: &str, link_id: &str) -> Result<()> {
1282 let headers = self.auth_headers()?;
1283 let url = self.platform_url(&format!("/issue/{issue_key}/remotelink/{link_id}"));
1284
1285 let http = &self.http;
1286 self.request_no_body(|| http.delete(&url).headers(headers.clone()))
1287 .await
1288 }
1289
1290 pub async fn get_all_issues(&self, jql: &str) -> Result<Vec<Issue>> {
1295 let mut all_issues = Vec::new();
1296 let mut next_page_token: Option<String> = None;
1297 let mut iterations = 0u32;
1298 const MAX_ITERATIONS: u32 = 500;
1299
1300 loop {
1301 iterations += 1;
1302 if iterations > MAX_ITERATIONS {
1303 break;
1304 }
1305
1306 let result = self
1307 .search_issues(jql, next_page_token.as_deref(), Some(100))
1308 .await?;
1309
1310 all_issues.extend(result.issues);
1311
1312 match result.next_page_token {
1313 Some(token) => next_page_token = Some(token),
1314 None => break,
1315 }
1316 }
1317
1318 Ok(all_issues)
1319 }
1320
1321 pub async fn archive_issues(&self, issue_keys: &[String]) -> Result<()> {
1323 if issue_keys.is_empty() {
1324 return Ok(());
1325 }
1326 let headers = self.auth_headers()?;
1327 let url = self.platform_url("/issue/archive");
1328
1329 for chunk in issue_keys.chunks(1000) {
1331 let body = json!({ "issueIdsOrKeys": chunk });
1332 let http = &self.http;
1333 let _: Value = self
1335 .request(|| http.put(&url).headers(headers.clone()).json(&body))
1336 .await?;
1337 }
1338
1339 Ok(())
1340 }
1341
1342 pub async fn move_issue(
1344 &self,
1345 issue_key: &str,
1346 target_project_key: &str,
1347 target_issue_type_id: &str,
1348 target_parent: Option<&str>,
1349 ) -> Result<Issue> {
1350 let mapping_key = match target_parent {
1351 Some(parent) => format!("{target_project_key},{target_issue_type_id},{parent}"),
1352 None => format!("{target_project_key},{target_issue_type_id}"),
1353 };
1354
1355 let body = json!({
1356 "sendBulkNotification": true,
1357 "targetToSourcesMapping": {
1358 mapping_key: {
1359 "inferClassificationDefaults": true,
1360 "inferFieldDefaults": true,
1361 "inferStatusDefaults": true,
1362 "inferSubtaskTypeDefault": true,
1363 "issueIdsOrKeys": [issue_key]
1364 }
1365 }
1366 });
1367
1368 let submitted = self
1369 .raw_request("POST", "/rest/api/3/bulk/issues/move", Some(body))
1370 .await?
1371 .ok_or_else(|| JiraError::Api {
1372 status: 0,
1373 message: "Bulk move returned an empty response".into(),
1374 })?;
1375
1376 let task_id = submitted
1377 .get("taskId")
1378 .and_then(|v| v.as_str())
1379 .ok_or_else(|| JiraError::Api {
1380 status: 0,
1381 message: "Bulk move response did not include a taskId".into(),
1382 })?;
1383
1384 const MAX_POLLS: usize = 60;
1385 for _ in 0..MAX_POLLS {
1386 tokio::time::sleep(Duration::from_secs(2)).await;
1387
1388 let progress = self
1389 .raw_request("GET", &format!("/rest/api/3/bulk/queue/{task_id}"), None)
1390 .await?
1391 .ok_or_else(|| JiraError::Api {
1392 status: 0,
1393 message: "Bulk move progress returned an empty response".into(),
1394 })?;
1395
1396 match progress
1397 .get("status")
1398 .and_then(|v| v.as_str())
1399 .unwrap_or("")
1400 {
1401 "COMPLETE" => return self.get_issue(issue_key).await,
1402 "FAILED" | "DEAD" | "CANCELLED" => {
1403 return Err(JiraError::Api {
1404 status: 0,
1405 message: progress.to_string(),
1406 });
1407 }
1408 _ => {}
1409 }
1410 }
1411
1412 Err(JiraError::Api {
1413 status: 0,
1414 message: format!("Timed out waiting for Jira bulk move task {task_id}"),
1415 })
1416 }
1417
1418 pub async fn raw_request(
1424 &self,
1425 method: &str,
1426 path: &str,
1427 body: Option<Value>,
1428 ) -> Result<Option<Value>> {
1429 let headers = self.auth_headers()?;
1430 let url = format!("{}{}", self.config.base_url.trim_end_matches('/'), path);
1431
1432 let http = &self.http;
1433 let response = self
1434 .execute_with_retry(|| {
1435 let req = match method.to_uppercase().as_str() {
1436 "GET" => http.get(&url),
1437 "POST" => http.post(&url),
1438 "PUT" => http.put(&url),
1439 "DELETE" => http.delete(&url),
1440 "PATCH" => http.patch(&url),
1441 _ => http.get(&url),
1442 };
1443 let req = req.headers(headers.clone());
1444 if let Some(b) = &body {
1445 req.json(b)
1446 } else {
1447 req
1448 }
1449 })
1450 .await?;
1451
1452 let status = response.status();
1453
1454 if status == StatusCode::NO_CONTENT {
1456 return Ok(None);
1457 }
1458
1459 if status.is_success() {
1460 let value: Value = response.json().await?;
1461 return Ok(Some(value));
1462 }
1463
1464 let body_text = response.text().await.unwrap_or_default();
1465 Err(match status {
1466 StatusCode::NOT_FOUND => JiraError::NotFound(body_text),
1467 StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => {
1468 JiraError::Auth(format!("HTTP {status}: {body_text}"))
1469 }
1470 _ => JiraError::Api {
1471 status: status.as_u16(),
1472 message: body_text,
1473 },
1474 })
1475 }
1476
1477 pub async fn is_premium(&self) -> bool {
1481 match self.get_server_info().await {
1482 Ok(info) => {
1483 let license = info
1484 .get("deploymentType")
1485 .and_then(|v| v.as_str())
1486 .unwrap_or("");
1487 let _ = license;
1489 let headers = match self.auth_headers() {
1491 Ok(h) => h,
1492 Err(_) => return false,
1493 };
1494 let url = self.platform_url("/plans/plan");
1495 let http = &self.http;
1496 matches!(
1497 http.get(&url).headers(headers).send().await,
1498 Ok(r) if r.status().is_success()
1499 )
1500 }
1501 Err(_) => false,
1502 }
1503 }
1504
1505 pub async fn get_plans(&self) -> Result<Vec<Value>> {
1507 let headers = self.auth_headers()?;
1508 let url = self.platform_url("/plans/plan");
1509
1510 #[derive(serde::Deserialize)]
1511 struct PlansResponse {
1512 values: Vec<Value>,
1513 }
1514
1515 let http = &self.http;
1516 let resp: PlansResponse = self
1517 .request(|| http.get(&url).headers(headers.clone()))
1518 .await?;
1519
1520 Ok(resp.values)
1521 }
1522
1523 pub async fn list_issue_link_types(&self) -> Result<Vec<IssueLinkType>> {
1527 let headers = self.auth_headers()?;
1528 let url = self.platform_url("/issueLinkType");
1529
1530 #[derive(serde::Deserialize)]
1531 struct LinkTypesResponse {
1532 #[serde(rename = "issueLinkTypes")]
1533 issue_link_types: Vec<IssueLinkType>,
1534 }
1535
1536 let http = &self.http;
1537 let resp: LinkTypesResponse = self
1538 .request(|| http.get(&url).headers(headers.clone()))
1539 .await?;
1540
1541 Ok(resp.issue_link_types)
1542 }
1543
1544 pub async fn link_issues(
1549 &self,
1550 outward_key: &str,
1551 inward_key: &str,
1552 type_name: &str,
1553 comment: Option<&str>,
1554 ) -> Result<()> {
1555 let headers = self.auth_headers()?;
1556 let url = self.platform_url("/issueLink");
1557
1558 let mut payload = json!({
1559 "type": { "name": type_name },
1560 "inwardIssue": { "key": inward_key },
1561 "outwardIssue": { "key": outward_key }
1562 });
1563
1564 if let Some(c) = comment {
1565 payload["comment"] = json!({
1566 "body": crate::adf::markdown_to_adf(c)
1567 });
1568 }
1569
1570 let http = &self.http;
1571 self.request_no_body(|| http.post(&url).headers(headers.clone()).json(&payload))
1572 .await
1573 }
1574
1575 pub async fn get_issue_link(&self, link_id: &str) -> Result<IssueLink> {
1577 let headers = self.auth_headers()?;
1578 let url = self.platform_url(&format!("/issueLink/{link_id}"));
1579
1580 let http = &self.http;
1581 self.request(|| http.get(&url).headers(headers.clone()))
1582 .await
1583 }
1584
1585 pub async fn delete_issue_link(&self, link_id: &str) -> Result<()> {
1587 let headers = self.auth_headers()?;
1588 let url = self.platform_url(&format!("/issueLink/{link_id}"));
1589
1590 let http = &self.http;
1591 self.request_no_body(|| http.delete(&url).headers(headers.clone()))
1592 .await
1593 }
1594}
1595
1596async fn handle_response<T>(response: Response) -> Result<T>
1597where
1598 T: serde::de::DeserializeOwned,
1599{
1600 let status = response.status();
1601
1602 if status.is_success() {
1603 if status == StatusCode::NO_CONTENT || status == StatusCode::RESET_CONTENT {
1606 return serde_json::from_value(serde_json::Value::Null).map_err(|_| JiraError::Api {
1607 status: status.as_u16(),
1608 message: "Unexpected empty response body".into(),
1609 });
1610 }
1611 let value: T = response.json().await?;
1612 return Ok(value);
1613 }
1614
1615 let body = response.text().await.unwrap_or_default();
1616
1617 match status {
1618 StatusCode::NOT_FOUND => Err(JiraError::NotFound(body)),
1619 StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => {
1620 Err(JiraError::Auth(format!("HTTP {status}: {body}")))
1621 }
1622 _ => Err(JiraError::Api {
1623 status: status.as_u16(),
1624 message: body,
1625 }),
1626 }
1627}
1628
1629fn current_jira_timestamp() -> String {
1631 chrono::Utc::now()
1632 .format("%Y-%m-%dT%H:%M:%S%.3f%z")
1633 .to_string()
1634}
1635
1636fn base64_encode(input: &str) -> String {
1637 use std::fmt::Write;
1638 const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
1639 let bytes = input.as_bytes();
1640 let mut result = String::new();
1641 let mut i = 0;
1642 while i < bytes.len() {
1643 let b0 = bytes[i] as u32;
1644 let b1 = if i + 1 < bytes.len() {
1645 bytes[i + 1] as u32
1646 } else {
1647 0
1648 };
1649 let b2 = if i + 2 < bytes.len() {
1650 bytes[i + 2] as u32
1651 } else {
1652 0
1653 };
1654
1655 let _ = write!(result, "{}", CHARS[((b0 >> 2) & 0x3F) as usize] as char);
1656 let _ = write!(
1657 result,
1658 "{}",
1659 CHARS[(((b0 & 0x3) << 4) | ((b1 >> 4) & 0xF)) as usize] as char
1660 );
1661 if i + 1 < bytes.len() {
1662 let _ = write!(
1663 result,
1664 "{}",
1665 CHARS[(((b1 & 0xF) << 2) | ((b2 >> 6) & 0x3)) as usize] as char
1666 );
1667 } else {
1668 result.push('=');
1669 }
1670 if i + 2 < bytes.len() {
1671 let _ = write!(result, "{}", CHARS[(b2 & 0x3F) as usize] as char);
1672 } else {
1673 result.push('=');
1674 }
1675 i += 3;
1676 }
1677 result
1678}
1679
1680#[cfg(test)]
1681mod tests {
1682 use super::*;
1683 use crate::{
1684 config::{JiraAuthType, JiraDeployment},
1685 model::FieldValue,
1686 };
1687 use wiremock::{
1688 matchers::{body_json, header, method, path, query_param},
1689 Mock, MockServer, ResponseTemplate,
1690 };
1691
1692 fn cloud_client(server: &MockServer) -> JiraClient {
1693 JiraClient::new(JiraConfig {
1694 profile_name: Some("cloud-main".into()),
1695 base_url: server.uri(),
1696 email: "dev@example.com".into(),
1697 token: Some("cloud-token".into()),
1698 project: None,
1699 timeout_secs: 30,
1700 deployment: JiraDeployment::Cloud,
1701 auth_type: JiraAuthType::CloudApiToken,
1702 api_version: 3,
1703 })
1704 }
1705
1706 fn cloud_auth() -> String {
1707 format!("Basic {}", base64_encode("dev@example.com:cloud-token"))
1708 }
1709
1710 #[tokio::test]
1711 async fn data_center_pat_uses_bearer_and_api_v2() {
1712 let server = MockServer::start().await;
1713
1714 Mock::given(method("GET"))
1715 .and(path("/rest/api/2/serverInfo"))
1716 .and(header("authorization", "Bearer dc-token"))
1717 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1718 "deploymentType": "Data Center",
1719 "version": "10.0.0"
1720 })))
1721 .mount(&server)
1722 .await;
1723
1724 let client = JiraClient::new(JiraConfig {
1725 profile_name: Some("dc-main".into()),
1726 base_url: server.uri(),
1727 email: String::new(),
1728 token: Some("dc-token".into()),
1729 project: None,
1730 timeout_secs: 30,
1731 deployment: JiraDeployment::DataCenter,
1732 auth_type: JiraAuthType::DataCenterPat,
1733 api_version: 2,
1734 });
1735
1736 let info = client.get_server_info().await.expect("server info");
1737 assert_eq!(info["deploymentType"], Value::String("Data Center".into()));
1738 }
1739
1740 #[tokio::test]
1741 async fn cloud_auth_uses_basic_and_api_v3() {
1742 let server = MockServer::start().await;
1743 let expected = format!("Basic {}", base64_encode("dev@example.com:cloud-token"));
1744
1745 Mock::given(method("GET"))
1746 .and(path("/rest/api/3/serverInfo"))
1747 .and(header("authorization", expected.as_str()))
1748 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1749 "deploymentType": "Cloud",
1750 "version": "1001.0.0"
1751 })))
1752 .mount(&server)
1753 .await;
1754
1755 let client = JiraClient::new(JiraConfig {
1756 profile_name: Some("cloud-main".into()),
1757 base_url: server.uri(),
1758 email: "dev@example.com".into(),
1759 token: Some("cloud-token".into()),
1760 project: None,
1761 timeout_secs: 30,
1762 deployment: JiraDeployment::Cloud,
1763 auth_type: JiraAuthType::CloudApiToken,
1764 api_version: 3,
1765 });
1766
1767 let info = client.get_server_info().await.expect("server info");
1768 assert_eq!(info["deploymentType"], Value::String("Cloud".into()));
1769 }
1770
1771 #[tokio::test]
1772 async fn get_fields_for_issue_type_supports_map_response() {
1773 let server = MockServer::start().await;
1774 let expected = format!("Basic {}", base64_encode("dev@example.com:cloud-token"));
1775
1776 Mock::given(method("GET"))
1777 .and(path("/rest/api/3/issue/createmeta/TEST/issuetypes/10001"))
1778 .and(header("authorization", expected.as_str()))
1779 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1780 "fields": {
1781 "summary": {
1782 "name": "Summary",
1783 "required": true,
1784 "schema": { "type": "string" }
1785 }
1786 }
1787 })))
1788 .mount(&server)
1789 .await;
1790
1791 let client = JiraClient::new(JiraConfig {
1792 profile_name: Some("cloud-main".into()),
1793 base_url: server.uri(),
1794 email: "dev@example.com".into(),
1795 token: Some("cloud-token".into()),
1796 project: None,
1797 timeout_secs: 30,
1798 deployment: JiraDeployment::Cloud,
1799 auth_type: JiraAuthType::CloudApiToken,
1800 api_version: 3,
1801 });
1802
1803 let fields = client
1804 .get_fields_for_issue_type("TEST", "10001")
1805 .await
1806 .expect("map response should parse");
1807
1808 assert_eq!(fields.len(), 1);
1809 assert_eq!(fields[0].id, "summary");
1810 assert_eq!(fields[0].name, "Summary");
1811 assert!(fields[0].required);
1812 assert_eq!(fields[0].field_type, "string");
1813 }
1814
1815 #[tokio::test]
1816 async fn get_fields_for_issue_type_supports_list_response() {
1817 let server = MockServer::start().await;
1818 let expected = format!("Basic {}", base64_encode("dev@example.com:cloud-token"));
1819
1820 Mock::given(method("GET"))
1821 .and(path("/rest/api/3/issue/createmeta/TEST/issuetypes/10002"))
1822 .and(header("authorization", expected.as_str()))
1823 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1824 "fields": [
1825 {
1826 "fieldId": "customfield_10553",
1827 "key": "customfield_10553",
1828 "name": "Labels (OSS)",
1829 "required": true,
1830 "schema": {
1831 "custom": "com.atlassian.jira.plugin.system.customfieldtypes:labels",
1832 "items": "string",
1833 "type": "array"
1834 },
1835 "allowedValues": []
1836 }
1837 ]
1838 })))
1839 .mount(&server)
1840 .await;
1841
1842 let client = JiraClient::new(JiraConfig {
1843 profile_name: Some("cloud-main".into()),
1844 base_url: server.uri(),
1845 email: "dev@example.com".into(),
1846 token: Some("cloud-token".into()),
1847 project: None,
1848 timeout_secs: 30,
1849 deployment: JiraDeployment::Cloud,
1850 auth_type: JiraAuthType::CloudApiToken,
1851 api_version: 3,
1852 });
1853
1854 let fields = client
1855 .get_fields_for_issue_type("TEST", "10002")
1856 .await
1857 .expect("list response should parse");
1858
1859 assert_eq!(fields.len(), 1);
1860 assert_eq!(fields[0].id, "customfield_10553");
1861 assert_eq!(fields[0].name, "Labels (OSS)");
1862 assert!(fields[0].required);
1863 assert_eq!(fields[0].field_type, "array");
1864 }
1865
1866 #[tokio::test]
1867 async fn search_users_supports_empty_query_and_no_results() {
1868 let server = MockServer::start().await;
1869 let expected_auth = cloud_auth();
1870
1871 Mock::given(method("GET"))
1872 .and(path("/rest/api/3/user/search"))
1873 .and(header("authorization", expected_auth.as_str()))
1874 .and(query_param("query", ""))
1875 .and(query_param("maxResults", "20"))
1876 .respond_with(ResponseTemplate::new(200).set_body_json(json!([])))
1877 .mount(&server)
1878 .await;
1879
1880 let client = cloud_client(&server);
1881 let users = client
1882 .search_users("")
1883 .await
1884 .expect("empty query should work");
1885
1886 assert!(users.is_empty());
1887 }
1888
1889 #[tokio::test]
1890 async fn search_users_preserves_users_without_email() {
1891 let server = MockServer::start().await;
1892 let expected_auth = cloud_auth();
1893
1894 Mock::given(method("GET"))
1895 .and(path("/rest/api/3/user/search"))
1896 .and(header("authorization", expected_auth.as_str()))
1897 .and(query_param("query", "alice"))
1898 .and(query_param("maxResults", "20"))
1899 .respond_with(ResponseTemplate::new(200).set_body_json(json!([
1900 {
1901 "accountId": "acct-1",
1902 "displayName": "Alice Example"
1903 }
1904 ])))
1905 .mount(&server)
1906 .await;
1907
1908 let client = cloud_client(&server);
1909 let users = client
1910 .search_users("alice")
1911 .await
1912 .expect("search should parse");
1913
1914 assert_eq!(users.len(), 1);
1915 assert_eq!(users[0].account_id, "acct-1");
1916 assert_eq!(users[0].display_name.as_deref(), Some("Alice Example"));
1917 assert!(users[0].email_address.is_none());
1918 }
1919
1920 #[tokio::test]
1921 async fn add_issue_to_sprint_posts_expected_payload() {
1922 let server = MockServer::start().await;
1923 let expected_auth = cloud_auth();
1924
1925 Mock::given(method("POST"))
1926 .and(path("/rest/agile/1.0/sprint/42/issue"))
1927 .and(header("authorization", expected_auth.as_str()))
1928 .and(body_json(json!({ "issues": ["TEST-123"] })))
1929 .respond_with(ResponseTemplate::new(200).set_body_json(json!({})))
1930 .mount(&server)
1931 .await;
1932
1933 let client = cloud_client(&server);
1934 client
1935 .add_issue_to_sprint(42, "TEST-123")
1936 .await
1937 .expect("add to sprint should succeed");
1938 }
1939
1940 #[tokio::test]
1941 async fn add_issue_to_sprint_propagates_non_success_response() {
1942 let server = MockServer::start().await;
1943 let expected_auth = cloud_auth();
1944
1945 Mock::given(method("POST"))
1946 .and(path("/rest/agile/1.0/sprint/42/issue"))
1947 .and(header("authorization", expected_auth.as_str()))
1948 .respond_with(ResponseTemplate::new(400).set_body_string("bad sprint request"))
1949 .mount(&server)
1950 .await;
1951
1952 let client = cloud_client(&server);
1953 let err = client
1954 .add_issue_to_sprint(42, "TEST-123")
1955 .await
1956 .expect_err("non-success should return error");
1957
1958 match err {
1959 JiraError::Api { status, message } => {
1960 assert_eq!(status, 400);
1961 assert!(message.contains("bad sprint request"));
1962 }
1963 other => panic!("expected JiraError::Api, got {other:?}"),
1964 }
1965 }
1966
1967 #[tokio::test]
1968 async fn list_sprints_for_project_returns_empty_when_no_boards_exist() {
1969 let server = MockServer::start().await;
1970 let expected_auth = cloud_auth();
1971
1972 Mock::given(method("GET"))
1973 .and(path("/rest/agile/1.0/board"))
1974 .and(header("authorization", expected_auth.as_str()))
1975 .and(query_param("projectKeyOrId", "TEST"))
1976 .and(query_param("maxResults", "100"))
1977 .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "values": [] })))
1978 .mount(&server)
1979 .await;
1980
1981 let client = cloud_client(&server);
1982 let sprints = client
1983 .list_sprints_for_project("TEST")
1984 .await
1985 .expect("no boards should not error");
1986
1987 assert!(sprints.is_empty());
1988 }
1989
1990 #[tokio::test]
1991 async fn list_sprints_for_project_dedups_sorts_and_skips_missing_board_sprints() {
1992 let server = MockServer::start().await;
1993 let expected_auth = cloud_auth();
1994
1995 Mock::given(method("GET"))
1996 .and(path("/rest/agile/1.0/board"))
1997 .and(header("authorization", expected_auth.as_str()))
1998 .and(query_param("projectKeyOrId", "TEST"))
1999 .and(query_param("maxResults", "100"))
2000 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2001 "values": [
2002 { "id": 1 },
2003 { "id": 2 },
2004 { "id": 3 }
2005 ]
2006 })))
2007 .mount(&server)
2008 .await;
2009
2010 Mock::given(method("GET"))
2011 .and(path("/rest/agile/1.0/board/1/sprint"))
2012 .and(header("authorization", expected_auth.as_str()))
2013 .and(query_param("state", "active,future"))
2014 .and(query_param("maxResults", "200"))
2015 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2016 "values": [
2017 { "id": 20, "name": "Future Sprint", "state": "future" },
2018 { "id": 10, "name": "Active Sprint", "state": "active" }
2019 ]
2020 })))
2021 .mount(&server)
2022 .await;
2023
2024 Mock::given(method("GET"))
2025 .and(path("/rest/agile/1.0/board/2/sprint"))
2026 .and(header("authorization", expected_auth.as_str()))
2027 .and(query_param("state", "active,future"))
2028 .and(query_param("maxResults", "200"))
2029 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2030 "values": [
2031 { "id": 10, "name": "Active Sprint", "state": "active" },
2032 { "id": 30, "name": "Alpha Future", "state": "future" }
2033 ]
2034 })))
2035 .mount(&server)
2036 .await;
2037
2038 Mock::given(method("GET"))
2039 .and(path("/rest/agile/1.0/board/3/sprint"))
2040 .and(header("authorization", expected_auth.as_str()))
2041 .and(query_param("state", "active,future"))
2042 .and(query_param("maxResults", "200"))
2043 .respond_with(ResponseTemplate::new(404).set_body_string("board not found"))
2044 .mount(&server)
2045 .await;
2046
2047 let client = cloud_client(&server);
2048 let sprints = client
2049 .list_sprints_for_project("TEST")
2050 .await
2051 .expect("404 on one board should be skipped");
2052
2053 assert_eq!(sprints.len(), 3);
2054 assert_eq!(sprints[0].id, 10);
2055 assert_eq!(sprints[0].state, "active");
2056 assert_eq!(sprints[1].id, 30);
2057 assert_eq!(sprints[1].state, "future");
2058 assert_eq!(sprints[2].id, 20);
2059 assert_eq!(sprints[2].state, "future");
2060 }
2061
2062 #[tokio::test]
2063 async fn list_sprints_for_project_handles_large_sprint_pages() {
2064 let server = MockServer::start().await;
2065 let expected_auth = cloud_auth();
2066
2067 let sprint_values: Vec<Value> = (1..=60)
2068 .map(|id| {
2069 json!({
2070 "id": id,
2071 "name": format!("Sprint {id:02}"),
2072 "state": if id == 1 { "active" } else { "future" }
2073 })
2074 })
2075 .collect();
2076
2077 Mock::given(method("GET"))
2078 .and(path("/rest/agile/1.0/board"))
2079 .and(header("authorization", expected_auth.as_str()))
2080 .and(query_param("projectKeyOrId", "TEST"))
2081 .and(query_param("maxResults", "100"))
2082 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2083 "values": [{ "id": 9 }]
2084 })))
2085 .mount(&server)
2086 .await;
2087
2088 Mock::given(method("GET"))
2089 .and(path("/rest/agile/1.0/board/9/sprint"))
2090 .and(header("authorization", expected_auth.as_str()))
2091 .and(query_param("state", "active,future"))
2092 .and(query_param("maxResults", "200"))
2093 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2094 "values": sprint_values
2095 })))
2096 .mount(&server)
2097 .await;
2098
2099 let client = cloud_client(&server);
2100 let sprints = client
2101 .list_sprints_for_project("TEST")
2102 .await
2103 .expect(">50 sprints in one response should parse");
2104
2105 assert_eq!(sprints.len(), 60);
2106 assert_eq!(sprints[0].id, 1);
2107 assert_eq!(sprints[0].state, "active");
2108 assert_eq!(sprints[59].id, 60);
2109 }
2110
2111 #[tokio::test]
2112 async fn create_sprint_posts_expected_payload() {
2113 let server = MockServer::start().await;
2114 let expected_auth = cloud_auth();
2115
2116 Mock::given(method("POST"))
2117 .and(path("/rest/agile/1.0/sprint"))
2118 .and(header("authorization", expected_auth.as_str()))
2119 .and(body_json(json!({
2120 "name": "Sprint 42",
2121 "originBoardId": 7,
2122 "startDate": "2026-05-20T00:00:00.000Z",
2123 "endDate": "2026-05-27T00:00:00.000Z",
2124 "goal": "Ship sprint lifecycle support"
2125 })))
2126 .respond_with(ResponseTemplate::new(201).set_body_json(json!({
2127 "id": 42,
2128 "name": "Sprint 42",
2129 "state": "future",
2130 "goal": "Ship sprint lifecycle support",
2131 "startDate": "2026-05-20T00:00:00.000Z",
2132 "endDate": "2026-05-27T00:00:00.000Z"
2133 })))
2134 .mount(&server)
2135 .await;
2136
2137 let client = cloud_client(&server);
2138 let sprint = client
2139 .create_sprint(
2140 7,
2141 "Sprint 42",
2142 Some("2026-05-20T00:00:00.000Z"),
2143 Some("2026-05-27T00:00:00.000Z"),
2144 Some("Ship sprint lifecycle support"),
2145 )
2146 .await
2147 .expect("create sprint should succeed");
2148
2149 assert_eq!(sprint.id, 42);
2150 assert_eq!(sprint.board_id, Some(7));
2151 assert_eq!(sprint.state, "future");
2152 assert_eq!(
2153 sprint.goal.as_deref(),
2154 Some("Ship sprint lifecycle support")
2155 );
2156 }
2157
2158 #[tokio::test]
2159 async fn update_sprint_puts_expected_payload() {
2160 let server = MockServer::start().await;
2161 let expected_auth = cloud_auth();
2162
2163 Mock::given(method("PUT"))
2164 .and(path("/rest/agile/1.0/sprint/42"))
2165 .and(header("authorization", expected_auth.as_str()))
2166 .and(body_json(json!({
2167 "state": "active",
2168 "startDate": "2026-05-20T00:00:00.000Z",
2169 "endDate": "2026-05-27T00:00:00.000Z"
2170 })))
2171 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2172 "id": 42,
2173 "name": "Sprint 42",
2174 "state": "active",
2175 "startDate": "2026-05-20T00:00:00.000Z",
2176 "endDate": "2026-05-27T00:00:00.000Z"
2177 })))
2178 .mount(&server)
2179 .await;
2180
2181 let client = cloud_client(&server);
2182 let sprint = client
2183 .update_sprint(
2184 42,
2185 json!({
2186 "state": "active",
2187 "startDate": "2026-05-20T00:00:00.000Z",
2188 "endDate": "2026-05-27T00:00:00.000Z"
2189 }),
2190 )
2191 .await
2192 .expect("update sprint should succeed");
2193
2194 assert_eq!(sprint.id, 42);
2195 assert_eq!(sprint.state, "active");
2196 }
2197
2198 #[tokio::test]
2199 async fn delete_sprint_uses_delete_endpoint() {
2200 let server = MockServer::start().await;
2201 let expected_auth = cloud_auth();
2202
2203 Mock::given(method("DELETE"))
2204 .and(path("/rest/agile/1.0/sprint/42"))
2205 .and(header("authorization", expected_auth.as_str()))
2206 .respond_with(ResponseTemplate::new(204))
2207 .mount(&server)
2208 .await;
2209
2210 let client = cloud_client(&server);
2211 client
2212 .delete_sprint(42)
2213 .await
2214 .expect("delete sprint should succeed");
2215 }
2216
2217 #[tokio::test]
2218 async fn create_issue_v2_prefers_adf_and_builds_extended_fields() {
2219 let server = MockServer::start().await;
2220 let expected_auth = cloud_auth();
2221 let description_adf = json!({
2222 "type": "doc",
2223 "version": 1,
2224 "content": [{
2225 "type": "paragraph",
2226 "content": [{ "type": "text", "text": "ADF body" }]
2227 }]
2228 });
2229
2230 Mock::given(method("GET"))
2231 .and(path("/rest/api/3/user/search"))
2232 .and(header("authorization", expected_auth.as_str()))
2233 .and(query_param("query", "alice@example.com"))
2234 .and(query_param("maxResults", "20"))
2235 .respond_with(ResponseTemplate::new(200).set_body_json(json!([
2236 {
2237 "accountId": "acct-1",
2238 "emailAddress": "alice@example.com",
2239 "displayName": "Alice"
2240 }
2241 ])))
2242 .mount(&server)
2243 .await;
2244
2245 Mock::given(method("POST"))
2246 .and(path("/rest/api/3/issue"))
2247 .and(header("authorization", expected_auth.as_str()))
2248 .and(body_json(json!({
2249 "fields": {
2250 "project": { "key": "TEST" },
2251 "summary": "Ship feature",
2252 "issuetype": { "name": "Task" },
2253 "description": description_adf,
2254 "assignee": { "accountId": "acct-1" },
2255 "priority": { "name": "High" },
2256 "labels": ["backend", "urgent"],
2257 "components": [{ "name": "api" }, { "name": "worker" }],
2258 "parent": { "key": "TEST-1" },
2259 "fixVersions": [{ "name": "v1.0" }, { "name": "v1.1" }],
2260 "customfield_10010": "hello",
2261 "customfield_10011": { "value": "Blue" },
2262 "customfield_10012": ["triage"]
2263 }
2264 })))
2265 .respond_with(ResponseTemplate::new(201).set_body_json(json!({ "key": "TEST-123" })))
2266 .mount(&server)
2267 .await;
2268
2269 Mock::given(method("GET"))
2270 .and(path("/rest/api/3/issue/TEST-123"))
2271 .and(header("authorization", expected_auth.as_str()))
2272 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2273 "id": "10001",
2274 "key": "TEST-123",
2275 "fields": {
2276 "summary": "Ship feature",
2277 "description": description_adf,
2278 "status": { "name": "To Do" },
2279 "issuetype": { "name": "Task" },
2280 "project": { "key": "TEST" },
2281 "created": "2023-01-01T00:00:00.000+0000",
2282 "updated": "2023-01-01T00:00:00.000+0000"
2283 }
2284 })))
2285 .mount(&server)
2286 .await;
2287
2288 let client = cloud_client(&server);
2289 let mut custom_fields = std::collections::HashMap::new();
2290 custom_fields.insert(
2291 "customfield_10010".to_string(),
2292 FieldValue::Text("hello".into()),
2293 );
2294 custom_fields.insert(
2295 "customfield_10011".to_string(),
2296 FieldValue::SelectName("Blue".into()),
2297 );
2298 custom_fields.insert(
2299 "customfield_10012".to_string(),
2300 FieldValue::Labels(vec!["triage".into()]),
2301 );
2302
2303 let issue = client
2304 .create_issue_v2(CreateIssueRequestV2 {
2305 project_key: "TEST".into(),
2306 summary: "Ship feature".into(),
2307 description: Some("markdown body should be ignored".into()),
2308 description_adf: Some(description_adf.clone()),
2309 issue_type: "Task".into(),
2310 assignee: Some("alice@example.com".into()),
2311 priority: Some("High".into()),
2312 labels: vec!["backend".into(), "urgent".into()],
2313 components: vec!["api".into(), "worker".into()],
2314 parent: Some("TEST-1".into()),
2315 fix_versions: vec!["v1.0".into(), "v1.1".into()],
2316 custom_fields,
2317 })
2318 .await
2319 .expect("create issue v2 should succeed");
2320
2321 assert_eq!(issue.key, "TEST-123");
2322 assert_eq!(issue.summary, "Ship feature");
2323 assert_eq!(issue.description, Some(description_adf));
2324 }
2325
2326 #[tokio::test]
2327 async fn create_issue_v2_uses_markdown_description_when_adf_missing() {
2328 let server = MockServer::start().await;
2329 let expected_auth = cloud_auth();
2330 let markdown_adf = markdown_to_adf("hello world");
2331
2332 Mock::given(method("POST"))
2333 .and(path("/rest/api/3/issue"))
2334 .and(header("authorization", expected_auth.as_str()))
2335 .and(body_json(json!({
2336 "fields": {
2337 "project": { "key": "TEST" },
2338 "summary": "Plain issue",
2339 "issuetype": { "name": "Task" },
2340 "description": markdown_adf
2341 }
2342 })))
2343 .respond_with(ResponseTemplate::new(201).set_body_json(json!({ "key": "TEST-124" })))
2344 .mount(&server)
2345 .await;
2346
2347 Mock::given(method("GET"))
2348 .and(path("/rest/api/3/issue/TEST-124"))
2349 .and(header("authorization", expected_auth.as_str()))
2350 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2351 "id": "10002",
2352 "key": "TEST-124",
2353 "fields": {
2354 "summary": "Plain issue",
2355 "description": markdown_to_adf("hello world"),
2356 "status": { "name": "To Do" },
2357 "issuetype": { "name": "Task" },
2358 "project": { "key": "TEST" },
2359 "created": "2023-01-01T00:00:00.000+0000",
2360 "updated": "2023-01-01T00:00:00.000+0000"
2361 }
2362 })))
2363 .mount(&server)
2364 .await;
2365
2366 let client = cloud_client(&server);
2367 let issue = client
2368 .create_issue_v2(CreateIssueRequestV2 {
2369 project_key: "TEST".into(),
2370 summary: "Plain issue".into(),
2371 description: Some("hello world".into()),
2372 description_adf: None,
2373 issue_type: "Task".into(),
2374 assignee: None,
2375 priority: None,
2376 labels: vec![],
2377 components: vec![],
2378 parent: None,
2379 fix_versions: vec![],
2380 custom_fields: std::collections::HashMap::new(),
2381 })
2382 .await
2383 .expect("markdown fallback should succeed");
2384
2385 assert_eq!(issue.key, "TEST-124");
2386 assert_eq!(issue.summary, "Plain issue");
2387 assert_eq!(issue.description, Some(markdown_to_adf("hello world")));
2388 }
2389
2390 #[tokio::test]
2391 async fn get_comments_parses_comment_text_and_mentions() {
2392 let server = MockServer::start().await;
2393 let expected_auth = cloud_auth();
2394
2395 Mock::given(method("GET"))
2396 .and(path("/rest/api/3/issue/TEST-1/comment"))
2397 .and(header("authorization", expected_auth.as_str()))
2398 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2399 "comments": [
2400 {
2401 "id": "10001",
2402 "author": {
2403 "displayName": "Alice",
2404 "accountId": "acct-1"
2405 },
2406 "body": {
2407 "type": "doc",
2408 "version": 1,
2409 "content": [{
2410 "type": "paragraph",
2411 "content": [
2412 { "type": "text", "text": "Hi " },
2413 { "type": "mention", "attrs": { "id": "acct-2", "text": "@Bob" } }
2414 ]
2415 }]
2416 },
2417 "created": "2023-01-01T00:00:00.000+0000",
2418 "updated": "2023-01-01T00:00:00.000+0000"
2419 }
2420 ]
2421 })))
2422 .mount(&server)
2423 .await;
2424
2425 let client = cloud_client(&server);
2426 let comments = client
2427 .get_comments("TEST-1")
2428 .await
2429 .expect("comments should parse");
2430
2431 assert_eq!(comments.len(), 1);
2432 assert_eq!(comments[0].id, "10001");
2433 assert_eq!(comments[0].author.as_deref(), Some("Alice"));
2434 assert_eq!(comments[0].author_account_id.as_deref(), Some("acct-1"));
2435 assert_eq!(comments[0].body.as_deref(), Some("Hi @Bob"));
2436 assert_eq!(comments[0].mentions, vec!["acct-2"]);
2437 }
2438
2439 #[tokio::test]
2440 async fn add_comment_adf_posts_prebuilt_adf_payload() {
2441 let server = MockServer::start().await;
2442 let expected_auth = cloud_auth();
2443 let adf = json!({
2444 "type": "doc",
2445 "version": 1,
2446 "content": [{
2447 "type": "paragraph",
2448 "content": [
2449 { "type": "text", "text": "Hi " },
2450 { "type": "mention", "attrs": { "id": "acct-2", "text": "@Bob" } }
2451 ]
2452 }]
2453 });
2454
2455 Mock::given(method("POST"))
2456 .and(path("/rest/api/3/issue/TEST-1/comment"))
2457 .and(header("authorization", expected_auth.as_str()))
2458 .and(body_json(json!({ "body": adf.clone() })))
2459 .respond_with(ResponseTemplate::new(201).set_body_json(json!({
2460 "id": "10002",
2461 "author": {
2462 "displayName": "Alice",
2463 "accountId": "acct-1"
2464 },
2465 "body": adf,
2466 "created": "2023-01-01T00:00:00.000+0000",
2467 "updated": "2023-01-01T00:00:00.000+0000"
2468 })))
2469 .mount(&server)
2470 .await;
2471
2472 let client = cloud_client(&server);
2473 let comment = client
2474 .add_comment_adf(
2475 "TEST-1",
2476 json!({
2477 "type": "doc",
2478 "version": 1,
2479 "content": [{
2480 "type": "paragraph",
2481 "content": [
2482 { "type": "text", "text": "Hi " },
2483 { "type": "mention", "attrs": { "id": "acct-2", "text": "@Bob" } }
2484 ]
2485 }]
2486 }),
2487 )
2488 .await
2489 .expect("add comment adf should succeed");
2490
2491 assert_eq!(comment.id, "10002");
2492 assert_eq!(comment.body.as_deref(), Some("Hi @Bob"));
2493 assert_eq!(comment.mentions, vec!["acct-2"]);
2494 }
2495
2496 #[tokio::test]
2497 async fn search_users_retries_after_429_then_succeeds() {
2498 let server = MockServer::start().await;
2499 let expected_auth = cloud_auth();
2500
2501 Mock::given(method("GET"))
2502 .and(path("/rest/api/3/user/search"))
2503 .and(header("authorization", expected_auth.as_str()))
2504 .and(query_param("query", "alice"))
2505 .and(query_param("maxResults", "20"))
2506 .respond_with(ResponseTemplate::new(429).insert_header("Retry-After", "0"))
2507 .up_to_n_times(1)
2508 .mount(&server)
2509 .await;
2510
2511 Mock::given(method("GET"))
2512 .and(path("/rest/api/3/user/search"))
2513 .and(header("authorization", expected_auth.as_str()))
2514 .and(query_param("query", "alice"))
2515 .and(query_param("maxResults", "20"))
2516 .respond_with(ResponseTemplate::new(200).set_body_json(json!([
2517 {
2518 "accountId": "acct-1",
2519 "displayName": "Alice Example"
2520 }
2521 ])))
2522 .mount(&server)
2523 .await;
2524
2525 let client = cloud_client(&server);
2526 let users = client
2527 .search_users("alice")
2528 .await
2529 .expect("request should retry and succeed");
2530
2531 assert_eq!(users.len(), 1);
2532 assert_eq!(users[0].account_id, "acct-1");
2533 }
2534
2535 #[tokio::test]
2536 async fn search_users_returns_rate_limit_after_max_retries() {
2537 let server = MockServer::start().await;
2538 let expected_auth = cloud_auth();
2539
2540 Mock::given(method("GET"))
2541 .and(path("/rest/api/3/user/search"))
2542 .and(header("authorization", expected_auth.as_str()))
2543 .and(query_param("query", "alice"))
2544 .and(query_param("maxResults", "20"))
2545 .respond_with(ResponseTemplate::new(429).insert_header("Retry-After", "0"))
2546 .mount(&server)
2547 .await;
2548
2549 let client = cloud_client(&server);
2550 let err = client
2551 .search_users("alice")
2552 .await
2553 .expect_err("request should fail after max retries");
2554
2555 match err {
2556 JiraError::RateLimit { retry_after } => assert_eq!(retry_after, 0),
2557 other => panic!("expected JiraError::RateLimit, got {other:?}"),
2558 }
2559 }
2560
2561 #[tokio::test]
2562 async fn move_issue_submits_bulk_move_and_fetches_issue_on_complete() {
2563 let server = MockServer::start().await;
2564 let expected_auth = cloud_auth();
2565
2566 Mock::given(method("POST"))
2567 .and(path("/rest/api/3/bulk/issues/move"))
2568 .and(header("authorization", expected_auth.as_str()))
2569 .and(body_json(json!({
2570 "sendBulkNotification": true,
2571 "targetToSourcesMapping": {
2572 "NEW,10002": {
2573 "inferClassificationDefaults": true,
2574 "inferFieldDefaults": true,
2575 "inferStatusDefaults": true,
2576 "inferSubtaskTypeDefault": true,
2577 "issueIdsOrKeys": ["TEST-1"]
2578 }
2579 }
2580 })))
2581 .respond_with(ResponseTemplate::new(201).set_body_json(json!({ "taskId": "task-1" })))
2582 .mount(&server)
2583 .await;
2584
2585 Mock::given(method("GET"))
2586 .and(path("/rest/api/3/bulk/queue/task-1"))
2587 .and(header("authorization", expected_auth.as_str()))
2588 .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "status": "COMPLETE" })))
2589 .mount(&server)
2590 .await;
2591
2592 Mock::given(method("GET"))
2593 .and(path("/rest/api/3/issue/TEST-1"))
2594 .and(header("authorization", expected_auth.as_str()))
2595 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2596 "id": "10001",
2597 "key": "TEST-1",
2598 "fields": {
2599 "summary": "Moved issue",
2600 "status": { "name": "To Do" },
2601 "issuetype": { "name": "Task" },
2602 "project": { "key": "NEW" },
2603 "created": "2023-01-01T00:00:00.000+0000",
2604 "updated": "2023-01-01T00:00:00.000+0000"
2605 }
2606 })))
2607 .mount(&server)
2608 .await;
2609
2610 let client = cloud_client(&server);
2611 let issue = client
2612 .move_issue("TEST-1", "NEW", "10002", None)
2613 .await
2614 .expect("move issue should complete");
2615
2616 assert_eq!(issue.key, "TEST-1");
2617 assert_eq!(issue.project_key, "NEW");
2618 }
2619
2620 #[tokio::test]
2621 async fn move_issue_returns_api_error_when_bulk_task_fails() {
2622 let server = MockServer::start().await;
2623 let expected_auth = cloud_auth();
2624
2625 Mock::given(method("POST"))
2626 .and(path("/rest/api/3/bulk/issues/move"))
2627 .and(header("authorization", expected_auth.as_str()))
2628 .respond_with(ResponseTemplate::new(201).set_body_json(json!({ "taskId": "task-2" })))
2629 .mount(&server)
2630 .await;
2631
2632 Mock::given(method("GET"))
2633 .and(path("/rest/api/3/bulk/queue/task-2"))
2634 .and(header("authorization", expected_auth.as_str()))
2635 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2636 "status": "FAILED",
2637 "errors": ["cannot move issue"]
2638 })))
2639 .mount(&server)
2640 .await;
2641
2642 let client = cloud_client(&server);
2643 let err = client
2644 .move_issue("TEST-1", "NEW", "10002", None)
2645 .await
2646 .expect_err("failed bulk task should return error");
2647
2648 match err {
2649 JiraError::Api { message, .. } => assert!(message.contains("FAILED")),
2650 other => panic!("expected JiraError::Api, got {other:?}"),
2651 }
2652 }
2653
2654 #[tokio::test]
2655 async fn issue_link_integration() {
2656 let server = MockServer::start().await;
2657 let expected_auth = format!("Basic {}", base64_encode("dev@example.com:cloud-token"));
2658
2659 Mock::given(method("GET"))
2661 .and(path("/rest/api/3/issueLinkType"))
2662 .and(header("authorization", expected_auth.as_str()))
2663 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2664 "issueLinkTypes": [
2665 {
2666 "id": "10000",
2667 "name": "Blocks",
2668 "inward": "is blocked by",
2669 "outward": "blocks"
2670 }
2671 ]
2672 })))
2673 .mount(&server)
2674 .await;
2675
2676 Mock::given(method("POST"))
2678 .and(path("/rest/api/3/issueLink"))
2679 .and(header("authorization", expected_auth.as_str()))
2680 .respond_with(ResponseTemplate::new(201))
2681 .mount(&server)
2682 .await;
2683
2684 let client = JiraClient::new(JiraConfig {
2685 profile_name: Some("cloud-main".into()),
2686 base_url: server.uri(),
2687 email: "dev@example.com".into(),
2688 token: Some("cloud-token".into()),
2689 project: None,
2690 timeout_secs: 30,
2691 deployment: JiraDeployment::Cloud,
2692 auth_type: JiraAuthType::CloudApiToken,
2693 api_version: 3,
2694 });
2695
2696 let link_types = client.list_issue_link_types().await.expect("list types");
2698 assert_eq!(link_types.len(), 1);
2699 assert_eq!(link_types[0].name, "Blocks");
2700
2701 client
2703 .link_issues("TEST-1", "TEST-2", "Blocks", Some("Adding dependency"))
2704 .await
2705 .expect("link issues");
2706 }
2707
2708 #[tokio::test]
2709 async fn get_issue_parses_links() {
2710 let server = MockServer::start().await;
2711 let expected_auth = format!("Basic {}", base64_encode("dev@example.com:cloud-token"));
2712
2713 Mock::given(method("GET"))
2714 .and(path("/rest/api/3/issue/TEST-1"))
2715 .and(header("authorization", expected_auth.as_str()))
2716 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2717 "id": "10001",
2718 "key": "TEST-1",
2719 "fields": {
2720 "summary": "Main Issue",
2721 "status": { "name": "To Do" },
2722 "issuetype": { "name": "Task" },
2723 "project": { "key": "TEST" },
2724 "created": "2023-01-01T00:00:00.000+0000",
2725 "updated": "2023-01-01T00:00:00.000+0000",
2726 "issuelinks": [
2727 {
2728 "id": "20000",
2729 "type": {
2730 "id": "10000",
2731 "name": "Blocks",
2732 "inward": "is blocked by",
2733 "outward": "blocks"
2734 },
2735 "outwardIssue": {
2736 "id": "10002",
2737 "key": "TEST-2",
2738 "fields": {
2739 "summary": "Blocked Issue",
2740 "status": { "name": "Open" },
2741 "priority": { "name": "High" },
2742 "issuetype": { "name": "Bug" }
2743 }
2744 }
2745 }
2746 ]
2747 }
2748 })))
2749 .mount(&server)
2750 .await;
2751
2752 let client = JiraClient::new(JiraConfig {
2753 profile_name: Some("cloud-main".into()),
2754 base_url: server.uri(),
2755 email: "dev@example.com".into(),
2756 token: Some("cloud-token".into()),
2757 project: None,
2758 timeout_secs: 30,
2759 deployment: JiraDeployment::Cloud,
2760 auth_type: JiraAuthType::CloudApiToken,
2761 api_version: 3,
2762 });
2763
2764 let issue = client.get_issue("TEST-1").await.expect("get issue");
2765 assert_eq!(issue.links.len(), 1);
2766 let link = &issue.links[0];
2767 assert_eq!(link.link_type.name, "Blocks");
2768 assert!(link.outward_issue.is_some());
2769 assert_eq!(link.outward_issue.as_ref().unwrap().key, "TEST-2");
2770 assert_eq!(
2771 link.outward_issue.as_ref().unwrap().summary,
2772 "Blocked Issue"
2773 );
2774 }
2775
2776 #[tokio::test]
2777 async fn get_remote_links_parses_object_title_and_url() {
2778 let server = MockServer::start().await;
2779
2780 Mock::given(method("GET"))
2781 .and(path("/rest/api/3/issue/TEST-1/remotelink"))
2782 .and(header("authorization", cloud_auth().as_str()))
2783 .respond_with(ResponseTemplate::new(200).set_body_json(json!([
2784 {
2785 "id": 10001,
2786 "self": "https://jira.example.com/rest/api/3/issue/TEST-1/remotelink/10001",
2787 "globalId": "system=https://docs.example.com&id=42",
2788 "relationship": "Wiki Page",
2789 "object": {
2790 "title": "Design Doc",
2791 "url": "https://docs.example.com/design",
2792 "summary": "Architecture overview"
2793 }
2794 },
2795 {
2796 "id": 10002,
2797 "object": {
2798 "title": "Tracking ticket",
2799 "url": "https://tracker.example.com/4242"
2800 }
2801 }
2802 ])))
2803 .mount(&server)
2804 .await;
2805
2806 let client = cloud_client(&server);
2807 let links = client
2808 .get_remote_links("TEST-1")
2809 .await
2810 .expect("remote links should parse");
2811
2812 assert_eq!(links.len(), 2);
2813 assert_eq!(links[0].id, 10001);
2814 assert_eq!(
2815 links[0].global_id.as_deref(),
2816 Some("system=https://docs.example.com&id=42")
2817 );
2818 assert_eq!(links[0].object.title, "Design Doc");
2819 assert_eq!(links[0].object.url, "https://docs.example.com/design");
2820 assert_eq!(
2821 links[0].object.summary.as_deref(),
2822 Some("Architecture overview")
2823 );
2824 assert_eq!(links[1].id, 10002);
2825 assert!(links[1].global_id.is_none());
2826 assert!(links[1].object.summary.is_none());
2827 }
2828
2829 #[tokio::test]
2830 async fn get_remote_links_returns_empty_when_no_links() {
2831 let server = MockServer::start().await;
2832
2833 Mock::given(method("GET"))
2834 .and(path("/rest/api/3/issue/TEST-1/remotelink"))
2835 .respond_with(ResponseTemplate::new(200).set_body_json(json!([])))
2836 .mount(&server)
2837 .await;
2838
2839 let client = cloud_client(&server);
2840 let links = client.get_remote_links("TEST-1").await.expect("parse");
2841 assert!(links.is_empty());
2842 }
2843
2844 #[tokio::test]
2845 async fn get_project_components_parses_id_and_name() {
2846 let server = MockServer::start().await;
2847
2848 Mock::given(method("GET"))
2849 .and(path("/rest/api/3/project/PROJ/components"))
2850 .and(header("authorization", cloud_auth().as_str()))
2851 .respond_with(ResponseTemplate::new(200).set_body_json(json!([
2852 {
2853 "id": "10100",
2854 "name": "Backend",
2855 "description": "Server-side code",
2856 "self": "https://jira.example.com/rest/api/3/component/10100"
2857 },
2858 {
2859 "id": "10101",
2860 "name": "Frontend"
2861 }
2862 ])))
2863 .mount(&server)
2864 .await;
2865
2866 let client = cloud_client(&server);
2867 let components = client
2868 .get_project_components("PROJ")
2869 .await
2870 .expect("components should parse");
2871
2872 assert_eq!(components.len(), 2);
2873 assert_eq!(components[0].id, "10100");
2874 assert_eq!(components[0].name, "Backend");
2875 assert_eq!(
2876 components[0].description.as_deref(),
2877 Some("Server-side code")
2878 );
2879 assert!(components[0].self_url.is_some());
2880 assert_eq!(components[1].name, "Frontend");
2881 assert!(components[1].description.is_none());
2882 }
2883
2884 #[tokio::test]
2885 async fn get_transitions_parses_id_name_and_preserves_extra_fields() {
2886 let server = MockServer::start().await;
2887
2888 Mock::given(method("GET"))
2889 .and(path("/rest/api/3/issue/TEST-1/transitions"))
2890 .and(header("authorization", cloud_auth().as_str()))
2891 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2892 "expand": "transitions",
2893 "transitions": [
2894 {
2895 "id": "21",
2896 "name": "In Progress",
2897 "hasScreen": false,
2898 "isGlobal": false,
2899 "isInitial": false,
2900 "isAvailable": true,
2901 "to": {
2902 "id": "3",
2903 "name": "In Progress",
2904 "statusCategory": { "key": "indeterminate" }
2905 }
2906 },
2907 {
2908 "id": "31",
2909 "name": "Done",
2910 "to": { "id": "10001", "name": "Done" }
2911 }
2912 ]
2913 })))
2914 .mount(&server)
2915 .await;
2916
2917 let client = cloud_client(&server);
2918 let transitions = client
2919 .get_transitions("TEST-1")
2920 .await
2921 .expect("transitions should parse");
2922
2923 assert_eq!(transitions.len(), 2);
2924 assert_eq!(transitions[0].id, "21");
2925 assert_eq!(transitions[0].name, "In Progress");
2926 assert_eq!(
2927 transitions[0].to.as_ref().and_then(|t| t.name.as_deref()),
2928 Some("In Progress")
2929 );
2930
2931 let reserialized = serde_json::to_value(&transitions[0]).expect("serialize");
2934 assert_eq!(reserialized["hasScreen"], json!(false));
2935 assert_eq!(reserialized["isAvailable"], json!(true));
2936
2937 assert_eq!(transitions[1].id, "31");
2938 assert!(transitions[1].to.is_some());
2939 }
2940
2941 #[tokio::test]
2942 async fn get_transitions_returns_empty_when_no_transitions_available() {
2943 let server = MockServer::start().await;
2944
2945 Mock::given(method("GET"))
2946 .and(path("/rest/api/3/issue/TEST-1/transitions"))
2947 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2948 "transitions": []
2949 })))
2950 .mount(&server)
2951 .await;
2952
2953 let client = cloud_client(&server);
2954 let transitions = client.get_transitions("TEST-1").await.expect("parse");
2955 assert!(transitions.is_empty());
2956 }
2957}