1fn safe_char_boundary(s: &str, max_bytes: usize) -> usize {
8 if max_bytes >= s.len() {
9 return s.len();
10 }
11 let mut i = max_bytes;
12 while i > 0 && !s.is_char_boundary(i) {
13 i -= 1;
14 }
15 i
16}
17
18use async_trait::async_trait;
19use devboy_core::{
20 AddStructureRowsInput, AssetCapabilities, AssetMeta, Comment, ContextCapabilities,
21 CreateIssueInput, CreateStructureInput, Error, ForestModifyResult, GetForestOptions,
22 GetStructureValuesInput, GetUsersOptions, Issue, IssueFilter, IssueLink, IssueProvider,
23 IssueRelations, IssueStatus, ListProjectVersionsParams, MergeRequestProvider,
24 MoveStructureRowsInput, PipelineProvider, ProjectVersion, Provider, ProviderResult, Result,
25 SaveStructureViewInput, Structure, StructureColumnValue, StructureForest, StructureNode,
26 StructureRowValues, StructureValues, StructureView, StructureViewColumn, UpdateIssueInput,
27 UpsertProjectVersionInput, User,
28};
29use secrecy::{ExposeSecret, SecretString};
30use tracing::{debug, warn};
31
32use crate::types::{
33 AddCommentPayload, CreateIssueFields, CreateIssueLinkPayload, CreateIssuePayload,
34 CreateIssueResponse, CreateVersionPayload, IssueKeyRef, IssueLinkTypeName, IssueType,
35 JiraAttachment, JiraCloudSearchResponse, JiraComment, JiraCommentsResponse,
36 JiraForestModifyResponse, JiraForestResponse, JiraIssue, JiraIssueTypeStatuses, JiraPriority,
37 JiraProjectStatus, JiraSearchResponse, JiraStatus, JiraStructure, JiraStructureListResponse,
38 JiraStructureValuesResponse, JiraStructureView, JiraStructureViewListResponse, JiraTransition,
39 JiraTransitionsResponse, JiraUser, JiraVersionDto, PriorityName, ProjectKey, TransitionId,
40 TransitionPayload, UpdateIssueFields, UpdateIssuePayload, UpdateVersionPayload,
41};
42
43#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
45#[serde(rename_all = "snake_case")]
46pub enum JiraFlavor {
47 Cloud,
49 SelfHosted,
51}
52
53pub struct JiraClient {
54 base_url: String,
55 instance_url: String,
58 project_key: String,
59 email: String,
60 token: SecretString,
61 flavor: JiraFlavor,
62 proxy_headers: Option<std::collections::HashMap<String, String>>,
63 client: reqwest::Client,
64}
65
66impl JiraClient {
67 pub fn new(
69 url: impl Into<String>,
70 project_key: impl Into<String>,
71 email: impl Into<String>,
72 token: SecretString,
73 ) -> Self {
74 let url = url.into();
75 let flavor = detect_flavor(&url);
76 let instance = url.trim_end_matches('/').to_string();
77 let api_base = build_api_base(&url, flavor);
78 Self {
79 base_url: api_base,
80 instance_url: instance,
81 project_key: project_key.into(),
82 email: email.into(),
83 token,
84 flavor,
85 proxy_headers: None,
86 client: reqwest::Client::builder()
87 .user_agent("devboy-tools")
88 .build()
89 .expect("Failed to create HTTP client"),
90 }
91 }
92
93 pub fn with_proxy(mut self, headers: std::collections::HashMap<String, String>) -> Self {
98 self.proxy_headers = Some(headers);
99 self
100 }
101
102 pub fn with_instance_url(mut self, url: impl Into<String>) -> Self {
105 self.instance_url = url.into().trim_end_matches('/').to_string();
106 self
107 }
108
109 pub fn with_flavor(mut self, flavor: JiraFlavor) -> Self {
113 if self.flavor != flavor {
114 let instance_url = instance_url_from_base(&self.base_url);
116 self.base_url = build_api_base(&instance_url, flavor);
117 self.flavor = flavor;
118 }
119 self
120 }
121
122 pub fn with_base_url(
125 base_url: impl Into<String>,
126 project_key: impl Into<String>,
127 email: impl Into<String>,
128 token: SecretString,
129 flavor: bool, ) -> Self {
131 let url = base_url.into().trim_end_matches('/').to_string();
132 Self {
133 instance_url: url.clone(),
134 base_url: url,
135 project_key: project_key.into(),
136 email: email.into(),
137 token,
138 flavor: if flavor {
139 JiraFlavor::Cloud
140 } else {
141 JiraFlavor::SelfHosted
142 },
143 proxy_headers: None,
144 client: reqwest::Client::builder()
145 .user_agent("devboy-tools")
146 .build()
147 .expect("Failed to create HTTP client"),
148 }
149 }
150
151 fn request(&self, method: reqwest::Method, url: &str) -> reqwest::RequestBuilder {
156 self.request_raw(method, url)
157 .header("Content-Type", "application/json")
158 }
159
160 fn request_raw(&self, method: reqwest::Method, url: &str) -> reqwest::RequestBuilder {
165 let mut builder = self.client.request(method, url);
166
167 if let Some(headers) = &self.proxy_headers {
168 for (key, value) in headers {
169 builder = builder.header(key.as_str(), value.as_str());
170 }
171 } else {
172 builder = match self.flavor {
173 JiraFlavor::Cloud => {
174 let token_value = self.token.expose_secret();
175 let credentials = base64_encode(&format!("{}:{}", self.email, token_value));
176 builder.header("Authorization", format!("Basic {}", credentials))
177 }
178 JiraFlavor::SelfHosted => {
179 let token_value = self.token.expose_secret();
180 if token_value.contains(':') {
181 let credentials = base64_encode(token_value);
182 builder.header("Authorization", format!("Basic {}", credentials))
183 } else {
184 builder.header("Authorization", format!("Bearer {}", token_value))
185 }
186 }
187 };
188 }
189 builder
190 }
191
192 async fn get<T: serde::de::DeserializeOwned>(&self, url: &str) -> Result<T> {
194 debug!(url = url, "Jira GET request");
195
196 let response = self
197 .request(reqwest::Method::GET, url)
198 .send()
199 .await
200 .map_err(|e| Error::Http(e.to_string()))?;
201
202 self.handle_response(response).await
203 }
204
205 async fn post<T: serde::de::DeserializeOwned, B: serde::Serialize>(
207 &self,
208 url: &str,
209 body: &B,
210 ) -> Result<T> {
211 debug!(url = url, "Jira POST request");
212
213 let response = self
214 .request(reqwest::Method::POST, url)
215 .json(body)
216 .send()
217 .await
218 .map_err(|e| Error::Http(e.to_string()))?;
219
220 self.handle_response(response).await
221 }
222
223 async fn post_no_content<B: serde::Serialize>(&self, url: &str, body: &B) -> Result<()> {
225 debug!(url = url, "Jira POST (no content) request");
226
227 let response = self
228 .request(reqwest::Method::POST, url)
229 .json(body)
230 .send()
231 .await
232 .map_err(|e| Error::Http(e.to_string()))?;
233
234 let status = response.status();
235 if !status.is_success() {
236 let status_code = status.as_u16();
237 let message = response.text().await.unwrap_or_default();
238 warn!(
239 status = status_code,
240 message = message,
241 "Jira API error response"
242 );
243 return Err(Error::from_status(status_code, message));
244 }
245
246 Ok(())
247 }
248
249 async fn put_with_response<T: serde::de::DeserializeOwned, B: serde::Serialize>(
255 &self,
256 url: &str,
257 body: &B,
258 ) -> Result<T> {
259 debug!(url = url, "Jira PUT request (typed response)");
260
261 let response = self
262 .request(reqwest::Method::PUT, url)
263 .json(body)
264 .send()
265 .await
266 .map_err(|e| Error::Http(e.to_string()))?;
267
268 self.handle_response(response).await
269 }
270
271 async fn put<B: serde::Serialize>(&self, url: &str, body: &B) -> Result<()> {
273 debug!(url = url, "Jira PUT request");
274
275 let response = self
276 .request(reqwest::Method::PUT, url)
277 .json(body)
278 .send()
279 .await
280 .map_err(|e| Error::Http(e.to_string()))?;
281
282 let status = response.status();
283 if !status.is_success() {
284 let status_code = status.as_u16();
285 let message = response.text().await.unwrap_or_default();
286 warn!(
287 status = status_code,
288 message = message,
289 "Jira API error response"
290 );
291 return Err(Error::from_status(status_code, message));
292 }
293
294 Ok(())
295 }
296
297 async fn handle_response<T: serde::de::DeserializeOwned>(
299 &self,
300 response: reqwest::Response,
301 ) -> Result<T> {
302 let status = response.status();
303
304 if !status.is_success() {
305 let status_code = status.as_u16();
306 let message = response.text().await.unwrap_or_default();
307 warn!(
308 status = status_code,
309 message = message,
310 "Jira API error response"
311 );
312 return Err(Error::from_status(status_code, message));
313 }
314
315 let body = response
316 .text()
317 .await
318 .map_err(|e| Error::InvalidData(format!("Failed to read response body: {}", e)))?;
319
320 serde_json::from_str::<T>(&body).map_err(|e| {
321 let preview = if body.len() > 500 {
323 let end = safe_char_boundary(&body, 500);
324 format!("{}...(truncated, total {} bytes)", &body[..end], body.len())
325 } else {
326 body.clone()
327 };
328 warn!(
329 error = %e,
330 body_preview = preview,
331 "Failed to parse Jira response"
332 );
333 let preview = if body.len() > 300 {
334 let end = safe_char_boundary(&body, 300);
335 format!("{}...(truncated)", &body[..end])
336 } else {
337 body.clone()
338 };
339 Error::InvalidData(format!(
340 "Failed to parse response: {}. Response preview: {}",
341 e, preview
342 ))
343 })
344 }
345
346 async fn transition_issue(&self, key: &str, target_status: &str) -> Result<()> {
355 let url = format!("{}/issue/{}/transitions", self.base_url, key);
356 let transitions: JiraTransitionsResponse = self.get(&url).await?;
357
358 let transition = transitions
360 .transitions
361 .iter()
362 .find(|t| t.to.name.eq_ignore_ascii_case(target_status))
363 .or_else(|| {
364 transitions
366 .transitions
367 .iter()
368 .find(|t| t.name.eq_ignore_ascii_case(target_status))
369 });
370
371 let transition = if let Some(t) = transition {
372 t
373 } else {
374 self.find_transition_by_project_statuses(target_status, &transitions)
376 .await?
377 .ok_or_else(|| {
378 let available: Vec<String> = transitions
379 .transitions
380 .iter()
381 .map(|t| {
382 let cat =
383 t.to.status_category
384 .as_ref()
385 .map(|sc| sc.key.as_str())
386 .unwrap_or("?");
387 format!("{} [{}]", t.to.name, cat)
388 })
389 .collect();
390 Error::InvalidData(format!(
391 "No transition to status '{}' found for issue {}. Available: {:?}",
392 target_status, key, available
393 ))
394 })?
395 };
396
397 let payload = TransitionPayload {
398 transition: TransitionId {
399 id: transition.id.clone(),
400 },
401 };
402
403 let post_url = format!("{}/issue/{}/transitions", self.base_url, key);
404 debug!(
405 issue = key,
406 transition_id = transition.id,
407 target = target_status,
408 "Transitioning issue"
409 );
410
411 let response = self
412 .request(reqwest::Method::POST, &post_url)
413 .json(&payload)
414 .send()
415 .await
416 .map_err(|e| Error::Http(e.to_string()))?;
417
418 let status = response.status();
419 if !status.is_success() {
420 let status_code = status.as_u16();
421 let message = response.text().await.unwrap_or_default();
422 return Err(Error::from_status(status_code, message));
423 }
424
425 Ok(())
426 }
427
428 async fn find_transition_by_project_statuses<'a>(
436 &self,
437 target_status: &str,
438 transitions: &'a JiraTransitionsResponse,
439 ) -> Result<Option<&'a JiraTransition>> {
440 let project_statuses = self.get_project_statuses().await.unwrap_or_default();
441
442 if project_statuses.is_empty() {
443 let category_key = generic_status_to_category(target_status);
445 return Ok(category_key.and_then(|cat| {
446 transitions.transitions.iter().find(|t| {
447 t.to.status_category
448 .as_ref()
449 .is_some_and(|sc| sc.key == cat)
450 })
451 }));
452 }
453
454 let matching_status = project_statuses
456 .iter()
457 .find(|s| s.name.eq_ignore_ascii_case(target_status));
458
459 if let Some(status) = matching_status {
460 if let Some(t) = transitions
462 .transitions
463 .iter()
464 .find(|t| t.to.name.eq_ignore_ascii_case(&status.name))
465 {
466 return Ok(Some(t));
467 }
468 }
469
470 if let Some(category_key) = generic_status_to_category(target_status) {
473 let category_status_names: Vec<&str> = project_statuses
475 .iter()
476 .filter(|s| {
477 s.status_category
478 .as_ref()
479 .is_some_and(|sc| sc.key == category_key)
480 })
481 .map(|s| s.name.as_str())
482 .collect();
483
484 debug!(
485 target = target_status,
486 category = category_key,
487 statuses = ?category_status_names,
488 "Resolved category to project statuses"
489 );
490
491 for status_name in &category_status_names {
493 if let Some(t) = transitions
494 .transitions
495 .iter()
496 .find(|t| t.to.name.eq_ignore_ascii_case(status_name))
497 {
498 return Ok(Some(t));
499 }
500 }
501
502 return Ok(transitions.transitions.iter().find(|t| {
504 t.to.status_category
505 .as_ref()
506 .is_some_and(|sc| sc.key == category_key)
507 }));
508 }
509
510 Ok(None)
511 }
512
513 async fn get_project_statuses(&self) -> Result<Vec<JiraProjectStatus>> {
518 let url = format!("{}/project/{}/statuses", self.base_url, self.project_key);
519 let issue_type_statuses: Vec<JiraIssueTypeStatuses> = self.get(&url).await?;
520
521 let mut seen = std::collections::HashSet::new();
522 let mut statuses = Vec::new();
523
524 for its in &issue_type_statuses {
525 for status in &its.statuses {
526 let name_lower = status.name.to_lowercase();
527 if seen.insert(name_lower) {
528 statuses.push(status.clone());
529 }
530 }
531 }
532
533 debug!(
534 project = self.project_key,
535 count = statuses.len(),
536 "Fetched project statuses"
537 );
538
539 Ok(statuses)
540 }
541
542 fn structure_url(&self, endpoint: &str) -> String {
554 let root = instance_url_from_base(&self.base_url);
555 format!("{}/rest/structure/2.0{}", root, endpoint)
556 }
557
558 async fn structure_get<T: serde::de::DeserializeOwned>(&self, endpoint: &str) -> Result<T> {
560 let url = self.structure_url(endpoint);
561 debug!(url = %url, "Jira Structure GET");
562 let response = self
563 .request(reqwest::Method::GET, &url)
564 .send()
565 .await
566 .map_err(|e| Error::Http(e.to_string()))?;
567 handle_structure_response(response).await
568 }
569
570 async fn structure_post<T: serde::de::DeserializeOwned, B: serde::Serialize>(
572 &self,
573 endpoint: &str,
574 body: &B,
575 ) -> Result<T> {
576 let url = self.structure_url(endpoint);
577 debug!(url = %url, "Jira Structure POST");
578 let response = self
579 .request(reqwest::Method::POST, &url)
580 .json(body)
581 .send()
582 .await
583 .map_err(|e| Error::Http(e.to_string()))?;
584 handle_structure_response(response).await
585 }
586
587 async fn structure_put<T: serde::de::DeserializeOwned, B: serde::Serialize>(
589 &self,
590 endpoint: &str,
591 body: &B,
592 ) -> Result<T> {
593 let url = self.structure_url(endpoint);
594 debug!(url = %url, "Jira Structure PUT");
595 let response = self
596 .request(reqwest::Method::PUT, &url)
597 .json(body)
598 .send()
599 .await
600 .map_err(|e| Error::Http(e.to_string()))?;
601 handle_structure_response(response).await
602 }
603
604 fn agile_url(&self, endpoint: &str) -> String {
607 let root = instance_url_from_base(&self.base_url);
608 format!("{}/rest/agile/1.0{}", root, endpoint)
609 }
610
611 async fn agile_get<T: serde::de::DeserializeOwned>(&self, endpoint: &str) -> Result<T> {
617 let url = self.agile_url(endpoint);
618 debug!(url = %url, "Jira Agile GET");
619 self.get(&url).await
620 }
621
622 async fn agile_post_void<B: serde::Serialize>(&self, endpoint: &str, body: &B) -> Result<()> {
626 let url = self.agile_url(endpoint);
627 debug!(url = %url, "Jira Agile POST");
628 self.post_no_content(&url, body).await
629 }
630
631 async fn structure_delete_request(&self, endpoint: &str) -> Result<()> {
633 let url = self.structure_url(endpoint);
634 debug!(url = %url, "Jira Structure DELETE");
635 let response = self
636 .request(reqwest::Method::DELETE, &url)
637 .send()
638 .await
639 .map_err(|e| Error::Http(e.to_string()))?;
640 let status = response.status();
641 if !status.is_success() {
642 let (content_type, body) = read_structure_error_body(response).await;
643 return Err(structure_error_from_status(
644 status.as_u16(),
645 &content_type,
646 body,
647 ));
648 }
649 Ok(())
650 }
651
652 pub async fn list_structures_for_metadata(
668 &self,
669 ) -> Result<Vec<crate::metadata::JiraStructureRef>> {
670 match self
671 .structure_get::<crate::types::JiraStructureListResponse>("/structure")
672 .await
673 {
674 Ok(resp) => Ok(resp
675 .structures
676 .into_iter()
677 .map(|s| crate::metadata::JiraStructureRef {
678 id: s.id,
679 name: s.name,
680 description: s.description,
681 })
682 .collect()),
683 Err(Error::NotFound(_)) => Ok(vec![]),
686 Err(other) => Err(other),
687 }
688 }
689}
690
691const STRUCTURE_PLUGIN_HINT: &str = "The Jira Structure plugin may not be installed, not enabled, or the endpoint has moved. Install or upgrade it from the Atlassian Marketplace: https://marketplace.atlassian.com/apps/34717/structure-manage-work-your-way";
696
697fn looks_like_html(content_type: &str, body: &str) -> bool {
703 let ct = content_type.to_ascii_lowercase();
704 if ct.contains("text/html") || ct.contains("application/xml") || ct.contains("text/xml") {
705 return true;
706 }
707 let head = body.trim_start();
708 head.starts_with("<!DOCTYPE")
709 || head.starts_with("<!doctype")
710 || head.starts_with("<html")
711 || head.starts_with("<HTML")
712 || head.starts_with("<?xml")
713}
714
715async fn read_structure_error_body(response: reqwest::Response) -> (String, String) {
718 let content_type = response
719 .headers()
720 .get(reqwest::header::CONTENT_TYPE)
721 .and_then(|v| v.to_str().ok())
722 .unwrap_or("")
723 .to_string();
724 let body = response.text().await.unwrap_or_default();
725 (content_type, body)
726}
727
728fn structure_error_from_status(status: u16, content_type: &str, body: String) -> Error {
740 let html = looks_like_html(content_type, &body);
741
742 if status == 404 && html {
743 return Error::from_status(
744 status,
745 format!("Structure API endpoint not found (HTTP 404). {STRUCTURE_PLUGIN_HINT}"),
746 );
747 }
748
749 if html {
750 return Error::from_status(
751 status,
752 format!(
753 "Jira returned a non-JSON (HTML/XML) response for a Structure API call (HTTP {status}). {STRUCTURE_PLUGIN_HINT}"
754 ),
755 );
756 }
757
758 let trimmed = if body.len() > 500 {
759 let end = safe_char_boundary(&body, 500);
760 format!("{}...(truncated, total {} bytes)", &body[..end], body.len())
761 } else {
762 body
763 };
764 Error::from_status(status, trimmed)
765}
766
767fn structure_parse_preview(content_type: &str, body: &str) -> String {
772 if looks_like_html(content_type, body) {
773 format!(
774 "<{} bytes of HTML/XML redacted — non-JSON body indicates a non-Structure endpoint or missing plugin>",
775 body.len()
776 )
777 } else if body.len() > 300 {
778 let end = safe_char_boundary(body, 300);
779 format!("{}...(truncated, total {} bytes)", &body[..end], body.len())
780 } else {
781 body.to_string()
782 }
783}
784
785async fn handle_structure_response<T: serde::de::DeserializeOwned>(
790 response: reqwest::Response,
791) -> Result<T> {
792 let status = response.status();
793
794 if !status.is_success() {
795 let (content_type, body) = read_structure_error_body(response).await;
796 warn!(
797 status = status.as_u16(),
798 content_type = %content_type,
799 body_len = body.len(),
800 "Jira Structure API error response"
801 );
802 return Err(structure_error_from_status(
803 status.as_u16(),
804 &content_type,
805 body,
806 ));
807 }
808
809 let content_type = response
813 .headers()
814 .get(reqwest::header::CONTENT_TYPE)
815 .and_then(|v| v.to_str().ok())
816 .unwrap_or("")
817 .to_string();
818
819 let body = response.text().await.map_err(|e| {
820 Error::InvalidData(format!("Failed to read Structure response body: {}", e))
821 })?;
822
823 serde_json::from_str::<T>(&body).map_err(|e| {
824 let preview = structure_parse_preview(&content_type, &body);
825 warn!(
826 error = %e,
827 body_preview = preview,
828 content_type = %content_type,
829 "Failed to parse Jira Structure response"
830 );
831 Error::InvalidData(format!(
832 "Failed to parse Jira Structure response: {}. Body preview: {}",
833 e, preview
834 ))
835 })
836}
837
838fn detect_flavor(url: &str) -> JiraFlavor {
844 if url.contains(".atlassian.net") {
845 JiraFlavor::Cloud
846 } else {
847 JiraFlavor::SelfHosted
848 }
849}
850
851fn build_api_base(url: &str, flavor: JiraFlavor) -> String {
853 let base = url.trim_end_matches('/');
854 match flavor {
855 JiraFlavor::Cloud => format!("{}/rest/api/3", base),
856 JiraFlavor::SelfHosted => format!("{}/rest/api/2", base),
857 }
858}
859
860fn base64_encode(input: &str) -> String {
862 const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
863 let bytes = input.as_bytes();
864 let mut result = String::new();
865
866 for chunk in bytes.chunks(3) {
867 let b0 = chunk[0] as u32;
868 let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
869 let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
870
871 let triple = (b0 << 16) | (b1 << 8) | b2;
872
873 result.push(CHARSET[((triple >> 18) & 0x3F) as usize] as char);
874 result.push(CHARSET[((triple >> 12) & 0x3F) as usize] as char);
875
876 if chunk.len() > 1 {
877 result.push(CHARSET[((triple >> 6) & 0x3F) as usize] as char);
878 } else {
879 result.push('=');
880 }
881
882 if chunk.len() > 2 {
883 result.push(CHARSET[(triple & 0x3F) as usize] as char);
884 } else {
885 result.push('=');
886 }
887 }
888
889 result
890}
891
892fn text_to_adf(text: &str) -> serde_json::Value {
900 if text.is_empty() {
901 return serde_json::json!({
902 "version": 1,
903 "type": "doc",
904 "content": [{
905 "type": "paragraph",
906 "content": []
907 }]
908 });
909 }
910
911 let paragraphs: Vec<&str> = text.split("\n\n").collect();
912 let content: Vec<serde_json::Value> = paragraphs
913 .iter()
914 .map(|para| {
915 let lines: Vec<&str> = para.split('\n').collect();
916 let mut inline_content: Vec<serde_json::Value> = Vec::new();
917
918 for (i, line) in lines.iter().enumerate() {
919 if i > 0 {
920 inline_content.push(serde_json::json!({ "type": "hardBreak" }));
921 }
922 if !line.is_empty() {
923 inline_content.push(serde_json::json!({
924 "type": "text",
925 "text": *line
926 }));
927 }
928 }
929
930 serde_json::json!({
931 "type": "paragraph",
932 "content": inline_content
933 })
934 })
935 .collect();
936
937 serde_json::json!({
938 "version": 1,
939 "type": "doc",
940 "content": content
941 })
942}
943
944fn adf_to_text(value: &serde_json::Value) -> String {
949 match value {
950 serde_json::Value::String(s) => s.clone(),
951 serde_json::Value::Object(obj) => {
952 let doc_type = obj.get("type").and_then(|t| t.as_str());
953
954 if doc_type == Some("text") {
956 return obj
957 .get("text")
958 .and_then(|t| t.as_str())
959 .unwrap_or("")
960 .to_string();
961 }
962
963 if doc_type == Some("hardBreak") {
965 return "\n".to_string();
966 }
967
968 if let Some(content) = obj.get("content").and_then(|c| c.as_array()) {
970 let texts: Vec<String> = content.iter().map(adf_to_text).collect();
971 let joined = texts.join("");
972
973 if doc_type == Some("paragraph") {
975 return joined;
976 }
977 if doc_type == Some("doc") {
978 let para_texts: Vec<String> = content
980 .iter()
981 .map(adf_to_text)
982 .filter(|s| !s.is_empty())
983 .collect();
984 return para_texts.join("\n\n");
985 }
986
987 return joined;
988 }
989
990 String::new()
991 }
992 serde_json::Value::Null => String::new(),
993 other => other.to_string(),
994 }
995}
996
997fn read_description(value: &Option<serde_json::Value>, flavor: JiraFlavor) -> Option<String> {
999 let value = value.as_ref()?;
1000 match value {
1001 serde_json::Value::Null => None,
1002 serde_json::Value::String(s) => {
1003 if s.is_empty() {
1004 None
1005 } else {
1006 Some(s.clone())
1007 }
1008 }
1009 _ => {
1010 if flavor == JiraFlavor::Cloud {
1011 let text = adf_to_text(value);
1012 if text.is_empty() { None } else { Some(text) }
1013 } else {
1014 Some(value.to_string())
1016 }
1017 }
1018 }
1019}
1020
1021fn read_comment_body(value: &Option<serde_json::Value>, flavor: JiraFlavor) -> String {
1023 match value {
1024 Some(serde_json::Value::String(s)) => s.clone(),
1025 Some(serde_json::Value::Null) | None => String::new(),
1026 Some(v) => {
1027 if flavor == JiraFlavor::Cloud {
1028 adf_to_text(v)
1029 } else {
1030 v.to_string()
1031 }
1032 }
1033 }
1034}
1035
1036fn map_user(jira_user: Option<&JiraUser>) -> Option<User> {
1041 jira_user.map(|u| {
1042 let id = u
1043 .account_id
1044 .clone()
1045 .or_else(|| u.name.clone())
1046 .unwrap_or_default();
1047 let username = u
1048 .name
1049 .clone()
1050 .or_else(|| u.account_id.clone())
1051 .unwrap_or_default();
1052 User {
1053 id,
1054 username,
1055 name: u.display_name.clone(),
1056 email: u.email_address.clone(),
1057 avatar_url: None,
1058 }
1059 })
1060}
1061
1062fn map_priority(jira_priority: Option<&JiraPriority>) -> Option<String> {
1063 jira_priority.map(|p| match p.name.to_lowercase().as_str() {
1064 "highest" | "critical" | "blocker" => "urgent".to_string(),
1065 "high" => "high".to_string(),
1066 "medium" => "normal".to_string(),
1067 "low" => "low".to_string(),
1068 "lowest" | "trivial" => "low".to_string(),
1069 other => other.to_string(),
1070 })
1071}
1072
1073fn map_state(status: Option<&JiraStatus>) -> String {
1074 status
1075 .map(|s| s.name.clone())
1076 .unwrap_or_else(|| "unknown".to_string())
1077}
1078
1079fn parse_jira_key(key: &str) -> &str {
1082 key.strip_prefix("jira#").unwrap_or(key)
1083}
1084
1085fn map_issue(issue: &JiraIssue, flavor: JiraFlavor, instance_url: &str) -> Issue {
1086 Issue {
1087 key: format!("jira#{}", issue.key),
1088 title: issue.fields.summary.clone().unwrap_or_default(),
1089 description: read_description(&issue.fields.description, flavor),
1090 state: map_state(issue.fields.status.as_ref()),
1091 source: "jira".to_string(),
1092 priority: map_priority(issue.fields.priority.as_ref()),
1093 labels: issue.fields.labels.clone(),
1094 author: map_user(issue.fields.reporter.as_ref()),
1095 assignees: issue
1096 .fields
1097 .assignee
1098 .as_ref()
1099 .map(|a| vec![map_user(Some(a)).unwrap()])
1100 .unwrap_or_default(),
1101 url: Some(format!("{}/browse/{}", instance_url, issue.key)),
1102 created_at: issue.fields.created.clone(),
1103 updated_at: issue.fields.updated.clone(),
1104 attachments_count: if issue.fields.attachment.is_empty() {
1105 None
1106 } else {
1107 Some(issue.fields.attachment.len() as u32)
1108 },
1109 parent: None,
1110 subtasks: vec![],
1111 }
1112}
1113
1114fn map_relations(issue: &JiraIssue, flavor: JiraFlavor, instance_url: &str) -> IssueRelations {
1115 let mut relations = IssueRelations::default();
1116
1117 if let Some(parent) = &issue.fields.parent {
1119 relations.parent = Some(map_issue(parent, flavor, instance_url));
1120 }
1121
1122 relations.subtasks = issue
1124 .fields
1125 .subtasks
1126 .iter()
1127 .map(|s| map_issue(s, flavor, instance_url))
1128 .collect();
1129
1130 for link in &issue.fields.issuelinks {
1132 let link_name = &link.link_type.name;
1133
1134 let outward_lower = link.link_type.outward.as_deref().map(str::to_lowercase);
1135 let inward_lower = link.link_type.inward.as_deref().map(str::to_lowercase);
1136
1137 if let Some(outward) = &link.outward_issue {
1138 let mapped = map_issue(outward, flavor, instance_url);
1139 let issue_link = IssueLink {
1140 issue: mapped,
1141 link_type: link_name.clone(),
1142 };
1143
1144 match outward_lower.as_deref() {
1145 Some(s) if s.contains("block") => relations.blocks.push(issue_link),
1146 Some(s) if s.contains("duplicate") => relations.duplicates.push(issue_link),
1147 _ => relations.related_to.push(issue_link),
1148 }
1149 }
1150
1151 if let Some(inward) = &link.inward_issue {
1152 let mapped = map_issue(inward, flavor, instance_url);
1153 let issue_link = IssueLink {
1154 issue: mapped,
1155 link_type: link_name.clone(),
1156 };
1157
1158 match inward_lower.as_deref() {
1159 Some(s) if s.contains("block") => relations.blocked_by.push(issue_link),
1160 Some(s) if s.contains("duplicate") => relations.duplicates.push(issue_link),
1161 _ => relations.related_to.push(issue_link),
1162 }
1163 }
1164 }
1165
1166 relations
1167}
1168
1169fn map_comment(jira_comment: &JiraComment, flavor: JiraFlavor) -> Comment {
1170 Comment {
1171 id: jira_comment.id.clone(),
1172 body: read_comment_body(&jira_comment.body, flavor),
1173 author: map_user(jira_comment.author.as_ref()),
1174 created_at: jira_comment.created.clone(),
1175 updated_at: jira_comment.updated.clone(),
1176 position: None,
1177 }
1178}
1179
1180fn map_jira_attachment(raw: &JiraAttachment) -> AssetMeta {
1182 let filename = raw
1187 .filename
1188 .clone()
1189 .unwrap_or_else(|| format!("attachment-{}", raw.id));
1190 let author = raw
1191 .author
1192 .as_ref()
1193 .and_then(|u| map_user(Some(u)))
1194 .map(|u| u.name.unwrap_or(u.username));
1195
1196 AssetMeta {
1197 id: raw.id.clone(),
1198 filename,
1199 mime_type: raw.mime_type.clone(),
1200 size: raw.size,
1201 url: raw.content.clone(),
1202 created_at: raw.created.clone(),
1203 author,
1204 cached: false,
1205 local_path: None,
1206 checksum_sha256: None,
1207 analysis: None,
1208 }
1209}
1210
1211fn priority_to_jira(priority: &str) -> String {
1213 match priority {
1214 "urgent" => "Highest".to_string(),
1215 "high" => "High".to_string(),
1216 "normal" => "Medium".to_string(),
1217 "low" => "Low".to_string(),
1218 other => other.to_string(),
1219 }
1220}
1221
1222fn escape_jql(value: &str) -> String {
1230 value.replace('\\', "\\\\").replace('"', "\\\"")
1231}
1232
1233fn merge_custom_fields_into_payload<T: serde::Serialize>(
1238 payload: T,
1239 custom_fields: &Option<serde_json::Value>,
1240) -> Result<(serde_json::Value, usize)> {
1241 let mut value = serde_json::to_value(payload)
1242 .map_err(|e| Error::InvalidData(format!("failed to serialize issue payload: {e}")))?;
1243 let mut merged_count = 0;
1244 if let Some(serde_json::Value::Object(cf)) = custom_fields
1245 && let Some(fields) = value.get_mut("fields").and_then(|f| f.as_object_mut())
1246 {
1247 for (k, v) in cf {
1248 if k.starts_with("customfield_") {
1249 fields.insert(k.clone(), v.clone());
1250 merged_count += 1;
1251 } else {
1252 tracing::warn!(field = %k, "Skipping non-custom field in customFields (expected customfield_* prefix)");
1253 }
1254 }
1255 }
1256 Ok((value, merged_count))
1257}
1258
1259fn has_project_clause(jql: &str) -> bool {
1263 let lower = jql.to_lowercase();
1264 let bytes = lower.as_bytes();
1265 let keyword = b"project";
1266 let mut in_quote = false;
1267 let mut i = 0;
1268
1269 while i < bytes.len() {
1270 if bytes[i] == b'\\' && in_quote && i + 1 < bytes.len() {
1272 i += 2; continue;
1274 }
1275 if bytes[i] == b'"' {
1276 in_quote = !in_quote;
1277 i += 1;
1278 continue;
1279 }
1280 if in_quote {
1281 i += 1;
1282 continue;
1283 }
1284
1285 if i + keyword.len() <= bytes.len() && &bytes[i..i + keyword.len()] == keyword {
1287 if i > 0 && (bytes[i - 1].is_ascii_alphanumeric() || bytes[i - 1] == b'_') {
1289 i += 1;
1290 continue;
1291 }
1292 let after = &lower[i + keyword.len()..];
1294 let trimmed = after.trim_start();
1295 if trimmed.starts_with("!=")
1296 || trimmed.starts_with("not in ")
1297 || trimmed.starts_with("not in(")
1298 || trimmed.starts_with('=')
1299 || trimmed.starts_with('~')
1300 || trimmed.starts_with("in ")
1301 || trimmed.starts_with("in(")
1302 {
1303 return true;
1304 }
1305 }
1306 i += 1;
1307 }
1308 false
1309}
1310
1311fn generic_status_to_category(status: &str) -> Option<&'static str> {
1314 match status.to_lowercase().as_str() {
1315 "closed" | "done" | "resolved" | "canceled" | "cancelled" => Some("done"),
1316 "open" | "new" | "todo" | "to do" | "reopen" | "reopened" => Some("new"),
1317 "in_progress" | "in progress" | "in-progress" => Some("indeterminate"),
1318 _ => None,
1319 }
1320}
1321
1322fn has_unquoted_keyword(jql: &str, keyword: &str) -> bool {
1324 let lower = jql.to_lowercase();
1325 let kw = keyword.to_lowercase();
1326 let kw_bytes = kw.as_bytes();
1327 let bytes = lower.as_bytes();
1328 let mut in_quote = false;
1329 let mut i = 0;
1330
1331 while i < bytes.len() {
1332 if bytes[i] == b'\\' && in_quote && i + 1 < bytes.len() {
1333 i += 2;
1334 continue;
1335 }
1336 if bytes[i] == b'"' {
1337 in_quote = !in_quote;
1338 i += 1;
1339 continue;
1340 }
1341 if !in_quote
1342 && i + kw_bytes.len() <= bytes.len()
1343 && bytes[i..i + kw_bytes.len()] == *kw_bytes
1344 {
1345 return true;
1346 }
1347 i += 1;
1348 }
1349 false
1350}
1351
1352fn instance_url_from_base(base_url: &str) -> String {
1354 base_url
1355 .trim_end_matches("/rest/api/3")
1356 .trim_end_matches("/rest/api/2")
1357 .to_string()
1358}
1359
1360fn build_forest_tree(
1370 rows: &[crate::types::JiraForestRow],
1371 depths: &[u32],
1372) -> Result<Vec<StructureNode>> {
1373 if rows.len() != depths.len() {
1374 return Err(Error::InvalidData(format!(
1375 "Structure forest response has {} rows but {} depths",
1376 rows.len(),
1377 depths.len()
1378 )));
1379 }
1380 let mut roots: Vec<StructureNode> = Vec::new();
1381 let mut stack: Vec<StructureNode> = Vec::new();
1382
1383 for (row, depth) in rows.iter().zip(depths.iter()) {
1384 let depth = *depth as usize;
1385 let node = StructureNode {
1386 row_id: row.id,
1387 item_id: row.item_id.clone(),
1388 item_type: row.item_type.clone(),
1389 children: Vec::new(),
1390 };
1391
1392 while stack.len() > depth {
1394 let child = stack.pop().expect("stack.len() > depth > 0");
1395 if let Some(parent) = stack.last_mut() {
1396 parent.children.push(child);
1397 } else {
1398 roots.push(child);
1399 }
1400 }
1401
1402 stack.push(node);
1403 }
1404
1405 while let Some(child) = stack.pop() {
1407 if let Some(parent) = stack.last_mut() {
1408 parent.children.push(child);
1409 } else {
1410 roots.push(child);
1411 }
1412 }
1413
1414 Ok(roots)
1415}
1416
1417fn map_structure_view(view: crate::types::JiraStructureView) -> StructureView {
1419 StructureView {
1420 id: view.id,
1421 name: view.name,
1422 structure_id: view.structure_id,
1423 columns: view
1424 .columns
1425 .into_iter()
1426 .map(|c| StructureViewColumn {
1427 id: c.id,
1428 field: c.field,
1429 formula: c.formula,
1430 width: c.width,
1431 })
1432 .collect(),
1433 group_by: view.group_by,
1434 sort_by: view.sort_by,
1435 filter: view.filter,
1436 }
1437}
1438
1439#[async_trait]
1444impl IssueProvider for JiraClient {
1445 async fn get_issues(&self, filter: IssueFilter) -> Result<ProviderResult<Issue>> {
1446 let limit = filter.limit.unwrap_or(20);
1447 if limit == 0 {
1448 return Ok(vec![].into());
1449 }
1450 let offset = filter.offset.unwrap_or(0);
1451
1452 let effective_project = filter
1455 .project_key
1456 .as_deref()
1457 .filter(|k| !k.trim().is_empty())
1458 .unwrap_or(&self.project_key);
1459
1460 let escaped_project = escape_jql(effective_project);
1462 let jql = if let Some(native) = &filter.native_query
1463 && !native.trim().is_empty()
1464 {
1465 if has_project_clause(native) {
1468 native.clone()
1469 } else if native.trim_start().to_lowercase().starts_with("order by") {
1470 format!("project = \"{}\" {}", escaped_project, native)
1471 } else {
1472 format!("project = \"{}\" AND {}", escaped_project, native)
1473 }
1474 } else {
1475 let mut jql_parts: Vec<String> = vec![format!("project = \"{}\"", escaped_project)];
1476
1477 if let Some(state) = &filter.state {
1479 match state.as_str() {
1480 "open" | "opened" => {
1481 jql_parts.push("statusCategory != Done".to_string());
1482 }
1483 "closed" | "done" => {
1484 jql_parts.push("statusCategory = Done".to_string());
1485 }
1486 "all" => {} other => {
1488 jql_parts.push(format!("status = \"{}\"", escape_jql(other)));
1490 }
1491 }
1492 }
1493
1494 if let Some(search) = &filter.search {
1495 jql_parts.push(format!("summary ~ \"{}\"", escape_jql(search)));
1496 }
1497
1498 if let Some(labels) = &filter.labels {
1499 for label in labels {
1500 jql_parts.push(format!("labels = \"{}\"", escape_jql(label)));
1501 }
1502 }
1503
1504 if let Some(assignee) = &filter.assignee {
1505 jql_parts.push(format!("assignee = \"{}\"", escape_jql(assignee)));
1506 }
1507
1508 jql_parts.join(" AND ")
1509 };
1510
1511 let order_by = match filter.sort_by.as_deref() {
1513 Some("created_at" | "created") => "created",
1514 Some("priority") => "priority",
1515 _ => "updated",
1516 };
1517 let order = match filter.sort_order.as_deref() {
1518 Some("asc") => "ASC",
1519 _ => "DESC",
1520 };
1521 let has_order_by = has_unquoted_keyword(&jql, "order by");
1522 let jql_with_order = if has_order_by {
1523 jql
1524 } else {
1525 format!("{} ORDER BY {} {}", jql, order_by, order)
1526 };
1527
1528 let instance_url = &self.instance_url;
1529
1530 match self.flavor {
1531 JiraFlavor::Cloud => {
1532 let url = format!("{}/search/jql", self.base_url);
1534
1535 let mut all_issues: Vec<Issue> = Vec::new();
1536 let mut next_page_token: Option<String> = None;
1537 let total_needed = offset.saturating_add(limit);
1538 let mut fetched_count = 0u32;
1539
1540 let fields = "summary,status,priority,assignee,reporter,labels,created,updated,parent,subtasks".to_string();
1544
1545 loop {
1546 let mut params: Vec<(&str, String)> = vec![
1547 ("jql", jql_with_order.clone()),
1548 ("maxResults", std::cmp::min(limit, 50).to_string()),
1549 ("fields", fields.clone()),
1550 ];
1551
1552 if let Some(token) = &next_page_token {
1553 params.push(("nextPageToken", token.clone()));
1554 }
1555
1556 let param_refs: Vec<(&str, &str)> =
1557 params.iter().map(|(k, v)| (*k, v.as_str())).collect();
1558
1559 debug!(url = url, params = ?param_refs, "Jira Cloud search");
1560
1561 let response = self
1562 .request(reqwest::Method::GET, &url)
1563 .query(¶m_refs)
1564 .send()
1565 .await
1566 .map_err(|e| Error::Http(e.to_string()))?;
1567
1568 let search_resp: JiraCloudSearchResponse =
1569 self.handle_response(response).await?;
1570
1571 let page_len = search_resp.issues.len() as u32;
1572 for issue in &search_resp.issues {
1573 if fetched_count >= offset && all_issues.len() < limit as usize {
1574 all_issues.push(map_issue(issue, self.flavor, instance_url));
1575 }
1576 fetched_count += 1;
1577 }
1578
1579 if all_issues.len() >= limit as usize {
1580 break;
1581 }
1582
1583 match search_resp.next_page_token {
1584 Some(token) if page_len > 0 && fetched_count < total_needed => {
1585 next_page_token = Some(token);
1586 }
1587 _ => break,
1588 }
1589 }
1590
1591 let mut result = ProviderResult::new(all_issues);
1592 result.pagination = Some(devboy_core::Pagination {
1593 offset,
1594 limit,
1595 total: None, has_more: next_page_token.is_some(),
1597 next_cursor: next_page_token,
1598 });
1599 result.sort_info = Some(devboy_core::SortInfo {
1600 sort_by: Some(order_by.into()),
1601 sort_order: match order {
1602 "ASC" => devboy_core::SortOrder::Asc,
1603 _ => devboy_core::SortOrder::Desc,
1604 },
1605 available_sorts: vec!["created".into(), "updated".into(), "priority".into()],
1606 });
1607 Ok(result)
1608 }
1609 JiraFlavor::SelfHosted => {
1610 let url = format!("{}/search", self.base_url);
1612
1613 let params: Vec<(&str, String)> = vec![
1614 ("jql", jql_with_order),
1615 ("startAt", offset.to_string()),
1616 ("maxResults", limit.to_string()),
1617 ("fields", "summary,status,priority,assignee,reporter,labels,created,updated,parent,subtasks".to_string()),
1618 ];
1619
1620 let param_refs: Vec<(&str, &str)> =
1621 params.iter().map(|(k, v)| (*k, v.as_str())).collect();
1622
1623 debug!(url = url, params = ?param_refs, "Jira Self-Hosted search");
1624
1625 let response = self
1626 .request(reqwest::Method::GET, &url)
1627 .query(¶m_refs)
1628 .send()
1629 .await
1630 .map_err(|e| Error::Http(e.to_string()))?;
1631
1632 let search_resp: JiraSearchResponse = self.handle_response(response).await?;
1633
1634 let total = search_resp.total;
1635 let has_more = match (total, search_resp.start_at, search_resp.max_results) {
1636 (Some(t), Some(s), Some(m)) => s + m < t,
1637 _ => false,
1638 };
1639
1640 let issues: Vec<Issue> = search_resp
1641 .issues
1642 .iter()
1643 .map(|i| map_issue(i, self.flavor, instance_url))
1644 .collect();
1645
1646 let mut result = ProviderResult::new(issues);
1647 result.pagination = Some(devboy_core::Pagination {
1648 offset,
1649 limit,
1650 total,
1651 has_more,
1652 next_cursor: None,
1653 });
1654 result.sort_info = Some(devboy_core::SortInfo {
1655 sort_by: Some(order_by.into()),
1656 sort_order: match order {
1657 "ASC" => devboy_core::SortOrder::Asc,
1658 _ => devboy_core::SortOrder::Desc,
1659 },
1660 available_sorts: vec!["created".into(), "updated".into(), "priority".into()],
1661 });
1662 Ok(result)
1663 }
1664 }
1665 }
1666
1667 async fn get_issue(&self, key: &str) -> Result<Issue> {
1668 let jira_key = parse_jira_key(key);
1669 let url = format!("{}/issue/{}", self.base_url, jira_key);
1670 let issue: JiraIssue = self.get(&url).await?;
1671 Ok(map_issue(&issue, self.flavor, &self.instance_url))
1672 }
1673
1674 async fn create_issue(&self, input: CreateIssueInput) -> Result<Issue> {
1675 let description = input.description.map(|d| {
1676 if self.flavor == JiraFlavor::Cloud {
1677 text_to_adf(&d)
1678 } else {
1679 serde_json::Value::String(d)
1680 }
1681 });
1682
1683 let labels = if input.labels.is_empty() {
1684 None
1685 } else {
1686 Some(input.labels)
1687 };
1688 let has_labels = labels.is_some();
1689
1690 let priority = input.priority.as_deref().map(|p| PriorityName {
1691 name: priority_to_jira(p),
1692 });
1693
1694 let assignee = input.assignees.first().map(|a| {
1695 if self.flavor == JiraFlavor::Cloud {
1696 serde_json::json!({ "accountId": a })
1697 } else {
1698 serde_json::json!({ "name": a })
1699 }
1700 });
1701
1702 let effective_project = input.project_id.unwrap_or_else(|| self.project_key.clone());
1703 let effective_issue_type = input.issue_type.unwrap_or_else(|| "Task".to_string());
1704
1705 let components = if input.components.is_empty() {
1707 None
1708 } else {
1709 Some(
1710 input
1711 .components
1712 .into_iter()
1713 .map(|name| crate::types::ComponentRef { name })
1714 .collect(),
1715 )
1716 };
1717
1718 let payload = CreateIssuePayload {
1719 fields: CreateIssueFields {
1720 project: ProjectKey {
1721 key: effective_project,
1722 },
1723 summary: input.title,
1724 issuetype: IssueType {
1725 name: effective_issue_type,
1726 },
1727 description,
1728 labels,
1729 priority,
1730 assignee,
1731 components,
1732 parent: input.parent.map(|key| crate::types::IssueKeyRef { key }),
1733 },
1734 };
1735
1736 let (mut payload, _) = merge_custom_fields_into_payload(payload, &input.custom_fields)?;
1737
1738 let url = format!("{}/issue", self.base_url);
1739 let create_result: std::result::Result<CreateIssueResponse, Error> =
1740 self.post(&url, &payload).await;
1741
1742 let create_resp = match create_result {
1743 Ok(resp) => resp,
1744 Err(e)
1745 if has_labels
1746 && e.to_string().contains("labels")
1747 && e.to_string().contains("not on the appropriate screen") =>
1748 {
1749 tracing::warn!("Create issue failed with labels, retrying without: {e}");
1753 let saved_labels = payload
1754 .get_mut("fields")
1755 .and_then(|f| f.as_object_mut())
1756 .and_then(|f| f.remove("labels"));
1757 let resp: CreateIssueResponse = self.post(&url, &payload).await?;
1758
1759 if let Some(lbl_value) = saved_labels
1761 && let Ok(lbl) = serde_json::from_value::<Vec<String>>(lbl_value)
1762 {
1763 let update = UpdateIssueInput {
1764 labels: Some(lbl),
1765 ..Default::default()
1766 };
1767 if let Err(e) = self.update_issue(&resp.key, update).await {
1768 tracing::warn!("Failed to set labels after create: {e}");
1769 }
1770 }
1771 resp
1772 }
1773 Err(e) => return Err(e),
1774 };
1775
1776 self.get_issue(&create_resp.key).await
1778 }
1779
1780 async fn update_issue(&self, key: &str, input: UpdateIssueInput) -> Result<Issue> {
1781 let jira_key = parse_jira_key(key);
1782
1783 let description = input.description.map(|d| {
1784 if self.flavor == JiraFlavor::Cloud {
1785 text_to_adf(&d)
1786 } else {
1787 serde_json::Value::String(d)
1788 }
1789 });
1790
1791 let priority = input.priority.as_deref().map(|p| PriorityName {
1792 name: priority_to_jira(p),
1793 });
1794
1795 let assignee = input.assignees.as_ref().and_then(|a| {
1796 a.first().map(|username| {
1797 if self.flavor == JiraFlavor::Cloud {
1798 serde_json::json!({ "accountId": username })
1799 } else {
1800 serde_json::json!({ "name": username })
1801 }
1802 })
1803 });
1804
1805 let labels = input.labels;
1806
1807 let components = input.components.map(|ids| {
1809 ids.into_iter()
1810 .map(|name| crate::types::ComponentRef { name })
1811 .collect()
1812 });
1813 let has_components = components.is_some();
1814
1815 let fields = UpdateIssueFields {
1816 summary: input.title,
1817 description,
1818 labels,
1819 priority,
1820 assignee,
1821 components,
1822 };
1823
1824 let has_custom_fields = input.custom_fields.as_ref().is_some_and(|v| {
1825 v.as_object()
1826 .is_some_and(|obj| obj.keys().any(|k| k.starts_with("customfield_")))
1827 });
1828
1829 let has_field_updates = fields.summary.is_some()
1831 || fields.description.is_some()
1832 || fields.labels.is_some()
1833 || fields.priority.is_some()
1834 || fields.assignee.is_some()
1835 || has_components
1836 || has_custom_fields;
1837
1838 if has_field_updates {
1839 let url = format!("{}/issue/{}", self.base_url, jira_key);
1840 let payload = UpdateIssuePayload { fields };
1841 let (payload, _) = merge_custom_fields_into_payload(payload, &input.custom_fields)?;
1842 self.put(&url, &payload).await?;
1843 }
1844
1845 if let Some(state) = &input.state {
1847 self.transition_issue(jira_key, state).await?;
1848 }
1849
1850 self.get_issue(jira_key).await
1852 }
1853
1854 async fn get_comments(&self, issue_key: &str) -> Result<ProviderResult<Comment>> {
1855 let jira_key = parse_jira_key(issue_key);
1856 let url = format!("{}/issue/{}/comment", self.base_url, jira_key);
1857 let response: JiraCommentsResponse = self.get(&url).await?;
1858 Ok(response
1859 .comments
1860 .iter()
1861 .map(|c| map_comment(c, self.flavor))
1862 .collect::<Vec<_>>()
1863 .into())
1864 }
1865
1866 async fn add_comment(&self, issue_key: &str, body: &str) -> Result<Comment> {
1867 let jira_key = parse_jira_key(issue_key);
1868 let comment_body = if self.flavor == JiraFlavor::Cloud {
1869 text_to_adf(body)
1870 } else {
1871 serde_json::Value::String(body.to_string())
1872 };
1873
1874 let payload = AddCommentPayload { body: comment_body };
1875
1876 let url = format!("{}/issue/{}/comment", self.base_url, jira_key);
1877 let jira_comment: JiraComment = self.post(&url, &payload).await?;
1878 Ok(map_comment(&jira_comment, self.flavor))
1879 }
1880
1881 async fn get_statuses(&self) -> Result<ProviderResult<IssueStatus>> {
1882 let project_statuses = self.get_project_statuses().await?;
1883
1884 let statuses: Vec<IssueStatus> = project_statuses
1885 .iter()
1886 .enumerate()
1887 .map(|(idx, s)| {
1888 let category = s
1889 .status_category
1890 .as_ref()
1891 .map(|sc| match sc.key.as_str() {
1892 "new" => "open".to_string(),
1893 "indeterminate" => "in_progress".to_string(),
1894 "done" => "done".to_string(),
1895 other => other.to_string(),
1896 })
1897 .unwrap_or_else(|| "custom".to_string());
1898
1899 IssueStatus {
1900 id: s.id.clone().unwrap_or_else(|| s.name.clone()),
1901 name: s.name.clone(),
1902 category,
1903 color: None,
1904 order: Some(idx as u32),
1905 }
1906 })
1907 .collect();
1908
1909 Ok(statuses.into())
1910 }
1911
1912 async fn get_users(&self, options: GetUsersOptions) -> Result<ProviderResult<User>> {
1913 let start_at = options.start_at.unwrap_or(0);
1914 let max_results = options.max_results.unwrap_or(50);
1915
1916 let url = if let Some(ref project_key) = options.project_key {
1918 format!(
1919 "{}/user/assignable/search?project={}&startAt={}&maxResults={}",
1920 self.base_url, project_key, start_at, max_results
1921 )
1922 } else {
1923 let query = options.search.as_deref().unwrap_or("");
1924 match self.flavor {
1925 JiraFlavor::Cloud => format!(
1926 "{}/user/search?query={}&startAt={}&maxResults={}",
1927 self.base_url, query, start_at, max_results
1928 ),
1929 JiraFlavor::SelfHosted => format!(
1930 "{}/user/search?username={}&startAt={}&maxResults={}",
1931 self.base_url,
1932 if query.is_empty() { "." } else { query },
1933 start_at,
1934 max_results
1935 ),
1936 }
1937 };
1938
1939 let jira_users: Vec<JiraUser> = self.get(&url).await?;
1940
1941 let users: Vec<User> = jira_users
1942 .iter()
1943 .map(|u| map_user(Some(u)).unwrap_or_default())
1944 .collect();
1945
1946 Ok(users.into())
1947 }
1948
1949 async fn link_issues(&self, source_key: &str, target_key: &str, link_type: &str) -> Result<()> {
1950 let source_jira_key = parse_jira_key(source_key).to_string();
1951 let target_jira_key = parse_jira_key(target_key).to_string();
1952
1953 let link_type_name = match link_type {
1954 "blocks" => "Blocks",
1955 "blocked_by" => "Blocks", "relates_to" => "Relates",
1957 "duplicates" => "Duplicate",
1958 "clones" => "Cloners",
1959 other => other,
1960 };
1961
1962 let (outward_key, inward_key) = if link_type == "blocked_by" {
1964 (target_jira_key, source_jira_key)
1965 } else {
1966 (source_jira_key, target_jira_key)
1967 };
1968
1969 let payload = CreateIssueLinkPayload {
1970 link_type: IssueLinkTypeName {
1971 name: link_type_name.to_string(),
1972 },
1973 outward_issue: IssueKeyRef { key: outward_key },
1974 inward_issue: IssueKeyRef { key: inward_key },
1975 };
1976
1977 let url = format!("{}/issueLink", self.base_url);
1978 self.post_no_content(&url, &payload).await?;
1979
1980 Ok(())
1981 }
1982
1983 async fn get_issue_relations(&self, issue_key: &str) -> Result<IssueRelations> {
1984 let jira_key = parse_jira_key(issue_key);
1985 let url = format!(
1986 "{}/issue/{}?fields=parent,subtasks,issuelinks,summary,status,priority",
1987 self.base_url, jira_key
1988 );
1989 let issue: JiraIssue = self.get(&url).await?;
1990 Ok(map_relations(&issue, self.flavor, &self.instance_url))
1991 }
1992
1993 async fn upload_attachment(
1994 &self,
1995 issue_key: &str,
1996 filename: &str,
1997 data: &[u8],
1998 ) -> Result<String> {
1999 let jira_key = parse_jira_key(issue_key);
2000 let url = format!("{}/issue/{}/attachments", self.base_url, jira_key);
2001
2002 let part = reqwest::multipart::Part::bytes(data.to_vec())
2003 .file_name(filename.to_string())
2004 .mime_str("application/octet-stream")
2005 .map_err(|e| Error::Http(format!("failed to build multipart: {e}")))?;
2006 let form = reqwest::multipart::Form::new().part("file", part);
2007
2008 let response = self
2012 .request_raw(reqwest::Method::POST, &url)
2013 .header("X-Atlassian-Token", "no-check")
2016 .multipart(form)
2017 .send()
2018 .await
2019 .map_err(|e| Error::Http(e.to_string()))?;
2020
2021 let status = response.status();
2022 if !status.is_success() {
2023 let message = response.text().await.unwrap_or_default();
2024 return Err(Error::from_status(status.as_u16(), message));
2025 }
2026
2027 let attachments: Vec<JiraAttachment> = response
2029 .json()
2030 .await
2031 .map_err(|e| Error::InvalidData(format!("failed to parse attachment response: {e}")))?;
2032 let url = attachments
2033 .into_iter()
2034 .next()
2035 .and_then(|a| a.content)
2036 .filter(|u| !u.is_empty())
2037 .ok_or_else(|| {
2038 Error::InvalidData(
2039 "Jira upload returned no attachment with a content URL".to_string(),
2040 )
2041 })?;
2042 Ok(url)
2043 }
2044
2045 async fn get_issue_attachments(&self, issue_key: &str) -> Result<Vec<AssetMeta>> {
2046 let jira_key = parse_jira_key(issue_key);
2047 let url = format!("{}/issue/{}?fields=attachment", self.base_url, jira_key);
2048 let issue: JiraIssue = self.get(&url).await?;
2049 Ok(issue
2050 .fields
2051 .attachment
2052 .iter()
2053 .map(map_jira_attachment)
2054 .collect())
2055 }
2056
2057 async fn download_attachment(&self, _issue_key: &str, asset_id: &str) -> Result<Vec<u8>> {
2058 let url = match self.flavor {
2062 JiraFlavor::Cloud => {
2063 format!("{}/attachment/content/{}", self.base_url, asset_id)
2064 }
2065 JiraFlavor::SelfHosted => {
2066 let meta_url = format!("{}/attachment/{}", self.base_url, asset_id);
2067 let meta: serde_json::Value = self.get(&meta_url).await?;
2068 meta.get("content")
2069 .and_then(|v| v.as_str())
2070 .ok_or_else(|| {
2071 Error::InvalidData(format!(
2072 "attachment {asset_id} metadata has no content URL"
2073 ))
2074 })?
2075 .to_string()
2076 }
2077 };
2078 let response = self
2079 .request(reqwest::Method::GET, &url)
2080 .send()
2081 .await
2082 .map_err(|e| Error::Http(e.to_string()))?;
2083
2084 let status = response.status();
2085 if !status.is_success() {
2086 let message = response.text().await.unwrap_or_default();
2087 return Err(Error::from_status(status.as_u16(), message));
2088 }
2089
2090 let bytes = response
2091 .bytes()
2092 .await
2093 .map_err(|e| Error::Http(format!("failed to read attachment bytes: {e}")))?;
2094 Ok(bytes.to_vec())
2095 }
2096
2097 async fn delete_attachment(&self, _issue_key: &str, asset_id: &str) -> Result<()> {
2098 let url = format!("{}/attachment/{}", self.base_url, asset_id);
2100 let response = self
2101 .request(reqwest::Method::DELETE, &url)
2102 .send()
2103 .await
2104 .map_err(|e| Error::Http(e.to_string()))?;
2105
2106 let status = response.status();
2107 if !status.is_success() {
2108 let message = response.text().await.unwrap_or_default();
2109 return Err(Error::from_status(status.as_u16(), message));
2110 }
2111 Ok(())
2112 }
2113
2114 fn asset_capabilities(&self) -> AssetCapabilities {
2115 AssetCapabilities {
2117 issue: ContextCapabilities {
2118 upload: true,
2119 download: true,
2120 delete: true,
2121 list: true,
2122 max_file_size: None,
2123 allowed_types: Vec::new(),
2124 },
2125 ..Default::default()
2126 }
2127 }
2128
2129 async fn get_structures(&self) -> Result<ProviderResult<Structure>> {
2132 let resp: JiraStructureListResponse = self.structure_get("/structure").await?;
2133 let items: Vec<Structure> = resp
2134 .structures
2135 .into_iter()
2136 .map(|s| Structure {
2137 id: s.id,
2138 name: s.name,
2139 description: s.description,
2140 })
2141 .collect();
2142 Ok(items.into())
2143 }
2144
2145 async fn get_structure_forest(
2146 &self,
2147 structure_id: u64,
2148 options: GetForestOptions,
2149 ) -> Result<StructureForest> {
2150 let mut spec = serde_json::Map::new();
2151 if let Some(offset) = options.offset {
2152 spec.insert("offset".into(), serde_json::json!(offset));
2153 }
2154 if let Some(limit) = options.limit {
2155 spec.insert("limit".into(), serde_json::json!(limit));
2156 }
2157
2158 let resp: JiraForestResponse = self
2159 .structure_post(
2160 &format!("/forest/{}/spec", structure_id),
2161 &serde_json::Value::Object(spec),
2162 )
2163 .await?;
2164
2165 let tree = build_forest_tree(&resp.rows, &resp.depths)?;
2166
2167 Ok(StructureForest {
2168 version: resp.version,
2169 structure_id,
2170 tree,
2171 total_count: resp.total_count,
2172 })
2173 }
2174
2175 async fn add_structure_rows(
2176 &self,
2177 structure_id: u64,
2178 input: AddStructureRowsInput,
2179 ) -> Result<ForestModifyResult> {
2180 let mut payload = serde_json::json!({
2181 "rows": input.items.iter().map(|i| {
2182 let mut row = serde_json::json!({"itemId": i.item_id});
2183 if let Some(ref t) = i.item_type {
2184 row["itemType"] = serde_json::json!(t);
2185 }
2186 row
2187 }).collect::<Vec<_>>()
2188 });
2189 if let Some(under) = input.under {
2190 payload["under"] = serde_json::json!(under);
2191 }
2192 if let Some(after) = input.after {
2193 payload["after"] = serde_json::json!(after);
2194 }
2195 if let Some(version) = input.forest_version {
2196 payload["forestVersion"] = serde_json::json!(version);
2197 }
2198
2199 let resp: JiraForestModifyResponse = self
2200 .structure_put(&format!("/forest/{}/item", structure_id), &payload)
2201 .await
2202 .map_err(|e| {
2203 if matches!(&e, Error::Api { status, .. } if *status == 409) {
2204 Error::Api {
2205 status: 409,
2206 message: "Forest version conflict. The structure was modified concurrently. Retry with the latest version.".to_string(),
2207 }
2208 } else {
2209 e
2210 }
2211 })?;
2212
2213 Ok(ForestModifyResult {
2214 version: resp.version,
2215 affected_count: input.items.len(),
2216 })
2217 }
2218
2219 async fn move_structure_rows(
2220 &self,
2221 structure_id: u64,
2222 input: MoveStructureRowsInput,
2223 ) -> Result<ForestModifyResult> {
2224 let mut payload = serde_json::json!({
2225 "rowIds": input.row_ids
2226 });
2227 if let Some(under) = input.under {
2228 payload["under"] = serde_json::json!(under);
2229 }
2230 if let Some(after) = input.after {
2231 payload["after"] = serde_json::json!(after);
2232 }
2233 if let Some(version) = input.forest_version {
2234 payload["forestVersion"] = serde_json::json!(version);
2235 }
2236
2237 let resp: JiraForestModifyResponse = self
2238 .structure_post(&format!("/forest/{}/move", structure_id), &payload)
2239 .await
2240 .map_err(|e| {
2241 if matches!(&e, Error::Api { status, .. } if *status == 409) {
2242 Error::Api {
2243 status: 409,
2244 message: "Forest version conflict. Retry with the latest version."
2245 .to_string(),
2246 }
2247 } else {
2248 e
2249 }
2250 })?;
2251
2252 Ok(ForestModifyResult {
2253 version: resp.version,
2254 affected_count: input.row_ids.len(),
2255 })
2256 }
2257
2258 async fn remove_structure_row(&self, structure_id: u64, row_id: u64) -> Result<()> {
2259 self.structure_delete_request(&format!("/forest/{}/item/{}", structure_id, row_id))
2260 .await
2261 }
2262
2263 async fn get_structure_values(
2264 &self,
2265 input: GetStructureValuesInput,
2266 ) -> Result<StructureValues> {
2267 let columns: Vec<serde_json::Value> = input
2268 .columns
2269 .iter()
2270 .map(|c| {
2271 let mut col = serde_json::Map::new();
2272 if let Some(ref id) = c.id {
2273 col.insert("id".into(), serde_json::json!(id));
2274 }
2275 if let Some(ref field) = c.field {
2276 col.insert("field".into(), serde_json::json!(field));
2277 }
2278 if let Some(ref formula) = c.formula {
2279 col.insert("formula".into(), serde_json::json!(formula));
2280 }
2281 serde_json::Value::Object(col)
2282 })
2283 .collect();
2284
2285 let payload = serde_json::json!({
2286 "structureId": input.structure_id,
2287 "rows": input.rows,
2288 "columns": columns,
2289 });
2290
2291 let resp: JiraStructureValuesResponse = self.structure_post("/value", &payload).await?;
2292
2293 let mut row_map: std::collections::BTreeMap<u64, Vec<StructureColumnValue>> =
2298 std::collections::BTreeMap::new();
2299 for entry in resp.values {
2300 let column = entry.column_id.ok_or_else(|| {
2301 Error::InvalidData(format!(
2302 "Structure value for row {} is missing `columnId`",
2303 entry.row_id
2304 ))
2305 })?;
2306 row_map
2307 .entry(entry.row_id)
2308 .or_default()
2309 .push(StructureColumnValue {
2310 column,
2311 value: entry.value,
2312 });
2313 }
2314
2315 let values = row_map
2316 .into_iter()
2317 .map(|(row_id, columns)| StructureRowValues { row_id, columns })
2318 .collect();
2319
2320 Ok(StructureValues {
2321 structure_id: input.structure_id,
2322 values,
2323 })
2324 }
2325
2326 async fn get_structure_views(
2327 &self,
2328 structure_id: u64,
2329 view_id: Option<u64>,
2330 ) -> Result<Vec<StructureView>> {
2331 if let Some(id) = view_id {
2332 let view: JiraStructureView = self.structure_get(&format!("/view/{}", id)).await?;
2333 if view.structure_id != structure_id {
2339 return Err(Error::InvalidData(format!(
2340 "view {id} belongs to structure {} but {structure_id} was requested",
2341 view.structure_id
2342 )));
2343 }
2344 Ok(vec![map_structure_view(view)])
2345 } else {
2346 let resp: JiraStructureViewListResponse = self
2347 .structure_get(&format!("/view?structureId={}", structure_id))
2348 .await?;
2349 Ok(resp.views.into_iter().map(map_structure_view).collect())
2350 }
2351 }
2352
2353 async fn save_structure_view(&self, input: SaveStructureViewInput) -> Result<StructureView> {
2354 let columns: Option<Vec<serde_json::Value>> = input.columns.as_ref().map(|cols| {
2355 cols.iter()
2356 .map(|c| {
2357 let mut col = serde_json::Map::new();
2358 if let Some(ref field) = c.field {
2359 col.insert("field".into(), serde_json::json!(field));
2360 }
2361 if let Some(ref formula) = c.formula {
2362 col.insert("formula".into(), serde_json::json!(formula));
2363 }
2364 if let Some(width) = c.width {
2365 col.insert("width".into(), serde_json::json!(width));
2366 }
2367 serde_json::Value::Object(col)
2368 })
2369 .collect()
2370 });
2371
2372 let mut payload = serde_json::json!({
2373 "structureId": input.structure_id,
2374 "name": input.name,
2375 });
2376 if let Some(cols) = columns {
2377 payload["columns"] = serde_json::json!(cols);
2378 }
2379 if let Some(ref g) = input.group_by {
2380 payload["groupBy"] = serde_json::json!(g);
2381 }
2382 if let Some(ref s) = input.sort_by {
2383 payload["sortBy"] = serde_json::json!(s);
2384 }
2385 if let Some(ref f) = input.filter {
2386 payload["filter"] = serde_json::json!(f);
2387 }
2388
2389 let view: JiraStructureView = if let Some(id) = input.id {
2390 self.structure_put(&format!("/view/{}", id), &payload)
2391 .await?
2392 } else {
2393 self.structure_post("/view", &payload).await?
2394 };
2395
2396 Ok(map_structure_view(view))
2397 }
2398
2399 async fn create_structure(&self, input: CreateStructureInput) -> Result<Structure> {
2400 let mut payload = serde_json::json!({"name": input.name});
2401 if let Some(ref desc) = input.description {
2402 payload["description"] = serde_json::json!(desc);
2403 }
2404 let s: JiraStructure = self.structure_post("/structure", &payload).await?;
2405 Ok(Structure {
2406 id: s.id,
2407 name: s.name,
2408 description: s.description,
2409 })
2410 }
2411
2412 async fn get_structure_generators(
2415 &self,
2416 structure_id: u64,
2417 ) -> Result<ProviderResult<devboy_core::StructureGenerator>> {
2418 #[derive(serde::Deserialize)]
2419 struct Resp {
2420 #[serde(default)]
2421 generators: Vec<RawGenerator>,
2422 }
2423 #[derive(serde::Deserialize)]
2424 struct RawGenerator {
2425 id: String,
2426 #[serde(rename = "type")]
2427 generator_type: String,
2428 #[serde(default)]
2429 spec: serde_json::Value,
2430 }
2431 let resp: Resp = self
2432 .structure_get(&format!("/structure/{}/generator", structure_id))
2433 .await?;
2434 let items: Vec<devboy_core::StructureGenerator> = resp
2435 .generators
2436 .into_iter()
2437 .map(|g| devboy_core::StructureGenerator {
2438 id: g.id,
2439 generator_type: g.generator_type,
2440 spec: g.spec,
2441 })
2442 .collect();
2443 Ok(items.into())
2444 }
2445
2446 async fn add_structure_generator(
2447 &self,
2448 input: devboy_core::AddStructureGeneratorInput,
2449 ) -> Result<devboy_core::StructureGenerator> {
2450 #[derive(serde::Deserialize)]
2453 struct Resp {
2454 id: String,
2455 #[serde(rename = "type")]
2456 generator_type: String,
2457 #[serde(default)]
2458 spec: serde_json::Value,
2459 }
2460 let body = serde_json::json!({
2461 "type": input.generator_type,
2462 "spec": input.spec,
2463 });
2464 let resp: Resp = self
2465 .structure_post(
2466 &format!("/structure/{}/generator", input.structure_id),
2467 &body,
2468 )
2469 .await?;
2470 Ok(devboy_core::StructureGenerator {
2471 id: resp.id,
2472 generator_type: resp.generator_type,
2473 spec: resp.spec,
2474 })
2475 }
2476
2477 async fn sync_structure_generator(
2478 &self,
2479 input: devboy_core::SyncStructureGeneratorInput,
2480 ) -> Result<()> {
2481 let body = serde_json::json!({});
2482 let _: serde_json::Value = self
2483 .structure_post(
2484 &format!(
2485 "/structure/{}/generator/{}/sync",
2486 input.structure_id, input.generator_id
2487 ),
2488 &body,
2489 )
2490 .await?;
2491 Ok(())
2492 }
2493
2494 async fn delete_structure(&self, structure_id: u64) -> Result<()> {
2497 self.structure_delete_request(&format!("/structure/{}", structure_id))
2498 .await
2499 }
2500
2501 async fn update_structure_automation(
2502 &self,
2503 input: devboy_core::UpdateStructureAutomationInput,
2504 ) -> Result<()> {
2505 let endpoint = match input.automation_id.as_deref() {
2508 Some(aid) => format!("/structure/{}/automation/{}", input.structure_id, aid),
2509 None => format!("/structure/{}/automation", input.structure_id),
2510 };
2511 let _: serde_json::Value = self.structure_put(&endpoint, &input.config).await?;
2512 Ok(())
2513 }
2514
2515 async fn trigger_structure_automation(&self, structure_id: u64) -> Result<()> {
2516 let body = serde_json::json!({});
2517 let _: serde_json::Value = self
2518 .structure_post(
2519 &format!("/structure/{}/automation/run", structure_id),
2520 &body,
2521 )
2522 .await?;
2523 Ok(())
2524 }
2525
2526 async fn get_board_sprints(
2529 &self,
2530 board_id: u64,
2531 state: devboy_core::SprintState,
2532 ) -> Result<ProviderResult<devboy_core::Sprint>> {
2533 #[derive(serde::Deserialize)]
2537 #[serde(rename_all = "camelCase")]
2538 struct Resp {
2539 #[serde(default)]
2540 is_last: bool,
2541 #[serde(default)]
2542 values: Vec<devboy_core::Sprint>,
2543 }
2544 const MAX_SPRINTS: usize = 5_000;
2547 const PAGE_SIZE: u32 = 50;
2548
2549 let state_param = state
2550 .as_query_value()
2551 .map(|s| format!("&state={}", s))
2552 .unwrap_or_default();
2553
2554 let mut sprints: Vec<devboy_core::Sprint> = Vec::new();
2555 let mut start_at: u32 = 0;
2556 loop {
2557 let endpoint = format!(
2558 "/board/{}/sprint?startAt={}&maxResults={}{}",
2559 board_id, start_at, PAGE_SIZE, state_param
2560 );
2561 let resp: Resp = self.agile_get(&endpoint).await?;
2562 let fetched = resp.values.len() as u32;
2563 sprints.extend(resp.values);
2564 if resp.is_last || fetched == 0 || sprints.len() >= MAX_SPRINTS {
2565 break;
2566 }
2567 start_at += fetched;
2568 }
2569 Ok(sprints.into())
2570 }
2571
2572 async fn assign_to_sprint(&self, input: devboy_core::AssignToSprintInput) -> Result<()> {
2573 let issues: Vec<String> = input
2576 .issue_keys
2577 .into_iter()
2578 .map(|k| parse_jira_key(&k).to_string())
2579 .collect();
2580 let body = serde_json::json!({ "issues": issues });
2581 self.agile_post_void(&format!("/sprint/{}/issue", input.sprint_id), &body)
2582 .await
2583 }
2584
2585 async fn list_project_versions(
2588 &self,
2589 params: ListProjectVersionsParams,
2590 ) -> Result<ProviderResult<ProjectVersion>> {
2591 let project_key = if params.project.is_empty() {
2592 self.project_key.clone()
2593 } else {
2594 params.project
2595 };
2596
2597 let mut url = format!("{}/project/{}/versions", self.base_url, project_key);
2608 if params.include_issue_count && self.flavor == JiraFlavor::Cloud {
2609 url.push_str("?expand=issuesstatus");
2610 }
2611
2612 let dtos: Vec<JiraVersionDto> = self.get(&url).await?;
2613
2614 let mut versions: Vec<ProjectVersion> = dtos
2615 .into_iter()
2616 .map(|dto| jira_version_to_project_version(dto, &project_key))
2617 .collect();
2618
2619 if let Some(want_released) = params.released {
2620 versions.retain(|v| v.released == want_released);
2621 }
2622 if let Some(want_archived) = params.archived {
2623 versions.retain(|v| v.archived == want_archived);
2624 }
2625
2626 versions.sort_by(|a, b| {
2636 use std::cmp::Ordering;
2637 let group = a.released.cmp(&b.released);
2638 if group != Ordering::Equal {
2639 return group;
2640 }
2641 let undated_first = !a.released;
2644 let date = match (&a.release_date, &b.release_date) {
2645 (Some(a_d), Some(b_d)) => b_d.cmp(a_d),
2646 (None, None) => Ordering::Equal,
2647 (None, Some(_)) if undated_first => Ordering::Less,
2648 (None, Some(_)) => Ordering::Greater,
2649 (Some(_), None) if undated_first => Ordering::Greater,
2650 (Some(_), None) => Ordering::Less,
2651 };
2652 date.then_with(|| compare_version_names(&b.name, &a.name))
2653 });
2654
2655 let total_after_filter = versions.len() as u32;
2656 let limit_applied = params.limit.unwrap_or(total_after_filter);
2657 if (limit_applied as usize) < versions.len() {
2658 versions.truncate(limit_applied as usize);
2659 }
2660
2661 let pagination = devboy_core::Pagination {
2666 offset: 0,
2667 limit: limit_applied,
2668 total: Some(total_after_filter),
2669 has_more: (versions.len() as u32) < total_after_filter,
2670 next_cursor: None,
2671 };
2672
2673 Ok(ProviderResult::new(versions).with_pagination(pagination))
2674 }
2675
2676 async fn upsert_project_version(
2677 &self,
2678 input: UpsertProjectVersionInput,
2679 ) -> Result<ProjectVersion> {
2680 let trimmed_name = input.name.trim().to_string();
2681 if trimmed_name.is_empty() {
2682 return Err(Error::InvalidData(
2683 "upsert_project_version: name must not be empty".into(),
2684 ));
2685 }
2686 if trimmed_name.chars().count() > 255 {
2689 return Err(Error::InvalidData(
2690 "upsert_project_version: name must be ≤ 255 characters".into(),
2691 ));
2692 }
2693 let project_key = if input.project.is_empty() {
2694 self.project_key.clone()
2695 } else {
2696 input.project.clone()
2697 };
2698
2699 let update_payload = UpdateVersionPayload {
2700 name: None,
2701 description: input.description.clone(),
2702 start_date: input.start_date.clone(),
2703 release_date: input.release_date.clone(),
2704 released: input.released,
2705 archived: input.archived,
2706 };
2707 let create_payload = CreateVersionPayload {
2708 name: trimmed_name.clone(),
2709 project: Some(project_key.clone()),
2710 project_id: None,
2711 description: input.description,
2712 start_date: input.start_date,
2713 release_date: input.release_date,
2714 released: input.released,
2715 archived: input.archived,
2716 };
2717
2718 let list_url = format!("{}/project/{}/versions", self.base_url, project_key);
2723 let dtos: Vec<JiraVersionDto> = self.get(&list_url).await?;
2724 let existing = dtos.into_iter().find(|d| d.name == trimmed_name);
2725
2726 let dto: JiraVersionDto = match existing {
2727 Some(existing) => {
2728 self.put_with_response(
2729 &format!("{}/version/{}", self.base_url, existing.id),
2730 &update_payload,
2731 )
2732 .await?
2733 }
2734 None => {
2735 match self
2741 .post::<JiraVersionDto, _>(
2742 &format!("{}/version", self.base_url),
2743 &create_payload,
2744 )
2745 .await
2746 {
2747 Ok(dto) => dto,
2748 Err(e) if is_duplicate_version_error(&e) => {
2749 let dtos: Vec<JiraVersionDto> = self.get(&list_url).await?;
2750 let recovered = dtos
2751 .into_iter()
2752 .find(|d| d.name == trimmed_name)
2753 .ok_or_else(|| {
2754 Error::InvalidData(format!(
2755 "upsert_project_version: create rejected as duplicate but version '{trimmed_name}' is not in the project list"
2756 ))
2757 })?;
2758 self.put_with_response(
2759 &format!("{}/version/{}", self.base_url, recovered.id),
2760 &update_payload,
2761 )
2762 .await?
2763 }
2764 Err(e) => return Err(e),
2765 }
2766 }
2767 };
2768
2769 Ok(jira_version_to_project_version(dto, &project_key))
2770 }
2771
2772 fn provider_name(&self) -> &'static str {
2773 "jira"
2774 }
2775}
2776
2777fn is_duplicate_version_error(e: &Error) -> bool {
2787 let lowered = e.to_string().to_lowercase();
2788 lowered.contains("already exists") || lowered.contains("already used")
2789}
2790
2791fn compare_version_names(a: &str, b: &str) -> std::cmp::Ordering {
2799 fn tokens(s: &str) -> Vec<(bool, &str)> {
2800 let mut out = Vec::new();
2801 let mut start = 0;
2802 let mut last_digit: Option<bool> = None;
2803 for (i, ch) in s.char_indices() {
2804 let is_digit = ch.is_ascii_digit();
2805 match last_digit {
2806 Some(prev) if prev != is_digit => {
2807 out.push((prev, &s[start..i]));
2808 start = i;
2809 }
2810 _ => {}
2811 }
2812 last_digit = Some(is_digit);
2813 }
2814 if let Some(prev) = last_digit {
2815 out.push((prev, &s[start..]));
2816 }
2817 out
2818 }
2819
2820 let a_toks = tokens(a);
2821 let b_toks = tokens(b);
2822 for (ax, bx) in a_toks.iter().zip(b_toks.iter()) {
2823 let cmp = match (ax, bx) {
2824 ((true, ad), (true, bd)) => {
2825 let an = ad.trim_start_matches('0');
2828 let bn = bd.trim_start_matches('0');
2829 an.len().cmp(&bn.len()).then_with(|| an.cmp(bn))
2830 }
2831 ((false, at), (false, bt)) => at.cmp(bt),
2832 ((true, _), (false, _)) => std::cmp::Ordering::Greater,
2835 ((false, _), (true, _)) => std::cmp::Ordering::Less,
2836 };
2837 if cmp != std::cmp::Ordering::Equal {
2838 return cmp;
2839 }
2840 }
2841 match a_toks.len().cmp(&b_toks.len()) {
2846 std::cmp::Ordering::Equal => std::cmp::Ordering::Equal,
2847 std::cmp::Ordering::Greater => {
2848 let next = a_toks[b_toks.len()].1;
2849 if next.starts_with('-') || next.starts_with('+') {
2850 std::cmp::Ordering::Less
2851 } else {
2852 std::cmp::Ordering::Greater
2853 }
2854 }
2855 std::cmp::Ordering::Less => {
2856 let next = b_toks[a_toks.len()].1;
2857 if next.starts_with('-') || next.starts_with('+') {
2858 std::cmp::Ordering::Greater
2859 } else {
2860 std::cmp::Ordering::Less
2861 }
2862 }
2863 }
2864}
2865
2866fn jira_version_to_project_version(dto: JiraVersionDto, project_fallback: &str) -> ProjectVersion {
2867 let issue_count = dto
2875 .issues_status_for_fix_version
2876 .as_ref()
2877 .map(|c| c.total());
2878 let unresolved_issue_count = dto.issues_unresolved_count;
2879
2880 ProjectVersion {
2881 id: dto.id,
2882 project: dto.project.unwrap_or_else(|| project_fallback.to_string()),
2883 name: dto.name,
2884 description: dto.description.filter(|d| !d.is_empty()),
2885 start_date: dto.start_date.filter(|d| !d.is_empty()),
2886 release_date: dto.release_date.filter(|d| !d.is_empty()),
2887 released: dto.released,
2888 archived: dto.archived,
2889 overdue: dto.overdue,
2890 issue_count,
2891 unresolved_issue_count,
2892 source: "jira".to_string(),
2893 }
2894}
2895
2896#[async_trait]
2897impl MergeRequestProvider for JiraClient {
2898 fn provider_name(&self) -> &'static str {
2899 "jira"
2900 }
2901}
2902
2903#[async_trait]
2904impl PipelineProvider for JiraClient {
2905 fn provider_name(&self) -> &'static str {
2906 "jira"
2907 }
2908}
2909
2910#[async_trait]
2911impl Provider for JiraClient {
2912 async fn get_current_user(&self) -> Result<User> {
2913 let url = format!("{}/myself", self.base_url);
2914 let jira_user: JiraUser = self.get(&url).await?;
2915 Ok(map_user(Some(&jira_user)).unwrap_or_default())
2916 }
2917}
2918
2919#[async_trait]
2922impl devboy_core::UserProvider for JiraClient {
2923 fn provider_name(&self) -> &'static str {
2924 "jira"
2925 }
2926
2927 async fn get_user_profile(&self, user_id: &str) -> Result<User> {
2928 let url = match self.flavor {
2929 JiraFlavor::Cloud => format!("{}/user?accountId={}", self.base_url, user_id),
2930 JiraFlavor::SelfHosted => format!("{}/user?username={}", self.base_url, user_id),
2931 };
2932 let jira_user: JiraUser = self.get(&url).await?;
2933 map_user(Some(&jira_user))
2934 .ok_or_else(|| Error::InvalidData("Jira /user returned no user".to_string()))
2935 }
2936
2937 async fn lookup_user_by_email(&self, email: &str) -> Result<Option<User>> {
2938 let url = match self.flavor {
2942 JiraFlavor::Cloud => format!("{}/user/search?query={}", self.base_url, email),
2943 JiraFlavor::SelfHosted => {
2944 format!("{}/user/search?username={}", self.base_url, email)
2945 }
2946 };
2947 let users: Vec<JiraUser> = self.get(&url).await?;
2948 Ok(users.into_iter().find_map(|u| map_user(Some(&u))))
2949 }
2950}
2951
2952#[cfg(test)]
2957mod tests {
2958 use super::*;
2959 use crate::types::*;
2960 use devboy_core::{CreateCommentInput, MrFilter};
2961
2962 fn token(s: &str) -> SecretString {
2963 SecretString::from(s.to_string())
2964 }
2965
2966 #[test]
2971 fn structure_install_hint_is_single_well_spaced_line() {
2972 assert!(
2975 !STRUCTURE_PLUGIN_HINT.contains(" "),
2976 "hint contains consecutive spaces: {STRUCTURE_PLUGIN_HINT:?}"
2977 );
2978 assert!(!STRUCTURE_PLUGIN_HINT.contains('\n'));
2979 assert!(STRUCTURE_PLUGIN_HINT.contains("marketplace.atlassian.com"));
2980 }
2981
2982 #[test]
2983 fn structure_404_with_html_returns_soft_endpoint_hint() {
2984 let html = "<!DOCTYPE html><html><body>Oops, you've found a dead link.</body></html>";
2985 let err = structure_error_from_status(404, "text/html;charset=UTF-8", html.into());
2986 let msg = err.to_string();
2987 assert!(!msg.contains("<!DOCTYPE"), "HTML leaked into error: {msg}");
2988 assert!(
2991 msg.contains("endpoint not found"),
2992 "expected soft 'endpoint not found' wording: {msg}"
2993 );
2994 assert!(
2995 msg.contains("may not be installed"),
2996 "expected soft install-hint wording: {msg}"
2997 );
2998 assert!(
2999 msg.contains("marketplace.atlassian.com"),
3000 "missing marketplace link: {msg}"
3001 );
3002 }
3003
3004 #[test]
3005 fn structure_500_with_html_strips_body() {
3006 let html = "<html><body>".to_string() + &"x".repeat(20_000) + "</body></html>";
3007 let err = structure_error_from_status(500, "text/html", html);
3008 let msg = err.to_string();
3009 assert!(
3010 !msg.contains("xxxx"),
3011 "raw HTML body leaked: {}",
3012 &msg[..msg.len().min(400)]
3013 );
3014 assert!(
3015 msg.contains("non-JSON"),
3016 "missing short status message: {msg}"
3017 );
3018 }
3019
3020 #[test]
3021 fn structure_json_error_is_forwarded_verbatim() {
3022 let body = r#"{"errorMessages":["Invalid forestVersion"],"errors":{}}"#;
3023 let err = structure_error_from_status(409, "application/json", body.into());
3024 let msg = err.to_string();
3025 assert!(
3026 msg.contains("Invalid forestVersion"),
3027 "JSON body dropped: {msg}"
3028 );
3029 }
3030
3031 #[test]
3032 fn structure_long_text_body_is_truncated() {
3033 let body = "plain text ".repeat(200); let err = structure_error_from_status(400, "text/plain", body);
3035 let msg = err.to_string();
3036 assert!(
3037 msg.contains("truncated"),
3038 "truncation marker missing: {msg}"
3039 );
3040 }
3041
3042 #[test]
3043 fn structure_html_detected_by_body_when_content_type_missing() {
3044 assert!(looks_like_html("", "<!DOCTYPE html><html>..."));
3045 assert!(looks_like_html("", "<html lang=\"en\">"));
3046 assert!(!looks_like_html("", " {\"ok\":true}"));
3047 assert!(!looks_like_html("application/json", "{\"ok\":true}"));
3048 }
3049
3050 #[test]
3051 fn structure_html_detected_by_content_type_only() {
3052 assert!(looks_like_html("text/html; charset=UTF-8", ""));
3053 assert!(looks_like_html("Text/HTML", ""));
3054 }
3055
3056 #[test]
3057 fn structure_xml_body_treated_as_non_json() {
3058 let xml = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><status><status-code>404</status-code><message>null for uri: /rest/structure/2.0/view?structureId=1</message></status>"#;
3060 assert!(looks_like_html("application/xml", xml));
3061 assert!(looks_like_html("", xml));
3062 let err = structure_error_from_status(404, "application/xml", xml.into());
3063 let msg = err.to_string();
3064 assert!(!msg.contains("<?xml"), "XML leaked into error: {msg}");
3065 assert!(
3066 msg.contains("endpoint not found"),
3067 "expected soft wording: {msg}"
3068 );
3069 }
3070
3071 #[test]
3072 fn structure_parse_preview_redacts_html_body() {
3073 let html = r#"<!DOCTYPE html><html><head><title>Login</title></head><body><form>…</form></body></html>"#;
3074 let preview = structure_parse_preview("text/html; charset=UTF-8", html);
3075 assert!(
3076 !preview.contains("<!DOCTYPE"),
3077 "HTML leaked into parse preview: {preview}"
3078 );
3079 assert!(
3080 !preview.contains("<html"),
3081 "HTML leaked into parse preview: {preview}"
3082 );
3083 assert!(
3084 preview.contains("redacted"),
3085 "expected redaction marker: {preview}"
3086 );
3087 assert!(
3088 preview.contains(&format!("{}", html.len())),
3089 "expected byte count in preview: {preview}"
3090 );
3091 }
3092
3093 #[test]
3094 fn structure_parse_preview_redacts_xml_body() {
3095 let xml = r#"<?xml version="1.0"?><status><code>200</code></status>"#;
3096 let preview = structure_parse_preview("application/xml", xml);
3097 assert!(!preview.contains("<?xml"), "XML leaked: {preview}");
3098 assert!(preview.contains("redacted"));
3099 }
3100
3101 #[test]
3102 fn structure_parse_preview_keeps_short_json_body_verbatim() {
3103 let body = r#"{"broken":"response"#; let preview = structure_parse_preview("application/json", body);
3105 assert_eq!(preview, body);
3106 }
3107
3108 #[test]
3109 fn structure_parse_preview_truncates_long_non_markup_body() {
3110 let body = "a".repeat(2000);
3111 let preview = structure_parse_preview("text/plain", &body);
3112 assert!(preview.contains("truncated"));
3113 assert!(preview.len() < body.len());
3114 }
3115
3116 #[test]
3121 fn test_flavor_detection_cloud() {
3122 assert_eq!(
3123 detect_flavor("https://company.atlassian.net"),
3124 JiraFlavor::Cloud
3125 );
3126 assert_eq!(
3127 detect_flavor("https://myorg.atlassian.net/"),
3128 JiraFlavor::Cloud
3129 );
3130 }
3131
3132 #[test]
3133 fn test_flavor_detection_self_hosted() {
3134 assert_eq!(
3135 detect_flavor("https://jira.company.com"),
3136 JiraFlavor::SelfHosted
3137 );
3138 assert_eq!(
3139 detect_flavor("https://jira.corp.internal"),
3140 JiraFlavor::SelfHosted
3141 );
3142 assert_eq!(
3143 detect_flavor("http://localhost:8080"),
3144 JiraFlavor::SelfHosted
3145 );
3146 }
3147
3148 #[test]
3153 fn test_api_url_cloud() {
3154 assert_eq!(
3155 build_api_base("https://company.atlassian.net", JiraFlavor::Cloud),
3156 "https://company.atlassian.net/rest/api/3"
3157 );
3158 }
3159
3160 #[test]
3161 fn test_api_url_self_hosted() {
3162 assert_eq!(
3163 build_api_base("https://jira.company.com", JiraFlavor::SelfHosted),
3164 "https://jira.company.com/rest/api/2"
3165 );
3166 }
3167
3168 #[test]
3169 fn test_api_url_strips_trailing_slash() {
3170 assert_eq!(
3171 build_api_base("https://company.atlassian.net/", JiraFlavor::Cloud),
3172 "https://company.atlassian.net/rest/api/3"
3173 );
3174 }
3175
3176 #[test]
3181 fn test_auth_header_cloud() {
3182 let client = JiraClient::with_base_url(
3183 "http://localhost",
3184 "PROJ",
3185 "user@example.com",
3186 token("api-token-123"),
3187 true,
3188 );
3189 let expected = base64_encode("user@example.com:api-token-123");
3191 let req = client.request(reqwest::Method::GET, "http://localhost/test");
3192 let built = req.build().unwrap();
3193 let auth = built
3194 .headers()
3195 .get("Authorization")
3196 .unwrap()
3197 .to_str()
3198 .unwrap();
3199 assert_eq!(auth, format!("Basic {}", expected));
3200 }
3201
3202 #[test]
3203 fn test_auth_header_self_hosted_bearer() {
3204 let client = JiraClient::with_base_url(
3205 "http://localhost",
3206 "PROJ",
3207 "user@example.com",
3208 token("personal-access-token"),
3209 false,
3210 );
3211 let req = client.request(reqwest::Method::GET, "http://localhost/test");
3212 let built = req.build().unwrap();
3213 let auth = built
3214 .headers()
3215 .get("Authorization")
3216 .unwrap()
3217 .to_str()
3218 .unwrap();
3219 assert_eq!(auth, "Bearer personal-access-token");
3220 }
3221
3222 #[test]
3223 fn test_auth_header_self_hosted_basic() {
3224 let client = JiraClient::with_base_url(
3225 "http://localhost",
3226 "PROJ",
3227 "user@example.com",
3228 token("user:password"),
3229 false,
3230 );
3231 let expected = base64_encode("user:password");
3232 let req = client.request(reqwest::Method::GET, "http://localhost/test");
3233 let built = req.build().unwrap();
3234 let auth = built
3235 .headers()
3236 .get("Authorization")
3237 .unwrap()
3238 .to_str()
3239 .unwrap();
3240 assert_eq!(auth, format!("Basic {}", expected));
3241 }
3242
3243 #[test]
3248 fn test_base64_encode() {
3249 assert_eq!(base64_encode("hello"), "aGVsbG8=");
3250 assert_eq!(base64_encode("user:pass"), "dXNlcjpwYXNz");
3251 assert_eq!(base64_encode(""), "");
3252 assert_eq!(base64_encode("a"), "YQ==");
3253 assert_eq!(base64_encode("ab"), "YWI=");
3254 assert_eq!(base64_encode("abc"), "YWJj");
3255 }
3256
3257 #[test]
3262 fn test_text_to_adf_simple() {
3263 let adf = text_to_adf("Hello world");
3264 assert_eq!(adf["type"], "doc");
3265 assert_eq!(adf["version"], 1);
3266 let content = adf["content"].as_array().unwrap();
3267 assert_eq!(content.len(), 1);
3268 assert_eq!(content[0]["type"], "paragraph");
3269 let inline = content[0]["content"].as_array().unwrap();
3270 assert_eq!(inline.len(), 1);
3271 assert_eq!(inline[0]["text"], "Hello world");
3272 }
3273
3274 #[test]
3275 fn test_text_to_adf_multi_paragraph() {
3276 let adf = text_to_adf("First paragraph\n\nSecond paragraph");
3277 let content = adf["content"].as_array().unwrap();
3278 assert_eq!(content.len(), 2);
3279 assert_eq!(content[0]["content"][0]["text"], "First paragraph");
3280 assert_eq!(content[1]["content"][0]["text"], "Second paragraph");
3281 }
3282
3283 #[test]
3284 fn test_text_to_adf_with_line_breaks() {
3285 let adf = text_to_adf("Line 1\nLine 2\nLine 3");
3286 let content = adf["content"].as_array().unwrap();
3287 assert_eq!(content.len(), 1);
3288 let inline = content[0]["content"].as_array().unwrap();
3289 assert_eq!(inline.len(), 5);
3291 assert_eq!(inline[0]["text"], "Line 1");
3292 assert_eq!(inline[1]["type"], "hardBreak");
3293 assert_eq!(inline[2]["text"], "Line 2");
3294 assert_eq!(inline[3]["type"], "hardBreak");
3295 assert_eq!(inline[4]["text"], "Line 3");
3296 }
3297
3298 #[test]
3299 fn test_text_to_adf_empty() {
3300 let adf = text_to_adf("");
3301 assert_eq!(adf["type"], "doc");
3302 let content = adf["content"].as_array().unwrap();
3303 assert_eq!(content.len(), 1);
3304 assert_eq!(content[0]["type"], "paragraph");
3305 assert!(content[0]["content"].as_array().unwrap().is_empty());
3306 }
3307
3308 #[test]
3309 fn test_adf_to_text_simple() {
3310 let adf = serde_json::json!({
3311 "version": 1,
3312 "type": "doc",
3313 "content": [{
3314 "type": "paragraph",
3315 "content": [{
3316 "type": "text",
3317 "text": "Hello world"
3318 }]
3319 }]
3320 });
3321 assert_eq!(adf_to_text(&adf), "Hello world");
3322 }
3323
3324 #[test]
3325 fn test_adf_to_text_multi() {
3326 let adf = serde_json::json!({
3327 "version": 1,
3328 "type": "doc",
3329 "content": [
3330 {
3331 "type": "paragraph",
3332 "content": [{
3333 "type": "text",
3334 "text": "First"
3335 }]
3336 },
3337 {
3338 "type": "paragraph",
3339 "content": [{
3340 "type": "text",
3341 "text": "Second"
3342 }]
3343 }
3344 ]
3345 });
3346 assert_eq!(adf_to_text(&adf), "First\n\nSecond");
3347 }
3348
3349 #[test]
3350 fn test_adf_to_text_with_hardbreak() {
3351 let adf = serde_json::json!({
3352 "version": 1,
3353 "type": "doc",
3354 "content": [{
3355 "type": "paragraph",
3356 "content": [
3357 {"type": "text", "text": "Line 1"},
3358 {"type": "hardBreak"},
3359 {"type": "text", "text": "Line 2"}
3360 ]
3361 }]
3362 });
3363 assert_eq!(adf_to_text(&adf), "Line 1\nLine 2");
3364 }
3365
3366 #[test]
3367 fn test_adf_to_text_empty() {
3368 let adf = serde_json::json!({
3369 "version": 1,
3370 "type": "doc",
3371 "content": []
3372 });
3373 assert_eq!(adf_to_text(&adf), "");
3374 }
3375
3376 #[test]
3377 fn test_adf_to_text_non_adf_string() {
3378 let value = serde_json::Value::String("plain text".to_string());
3379 assert_eq!(adf_to_text(&value), "plain text");
3380 }
3381
3382 #[test]
3383 fn test_adf_to_text_null() {
3384 assert_eq!(adf_to_text(&serde_json::Value::Null), "");
3385 }
3386
3387 fn sample_jira_user_cloud() -> JiraUser {
3392 JiraUser {
3393 account_id: Some("5b10a2844c20165700ede21g".to_string()),
3394 name: None,
3395 display_name: Some("John Doe".to_string()),
3396 email_address: Some("john@example.com".to_string()),
3397 }
3398 }
3399
3400 fn sample_jira_user_self_hosted() -> JiraUser {
3401 JiraUser {
3402 account_id: None,
3403 name: Some("jdoe".to_string()),
3404 display_name: Some("John Doe".to_string()),
3405 email_address: Some("john@example.com".to_string()),
3406 }
3407 }
3408
3409 #[test]
3410 fn test_map_user_cloud() {
3411 let user = map_user(Some(&sample_jira_user_cloud())).unwrap();
3412 assert_eq!(user.id, "5b10a2844c20165700ede21g");
3413 assert_eq!(user.username, "5b10a2844c20165700ede21g");
3414 assert_eq!(user.name, Some("John Doe".to_string()));
3415 assert_eq!(user.email, Some("john@example.com".to_string()));
3416 }
3417
3418 #[test]
3419 fn test_map_user_self_hosted() {
3420 let user = map_user(Some(&sample_jira_user_self_hosted())).unwrap();
3421 assert_eq!(user.id, "jdoe");
3422 assert_eq!(user.username, "jdoe");
3423 assert_eq!(user.name, Some("John Doe".to_string()));
3424 }
3425
3426 #[test]
3427 fn test_map_user_none() {
3428 assert!(map_user(None).is_none());
3429 }
3430
3431 #[test]
3432 fn test_map_priority() {
3433 let make_priority = |name: &str| JiraPriority {
3434 name: name.to_string(),
3435 };
3436
3437 assert_eq!(
3438 map_priority(Some(&make_priority("Highest"))),
3439 Some("urgent".to_string())
3440 );
3441 assert_eq!(
3442 map_priority(Some(&make_priority("High"))),
3443 Some("high".to_string())
3444 );
3445 assert_eq!(
3446 map_priority(Some(&make_priority("Medium"))),
3447 Some("normal".to_string())
3448 );
3449 assert_eq!(
3450 map_priority(Some(&make_priority("Low"))),
3451 Some("low".to_string())
3452 );
3453 assert_eq!(
3454 map_priority(Some(&make_priority("Lowest"))),
3455 Some("low".to_string())
3456 );
3457 assert_eq!(
3458 map_priority(Some(&make_priority("Blocker"))),
3459 Some("urgent".to_string())
3460 );
3461 assert_eq!(map_priority(None), None);
3462 }
3463
3464 #[test]
3465 fn test_map_issue() {
3466 let issue = JiraIssue {
3467 id: "10001".to_string(),
3468 key: "PROJ-123".to_string(),
3469 fields: JiraIssueFields {
3470 summary: Some("Fix login bug".to_string()),
3471 description: Some(serde_json::Value::String(
3472 "Login fails on mobile".to_string(),
3473 )),
3474 status: Some(JiraStatus {
3475 name: "In Progress".to_string(),
3476 status_category: None,
3477 }),
3478 priority: Some(JiraPriority {
3479 name: "High".to_string(),
3480 }),
3481 assignee: Some(sample_jira_user_self_hosted()),
3482 reporter: Some(JiraUser {
3483 account_id: None,
3484 name: Some("reporter".to_string()),
3485 display_name: Some("Reporter".to_string()),
3486 email_address: None,
3487 }),
3488 labels: vec!["bug".to_string(), "mobile".to_string()],
3489 created: Some("2024-01-01T10:00:00.000+0000".to_string()),
3490 updated: Some("2024-01-02T15:30:00.000+0000".to_string()),
3491 parent: None,
3492 subtasks: vec![],
3493 issuelinks: vec![],
3494 attachment: vec![],
3495 },
3496 };
3497
3498 let mapped = map_issue(&issue, JiraFlavor::SelfHosted, "https://jira.example.com");
3499 assert_eq!(mapped.key, "jira#PROJ-123");
3500 assert_eq!(mapped.title, "Fix login bug");
3501 assert_eq!(
3502 mapped.description,
3503 Some("Login fails on mobile".to_string())
3504 );
3505 assert_eq!(mapped.state, "In Progress");
3506 assert_eq!(mapped.source, "jira");
3507 assert_eq!(mapped.priority, Some("high".to_string()));
3508 assert_eq!(mapped.labels, vec!["bug", "mobile"]);
3509 assert_eq!(mapped.assignees.len(), 1);
3510 assert_eq!(mapped.assignees[0].username, "jdoe");
3511 assert!(mapped.author.is_some());
3512 assert_eq!(mapped.author.unwrap().username, "reporter");
3513 assert_eq!(
3514 mapped.url,
3515 Some("https://jira.example.com/browse/PROJ-123".to_string())
3516 );
3517 assert_eq!(
3518 mapped.created_at,
3519 Some("2024-01-01T10:00:00.000+0000".to_string())
3520 );
3521 }
3522
3523 #[test]
3524 fn test_map_issue_cloud_adf_description() {
3525 let adf_desc = serde_json::json!({
3526 "version": 1,
3527 "type": "doc",
3528 "content": [{
3529 "type": "paragraph",
3530 "content": [{
3531 "type": "text",
3532 "text": "ADF description"
3533 }]
3534 }]
3535 });
3536
3537 let issue = JiraIssue {
3538 id: "10001".to_string(),
3539 key: "PROJ-1".to_string(),
3540 fields: JiraIssueFields {
3541 summary: Some("Test".to_string()),
3542 description: Some(adf_desc),
3543 status: None,
3544 priority: None,
3545 assignee: None,
3546 reporter: None,
3547 labels: vec![],
3548 created: None,
3549 updated: None,
3550 parent: None,
3551 subtasks: vec![],
3552 issuelinks: vec![],
3553 attachment: vec![],
3554 },
3555 };
3556
3557 let mapped = map_issue(&issue, JiraFlavor::Cloud, "https://test.atlassian.net");
3558 assert_eq!(mapped.description, Some("ADF description".to_string()));
3559 }
3560
3561 #[test]
3562 fn test_map_issue_self_hosted_plain_description() {
3563 let issue = JiraIssue {
3564 id: "10001".to_string(),
3565 key: "PROJ-1".to_string(),
3566 fields: JiraIssueFields {
3567 summary: Some("Test".to_string()),
3568 description: Some(serde_json::Value::String("Plain text desc".to_string())),
3569 status: None,
3570 priority: None,
3571 assignee: None,
3572 reporter: None,
3573 labels: vec![],
3574 created: None,
3575 updated: None,
3576 parent: None,
3577 subtasks: vec![],
3578 issuelinks: vec![],
3579 attachment: vec![],
3580 },
3581 };
3582
3583 let mapped = map_issue(&issue, JiraFlavor::SelfHosted, "https://jira.example.com");
3584 assert_eq!(mapped.description, Some("Plain text desc".to_string()));
3585 }
3586
3587 #[test]
3588 fn test_map_comment() {
3589 let comment = JiraComment {
3590 id: "100".to_string(),
3591 body: Some(serde_json::Value::String("Nice work!".to_string())),
3592 author: Some(sample_jira_user_self_hosted()),
3593 created: Some("2024-01-01T10:00:00.000+0000".to_string()),
3594 updated: Some("2024-01-01T11:00:00.000+0000".to_string()),
3595 };
3596
3597 let mapped = map_comment(&comment, JiraFlavor::SelfHosted);
3598 assert_eq!(mapped.id, "100");
3599 assert_eq!(mapped.body, "Nice work!");
3600 assert!(mapped.author.is_some());
3601 assert_eq!(mapped.author.unwrap().username, "jdoe");
3602 }
3603
3604 #[test]
3605 fn test_map_comment_cloud_adf() {
3606 let adf_body = serde_json::json!({
3607 "version": 1,
3608 "type": "doc",
3609 "content": [{
3610 "type": "paragraph",
3611 "content": [{
3612 "type": "text",
3613 "text": "ADF comment"
3614 }]
3615 }]
3616 });
3617
3618 let comment = JiraComment {
3619 id: "200".to_string(),
3620 body: Some(adf_body),
3621 author: None,
3622 created: None,
3623 updated: None,
3624 };
3625
3626 let mapped = map_comment(&comment, JiraFlavor::Cloud);
3627 assert_eq!(mapped.body, "ADF comment");
3628 }
3629
3630 #[test]
3635 fn test_provider_name() {
3636 let client = JiraClient::with_base_url(
3637 "http://localhost",
3638 "PROJ",
3639 "user@example.com",
3640 token("token"),
3641 false,
3642 );
3643 assert_eq!(IssueProvider::provider_name(&client), "jira");
3644 assert_eq!(MergeRequestProvider::provider_name(&client), "jira");
3645 }
3646
3647 #[test]
3652 fn test_generic_status_to_category() {
3653 assert_eq!(generic_status_to_category("closed"), Some("done"));
3655 assert_eq!(generic_status_to_category("done"), Some("done"));
3656 assert_eq!(generic_status_to_category("resolved"), Some("done"));
3657 assert_eq!(generic_status_to_category("canceled"), Some("done"));
3658 assert_eq!(generic_status_to_category("cancelled"), Some("done"));
3659 assert_eq!(generic_status_to_category("CLOSED"), Some("done"));
3660
3661 assert_eq!(generic_status_to_category("open"), Some("new"));
3663 assert_eq!(generic_status_to_category("new"), Some("new"));
3664 assert_eq!(generic_status_to_category("todo"), Some("new"));
3665 assert_eq!(generic_status_to_category("to do"), Some("new"));
3666 assert_eq!(generic_status_to_category("reopen"), Some("new"));
3667 assert_eq!(generic_status_to_category("reopened"), Some("new"));
3668
3669 assert_eq!(
3671 generic_status_to_category("in_progress"),
3672 Some("indeterminate")
3673 );
3674 assert_eq!(
3675 generic_status_to_category("in progress"),
3676 Some("indeterminate")
3677 );
3678 assert_eq!(
3679 generic_status_to_category("in-progress"),
3680 Some("indeterminate")
3681 );
3682
3683 assert_eq!(generic_status_to_category("custom status"), None);
3685 assert_eq!(generic_status_to_category("review"), None);
3686 }
3687
3688 #[test]
3689 fn test_priority_to_jira() {
3690 assert_eq!(priority_to_jira("urgent"), "Highest");
3691 assert_eq!(priority_to_jira("high"), "High");
3692 assert_eq!(priority_to_jira("normal"), "Medium");
3693 assert_eq!(priority_to_jira("low"), "Low");
3694 assert_eq!(priority_to_jira("custom"), "custom");
3695 }
3696
3697 #[test]
3702 fn test_instance_url_from_base() {
3703 assert_eq!(
3704 instance_url_from_base("https://company.atlassian.net/rest/api/3"),
3705 "https://company.atlassian.net"
3706 );
3707 assert_eq!(
3708 instance_url_from_base("https://jira.corp.com/rest/api/2"),
3709 "https://jira.corp.com"
3710 );
3711 assert_eq!(
3712 instance_url_from_base("http://localhost:8080"),
3713 "http://localhost:8080"
3714 );
3715 }
3716
3717 mod integration {
3722 use super::*;
3723 use httpmock::prelude::*;
3724
3725 fn token(s: &str) -> SecretString {
3726 SecretString::from(s.to_string())
3727 }
3728
3729 fn create_self_hosted_client(server: &MockServer) -> JiraClient {
3730 JiraClient::with_base_url(
3731 server.base_url(),
3732 "PROJ",
3733 "user@example.com",
3734 token("pat-token"),
3735 false,
3736 )
3737 }
3738
3739 fn create_cloud_client(server: &MockServer) -> JiraClient {
3740 JiraClient::with_base_url(
3741 server.base_url(),
3742 "PROJ",
3743 "user@example.com",
3744 token("api-token"),
3745 true,
3746 )
3747 }
3748
3749 fn sample_issue_json() -> serde_json::Value {
3750 serde_json::json!({
3751 "id": "10001",
3752 "key": "PROJ-1",
3753 "fields": {
3754 "summary": "Fix login bug",
3755 "description": "Login fails on mobile",
3756 "status": {"name": "Open"},
3757 "priority": {"name": "High"},
3758 "assignee": {
3759 "name": "jdoe",
3760 "displayName": "John Doe",
3761 "emailAddress": "john@example.com"
3762 },
3763 "reporter": {
3764 "name": "reporter",
3765 "displayName": "Reporter"
3766 },
3767 "labels": ["bug"],
3768 "created": "2024-01-01T10:00:00.000+0000",
3769 "updated": "2024-01-02T15:30:00.000+0000"
3770 }
3771 })
3772 }
3773
3774 fn sample_cloud_issue_json() -> serde_json::Value {
3775 serde_json::json!({
3776 "id": "10001",
3777 "key": "PROJ-1",
3778 "fields": {
3779 "summary": "Fix login bug",
3780 "description": {
3781 "version": 1,
3782 "type": "doc",
3783 "content": [{
3784 "type": "paragraph",
3785 "content": [{
3786 "type": "text",
3787 "text": "Login fails on mobile"
3788 }]
3789 }]
3790 },
3791 "status": {"name": "Open"},
3792 "priority": {"name": "High"},
3793 "assignee": {
3794 "accountId": "5b10a2844c20165700ede21g",
3795 "displayName": "John Doe",
3796 "emailAddress": "john@example.com"
3797 },
3798 "reporter": {
3799 "accountId": "5b10a284reporter",
3800 "displayName": "Reporter"
3801 },
3802 "labels": ["bug"],
3803 "created": "2024-01-01T10:00:00.000+0000",
3804 "updated": "2024-01-02T15:30:00.000+0000"
3805 }
3806 })
3807 }
3808
3809 #[tokio::test]
3814 async fn test_get_issues() {
3815 let server = MockServer::start();
3816
3817 server.mock(|when, then| {
3818 when.method(GET).path("/search").query_param_exists("jql");
3819 then.status(200).json_body(serde_json::json!({
3820 "issues": [sample_issue_json()],
3821 "startAt": 0,
3822 "maxResults": 20,
3823 "total": 1
3824 }));
3825 });
3826
3827 let client = create_self_hosted_client(&server);
3828 let issues = client
3829 .get_issues(IssueFilter::default())
3830 .await
3831 .unwrap()
3832 .items;
3833
3834 assert_eq!(issues.len(), 1);
3835 assert_eq!(issues[0].key, "jira#PROJ-1");
3836 assert_eq!(issues[0].title, "Fix login bug");
3837 assert_eq!(issues[0].source, "jira");
3838 assert_eq!(issues[0].priority, Some("high".to_string()));
3839 assert_eq!(
3840 issues[0].description,
3841 Some("Login fails on mobile".to_string())
3842 );
3843 }
3844
3845 #[tokio::test]
3846 async fn test_get_issues_with_filters() {
3847 let server = MockServer::start();
3848
3849 server.mock(|when, then| {
3850 when.method(GET)
3851 .path("/search")
3852 .query_param_includes("jql", "labels = \"bug\"")
3853 .query_param_includes("jql", "assignee = \"jdoe\"");
3854 then.status(200).json_body(serde_json::json!({
3855 "issues": [sample_issue_json()],
3856 "startAt": 0,
3857 "maxResults": 20,
3858 "total": 1
3859 }));
3860 });
3861
3862 let client = create_self_hosted_client(&server);
3863 let issues = client
3864 .get_issues(IssueFilter {
3865 labels: Some(vec!["bug".to_string()]),
3866 assignee: Some("jdoe".to_string()),
3867 ..Default::default()
3868 })
3869 .await
3870 .unwrap()
3871 .items;
3872
3873 assert_eq!(issues.len(), 1);
3874 }
3875
3876 #[tokio::test]
3877 async fn test_get_issues_pagination() {
3878 let server = MockServer::start();
3879
3880 server.mock(|when, then| {
3881 when.method(GET)
3882 .path("/search")
3883 .query_param("startAt", "5")
3884 .query_param("maxResults", "10");
3885 then.status(200).json_body(serde_json::json!({
3886 "issues": [sample_issue_json()],
3887 "startAt": 5,
3888 "maxResults": 10,
3889 "total": 20
3890 }));
3891 });
3892
3893 let client = create_self_hosted_client(&server);
3894 let issues = client
3895 .get_issues(IssueFilter {
3896 offset: Some(5),
3897 limit: Some(10),
3898 ..Default::default()
3899 })
3900 .await
3901 .unwrap()
3902 .items;
3903
3904 assert_eq!(issues.len(), 1);
3905 }
3906
3907 #[tokio::test]
3908 async fn test_get_issues_project_key_override() {
3909 let server = MockServer::start();
3910
3911 server.mock(|when, then| {
3912 when.method(GET)
3913 .path("/search")
3914 .query_param_includes("jql", "project = \"OTHER\"");
3915 then.status(200).json_body(serde_json::json!({
3916 "issues": [sample_issue_json()],
3917 "startAt": 0,
3918 "maxResults": 20,
3919 "total": 1
3920 }));
3921 });
3922
3923 let client = create_self_hosted_client(&server);
3924 let issues = client
3925 .get_issues(IssueFilter {
3926 project_key: Some("OTHER".to_string()),
3927 ..Default::default()
3928 })
3929 .await
3930 .unwrap()
3931 .items;
3932
3933 assert_eq!(issues.len(), 1);
3934 }
3935
3936 #[tokio::test]
3937 async fn test_get_issues_native_query_passthrough() {
3938 let server = MockServer::start();
3939
3940 server.mock(|when, then| {
3941 when.method(GET)
3942 .path("/search")
3943 .query_param_includes("jql", "project = \"CUSTOM\" AND fixVersion = \"1.0\"");
3944 then.status(200).json_body(serde_json::json!({
3945 "issues": [sample_issue_json()],
3946 "startAt": 0,
3947 "maxResults": 20,
3948 "total": 1
3949 }));
3950 });
3951
3952 let client = create_self_hosted_client(&server);
3953 let issues = client
3954 .get_issues(IssueFilter {
3955 native_query: Some("project = \"CUSTOM\" AND fixVersion = \"1.0\"".to_string()),
3956 ..Default::default()
3957 })
3958 .await
3959 .unwrap()
3960 .items;
3961
3962 assert_eq!(issues.len(), 1);
3963 }
3964
3965 #[tokio::test]
3966 async fn test_get_issues_native_query_auto_injects_project() {
3967 let server = MockServer::start();
3968
3969 server.mock(|when, then| {
3972 when.method(GET)
3973 .path("/search")
3974 .query_param_includes("jql", "project = \"PROJ\" AND fixVersion = \"2.0\"");
3975 then.status(200).json_body(serde_json::json!({
3976 "issues": [sample_issue_json()],
3977 "startAt": 0,
3978 "maxResults": 20,
3979 "total": 1
3980 }));
3981 });
3982
3983 let client = create_self_hosted_client(&server);
3984 let issues = client
3985 .get_issues(IssueFilter {
3986 native_query: Some("fixVersion = \"2.0\"".to_string()),
3987 ..Default::default()
3988 })
3989 .await
3990 .unwrap()
3991 .items;
3992
3993 assert_eq!(issues.len(), 1);
3994 }
3995
3996 #[tokio::test]
3997 async fn test_get_issues_native_query_with_project_in() {
3998 let server = MockServer::start();
3999
4000 server.mock(|when, then| {
4002 when.method(GET)
4003 .path("/search")
4004 .query_param_includes("jql", "project IN (\"A\", \"B\") AND status = \"Open\"");
4005 then.status(200).json_body(serde_json::json!({
4006 "issues": [sample_issue_json()],
4007 "startAt": 0,
4008 "maxResults": 20,
4009 "total": 1
4010 }));
4011 });
4012
4013 let client = create_self_hosted_client(&server);
4014 let issues = client
4015 .get_issues(IssueFilter {
4016 native_query: Some(
4017 "project IN (\"A\", \"B\") AND status = \"Open\"".to_string(),
4018 ),
4019 ..Default::default()
4020 })
4021 .await
4022 .unwrap()
4023 .items;
4024
4025 assert_eq!(issues.len(), 1);
4026 }
4027
4028 #[tokio::test]
4029 async fn test_get_issues_project_key_with_native_query() {
4030 let server = MockServer::start();
4031
4032 server.mock(|when, then| {
4035 when.method(GET)
4036 .path("/search")
4037 .query_param_includes("jql", "project = \"OVERRIDE\" AND sprint = 42");
4038 then.status(200).json_body(serde_json::json!({
4039 "issues": [sample_issue_json()],
4040 "startAt": 0,
4041 "maxResults": 20,
4042 "total": 1
4043 }));
4044 });
4045
4046 let client = create_self_hosted_client(&server); let issues = client
4048 .get_issues(IssueFilter {
4049 project_key: Some("OVERRIDE".to_string()),
4050 native_query: Some("sprint = 42".to_string()),
4051 ..Default::default()
4052 })
4053 .await
4054 .unwrap()
4055 .items;
4056
4057 assert_eq!(issues.len(), 1);
4058 }
4059
4060 #[tokio::test]
4061 async fn test_get_issues_empty_native_query_falls_back() {
4062 let server = MockServer::start();
4063
4064 server.mock(|when, then| {
4066 when.method(GET)
4067 .path("/search")
4068 .query_param_includes("jql", "project = \"PROJ\"");
4069 then.status(200).json_body(serde_json::json!({
4070 "issues": [sample_issue_json()],
4071 "startAt": 0,
4072 "maxResults": 20,
4073 "total": 1
4074 }));
4075 });
4076
4077 let client = create_self_hosted_client(&server);
4078 let issues = client
4079 .get_issues(IssueFilter {
4080 native_query: Some("".to_string()),
4081 ..Default::default()
4082 })
4083 .await
4084 .unwrap()
4085 .items;
4086
4087 assert_eq!(issues.len(), 1);
4088 }
4089
4090 #[tokio::test]
4091 async fn test_get_issues_native_query_order_by_only() {
4092 let server = MockServer::start();
4093
4094 server.mock(|when, then| {
4097 when.method(GET)
4098 .path("/search")
4099 .query_param_includes("jql", "project = \"PROJ\" ORDER BY created ASC");
4100 then.status(200).json_body(serde_json::json!({
4101 "issues": [sample_issue_json()],
4102 "startAt": 0,
4103 "maxResults": 20,
4104 "total": 1
4105 }));
4106 });
4107
4108 let client = create_self_hosted_client(&server);
4109 let issues = client
4110 .get_issues(IssueFilter {
4111 native_query: Some("ORDER BY created ASC".to_string()),
4112 ..Default::default()
4113 })
4114 .await
4115 .unwrap()
4116 .items;
4117
4118 assert_eq!(issues.len(), 1);
4119 }
4120
4121 #[tokio::test]
4122 async fn test_get_issue() {
4123 let server = MockServer::start();
4124
4125 server.mock(|when, then| {
4126 when.method(GET).path("/issue/PROJ-1");
4127 then.status(200).json_body(sample_issue_json());
4128 });
4129
4130 let client = create_self_hosted_client(&server);
4131 let issue = client.get_issue("jira#PROJ-1").await.unwrap();
4132
4133 assert_eq!(issue.key, "jira#PROJ-1");
4134 assert_eq!(issue.title, "Fix login bug");
4135 }
4136
4137 #[tokio::test]
4138 async fn test_create_issue() {
4139 let server = MockServer::start();
4140
4141 server.mock(|when, then| {
4142 when.method(POST)
4143 .path("/issue")
4144 .body_includes("\"summary\":\"New task\"");
4145 then.status(201).json_body(serde_json::json!({
4146 "id": "10002",
4147 "key": "PROJ-2"
4148 }));
4149 });
4150
4151 server.mock(|when, then| {
4152 when.method(GET).path("/issue/PROJ-2");
4153 then.status(200).json_body(serde_json::json!({
4154 "id": "10002",
4155 "key": "PROJ-2",
4156 "fields": {
4157 "summary": "New task",
4158 "status": {"name": "Open"},
4159 "labels": [],
4160 "created": "2024-01-03T10:00:00.000+0000"
4161 }
4162 }));
4163 });
4164
4165 let client = create_self_hosted_client(&server);
4166 let issue = client
4167 .create_issue(CreateIssueInput {
4168 title: "New task".to_string(),
4169 description: Some("Task description".to_string()),
4170 ..Default::default()
4171 })
4172 .await
4173 .unwrap();
4174
4175 assert_eq!(issue.key, "jira#PROJ-2");
4176 assert_eq!(issue.title, "New task");
4177 }
4178
4179 #[tokio::test]
4180 async fn test_create_issue_with_project_id_override() {
4181 let server = MockServer::start();
4182
4183 server.mock(|when, then| {
4185 when.method(POST)
4186 .path("/issue")
4187 .body_includes("\"key\":\"OTHER\"");
4188 then.status(201).json_body(serde_json::json!({
4189 "id": "10003",
4190 "key": "OTHER-1"
4191 }));
4192 });
4193
4194 server.mock(|when, then| {
4195 when.method(GET).path("/issue/OTHER-1");
4196 then.status(200).json_body(serde_json::json!({
4197 "id": "10003",
4198 "key": "OTHER-1",
4199 "fields": {
4200 "summary": "Task in other project",
4201 "status": {"name": "Open"},
4202 "labels": [],
4203 "created": "2024-01-03T10:00:00.000+0000"
4204 }
4205 }));
4206 });
4207
4208 let client = create_self_hosted_client(&server); let issue = client
4210 .create_issue(CreateIssueInput {
4211 title: "Task in other project".to_string(),
4212 project_id: Some("OTHER".to_string()),
4213 ..Default::default()
4214 })
4215 .await
4216 .unwrap();
4217
4218 assert_eq!(issue.key, "jira#OTHER-1");
4219 }
4220
4221 #[tokio::test]
4222 async fn test_create_issue_with_issue_type() {
4223 let server = MockServer::start();
4224
4225 server.mock(|when, then| {
4227 when.method(POST)
4228 .path("/issue")
4229 .body_includes("\"name\":\"Bug\"");
4230 then.status(201).json_body(serde_json::json!({
4231 "id": "10004",
4232 "key": "PROJ-3"
4233 }));
4234 });
4235
4236 server.mock(|when, then| {
4237 when.method(GET).path("/issue/PROJ-3");
4238 then.status(200).json_body(serde_json::json!({
4239 "id": "10004",
4240 "key": "PROJ-3",
4241 "fields": {
4242 "summary": "Bug report",
4243 "status": {"name": "Open"},
4244 "labels": [],
4245 "created": "2024-01-03T10:00:00.000+0000"
4246 }
4247 }));
4248 });
4249
4250 let client = create_self_hosted_client(&server);
4251 let issue = client
4252 .create_issue(CreateIssueInput {
4253 title: "Bug report".to_string(),
4254 issue_type: Some("Bug".to_string()),
4255 ..Default::default()
4256 })
4257 .await
4258 .unwrap();
4259
4260 assert_eq!(issue.key, "jira#PROJ-3");
4261 }
4262
4263 #[tokio::test]
4264 async fn test_create_issue_with_custom_fields() {
4265 let server = MockServer::start();
4266
4267 server.mock(|when, then| {
4269 when.method(POST)
4270 .path("/issue")
4271 .body_includes("\"customfield_10001\":8")
4272 .body_includes("\"customfield_10002\":\"goal-a\"");
4273 then.status(201).json_body(serde_json::json!({
4274 "id": "10005",
4275 "key": "PROJ-5"
4276 }));
4277 });
4278
4279 server.mock(|when, then| {
4280 when.method(GET).path("/issue/PROJ-5");
4281 then.status(200).json_body(serde_json::json!({
4282 "id": "10005",
4283 "key": "PROJ-5",
4284 "fields": {
4285 "summary": "With custom fields",
4286 "status": {"name": "Open"},
4287 "labels": [],
4288 "created": "2024-01-03T10:00:00.000+0000"
4289 }
4290 }));
4291 });
4292
4293 let client = create_self_hosted_client(&server);
4294 let issue = client
4295 .create_issue(CreateIssueInput {
4296 title: "With custom fields".to_string(),
4297 custom_fields: Some(serde_json::json!({
4298 "customfield_10001": 8,
4299 "customfield_10002": "goal-a"
4300 })),
4301 ..Default::default()
4302 })
4303 .await
4304 .unwrap();
4305
4306 assert_eq!(issue.key, "jira#PROJ-5");
4307 }
4308
4309 #[tokio::test]
4310 async fn test_update_issue_with_custom_fields() {
4311 let server = MockServer::start();
4312
4313 server.mock(|when, then| {
4315 when.method(PUT)
4316 .path("/issue/PROJ-1")
4317 .body_includes("\"customfield_10001\":5");
4318 then.status(204);
4319 });
4320
4321 server.mock(|when, then| {
4322 when.method(GET).path("/issue/PROJ-1");
4323 then.status(200).json_body(serde_json::json!({
4324 "id": "10001",
4325 "key": "PROJ-1",
4326 "fields": {
4327 "summary": "Fix login bug",
4328 "status": {"name": "Open"},
4329 "labels": [],
4330 "created": "2024-01-01T10:00:00.000+0000"
4331 }
4332 }));
4333 });
4334
4335 let client = create_self_hosted_client(&server);
4336 let issue = client
4337 .update_issue(
4338 "PROJ-1",
4339 UpdateIssueInput {
4340 custom_fields: Some(serde_json::json!({
4341 "customfield_10001": 5
4342 })),
4343 ..Default::default()
4344 },
4345 )
4346 .await
4347 .unwrap();
4348
4349 assert_eq!(issue.key, "jira#PROJ-1");
4350 }
4351
4352 #[tokio::test]
4354 async fn test_create_issue_with_components() {
4355 let server = MockServer::start();
4356
4357 server.mock(|when, then| {
4358 when.method(POST).path("/issue").body_includes(
4359 "\"components\":[{\"name\":\"Backend\"},{\"name\":\"Frontend\"}]",
4360 );
4361 then.status(201).json_body(serde_json::json!({
4362 "id": "10010",
4363 "key": "PROJ-10"
4364 }));
4365 });
4366
4367 server.mock(|when, then| {
4368 when.method(GET).path("/issue/PROJ-10");
4369 then.status(200).json_body(serde_json::json!({
4370 "id": "10010",
4371 "key": "PROJ-10",
4372 "fields": {
4373 "summary": "With components",
4374 "status": {"name": "Open"},
4375 "labels": [],
4376 "created": "2024-01-05T10:00:00.000+0000"
4377 }
4378 }));
4379 });
4380
4381 let client = create_self_hosted_client(&server);
4382 let issue = client
4383 .create_issue(CreateIssueInput {
4384 title: "With components".to_string(),
4385 components: vec!["Backend".to_string(), "Frontend".to_string()],
4386 ..Default::default()
4387 })
4388 .await
4389 .unwrap();
4390
4391 assert_eq!(issue.key, "jira#PROJ-10");
4392 }
4393
4394 #[tokio::test]
4397 async fn test_create_issue_without_components_omits_field() {
4398 let server = MockServer::start();
4399
4400 server.mock(|when, then| {
4401 when.method(POST).path("/issue").is_true(|req| {
4402 let body = String::from_utf8_lossy(req.body().as_ref());
4403 !body.contains("\"components\"")
4404 });
4405 then.status(201).json_body(serde_json::json!({
4406 "id": "10011",
4407 "key": "PROJ-11"
4408 }));
4409 });
4410
4411 server.mock(|when, then| {
4412 when.method(GET).path("/issue/PROJ-11");
4413 then.status(200).json_body(serde_json::json!({
4414 "id": "10011",
4415 "key": "PROJ-11",
4416 "fields": {
4417 "summary": "No components",
4418 "status": {"name": "Open"},
4419 "labels": [],
4420 "created": "2024-01-05T10:00:00.000+0000"
4421 }
4422 }));
4423 });
4424
4425 let client = create_self_hosted_client(&server);
4426 let issue = client
4427 .create_issue(CreateIssueInput {
4428 title: "No components".to_string(),
4429 components: vec![],
4430 ..Default::default()
4431 })
4432 .await
4433 .unwrap();
4434
4435 assert_eq!(issue.key, "jira#PROJ-11");
4436 }
4437
4438 #[tokio::test]
4442 async fn test_create_issue_subtask_includes_parent_in_payload() {
4443 let server = MockServer::start();
4444
4445 server.mock(|when, then| {
4446 when.method(POST).path("/issue").is_true(|req| {
4447 let body = String::from_utf8_lossy(req.body().as_ref());
4448 body.contains("\"parent\":{\"key\":\"PROJ-1\"}")
4449 && body.contains("\"name\":\"Sub-task\"")
4450 });
4451 then.status(201).json_body(serde_json::json!({
4452 "id": "10010",
4453 "key": "PROJ-10"
4454 }));
4455 });
4456
4457 server.mock(|when, then| {
4458 when.method(GET).path("/issue/PROJ-10");
4459 then.status(200).json_body(serde_json::json!({
4460 "id": "10010",
4461 "key": "PROJ-10",
4462 "fields": {
4463 "summary": "Sub task work",
4464 "status": {"name": "Open"},
4465 "labels": [],
4466 "created": "2024-01-06T10:00:00.000+0000"
4467 }
4468 }));
4469 });
4470
4471 let client = create_self_hosted_client(&server);
4472 let issue = client
4473 .create_issue(CreateIssueInput {
4474 title: "Sub task work".to_string(),
4475 issue_type: Some("Sub-task".to_string()),
4476 parent: Some("PROJ-1".to_string()),
4477 ..Default::default()
4478 })
4479 .await
4480 .unwrap();
4481
4482 assert_eq!(issue.key, "jira#PROJ-10");
4483 }
4484
4485 #[tokio::test]
4489 async fn test_create_issue_without_parent_omits_field() {
4490 let server = MockServer::start();
4491
4492 server.mock(|when, then| {
4493 when.method(POST).path("/issue").is_true(|req| {
4494 let body = String::from_utf8_lossy(req.body().as_ref());
4495 !body.contains("\"parent\"")
4496 });
4497 then.status(201).json_body(serde_json::json!({
4498 "id": "10011",
4499 "key": "PROJ-11"
4500 }));
4501 });
4502
4503 server.mock(|when, then| {
4504 when.method(GET).path("/issue/PROJ-11");
4505 then.status(200).json_body(serde_json::json!({
4506 "id": "10011",
4507 "key": "PROJ-11",
4508 "fields": {
4509 "summary": "Plain task",
4510 "status": {"name": "Open"},
4511 "labels": [],
4512 "created": "2024-01-06T10:00:00.000+0000"
4513 }
4514 }));
4515 });
4516
4517 let client = create_self_hosted_client(&server);
4518 let issue = client
4519 .create_issue(CreateIssueInput {
4520 title: "Plain task".to_string(),
4521 parent: None,
4522 ..Default::default()
4523 })
4524 .await
4525 .unwrap();
4526
4527 assert_eq!(issue.key, "jira#PROJ-11");
4528 }
4529
4530 #[tokio::test]
4533 async fn test_update_issue_replaces_components() {
4534 let server = MockServer::start();
4535
4536 server.mock(|when, then| {
4537 when.method(PUT)
4538 .path("/issue/PROJ-1")
4539 .body_includes("\"components\":[{\"name\":\"Backend\"}]");
4540 then.status(204);
4541 });
4542
4543 server.mock(|when, then| {
4544 when.method(GET).path("/issue/PROJ-1");
4545 then.status(200).json_body(serde_json::json!({
4546 "id": "10001",
4547 "key": "PROJ-1",
4548 "fields": {
4549 "summary": "Updated",
4550 "status": {"name": "Open"},
4551 "labels": [],
4552 "created": "2024-01-01T10:00:00.000+0000"
4553 }
4554 }));
4555 });
4556
4557 let client = create_self_hosted_client(&server);
4558 let issue = client
4559 .update_issue(
4560 "PROJ-1",
4561 UpdateIssueInput {
4562 components: Some(vec!["Backend".to_string()]),
4563 ..Default::default()
4564 },
4565 )
4566 .await
4567 .unwrap();
4568
4569 assert_eq!(issue.key, "jira#PROJ-1");
4570 }
4571
4572 #[tokio::test]
4573 async fn test_update_issue() {
4574 let server = MockServer::start();
4575
4576 server.mock(|when, then| {
4577 when.method(PUT)
4578 .path("/issue/PROJ-1")
4579 .body_includes("\"summary\":\"Updated title\"");
4580 then.status(204);
4581 });
4582
4583 server.mock(|when, then| {
4584 when.method(GET).path("/issue/PROJ-1");
4585 then.status(200).json_body(serde_json::json!({
4586 "id": "10001",
4587 "key": "PROJ-1",
4588 "fields": {
4589 "summary": "Updated title",
4590 "status": {"name": "Open"},
4591 "labels": [],
4592 "created": "2024-01-01T10:00:00.000+0000"
4593 }
4594 }));
4595 });
4596
4597 let client = create_self_hosted_client(&server);
4598 let issue = client
4599 .update_issue(
4600 "PROJ-1",
4601 UpdateIssueInput {
4602 title: Some("Updated title".to_string()),
4603 ..Default::default()
4604 },
4605 )
4606 .await
4607 .unwrap();
4608
4609 assert_eq!(issue.title, "Updated title");
4610 }
4611
4612 #[tokio::test]
4613 async fn test_update_issue_with_status_transition() {
4614 let server = MockServer::start();
4615
4616 server.mock(|when, then| {
4618 when.method(GET).path("/issue/PROJ-1/transitions");
4619 then.status(200).json_body(serde_json::json!({
4620 "transitions": [
4621 {
4622 "id": "21",
4623 "name": "Start Progress",
4624 "to": {"name": "In Progress"}
4625 },
4626 {
4627 "id": "31",
4628 "name": "Done",
4629 "to": {"name": "Done"}
4630 }
4631 ]
4632 }));
4633 });
4634
4635 server.mock(|when, then| {
4637 when.method(POST)
4638 .path("/issue/PROJ-1/transitions")
4639 .body_includes("\"id\":\"31\"");
4640 then.status(204);
4641 });
4642
4643 server.mock(|when, then| {
4645 when.method(GET).path("/issue/PROJ-1");
4646 then.status(200).json_body(serde_json::json!({
4647 "id": "10001",
4648 "key": "PROJ-1",
4649 "fields": {
4650 "summary": "Test",
4651 "status": {"name": "Done"},
4652 "labels": []
4653 }
4654 }));
4655 });
4656
4657 let client = create_self_hosted_client(&server);
4658 let issue = client
4659 .update_issue(
4660 "PROJ-1",
4661 UpdateIssueInput {
4662 state: Some("Done".to_string()),
4663 ..Default::default()
4664 },
4665 )
4666 .await
4667 .unwrap();
4668
4669 assert_eq!(issue.state, "Done");
4670 }
4671
4672 fn mock_project_statuses(server: &MockServer, statuses: serde_json::Value) {
4674 server.mock(|when, then| {
4675 when.method(GET).path("/project/PROJ/statuses");
4676 then.status(200).json_body(statuses);
4677 });
4678 }
4679
4680 fn sample_project_statuses_json() -> serde_json::Value {
4682 serde_json::json!([{
4683 "name": "Task",
4684 "statuses": [
4685 {"name": "Offen", "id": "1", "statusCategory": {"key": "new"}},
4686 {"name": "In Bearbeitung", "id": "2", "statusCategory": {"key": "indeterminate"}},
4687 {"name": "Erledigt", "id": "3", "statusCategory": {"key": "done"}},
4688 {"name": "Abgebrochen", "id": "4", "statusCategory": {"key": "done"}}
4689 ]
4690 }])
4691 }
4692
4693 #[tokio::test]
4694 async fn test_update_issue_generic_closed_maps_to_done_category() {
4695 let server = MockServer::start();
4696
4697 server.mock(|when, then| {
4699 when.method(GET).path("/issue/PROJ-1/transitions");
4700 then.status(200).json_body(serde_json::json!({
4701 "transitions": [
4702 {
4703 "id": "21",
4704 "name": "Start Progress",
4705 "to": {
4706 "name": "In Bearbeitung",
4707 "statusCategory": {"key": "indeterminate"}
4708 }
4709 },
4710 {
4711 "id": "31",
4712 "name": "Erledigt",
4713 "to": {
4714 "name": "Erledigt",
4715 "statusCategory": {"key": "done"}
4716 }
4717 }
4718 ]
4719 }));
4720 });
4721
4722 mock_project_statuses(&server, sample_project_statuses_json());
4724
4725 server.mock(|when, then| {
4727 when.method(POST)
4728 .path("/issue/PROJ-1/transitions")
4729 .body_includes("\"id\":\"31\"");
4730 then.status(204);
4731 });
4732
4733 server.mock(|when, then| {
4735 when.method(GET).path("/issue/PROJ-1");
4736 then.status(200).json_body(serde_json::json!({
4737 "id": "10001",
4738 "key": "PROJ-1",
4739 "fields": {
4740 "summary": "Test",
4741 "status": {"name": "Erledigt"},
4742 "labels": []
4743 }
4744 }));
4745 });
4746
4747 let client = create_self_hosted_client(&server);
4748 let issue = client
4749 .update_issue(
4750 "PROJ-1",
4751 UpdateIssueInput {
4752 state: Some("closed".to_string()),
4753 ..Default::default()
4754 },
4755 )
4756 .await
4757 .unwrap();
4758
4759 assert_eq!(issue.state, "Erledigt");
4760 }
4761
4762 #[tokio::test]
4763 async fn test_update_issue_generic_open_maps_to_new_category() {
4764 let server = MockServer::start();
4765
4766 server.mock(|when, then| {
4767 when.method(GET).path("/issue/PROJ-1/transitions");
4768 then.status(200).json_body(serde_json::json!({
4769 "transitions": [
4770 {
4771 "id": "11",
4772 "name": "Offen",
4773 "to": {
4774 "name": "Offen",
4775 "statusCategory": {"key": "new"}
4776 }
4777 },
4778 {
4779 "id": "21",
4780 "name": "In Bearbeitung",
4781 "to": {
4782 "name": "In Bearbeitung",
4783 "statusCategory": {"key": "indeterminate"}
4784 }
4785 }
4786 ]
4787 }));
4788 });
4789
4790 mock_project_statuses(&server, sample_project_statuses_json());
4791
4792 server.mock(|when, then| {
4793 when.method(POST)
4794 .path("/issue/PROJ-1/transitions")
4795 .body_includes("\"id\":\"11\"");
4796 then.status(204);
4797 });
4798
4799 server.mock(|when, then| {
4800 when.method(GET).path("/issue/PROJ-1");
4801 then.status(200).json_body(serde_json::json!({
4802 "id": "10001",
4803 "key": "PROJ-1",
4804 "fields": {
4805 "summary": "Test",
4806 "status": {"name": "Offen"},
4807 "labels": []
4808 }
4809 }));
4810 });
4811
4812 let client = create_self_hosted_client(&server);
4813 let issue = client
4814 .update_issue(
4815 "PROJ-1",
4816 UpdateIssueInput {
4817 state: Some("open".to_string()),
4818 ..Default::default()
4819 },
4820 )
4821 .await
4822 .unwrap();
4823
4824 assert_eq!(issue.state, "Offen");
4825 }
4826
4827 #[tokio::test]
4828 async fn test_update_issue_canceled_resolves_via_project_statuses() {
4829 let server = MockServer::start();
4830
4831 server.mock(|when, then| {
4833 when.method(GET).path("/issue/PROJ-1/transitions");
4834 then.status(200).json_body(serde_json::json!({
4835 "transitions": [
4836 {
4837 "id": "21",
4838 "name": "Start Progress",
4839 "to": {
4840 "name": "In Bearbeitung",
4841 "statusCategory": {"key": "indeterminate"}
4842 }
4843 },
4844 {
4845 "id": "41",
4846 "name": "Cancel",
4847 "to": {
4848 "name": "Abgebrochen",
4849 "statusCategory": {"key": "done"}
4850 }
4851 }
4852 ]
4853 }));
4854 });
4855
4856 mock_project_statuses(&server, sample_project_statuses_json());
4858
4859 server.mock(|when, then| {
4861 when.method(POST)
4862 .path("/issue/PROJ-1/transitions")
4863 .body_includes("\"id\":\"41\"");
4864 then.status(204);
4865 });
4866
4867 server.mock(|when, then| {
4868 when.method(GET).path("/issue/PROJ-1");
4869 then.status(200).json_body(serde_json::json!({
4870 "id": "10001",
4871 "key": "PROJ-1",
4872 "fields": {
4873 "summary": "Test",
4874 "status": {"name": "Abgebrochen"},
4875 "labels": []
4876 }
4877 }));
4878 });
4879
4880 let client = create_self_hosted_client(&server);
4881 let issue = client
4882 .update_issue(
4883 "PROJ-1",
4884 UpdateIssueInput {
4885 state: Some("canceled".to_string()),
4886 ..Default::default()
4887 },
4888 )
4889 .await
4890 .unwrap();
4891
4892 assert_eq!(issue.state, "Abgebrochen");
4893 }
4894
4895 #[tokio::test]
4896 async fn test_update_issue_exact_project_status_name_match() {
4897 let server = MockServer::start();
4898
4899 server.mock(|when, then| {
4901 when.method(GET).path("/issue/PROJ-1/transitions");
4902 then.status(200).json_body(serde_json::json!({
4903 "transitions": [
4904 {
4905 "id": "41",
4906 "name": "Cancel",
4907 "to": {"name": "Abgebrochen", "statusCategory": {"key": "done"}}
4908 },
4909 {
4910 "id": "31",
4911 "name": "Done",
4912 "to": {"name": "Erledigt", "statusCategory": {"key": "done"}}
4913 }
4914 ]
4915 }));
4916 });
4917
4918 mock_project_statuses(&server, sample_project_statuses_json());
4919
4920 server.mock(|when, then| {
4922 when.method(POST)
4923 .path("/issue/PROJ-1/transitions")
4924 .body_includes("\"id\":\"41\"");
4925 then.status(204);
4926 });
4927
4928 server.mock(|when, then| {
4929 when.method(GET).path("/issue/PROJ-1");
4930 then.status(200).json_body(serde_json::json!({
4931 "id": "10001",
4932 "key": "PROJ-1",
4933 "fields": {
4934 "summary": "Test",
4935 "status": {"name": "Abgebrochen"},
4936 "labels": []
4937 }
4938 }));
4939 });
4940
4941 let client = create_self_hosted_client(&server);
4942 let issue = client
4943 .update_issue(
4944 "PROJ-1",
4945 UpdateIssueInput {
4946 state: Some("Abgebrochen".to_string()),
4947 ..Default::default()
4948 },
4949 )
4950 .await
4951 .unwrap();
4952
4953 assert_eq!(issue.state, "Abgebrochen");
4954 }
4955
4956 #[tokio::test]
4957 async fn test_update_issue_fallback_when_project_statuses_unavailable() {
4958 let server = MockServer::start();
4959
4960 server.mock(|when, then| {
4962 when.method(GET).path("/issue/PROJ-1/transitions");
4963 then.status(200).json_body(serde_json::json!({
4964 "transitions": [{
4965 "id": "31",
4966 "name": "Done",
4967 "to": {"name": "Done", "statusCategory": {"key": "done"}}
4968 }]
4969 }));
4970 });
4971
4972 server.mock(|when, then| {
4974 when.method(GET).path("/project/PROJ/statuses");
4975 then.status(403).body("Forbidden");
4976 });
4977
4978 server.mock(|when, then| {
4979 when.method(POST)
4980 .path("/issue/PROJ-1/transitions")
4981 .body_includes("\"id\":\"31\"");
4982 then.status(204);
4983 });
4984
4985 server.mock(|when, then| {
4986 when.method(GET).path("/issue/PROJ-1");
4987 then.status(200).json_body(serde_json::json!({
4988 "id": "10001",
4989 "key": "PROJ-1",
4990 "fields": {
4991 "summary": "Test",
4992 "status": {"name": "Done"},
4993 "labels": []
4994 }
4995 }));
4996 });
4997
4998 let client = create_self_hosted_client(&server);
4999 let issue = client
5001 .update_issue(
5002 "PROJ-1",
5003 UpdateIssueInput {
5004 state: Some("closed".to_string()),
5005 ..Default::default()
5006 },
5007 )
5008 .await
5009 .unwrap();
5010
5011 assert_eq!(issue.state, "Done");
5012 }
5013
5014 #[tokio::test]
5015 async fn test_get_comments() {
5016 let server = MockServer::start();
5017
5018 server.mock(|when, then| {
5019 when.method(GET).path("/issue/PROJ-1/comment");
5020 then.status(200).json_body(serde_json::json!({
5021 "comments": [{
5022 "id": "100",
5023 "body": "Great work!",
5024 "author": {
5025 "name": "reviewer",
5026 "displayName": "Reviewer"
5027 },
5028 "created": "2024-01-01T12:00:00.000+0000",
5029 "updated": "2024-01-01T12:00:00.000+0000"
5030 }]
5031 }));
5032 });
5033
5034 let client = create_self_hosted_client(&server);
5035 let comments = client.get_comments("PROJ-1").await.unwrap().items;
5036
5037 assert_eq!(comments.len(), 1);
5038 assert_eq!(comments[0].id, "100");
5039 assert_eq!(comments[0].body, "Great work!");
5040 assert_eq!(comments[0].author.as_ref().unwrap().username, "reviewer");
5041 }
5042
5043 #[tokio::test]
5044 async fn test_add_comment() {
5045 let server = MockServer::start();
5046
5047 server.mock(|when, then| {
5048 when.method(POST)
5049 .path("/issue/PROJ-1/comment")
5050 .body_includes("\"body\":\"My comment\"");
5051 then.status(201).json_body(serde_json::json!({
5052 "id": "101",
5053 "body": "My comment",
5054 "author": {
5055 "name": "user",
5056 "displayName": "User"
5057 },
5058 "created": "2024-01-01T13:00:00.000+0000"
5059 }));
5060 });
5061
5062 let client = create_self_hosted_client(&server);
5063 let comment = IssueProvider::add_comment(&client, "PROJ-1", "My comment")
5064 .await
5065 .unwrap();
5066
5067 assert_eq!(comment.id, "101");
5068 assert_eq!(comment.body, "My comment");
5069 }
5070
5071 #[tokio::test]
5076 async fn test_cloud_get_issues() {
5077 let server = MockServer::start();
5078
5079 server.mock(|when, then| {
5080 when.method(GET)
5081 .path("/search/jql")
5082 .query_param_exists("jql");
5083 then.status(200).json_body(serde_json::json!({
5084 "issues": [sample_cloud_issue_json()]
5085 }));
5086 });
5087
5088 let client = create_cloud_client(&server);
5089 let issues = client
5090 .get_issues(IssueFilter::default())
5091 .await
5092 .unwrap()
5093 .items;
5094
5095 assert_eq!(issues.len(), 1);
5096 assert_eq!(issues[0].key, "jira#PROJ-1");
5097 assert_eq!(
5098 issues[0].description,
5099 Some("Login fails on mobile".to_string())
5100 );
5101 }
5102
5103 #[tokio::test]
5104 async fn test_cloud_create_issue_adf() {
5105 let server = MockServer::start();
5106
5107 server.mock(|when, then| {
5109 when.method(POST)
5110 .path("/issue")
5111 .body_includes("\"type\":\"doc\"")
5112 .body_includes("\"version\":1");
5113 then.status(201).json_body(serde_json::json!({
5114 "id": "10003",
5115 "key": "PROJ-3"
5116 }));
5117 });
5118
5119 server.mock(|when, then| {
5120 when.method(GET).path("/issue/PROJ-3");
5121 then.status(200).json_body(serde_json::json!({
5122 "id": "10003",
5123 "key": "PROJ-3",
5124 "fields": {
5125 "summary": "Cloud task",
5126 "description": {
5127 "version": 1,
5128 "type": "doc",
5129 "content": [{
5130 "type": "paragraph",
5131 "content": [{"type": "text", "text": "Cloud description"}]
5132 }]
5133 },
5134 "status": {"name": "To Do"},
5135 "labels": []
5136 }
5137 }));
5138 });
5139
5140 let client = create_cloud_client(&server);
5141 let issue = client
5142 .create_issue(CreateIssueInput {
5143 title: "Cloud task".to_string(),
5144 description: Some("Cloud description".to_string()),
5145 ..Default::default()
5146 })
5147 .await
5148 .unwrap();
5149
5150 assert_eq!(issue.key, "jira#PROJ-3");
5151 assert_eq!(issue.description, Some("Cloud description".to_string()));
5152 }
5153
5154 #[tokio::test]
5155 async fn test_cloud_add_comment_adf() {
5156 let server = MockServer::start();
5157
5158 server.mock(|when, then| {
5159 when.method(POST)
5160 .path("/issue/PROJ-1/comment")
5161 .body_includes("\"type\":\"doc\"");
5162 then.status(201).json_body(serde_json::json!({
5163 "id": "201",
5164 "body": {
5165 "version": 1,
5166 "type": "doc",
5167 "content": [{
5168 "type": "paragraph",
5169 "content": [{"type": "text", "text": "ADF comment body"}]
5170 }]
5171 },
5172 "author": {
5173 "accountId": "abc123",
5174 "displayName": "Commenter"
5175 },
5176 "created": "2024-01-02T10:00:00.000+0000"
5177 }));
5178 });
5179
5180 let client = create_cloud_client(&server);
5181 let comment = IssueProvider::add_comment(&client, "PROJ-1", "ADF comment body")
5182 .await
5183 .unwrap();
5184
5185 assert_eq!(comment.id, "201");
5186 assert_eq!(comment.body, "ADF comment body");
5187 }
5188
5189 #[tokio::test]
5190 async fn test_cloud_get_issue_adf_description() {
5191 let server = MockServer::start();
5192
5193 server.mock(|when, then| {
5194 when.method(GET).path("/issue/PROJ-1");
5195 then.status(200).json_body(sample_cloud_issue_json());
5196 });
5197
5198 let client = create_cloud_client(&server);
5199 let issue = client.get_issue("PROJ-1").await.unwrap();
5200
5201 assert_eq!(issue.description, Some("Login fails on mobile".to_string()));
5202 }
5203
5204 #[tokio::test]
5209 async fn test_handle_401() {
5210 let server = MockServer::start();
5211
5212 server.mock(|when, then| {
5213 when.method(GET).path("/issue/PROJ-1");
5214 then.status(401).body("Unauthorized");
5215 });
5216
5217 let client = create_self_hosted_client(&server);
5218 let result = client.get_issue("PROJ-1").await;
5219
5220 assert!(result.is_err());
5221 assert!(matches!(result.unwrap_err(), Error::Unauthorized(_)));
5222 }
5223
5224 #[tokio::test]
5225 async fn test_handle_404() {
5226 let server = MockServer::start();
5227
5228 server.mock(|when, then| {
5229 when.method(GET).path("/issue/PROJ-999");
5230 then.status(404).body("Issue not found");
5231 });
5232
5233 let client = create_self_hosted_client(&server);
5234 let result = client.get_issue("PROJ-999").await;
5235
5236 assert!(result.is_err());
5237 assert!(matches!(result.unwrap_err(), Error::NotFound(_)));
5238 }
5239
5240 #[tokio::test]
5241 async fn test_handle_500() {
5242 let server = MockServer::start();
5243
5244 server.mock(|when, then| {
5245 when.method(GET).path("/search");
5246 then.status(500).body("Internal Server Error");
5247 });
5248
5249 let client = create_self_hosted_client(&server);
5250 let result = client.get_issues(IssueFilter::default()).await;
5251
5252 assert!(result.is_err());
5253 assert!(matches!(result.unwrap_err(), Error::ServerError { .. }));
5254 }
5255
5256 #[tokio::test]
5261 async fn test_mr_methods_unsupported() {
5262 let client = JiraClient::with_base_url(
5263 "http://localhost",
5264 "PROJ",
5265 "user@example.com",
5266 token("token"),
5267 false,
5268 );
5269
5270 let result = client.get_merge_requests(MrFilter::default()).await;
5271 assert!(matches!(
5272 result.unwrap_err(),
5273 Error::ProviderUnsupported { .. }
5274 ));
5275
5276 let result = client.get_merge_request("mr#1").await;
5277 assert!(matches!(
5278 result.unwrap_err(),
5279 Error::ProviderUnsupported { .. }
5280 ));
5281
5282 let result = client.get_discussions("mr#1").await;
5283 assert!(matches!(
5284 result.unwrap_err(),
5285 Error::ProviderUnsupported { .. }
5286 ));
5287
5288 let result = client.get_diffs("mr#1").await;
5289 assert!(matches!(
5290 result.unwrap_err(),
5291 Error::ProviderUnsupported { .. }
5292 ));
5293
5294 let result = MergeRequestProvider::add_comment(
5295 &client,
5296 "mr#1",
5297 CreateCommentInput {
5298 body: "test".to_string(),
5299 position: None,
5300 discussion_id: None,
5301 },
5302 )
5303 .await;
5304 assert!(matches!(
5305 result.unwrap_err(),
5306 Error::ProviderUnsupported { .. }
5307 ));
5308 }
5309
5310 #[tokio::test]
5315 async fn test_get_current_user() {
5316 let server = MockServer::start();
5317
5318 server.mock(|when, then| {
5319 when.method(GET).path("/myself");
5320 then.status(200).json_body(serde_json::json!({
5321 "name": "jdoe",
5322 "displayName": "John Doe",
5323 "emailAddress": "john@example.com"
5324 }));
5325 });
5326
5327 let client = create_self_hosted_client(&server);
5328 let user = client.get_current_user().await.unwrap();
5329
5330 assert_eq!(user.username, "jdoe");
5331 assert_eq!(user.name, Some("John Doe".to_string()));
5332 assert_eq!(user.email, Some("john@example.com".to_string()));
5333 }
5334
5335 #[tokio::test]
5336 async fn test_get_current_user_auth_failure() {
5337 let server = MockServer::start();
5338
5339 server.mock(|when, then| {
5340 when.method(GET).path("/myself");
5341 then.status(401).body("Unauthorized");
5342 });
5343
5344 let client = create_self_hosted_client(&server);
5345 let result = client.get_current_user().await;
5346
5347 assert!(result.is_err());
5348 assert!(matches!(result.unwrap_err(), Error::Unauthorized(_)));
5349 }
5350
5351 #[tokio::test]
5352 async fn test_transition_not_found_error_lists_available() {
5353 let server = MockServer::start();
5354
5355 server.mock(|when, then| {
5356 when.method(GET).path("/issue/PROJ-1/transitions");
5357 then.status(200).json_body(serde_json::json!({
5358 "transitions": [
5359 {
5360 "id": "21",
5361 "name": "Start Progress",
5362 "to": {
5363 "name": "In Bearbeitung",
5364 "statusCategory": {"key": "indeterminate"}
5365 }
5366 }
5367 ]
5368 }));
5369 });
5370
5371 mock_project_statuses(&server, sample_project_statuses_json());
5373
5374 let client = create_self_hosted_client(&server);
5375 let result = client
5376 .update_issue(
5377 "PROJ-1",
5378 UpdateIssueInput {
5379 state: Some("nonexistent".to_string()),
5380 ..Default::default()
5381 },
5382 )
5383 .await;
5384
5385 assert!(result.is_err());
5386 let err = result.unwrap_err().to_string();
5387 assert!(err.contains("No transition to status"), "got: {}", err);
5388 assert!(
5389 err.contains("In Bearbeitung"),
5390 "should list available: {}",
5391 err
5392 );
5393 }
5394
5395 #[tokio::test]
5396 async fn test_cloud_get_issues_pagination_next_page_token() {
5397 let server = MockServer::start();
5398
5399 server.mock(|when, then| {
5402 when.method(GET)
5403 .path("/search/jql")
5404 .query_param("nextPageToken", "page2token");
5405 then.status(200).json_body(serde_json::json!({
5406 "issues": [
5407 {
5408 "id": "10003",
5409 "key": "PROJ-3",
5410 "fields": {
5411 "summary": "Issue 3",
5412 "status": {"name": "Done"},
5413 "labels": [],
5414 "created": "2024-01-03T10:00:00.000+0000"
5415 }
5416 }
5417 ]
5418 }));
5419 });
5420
5421 server.mock(|when, then| {
5423 when.method(GET)
5424 .path("/search/jql")
5425 .query_param_exists("jql");
5426 then.status(200).json_body(serde_json::json!({
5427 "issues": [
5428 {
5429 "id": "10001",
5430 "key": "PROJ-1",
5431 "fields": {
5432 "summary": "Issue 1",
5433 "status": {"name": "Open"},
5434 "labels": [],
5435 "created": "2024-01-01T10:00:00.000+0000"
5436 }
5437 },
5438 {
5439 "id": "10002",
5440 "key": "PROJ-2",
5441 "fields": {
5442 "summary": "Issue 2",
5443 "status": {"name": "Open"},
5444 "labels": [],
5445 "created": "2024-01-02T10:00:00.000+0000"
5446 }
5447 }
5448 ],
5449 "nextPageToken": "page2token"
5450 }));
5451 });
5452
5453 let client = create_cloud_client(&server);
5454 let issues = client
5455 .get_issues(IssueFilter {
5456 limit: Some(3),
5457 ..Default::default()
5458 })
5459 .await
5460 .unwrap()
5461 .items;
5462
5463 assert_eq!(issues.len(), 3);
5464 assert_eq!(issues[0].key, "jira#PROJ-1");
5465 assert_eq!(issues[1].key, "jira#PROJ-2");
5466 assert_eq!(issues[2].key, "jira#PROJ-3");
5467 }
5468
5469 #[test]
5470 fn test_escape_jql() {
5471 assert_eq!(escape_jql("simple"), "simple");
5472 assert_eq!(escape_jql(r#"has "quotes""#), r#"has \"quotes\""#);
5473 assert_eq!(escape_jql(r"back\slash"), r"back\\slash");
5474 assert_eq!(
5475 escape_jql(r#"both "and" \ here"#),
5476 r#"both \"and\" \\ here"#
5477 );
5478 }
5479
5480 #[test]
5481 fn test_has_project_clause() {
5482 assert!(has_project_clause("project = \"PROJ\""));
5484 assert!(has_project_clause("project = PROJ AND status = Open"));
5485 assert!(has_project_clause("project IN (\"A\", \"B\")"));
5486 assert!(has_project_clause("project in(A, B)"));
5487 assert!(has_project_clause("PROJECT = KEY")); assert!(has_project_clause("status = Open AND project = X"));
5489 assert!(has_project_clause("project ~ KEY")); assert!(has_project_clause("project != \"PROJ\""));
5492 assert!(has_project_clause("project NOT IN (\"A\", \"B\")"));
5493 assert!(has_project_clause("project not in(A)"));
5494 assert!(!has_project_clause("fixVersion = \"1.0\""));
5496 assert!(!has_project_clause("status = Done"));
5497 assert!(!has_project_clause("summary ~ \"project plan\""));
5499 assert!(!has_project_clause("summary ~ \"project information\""));
5500 assert!(!has_project_clause("summary ~ \"project = foo\""));
5501 assert!(!has_project_clause("my_project = X"));
5503 }
5504
5505 #[test]
5510 fn test_merge_custom_fields_into_payload() {
5511 use crate::types::*;
5512 let payload = CreateIssuePayload {
5513 fields: CreateIssueFields {
5514 project: ProjectKey { key: "PROJ".into() },
5515 summary: "Test".into(),
5516 issuetype: IssueType {
5517 name: "Task".into(),
5518 },
5519 description: None,
5520 labels: None,
5521 priority: None,
5522 assignee: None,
5523 components: None,
5524 parent: None,
5525 },
5526 };
5527
5528 let cf = Some(serde_json::json!({"customfield_10001": 8, "customfield_10002": "x"}));
5529 let (merged, count) = merge_custom_fields_into_payload(payload, &cf).unwrap();
5530
5531 let fields = merged.get("fields").unwrap();
5532 assert_eq!(fields["customfield_10001"], 8);
5533 assert_eq!(fields["customfield_10002"], "x");
5534 assert_eq!(count, 2);
5535 assert_eq!(fields["summary"], "Test");
5536 assert_eq!(fields["project"]["key"], "PROJ");
5537 }
5538
5539 #[test]
5540 fn test_merge_custom_fields_none_is_noop() {
5541 use crate::types::*;
5542 let payload = CreateIssuePayload {
5543 fields: CreateIssueFields {
5544 project: ProjectKey { key: "PROJ".into() },
5545 summary: "Test".into(),
5546 issuetype: IssueType {
5547 name: "Task".into(),
5548 },
5549 description: None,
5550 labels: None,
5551 priority: None,
5552 assignee: None,
5553 components: None,
5554 parent: None,
5555 },
5556 };
5557
5558 let (merged, count) = merge_custom_fields_into_payload(payload, &None).unwrap();
5559 assert_eq!(count, 0);
5560 let fields = merged.get("fields").unwrap();
5561 assert_eq!(fields["summary"], "Test");
5562 assert!(fields.get("customfield_10001").is_none());
5563 }
5564
5565 #[test]
5566 fn test_merge_custom_fields_rejects_non_custom_keys() {
5567 use crate::types::*;
5568 let payload = CreateIssuePayload {
5569 fields: CreateIssueFields {
5570 project: ProjectKey { key: "PROJ".into() },
5571 summary: "Test".into(),
5572 issuetype: IssueType {
5573 name: "Task".into(),
5574 },
5575 description: None,
5576 labels: None,
5577 priority: None,
5578 assignee: None,
5579 components: None,
5580 parent: None,
5581 },
5582 };
5583
5584 let cf = Some(serde_json::json!({"summary": "HACKED", "customfield_10001": 5}));
5586 let (merged, count) = merge_custom_fields_into_payload(payload, &cf).unwrap();
5587
5588 let fields = merged.get("fields").unwrap();
5589 assert_eq!(fields["summary"], "Test"); assert_eq!(fields["customfield_10001"], 5); assert_eq!(count, 1); }
5593
5594 #[tokio::test]
5599 async fn test_get_issue_relations() {
5600 let server = MockServer::start();
5601
5602 server.mock(|when, then| {
5603 when.method(GET)
5604 .path("/issue/PROJ-1")
5605 .query_param_includes("fields", "parent");
5606 then.status(200).json_body(serde_json::json!({
5607 "id": "10001",
5608 "key": "PROJ-1",
5609 "fields": {
5610 "summary": "Main issue",
5611 "status": {"name": "Open"},
5612 "labels": [],
5613 "parent": {
5614 "id": "10000",
5615 "key": "PROJ-0",
5616 "fields": {
5617 "summary": "Parent issue",
5618 "status": {"name": "Open"},
5619 "labels": []
5620 }
5621 },
5622 "subtasks": [
5623 {
5624 "id": "10002",
5625 "key": "PROJ-2",
5626 "fields": {
5627 "summary": "Subtask 1",
5628 "status": {"name": "In Progress"},
5629 "labels": []
5630 }
5631 }
5632 ],
5633 "issuelinks": [
5634 {
5635 "type": {
5636 "name": "Blocks",
5637 "outward": "blocks",
5638 "inward": "is blocked by"
5639 },
5640 "outwardIssue": {
5641 "id": "10003",
5642 "key": "PROJ-3",
5643 "fields": {
5644 "summary": "Blocked issue",
5645 "status": {"name": "Open"},
5646 "labels": []
5647 }
5648 }
5649 }
5650 ]
5651 }
5652 }));
5653 });
5654
5655 let client = create_self_hosted_client(&server);
5656 let relations = client.get_issue_relations("jira#PROJ-1").await.unwrap();
5657
5658 assert!(relations.parent.is_some());
5659 assert_eq!(relations.parent.unwrap().key, "jira#PROJ-0");
5660 assert_eq!(relations.subtasks.len(), 1);
5661 assert_eq!(relations.subtasks[0].key, "jira#PROJ-2");
5662 assert_eq!(relations.blocks.len(), 1);
5663 assert_eq!(relations.blocks[0].issue.key, "jira#PROJ-3");
5664 }
5665
5666 #[tokio::test]
5671 async fn test_get_issue_attachments_maps_fields() {
5672 let server = MockServer::start();
5673
5674 server.mock(|when, then| {
5675 when.method(GET)
5676 .path("/issue/PROJ-1")
5677 .query_param("fields", "attachment");
5678 then.status(200).json_body(serde_json::json!({
5679 "id": "10001",
5680 "key": "PROJ-1",
5681 "fields": {
5682 "attachment": [
5683 {
5684 "id": "42",
5685 "filename": "crash.log",
5686 "content": "https://example/rest/api/2/attachment/content/42",
5687 "size": 2048,
5688 "mimeType": "text/plain",
5689 "created": "2024-01-01T00:00:00.000+0000",
5690 "author": {
5691 "name": "uploader",
5692 "displayName": "Upload User"
5693 }
5694 }
5695 ]
5696 }
5697 }));
5698 });
5699
5700 let client = create_self_hosted_client(&server);
5701 let assets = client.get_issue_attachments("jira#PROJ-1").await.unwrap();
5702 assert_eq!(assets.len(), 1);
5703 let a = &assets[0];
5704 assert_eq!(a.id, "42");
5705 assert_eq!(a.filename, "crash.log");
5706 assert_eq!(a.mime_type.as_deref(), Some("text/plain"));
5707 assert_eq!(a.size, Some(2048));
5708 assert_eq!(a.author.as_deref(), Some("Upload User"));
5709 }
5710
5711 #[tokio::test]
5712 async fn test_download_attachment_returns_bytes() {
5713 let server = MockServer::start();
5714
5715 let content_url = server.url("/secure/attachment/42/trace.log");
5717 server.mock(|when, then| {
5718 when.method(GET).path("/attachment/42");
5719 then.status(200).json_body(serde_json::json!({
5720 "self": "http://localhost/rest/api/2/attachment/42",
5721 "id": "42",
5722 "filename": "trace.log",
5723 "content": content_url,
5724 }));
5725 });
5726 server.mock(|when, then| {
5727 when.method(GET).path("/secure/attachment/42/trace.log");
5728 then.status(200).body("stack trace here");
5729 });
5730
5731 let client = create_self_hosted_client(&server);
5732 let bytes = client
5733 .download_attachment("jira#PROJ-1", "42")
5734 .await
5735 .unwrap();
5736 assert_eq!(bytes, b"stack trace here");
5737 }
5738
5739 #[tokio::test]
5740 async fn test_delete_attachment_ok() {
5741 let server = MockServer::start();
5742
5743 let mock = server.mock(|when, then| {
5744 when.method(DELETE).path("/attachment/42");
5745 then.status(204);
5746 });
5747
5748 let client = create_self_hosted_client(&server);
5749 client.delete_attachment("jira#PROJ-1", "42").await.unwrap();
5750 mock.assert();
5751 }
5752
5753 #[tokio::test]
5754 async fn test_upload_attachment_returns_content_url() {
5755 let server = MockServer::start();
5756
5757 server.mock(|when, then| {
5758 when.method(POST)
5759 .path("/issue/PROJ-1/attachments")
5760 .header("X-Atlassian-Token", "no-check");
5761 then.status(200).json_body(serde_json::json!([
5762 {
5763 "id": "99",
5764 "filename": "report.txt",
5765 "content": "https://example/rest/api/2/attachment/content/99",
5766 "size": 10
5767 }
5768 ]));
5769 });
5770
5771 let client = create_self_hosted_client(&server);
5772 let url = client
5773 .upload_attachment("jira#PROJ-1", "report.txt", b"0123456789")
5774 .await
5775 .unwrap();
5776 assert_eq!(url, "https://example/rest/api/2/attachment/content/99");
5777 }
5778
5779 #[tokio::test]
5780 async fn test_jira_asset_capabilities() {
5781 let server = MockServer::start();
5782 let client = create_self_hosted_client(&server);
5783 let caps = client.asset_capabilities();
5784 assert!(caps.issue.upload);
5785 assert!(caps.issue.download);
5786 assert!(caps.issue.delete);
5787 assert!(caps.issue.list);
5788 }
5789 }
5790
5791 #[test]
5796 fn test_map_relations_empty() {
5797 let issue = JiraIssue {
5798 id: "10001".to_string(),
5799 key: "PROJ-1".to_string(),
5800 fields: JiraIssueFields {
5801 summary: Some("Test".to_string()),
5802 description: None,
5803 status: None,
5804 priority: None,
5805 assignee: None,
5806 reporter: None,
5807 labels: vec![],
5808 created: None,
5809 updated: None,
5810 parent: None,
5811 subtasks: vec![],
5812 issuelinks: vec![],
5813 attachment: vec![],
5814 },
5815 };
5816
5817 let relations = map_relations(&issue, JiraFlavor::Cloud, "https://test.atlassian.net");
5818
5819 assert!(relations.parent.is_none());
5820 assert!(relations.subtasks.is_empty());
5821 assert!(relations.blocks.is_empty());
5822 assert!(relations.blocked_by.is_empty());
5823 assert!(relations.related_to.is_empty());
5824 assert!(relations.duplicates.is_empty());
5825 }
5826
5827 #[test]
5828 fn test_map_relations_with_parent() {
5829 let parent = Box::new(JiraIssue {
5830 id: "10000".to_string(),
5831 key: "PROJ-0".to_string(),
5832 fields: JiraIssueFields {
5833 summary: Some("Parent Issue".to_string()),
5834 description: None,
5835 status: Some(JiraStatus {
5836 name: "Open".to_string(),
5837 status_category: None,
5838 }),
5839 priority: None,
5840 assignee: None,
5841 reporter: None,
5842 labels: vec![],
5843 created: None,
5844 updated: None,
5845 parent: None,
5846 subtasks: vec![],
5847 issuelinks: vec![],
5848 attachment: vec![],
5849 },
5850 });
5851
5852 let issue = JiraIssue {
5853 id: "10001".to_string(),
5854 key: "PROJ-1".to_string(),
5855 fields: JiraIssueFields {
5856 summary: Some("Child Issue".to_string()),
5857 description: None,
5858 status: None,
5859 priority: None,
5860 assignee: None,
5861 reporter: None,
5862 labels: vec![],
5863 created: None,
5864 updated: None,
5865 parent: Some(parent),
5866 subtasks: vec![],
5867 issuelinks: vec![],
5868 attachment: vec![],
5869 },
5870 };
5871
5872 let relations = map_relations(&issue, JiraFlavor::SelfHosted, "https://jira.example.com");
5873
5874 assert!(relations.parent.is_some());
5875 let parent_issue = relations.parent.unwrap();
5876 assert_eq!(parent_issue.key, "jira#PROJ-0");
5877 assert_eq!(parent_issue.title, "Parent Issue");
5878 }
5879
5880 #[test]
5881 fn test_map_relations_with_subtasks() {
5882 let issue = JiraIssue {
5883 id: "10001".to_string(),
5884 key: "PROJ-1".to_string(),
5885 fields: JiraIssueFields {
5886 summary: Some("Epic".to_string()),
5887 description: None,
5888 status: None,
5889 priority: None,
5890 assignee: None,
5891 reporter: None,
5892 labels: vec![],
5893 created: None,
5894 updated: None,
5895 parent: None,
5896 subtasks: vec![
5897 JiraIssue {
5898 id: "10002".to_string(),
5899 key: "PROJ-2".to_string(),
5900 fields: JiraIssueFields {
5901 summary: Some("Subtask 1".to_string()),
5902 description: None,
5903 status: Some(JiraStatus {
5904 name: "In Progress".to_string(),
5905 status_category: None,
5906 }),
5907 priority: None,
5908 assignee: None,
5909 reporter: None,
5910 labels: vec![],
5911 created: None,
5912 updated: None,
5913 parent: None,
5914 subtasks: vec![],
5915 issuelinks: vec![],
5916 attachment: vec![],
5917 },
5918 },
5919 JiraIssue {
5920 id: "10003".to_string(),
5921 key: "PROJ-3".to_string(),
5922 fields: JiraIssueFields {
5923 summary: Some("Subtask 2".to_string()),
5924 description: None,
5925 status: None,
5926 priority: None,
5927 assignee: None,
5928 reporter: None,
5929 labels: vec![],
5930 created: None,
5931 updated: None,
5932 parent: None,
5933 subtasks: vec![],
5934 issuelinks: vec![],
5935 attachment: vec![],
5936 },
5937 },
5938 ],
5939 issuelinks: vec![],
5940 attachment: vec![],
5941 },
5942 };
5943
5944 let relations = map_relations(&issue, JiraFlavor::Cloud, "https://test.atlassian.net");
5945
5946 assert_eq!(relations.subtasks.len(), 2);
5947 assert_eq!(relations.subtasks[0].key, "jira#PROJ-2");
5948 assert_eq!(relations.subtasks[0].title, "Subtask 1");
5949 assert_eq!(relations.subtasks[1].key, "jira#PROJ-3");
5950 assert_eq!(relations.subtasks[1].title, "Subtask 2");
5951 }
5952
5953 #[test]
5954 fn test_map_relations_with_issuelinks_blocks() {
5955 let issue = JiraIssue {
5956 id: "10001".to_string(),
5957 key: "PROJ-1".to_string(),
5958 fields: JiraIssueFields {
5959 summary: Some("Test".to_string()),
5960 description: None,
5961 status: None,
5962 priority: None,
5963 assignee: None,
5964 reporter: None,
5965 labels: vec![],
5966 created: None,
5967 updated: None,
5968 parent: None,
5969 subtasks: vec![],
5970 issuelinks: vec![
5971 JiraIssueLink {
5973 id: Some("1".to_string()),
5974 link_type: JiraIssueLinkType {
5975 name: "Blocks".to_string(),
5976 outward: Some("blocks".to_string()),
5977 inward: Some("is blocked by".to_string()),
5978 },
5979 outward_issue: Some(Box::new(JiraIssue {
5980 id: "10002".to_string(),
5981 key: "PROJ-2".to_string(),
5982 fields: JiraIssueFields {
5983 summary: Some("Blocked".to_string()),
5984 description: None,
5985 status: None,
5986 priority: None,
5987 assignee: None,
5988 reporter: None,
5989 labels: vec![],
5990 created: None,
5991 updated: None,
5992 parent: None,
5993 subtasks: vec![],
5994 issuelinks: vec![],
5995 attachment: vec![],
5996 },
5997 })),
5998 inward_issue: None,
5999 },
6000 JiraIssueLink {
6002 id: Some("2".to_string()),
6003 link_type: JiraIssueLinkType {
6004 name: "Blocks".to_string(),
6005 outward: Some("blocks".to_string()),
6006 inward: Some("is blocked by".to_string()),
6007 },
6008 outward_issue: None,
6009 inward_issue: Some(Box::new(JiraIssue {
6010 id: "10003".to_string(),
6011 key: "PROJ-3".to_string(),
6012 fields: JiraIssueFields {
6013 summary: Some("Blocker".to_string()),
6014 description: None,
6015 status: None,
6016 priority: None,
6017 assignee: None,
6018 reporter: None,
6019 labels: vec![],
6020 created: None,
6021 updated: None,
6022 parent: None,
6023 subtasks: vec![],
6024 issuelinks: vec![],
6025 attachment: vec![],
6026 },
6027 })),
6028 },
6029 ],
6030 attachment: vec![],
6031 },
6032 };
6033
6034 let relations = map_relations(&issue, JiraFlavor::Cloud, "https://test.atlassian.net");
6035
6036 assert_eq!(relations.blocks.len(), 1);
6037 assert_eq!(relations.blocks[0].issue.key, "jira#PROJ-2");
6038 assert_eq!(relations.blocks[0].link_type, "Blocks");
6039 assert_eq!(relations.blocked_by.len(), 1);
6040 assert_eq!(relations.blocked_by[0].issue.key, "jira#PROJ-3");
6041 }
6042
6043 #[test]
6044 fn test_map_relations_with_issuelinks_duplicates() {
6045 let issue = JiraIssue {
6046 id: "10001".to_string(),
6047 key: "PROJ-1".to_string(),
6048 fields: JiraIssueFields {
6049 summary: Some("Test".to_string()),
6050 description: None,
6051 status: None,
6052 priority: None,
6053 assignee: None,
6054 reporter: None,
6055 labels: vec![],
6056 created: None,
6057 updated: None,
6058 parent: None,
6059 subtasks: vec![],
6060 issuelinks: vec![
6061 JiraIssueLink {
6063 id: Some("1".to_string()),
6064 link_type: JiraIssueLinkType {
6065 name: "Duplicate".to_string(),
6066 outward: Some("duplicates".to_string()),
6067 inward: Some("is duplicated by".to_string()),
6068 },
6069 outward_issue: Some(Box::new(JiraIssue {
6070 id: "10002".to_string(),
6071 key: "PROJ-2".to_string(),
6072 fields: JiraIssueFields {
6073 summary: Some("Dup outward".to_string()),
6074 description: None,
6075 status: None,
6076 priority: None,
6077 assignee: None,
6078 reporter: None,
6079 labels: vec![],
6080 created: None,
6081 updated: None,
6082 parent: None,
6083 subtasks: vec![],
6084 issuelinks: vec![],
6085 attachment: vec![],
6086 },
6087 })),
6088 inward_issue: None,
6089 },
6090 JiraIssueLink {
6092 id: Some("2".to_string()),
6093 link_type: JiraIssueLinkType {
6094 name: "Duplicate".to_string(),
6095 outward: Some("duplicates".to_string()),
6096 inward: Some("is duplicated by".to_string()),
6097 },
6098 outward_issue: None,
6099 inward_issue: Some(Box::new(JiraIssue {
6100 id: "10003".to_string(),
6101 key: "PROJ-3".to_string(),
6102 fields: JiraIssueFields {
6103 summary: Some("Dup inward".to_string()),
6104 description: None,
6105 status: None,
6106 priority: None,
6107 assignee: None,
6108 reporter: None,
6109 labels: vec![],
6110 created: None,
6111 updated: None,
6112 parent: None,
6113 subtasks: vec![],
6114 issuelinks: vec![],
6115 attachment: vec![],
6116 },
6117 })),
6118 },
6119 ],
6120 attachment: vec![],
6121 },
6122 };
6123
6124 let relations = map_relations(&issue, JiraFlavor::Cloud, "https://test.atlassian.net");
6125
6126 assert_eq!(relations.duplicates.len(), 2);
6128 assert_eq!(relations.duplicates[0].issue.key, "jira#PROJ-2");
6129 assert_eq!(relations.duplicates[1].issue.key, "jira#PROJ-3");
6130 }
6131
6132 #[test]
6133 fn test_map_relations_with_issuelinks_relates() {
6134 let issue = JiraIssue {
6135 id: "10001".to_string(),
6136 key: "PROJ-1".to_string(),
6137 fields: JiraIssueFields {
6138 summary: Some("Test".to_string()),
6139 description: None,
6140 status: None,
6141 priority: None,
6142 assignee: None,
6143 reporter: None,
6144 labels: vec![],
6145 created: None,
6146 updated: None,
6147 parent: None,
6148 subtasks: vec![],
6149 issuelinks: vec![JiraIssueLink {
6150 id: Some("1".to_string()),
6151 link_type: JiraIssueLinkType {
6152 name: "Relates".to_string(),
6153 outward: Some("relates to".to_string()),
6154 inward: Some("relates to".to_string()),
6155 },
6156 outward_issue: Some(Box::new(JiraIssue {
6157 id: "10002".to_string(),
6158 key: "PROJ-2".to_string(),
6159 fields: JiraIssueFields {
6160 summary: Some("Related".to_string()),
6161 description: None,
6162 status: None,
6163 priority: None,
6164 assignee: None,
6165 reporter: None,
6166 labels: vec![],
6167 created: None,
6168 updated: None,
6169 parent: None,
6170 subtasks: vec![],
6171 issuelinks: vec![],
6172 attachment: vec![],
6173 },
6174 })),
6175 inward_issue: None,
6176 }],
6177 attachment: vec![],
6178 },
6179 };
6180
6181 let relations = map_relations(&issue, JiraFlavor::Cloud, "https://test.atlassian.net");
6182
6183 assert_eq!(relations.related_to.len(), 1);
6184 assert_eq!(relations.related_to[0].issue.key, "jira#PROJ-2");
6185 assert_eq!(relations.related_to[0].link_type, "Relates");
6186 }
6187
6188 #[test]
6189 fn test_map_relations_mixed() {
6190 let issue = JiraIssue {
6191 id: "10001".to_string(),
6192 key: "PROJ-1".to_string(),
6193 fields: JiraIssueFields {
6194 summary: Some("Main".to_string()),
6195 description: None,
6196 status: None,
6197 priority: None,
6198 assignee: None,
6199 reporter: None,
6200 labels: vec![],
6201 created: None,
6202 updated: None,
6203 parent: Some(Box::new(JiraIssue {
6204 id: "10000".to_string(),
6205 key: "PROJ-0".to_string(),
6206 fields: JiraIssueFields {
6207 summary: Some("Parent".to_string()),
6208 description: None,
6209 status: None,
6210 priority: None,
6211 assignee: None,
6212 reporter: None,
6213 labels: vec![],
6214 created: None,
6215 updated: None,
6216 parent: None,
6217 subtasks: vec![],
6218 issuelinks: vec![],
6219 attachment: vec![],
6220 },
6221 })),
6222 subtasks: vec![JiraIssue {
6223 id: "10002".to_string(),
6224 key: "PROJ-2".to_string(),
6225 fields: JiraIssueFields {
6226 summary: Some("Sub".to_string()),
6227 description: None,
6228 status: None,
6229 priority: None,
6230 assignee: None,
6231 reporter: None,
6232 labels: vec![],
6233 created: None,
6234 updated: None,
6235 parent: None,
6236 subtasks: vec![],
6237 issuelinks: vec![],
6238 attachment: vec![],
6239 },
6240 }],
6241 issuelinks: vec![JiraIssueLink {
6242 id: Some("1".to_string()),
6243 link_type: JiraIssueLinkType {
6244 name: "Blocks".to_string(),
6245 outward: Some("blocks".to_string()),
6246 inward: Some("is blocked by".to_string()),
6247 },
6248 outward_issue: Some(Box::new(JiraIssue {
6249 id: "10003".to_string(),
6250 key: "PROJ-3".to_string(),
6251 fields: JiraIssueFields {
6252 summary: Some("Blocked".to_string()),
6253 description: None,
6254 status: None,
6255 priority: None,
6256 assignee: None,
6257 reporter: None,
6258 labels: vec![],
6259 created: None,
6260 updated: None,
6261 parent: None,
6262 subtasks: vec![],
6263 issuelinks: vec![],
6264 attachment: vec![],
6265 },
6266 })),
6267 inward_issue: None,
6268 }],
6269 attachment: vec![],
6270 },
6271 };
6272
6273 let relations = map_relations(&issue, JiraFlavor::Cloud, "https://test.atlassian.net");
6274
6275 assert!(relations.parent.is_some());
6276 assert_eq!(relations.parent.unwrap().key, "jira#PROJ-0");
6277 assert_eq!(relations.subtasks.len(), 1);
6278 assert_eq!(relations.subtasks[0].key, "jira#PROJ-2");
6279 assert_eq!(relations.blocks.len(), 1);
6280 assert_eq!(relations.blocks[0].issue.key, "jira#PROJ-3");
6281 assert!(relations.blocked_by.is_empty());
6282 assert!(relations.related_to.is_empty());
6283 assert!(relations.duplicates.is_empty());
6284 }
6285
6286 #[test]
6291 fn test_build_forest_tree_empty() {
6292 let tree = build_forest_tree(&[], &[]).unwrap();
6293 assert!(tree.is_empty());
6294 }
6295
6296 #[test]
6297 fn test_build_forest_tree_flat() {
6298 let rows = vec![
6299 JiraForestRow {
6300 id: 1,
6301 item_id: Some("PROJ-1".into()),
6302 item_type: Some("issue".into()),
6303 },
6304 JiraForestRow {
6305 id: 2,
6306 item_id: Some("PROJ-2".into()),
6307 item_type: Some("issue".into()),
6308 },
6309 ];
6310 let depths = vec![0, 0];
6311 let tree = build_forest_tree(&rows, &depths).unwrap();
6312 assert_eq!(tree.len(), 2);
6313 assert_eq!(tree[0].row_id, 1);
6314 assert_eq!(tree[1].row_id, 2);
6315 assert!(tree[0].children.is_empty());
6316 assert!(tree[1].children.is_empty());
6317 }
6318
6319 #[test]
6320 fn test_build_forest_tree_rejects_mismatched_lengths() {
6321 let rows = vec![JiraForestRow {
6322 id: 1,
6323 item_id: Some("PROJ-1".into()),
6324 item_type: None,
6325 }];
6326 let depths = vec![0, 1];
6327 let err = build_forest_tree(&rows, &depths).expect_err("mismatch must be rejected");
6328 assert!(
6329 matches!(err, Error::InvalidData(ref msg) if msg.contains("1 rows but 2 depths")),
6330 "unexpected error: {err:?}"
6331 );
6332 }
6333
6334 #[test]
6335 fn test_build_forest_tree_nested() {
6336 let rows = vec![
6341 JiraForestRow {
6342 id: 1,
6343 item_id: Some("PROJ-1".into()),
6344 item_type: None,
6345 },
6346 JiraForestRow {
6347 id: 2,
6348 item_id: Some("PROJ-2".into()),
6349 item_type: None,
6350 },
6351 JiraForestRow {
6352 id: 3,
6353 item_id: Some("PROJ-3".into()),
6354 item_type: None,
6355 },
6356 JiraForestRow {
6357 id: 4,
6358 item_id: Some("PROJ-4".into()),
6359 item_type: None,
6360 },
6361 ];
6362 let depths = vec![0, 1, 2, 1];
6363 let tree = build_forest_tree(&rows, &depths).unwrap();
6364
6365 assert_eq!(tree.len(), 1);
6366 assert_eq!(tree[0].row_id, 1);
6367 assert_eq!(tree[0].children.len(), 2);
6368 assert_eq!(tree[0].children[0].row_id, 2);
6369 assert_eq!(tree[0].children[0].children.len(), 1);
6370 assert_eq!(tree[0].children[0].children[0].row_id, 3);
6371 assert_eq!(tree[0].children[1].row_id, 4);
6372 assert!(tree[0].children[1].children.is_empty());
6373 }
6374
6375 #[test]
6376 fn test_build_forest_tree_multiple_roots() {
6377 let rows = vec![
6378 JiraForestRow {
6379 id: 1,
6380 item_id: Some("PROJ-1".into()),
6381 item_type: None,
6382 },
6383 JiraForestRow {
6384 id: 2,
6385 item_id: Some("PROJ-2".into()),
6386 item_type: None,
6387 },
6388 JiraForestRow {
6389 id: 3,
6390 item_id: Some("PROJ-3".into()),
6391 item_type: None,
6392 },
6393 JiraForestRow {
6394 id: 4,
6395 item_id: Some("PROJ-4".into()),
6396 item_type: None,
6397 },
6398 ];
6399 let depths = vec![0, 1, 0, 1];
6400 let tree = build_forest_tree(&rows, &depths).unwrap();
6401
6402 assert_eq!(tree.len(), 2);
6403 assert_eq!(tree[0].children.len(), 1);
6404 assert_eq!(tree[1].children.len(), 1);
6405 }
6406
6407 mod structure_integration {
6412 use super::*;
6413 use devboy_core::StructureRowItem;
6414 use httpmock::prelude::*;
6415
6416 fn token(s: &str) -> SecretString {
6417 SecretString::from(s.to_string())
6418 }
6419
6420 fn create_client(server: &MockServer) -> JiraClient {
6421 JiraClient::with_base_url(
6426 server.base_url(),
6427 "PROJ",
6428 "user@example.com",
6429 token("token"),
6430 false,
6431 )
6432 }
6433
6434 #[tokio::test]
6435 async fn test_get_structures() {
6436 let server = MockServer::start();
6437
6438 server.mock(|when, then| {
6439 when.method(GET).path("/rest/structure/2.0/structure");
6440 then.status(200).json_body(serde_json::json!({
6441 "structures": [
6442 {"id": 1, "name": "Q1 Planning", "description": "Quarter 1"},
6443 {"id": 2, "name": "Sprint Board"}
6444 ]
6445 }));
6446 });
6447
6448 let client = create_client(&server);
6449 let result = client.get_structures().await.unwrap();
6450 assert_eq!(result.items.len(), 2);
6451 assert_eq!(result.items[0].name, "Q1 Planning");
6452 assert_eq!(result.items[1].id, 2);
6453 }
6454
6455 #[tokio::test]
6456 async fn test_get_structure_forest() {
6457 let server = MockServer::start();
6458
6459 server.mock(|when, then| {
6460 when.method(POST).path("/rest/structure/2.0/forest/1/spec");
6461 then.status(200).json_body(serde_json::json!({
6462 "version": 42,
6463 "rows": [
6464 {"id": 100, "itemId": "PROJ-1", "itemType": "issue"},
6465 {"id": 101, "itemId": "PROJ-2", "itemType": "issue"},
6466 {"id": 102, "itemId": "PROJ-3", "itemType": "issue"}
6467 ],
6468 "depths": [0, 1, 1],
6469 "totalCount": 3
6470 }));
6471 });
6472
6473 let client = create_client(&server);
6474 let forest = client
6475 .get_structure_forest(
6476 1,
6477 GetForestOptions {
6478 offset: None,
6479 limit: Some(200),
6480 },
6481 )
6482 .await
6483 .unwrap();
6484
6485 assert_eq!(forest.version, 42);
6486 assert_eq!(forest.structure_id, 1);
6487 assert_eq!(forest.total_count, Some(3));
6488 assert_eq!(forest.tree.len(), 1); assert_eq!(forest.tree[0].item_id, Some("PROJ-1".into()));
6490 assert_eq!(forest.tree[0].children.len(), 2);
6491 }
6492
6493 #[tokio::test]
6494 async fn test_create_structure() {
6495 let server = MockServer::start();
6496
6497 server.mock(|when, then| {
6498 when.method(POST).path("/rest/structure/2.0/structure");
6499 then.status(200).json_body(serde_json::json!({
6500 "id": 99,
6501 "name": "New Structure",
6502 "description": "Test"
6503 }));
6504 });
6505
6506 let client = create_client(&server);
6507 let result = client
6508 .create_structure(CreateStructureInput {
6509 name: "New Structure".into(),
6510 description: Some("Test".into()),
6511 })
6512 .await
6513 .unwrap();
6514
6515 assert_eq!(result.id, 99);
6516 assert_eq!(result.name, "New Structure");
6517 }
6518
6519 #[tokio::test]
6520 async fn test_remove_structure_row() {
6521 let server = MockServer::start();
6522
6523 server.mock(|when, then| {
6524 when.method(DELETE)
6525 .path("/rest/structure/2.0/forest/1/item/100");
6526 then.status(204);
6527 });
6528
6529 let client = create_client(&server);
6530 client.remove_structure_row(1, 100).await.unwrap();
6531 }
6532
6533 #[tokio::test]
6534 async fn test_get_structure_views() {
6535 let server = MockServer::start();
6536
6537 server.mock(|when, then| {
6538 when.method(GET)
6539 .path("/rest/structure/2.0/view")
6540 .query_param("structureId", "1");
6541 then.status(200).json_body(serde_json::json!({
6542 "views": [
6543 {"id": 10, "name": "Default View", "structureId": 1, "columns": []},
6544 {"id": 11, "name": "Sprint View", "structureId": 1, "columns": [
6545 {"field": "summary"},
6546 {"field": "status"},
6547 {"formula": "SUM(\"Story Points\")"}
6548 ]}
6549 ]
6550 }));
6551 });
6552
6553 let client = create_client(&server);
6554 let views = client.get_structure_views(1, None).await.unwrap();
6555 assert_eq!(views.len(), 2);
6556 assert_eq!(views[1].columns.len(), 3);
6557 }
6558
6559 #[tokio::test]
6560 async fn test_get_structure_views_by_id_accepts_matching_structure() {
6561 let server = MockServer::start();
6562 server.mock(|when, then| {
6563 when.method(GET).path("/rest/structure/2.0/view/10");
6564 then.status(200).json_body(serde_json::json!({
6565 "id": 10,
6566 "name": "Default View",
6567 "structureId": 1,
6568 "columns": []
6569 }));
6570 });
6571
6572 let client = create_client(&server);
6573 let views = client.get_structure_views(1, Some(10)).await.unwrap();
6574 assert_eq!(views.len(), 1);
6575 assert_eq!(views[0].id, 10);
6576 }
6577
6578 #[tokio::test]
6579 async fn test_get_structure_views_by_id_rejects_cross_structure_view() {
6580 let server = MockServer::start();
6584 server.mock(|when, then| {
6585 when.method(GET).path("/rest/structure/2.0/view/99");
6586 then.status(200).json_body(serde_json::json!({
6587 "id": 99,
6588 "name": "Sibling view",
6589 "structureId": 7,
6590 "columns": []
6591 }));
6592 });
6593
6594 let client = create_client(&server);
6595 let err = client
6596 .get_structure_views(1, Some(99))
6597 .await
6598 .expect_err("mismatched structure must error");
6599 match err {
6600 Error::InvalidData(msg) => {
6601 assert!(msg.contains("belongs to structure 7"), "got: {msg}");
6602 assert!(msg.contains("but 1 was requested"), "got: {msg}");
6603 }
6604 other => panic!("expected InvalidData, got {other:?}"),
6605 }
6606 }
6607
6608 #[tokio::test]
6613 async fn test_structure_api_404_html_is_sanitised_end_to_end() {
6614 let server = MockServer::start();
6618 let jira_404_html =
6619 "<!DOCTYPE html><html><head><title>Oops, you've found a dead link.</title>"
6620 .to_string()
6621 + &"<script>var a=1;</script>".repeat(100)
6622 + "</head><body>404</body></html>";
6623 server.mock(|when, then| {
6624 when.method(GET).path("/rest/structure/2.0/structure");
6625 then.status(404)
6626 .header("content-type", "text/html;charset=UTF-8")
6627 .body(jira_404_html.clone());
6628 });
6629
6630 let client = create_client(&server);
6631 let err = client
6632 .get_structures()
6633 .await
6634 .expect_err("404 must error out");
6635 let msg = err.to_string();
6636 assert!(
6637 !msg.contains("<!DOCTYPE") && !msg.contains("<script>"),
6638 "HTML leaked into error message: {}",
6639 &msg[..msg.len().min(400)]
6640 );
6641 assert!(
6642 msg.contains("endpoint not found"),
6643 "expected soft wording: {msg}"
6644 );
6645 }
6646
6647 #[tokio::test]
6648 async fn test_structure_api_xml_404_is_sanitised_end_to_end() {
6649 let server = MockServer::start();
6652 let xml = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><status><status-code>404</status-code><message>null for uri: /rest/structure/2.0/structure</message></status>"#;
6653 server.mock(|when, then| {
6654 when.method(GET).path("/rest/structure/2.0/structure");
6655 then.status(404)
6656 .header("content-type", "application/xml")
6657 .body(xml);
6658 });
6659
6660 let client = create_client(&server);
6661 let err = client
6662 .get_structures()
6663 .await
6664 .expect_err("XML 404 must error out");
6665 let msg = err.to_string();
6666 assert!(!msg.contains("<?xml"), "XML leaked: {msg}");
6667 assert!(msg.contains("endpoint not found"));
6668 }
6669
6670 #[tokio::test]
6671 async fn test_structure_api_json_error_forwarded_verbatim() {
6672 let server = MockServer::start();
6676 server.mock(|when, then| {
6677 when.method(PUT).path("/rest/structure/2.0/forest/1/item");
6678 then.status(409).json_body(serde_json::json!({
6679 "errorMessages": ["Forest version conflict"],
6680 "errors": {}
6681 }));
6682 });
6683
6684 let client = create_client(&server);
6685 let err = client
6686 .add_structure_rows(
6687 1,
6688 AddStructureRowsInput {
6689 items: vec![StructureRowItem {
6690 item_id: "PROJ-1".into(),
6691 item_type: None,
6692 }],
6693 under: None,
6694 after: None,
6695 forest_version: Some(100),
6696 },
6697 )
6698 .await
6699 .expect_err("409 must error out");
6700 let msg = err.to_string();
6701 assert!(
6702 msg.contains("Forest version conflict"),
6703 "JSON dropped: {msg}"
6704 );
6705 }
6706
6707 #[tokio::test]
6708 async fn test_structure_api_200_with_html_body_does_not_leak() {
6709 let server = MockServer::start();
6713 let html = "<!DOCTYPE html><html><body>".to_string()
6714 + &"password=secret".repeat(50)
6715 + "</body></html>";
6716 server.mock(|when, then| {
6717 when.method(GET).path("/rest/structure/2.0/structure");
6718 then.status(200)
6719 .header("content-type", "text/html;charset=UTF-8")
6720 .body(html.clone());
6721 });
6722
6723 let client = create_client(&server);
6724 let err = client
6725 .get_structures()
6726 .await
6727 .expect_err("HTML body must fail to parse");
6728 let msg = err.to_string();
6729 assert!(
6730 !msg.contains("password=secret") && !msg.contains("<!DOCTYPE"),
6731 "HTML body leaked into parse-error message: {}",
6732 &msg[..msg.len().min(400)]
6733 );
6734 assert!(msg.contains("redacted"), "missing redaction marker: {msg}");
6735 }
6736
6737 #[tokio::test]
6744 async fn test_list_structures_for_metadata_maps_response() {
6745 let server = MockServer::start();
6746 server.mock(|when, then| {
6747 when.method(GET).path("/rest/structure/2.0/structure");
6748 then.status(200).json_body(serde_json::json!({
6749 "structures": [
6750 {"id": 1, "name": "Q1 Planning", "description": "Quarter 1 plan"},
6751 {"id": 2, "name": "Sprint Board"}
6752 ]
6753 }));
6754 });
6755
6756 let client = create_client(&server);
6757 let refs = client.list_structures_for_metadata().await.unwrap();
6758
6759 assert_eq!(refs.len(), 2);
6760 assert_eq!(refs[0].id, 1);
6761 assert_eq!(refs[0].name, "Q1 Planning");
6762 assert_eq!(refs[0].description.as_deref(), Some("Quarter 1 plan"));
6763 assert_eq!(refs[1].id, 2);
6764 assert_eq!(refs[1].description, None);
6765 }
6766
6767 #[tokio::test]
6768 async fn test_list_structures_for_metadata_returns_empty_on_plugin_missing() {
6769 let server = MockServer::start();
6774 server.mock(|when, then| {
6775 when.method(GET).path("/rest/structure/2.0/structure");
6776 then.status(404)
6777 .header("content-type", "text/html;charset=UTF-8")
6778 .body("<!DOCTYPE html><html><title>Oops</title></html>");
6779 });
6780
6781 let client = create_client(&server);
6782 let refs = client.list_structures_for_metadata().await.unwrap();
6783 assert!(refs.is_empty());
6784 }
6785
6786 #[tokio::test]
6787 async fn test_list_structures_for_metadata_returns_empty_on_200_empty_list() {
6788 let server = MockServer::start();
6789 server.mock(|when, then| {
6790 when.method(GET).path("/rest/structure/2.0/structure");
6791 then.status(200)
6792 .json_body(serde_json::json!({ "structures": [] }));
6793 });
6794
6795 let client = create_client(&server);
6796 let refs = client.list_structures_for_metadata().await.unwrap();
6797 assert!(refs.is_empty());
6798 }
6799
6800 #[tokio::test]
6801 async fn test_list_structures_for_metadata_propagates_401() {
6802 let server = MockServer::start();
6806 server.mock(|when, then| {
6807 when.method(GET).path("/rest/structure/2.0/structure");
6808 then.status(401).body("Unauthorized");
6809 });
6810
6811 let client = create_client(&server);
6812 let err = client
6813 .list_structures_for_metadata()
6814 .await
6815 .expect_err("401 must not be swallowed");
6816 assert!(matches!(err, Error::Unauthorized(_)), "got {err:?}");
6817 }
6818
6819 #[tokio::test]
6820 async fn test_list_structures_for_metadata_propagates_403() {
6821 let server = MockServer::start();
6822 server.mock(|when, then| {
6823 when.method(GET).path("/rest/structure/2.0/structure");
6824 then.status(403).body("Forbidden");
6825 });
6826
6827 let client = create_client(&server);
6828 let err = client
6829 .list_structures_for_metadata()
6830 .await
6831 .expect_err("403 must not be swallowed");
6832 assert!(matches!(err, Error::Forbidden(_)), "got {err:?}");
6833 }
6834
6835 #[tokio::test]
6840 async fn test_structure_generator_lifecycle() {
6841 let server = MockServer::start();
6842
6843 server.mock(|when, then| {
6845 when.method(GET)
6846 .path("/rest/structure/2.0/structure/1/generator");
6847 then.status(200).json_body(serde_json::json!({
6848 "generators": [
6849 { "id": "g1", "type": "jql", "spec": {"query": "project = PROJ"} }
6850 ]
6851 }));
6852 });
6853 server.mock(|when, then| {
6855 when.method(POST)
6856 .path("/rest/structure/2.0/structure/1/generator")
6857 .body_includes("\"type\":\"agile-board\"");
6858 then.status(200).json_body(serde_json::json!({
6859 "id": "g2",
6860 "type": "agile-board",
6861 "spec": {"boardId": 42}
6862 }));
6863 });
6864 server.mock(|when, then| {
6866 when.method(POST)
6867 .path("/rest/structure/2.0/structure/1/generator/g2/sync");
6868 then.status(200).json_body(serde_json::json!({}));
6869 });
6870
6871 let client = create_client(&server);
6872
6873 let list = client.get_structure_generators(1).await.unwrap();
6874 assert_eq!(list.items.len(), 1);
6875 assert_eq!(list.items[0].generator_type, "jql");
6876
6877 let added = client
6878 .add_structure_generator(devboy_core::AddStructureGeneratorInput {
6879 structure_id: 1,
6880 generator_type: "agile-board".into(),
6881 spec: serde_json::json!({"boardId": 42}),
6882 })
6883 .await
6884 .unwrap();
6885 assert_eq!(added.id, "g2");
6886
6887 client
6888 .sync_structure_generator(devboy_core::SyncStructureGeneratorInput {
6889 structure_id: 1,
6890 generator_id: "g2".into(),
6891 })
6892 .await
6893 .unwrap();
6894 }
6895
6896 #[tokio::test]
6901 async fn test_delete_structure() {
6902 let server = MockServer::start();
6903 server.mock(|when, then| {
6904 when.method(DELETE).path("/rest/structure/2.0/structure/7");
6905 then.status(204);
6906 });
6907
6908 let client = create_client(&server);
6909 client.delete_structure(7).await.unwrap();
6910 }
6911
6912 #[tokio::test]
6913 async fn test_structure_automation() {
6914 let server = MockServer::start();
6915
6916 server.mock(|when, then| {
6917 when.method(PUT)
6918 .path("/rest/structure/2.0/structure/5/automation")
6919 .body_includes("\"enabled\":true");
6920 then.status(200).json_body(serde_json::json!({}));
6921 });
6922 server.mock(|when, then| {
6923 when.method(POST)
6924 .path("/rest/structure/2.0/structure/5/automation/run");
6925 then.status(200).json_body(serde_json::json!({}));
6926 });
6927
6928 let client = create_client(&server);
6929 client
6931 .update_structure_automation(devboy_core::UpdateStructureAutomationInput {
6932 structure_id: 5,
6933 automation_id: None,
6934 config: serde_json::json!({"enabled": true}),
6935 })
6936 .await
6937 .unwrap();
6938 client.trigger_structure_automation(5).await.unwrap();
6939 }
6940
6941 #[tokio::test]
6944 async fn test_structure_automation_rule_scoped() {
6945 let server = MockServer::start();
6946 server.mock(|when, then| {
6947 when.method(PUT)
6948 .path("/rest/structure/2.0/structure/5/automation/rule-7")
6949 .body_includes("\"action\":\"move\"");
6950 then.status(200).json_body(serde_json::json!({}));
6951 });
6952
6953 let client = create_client(&server);
6954 client
6955 .update_structure_automation(devboy_core::UpdateStructureAutomationInput {
6956 structure_id: 5,
6957 automation_id: Some("rule-7".into()),
6958 config: serde_json::json!({"action": "move"}),
6959 })
6960 .await
6961 .unwrap();
6962 }
6963 }
6964
6965 mod agile_integration {
6969 use super::*;
6970 use httpmock::prelude::*;
6971
6972 fn token(s: &str) -> SecretString {
6973 SecretString::from(s.to_string())
6974 }
6975
6976 fn create_client(server: &MockServer) -> JiraClient {
6977 JiraClient::with_base_url(
6978 server.base_url(),
6979 "PROJ",
6980 "user@example.com",
6981 token("token"),
6982 false,
6983 )
6984 }
6985
6986 #[tokio::test]
6987 async fn test_get_board_sprints_active() {
6988 let server = MockServer::start();
6989 server.mock(|when, then| {
6990 when.method(GET)
6991 .path("/rest/agile/1.0/board/10/sprint")
6992 .query_param("state", "active");
6993 then.status(200).json_body(serde_json::json!({
6994 "isLast": true,
6995 "values": [
6996 {
6997 "id": 1,
6998 "name": "Sprint 1",
6999 "state": "active",
7000 "originBoardId": 10,
7001 "startDate": "2026-04-01T00:00:00.000Z"
7002 }
7003 ]
7004 }));
7005 });
7006
7007 let client = create_client(&server);
7008 let sprints = client
7009 .get_board_sprints(10, devboy_core::SprintState::Active)
7010 .await
7011 .unwrap();
7012 assert_eq!(sprints.items.len(), 1);
7013 assert_eq!(sprints.items[0].state, "active");
7014 assert_eq!(sprints.items[0].origin_board_id, Some(10));
7015 }
7016
7017 #[tokio::test]
7020 async fn test_get_board_sprints_walks_pagination() {
7021 let server = MockServer::start();
7022 server.mock(|when, then| {
7023 when.method(GET)
7024 .path("/rest/agile/1.0/board/10/sprint")
7025 .query_param("startAt", "0");
7026 then.status(200).json_body(serde_json::json!({
7027 "isLast": false,
7028 "values": [
7029 {"id": 1, "name": "S1", "state": "closed"},
7030 {"id": 2, "name": "S2", "state": "closed"}
7031 ]
7032 }));
7033 });
7034 server.mock(|when, then| {
7035 when.method(GET)
7036 .path("/rest/agile/1.0/board/10/sprint")
7037 .query_param("startAt", "2");
7038 then.status(200).json_body(serde_json::json!({
7039 "isLast": true,
7040 "values": [
7041 {"id": 3, "name": "S3", "state": "active"}
7042 ]
7043 }));
7044 });
7045
7046 let client = create_client(&server);
7047 let sprints = client
7048 .get_board_sprints(10, devboy_core::SprintState::All)
7049 .await
7050 .unwrap();
7051 assert_eq!(sprints.items.len(), 3);
7052 assert_eq!(sprints.items[2].name, "S3");
7053 }
7054
7055 #[tokio::test]
7056 async fn test_get_board_sprints_all_omits_state() {
7057 let server = MockServer::start();
7058 server.mock(|when, then| {
7059 when.method(GET)
7060 .path("/rest/agile/1.0/board/10/sprint")
7061 .is_true(|req| req.query_params().iter().all(|(k, _)| k != "state"));
7062 then.status(200)
7063 .json_body(serde_json::json!({"values": []}));
7064 });
7065
7066 let client = create_client(&server);
7067 let sprints = client
7068 .get_board_sprints(10, devboy_core::SprintState::All)
7069 .await
7070 .unwrap();
7071 assert_eq!(sprints.items.len(), 0);
7072 }
7073
7074 #[tokio::test]
7075 async fn test_assign_to_sprint_strips_jira_prefix() {
7076 let server = MockServer::start();
7077 server.mock(|when, then| {
7078 when.method(POST)
7079 .path("/rest/agile/1.0/sprint/42/issue")
7080 .body_includes("\"issues\":[\"PROJ-1\",\"PROJ-2\"]");
7081 then.status(204);
7082 });
7083
7084 let client = create_client(&server);
7085 client
7086 .assign_to_sprint(devboy_core::AssignToSprintInput {
7087 sprint_id: 42,
7088 issue_keys: vec!["jira#PROJ-1".to_string(), "PROJ-2".to_string()],
7089 })
7090 .await
7091 .unwrap();
7092 }
7093 }
7094
7095 mod versions_integration {
7099 use super::*;
7100 use devboy_core::{ListProjectVersionsParams, UpsertProjectVersionInput};
7101 use httpmock::prelude::*;
7102
7103 fn token(s: &str) -> SecretString {
7104 SecretString::from(s.to_string())
7105 }
7106
7107 fn create_client(server: &MockServer) -> JiraClient {
7108 JiraClient::with_base_url(
7109 server.base_url(),
7110 "PROJ",
7111 "user@example.com",
7112 token("pat-token"),
7113 false,
7114 )
7115 }
7116
7117 fn create_cloud_client(server: &MockServer) -> JiraClient {
7118 JiraClient::with_base_url(
7119 server.base_url(),
7120 "PROJ",
7121 "user@example.com",
7122 token("api-token"),
7123 true,
7124 )
7125 }
7126
7127 fn version_dto(
7128 id: &str,
7129 name: &str,
7130 release_date: Option<&str>,
7131 released: bool,
7132 archived: bool,
7133 ) -> serde_json::Value {
7134 let mut v = serde_json::json!({
7135 "id": id,
7136 "name": name,
7137 "project": "PROJ",
7138 "released": released,
7139 "archived": archived,
7140 });
7141 if let Some(d) = release_date {
7142 v["releaseDate"] = serde_json::json!(d);
7143 }
7144 v
7145 }
7146
7147 #[tokio::test]
7148 async fn list_project_versions_returns_rich_payload() {
7149 let server = MockServer::start();
7150 server.mock(|when, then| {
7151 when.method(GET).path("/project/PROJ/versions");
7152 then.status(200).json_body(serde_json::json!([
7153 {
7154 "id": "10001",
7155 "name": "1.0.0",
7156 "project": "PROJ",
7157 "description": "Initial release",
7158 "startDate": "2025-01-01",
7159 "releaseDate": "2025-02-01",
7160 "released": true,
7161 "archived": false,
7162 "overdue": false,
7163 },
7164 version_dto("10002", "2.0.0", Some("2026-04-01"), false, false),
7165 version_dto("10003", "0.9.0", Some("2024-06-01"), true, true),
7166 ]));
7167 });
7168
7169 let client = create_client(&server);
7170 let result = client
7171 .list_project_versions(ListProjectVersionsParams {
7172 project: "PROJ".into(),
7173 released: None,
7174 archived: None,
7175 limit: None,
7176 include_issue_count: false,
7177 })
7178 .await
7179 .unwrap();
7180
7181 assert_eq!(result.items.len(), 3);
7182 assert_eq!(result.items[0].name, "2.0.0");
7184 assert_eq!(result.items[1].name, "1.0.0");
7185 assert_eq!(result.items[2].name, "0.9.0");
7186 assert_eq!(
7187 result.items[1].description.as_deref(),
7188 Some("Initial release")
7189 );
7190 assert_eq!(result.items[1].source, "jira");
7191 }
7192
7193 #[tokio::test]
7194 async fn list_project_versions_filters_archived_and_released() {
7195 let server = MockServer::start();
7196 server.mock(|when, then| {
7197 when.method(GET).path("/project/PROJ/versions");
7198 then.status(200).json_body(serde_json::json!([
7199 version_dto("1", "current", Some("2026-04-01"), false, false),
7200 version_dto("2", "shipped", Some("2025-12-01"), true, false),
7201 version_dto("3", "old", Some("2024-01-01"), true, true),
7202 ]));
7203 });
7204
7205 let client = create_client(&server);
7206
7207 let unreleased_only = client
7208 .list_project_versions(ListProjectVersionsParams {
7209 project: "PROJ".into(),
7210 released: Some(false),
7211 archived: Some(false),
7212 limit: None,
7213 include_issue_count: false,
7214 })
7215 .await
7216 .unwrap();
7217 assert_eq!(unreleased_only.items.len(), 1);
7218 assert_eq!(unreleased_only.items[0].name, "current");
7219
7220 }
7223
7224 #[tokio::test]
7225 async fn list_project_versions_applies_limit_and_keeps_most_recent() {
7226 let server = MockServer::start();
7227 server.mock(|when, then| {
7228 when.method(GET).path("/project/PROJ/versions");
7229 then.status(200).json_body(serde_json::json!([
7230 version_dto("1", "v1", Some("2024-01-01"), true, false),
7231 version_dto("2", "v2", Some("2025-01-01"), true, false),
7232 version_dto("3", "v3", Some("2026-01-01"), true, false),
7233 version_dto("4", "v4", Some("2026-02-01"), false, false),
7234 ]));
7235 });
7236
7237 let client = create_client(&server);
7238 let result = client
7239 .list_project_versions(ListProjectVersionsParams {
7240 project: "PROJ".into(),
7241 released: None,
7242 archived: None,
7243 limit: Some(2),
7244 include_issue_count: false,
7245 })
7246 .await
7247 .unwrap();
7248 assert_eq!(result.items.len(), 2);
7249 assert_eq!(result.items[0].name, "v4");
7250 assert_eq!(result.items[1].name, "v3");
7251 }
7252
7253 #[tokio::test]
7254 async fn list_project_versions_passes_expand_query_on_cloud() {
7255 let server = MockServer::start();
7260 let mock = server.mock(|when, then| {
7261 when.method(GET)
7262 .path("/project/PROJ/versions")
7263 .query_param("expand", "issuesstatus");
7264 then.status(200).json_body(serde_json::json!([
7265 {
7266 "id": "1",
7267 "name": "v1",
7268 "released": false,
7269 "archived": false,
7270 "issuesStatusForFixVersion": {
7271 "unmapped": 0,
7272 "toDo": 5,
7273 "inProgress": 3,
7274 "done": 2
7275 }
7276 }
7277 ]));
7278 });
7279
7280 let client = create_cloud_client(&server);
7281 let result = client
7282 .list_project_versions(ListProjectVersionsParams {
7283 project: "PROJ".into(),
7284 released: None,
7285 archived: None,
7286 limit: None,
7287 include_issue_count: true,
7288 })
7289 .await
7290 .unwrap();
7291 mock.assert();
7292 assert_eq!(result.items.len(), 1);
7293 assert_eq!(result.items[0].issue_count, Some(10));
7294 }
7295
7296 #[tokio::test]
7297 async fn list_project_versions_omits_expand_on_self_hosted() {
7298 let server = MockServer::start();
7304 let bare_mock = server.mock(|when, then| {
7305 when.method(GET).path("/project/PROJ/versions");
7306 then.status(200).json_body(serde_json::json!([{
7307 "id": "1",
7308 "name": "v1",
7309 "released": false,
7310 "archived": false,
7311 "issuesUnresolvedCount": 4,
7312 }]));
7313 });
7314 let expanded_mock = server.mock(|when, then| {
7315 when.method(GET)
7316 .path("/project/PROJ/versions")
7317 .query_param("expand", "issuesstatus");
7318 then.status(500); });
7320
7321 let client = create_client(&server); let result = client
7323 .list_project_versions(ListProjectVersionsParams {
7324 project: "PROJ".into(),
7325 released: None,
7326 archived: None,
7327 limit: None,
7328 include_issue_count: true,
7329 })
7330 .await
7331 .unwrap();
7332 bare_mock.assert();
7333 expanded_mock.assert_calls(0);
7334 assert_eq!(result.items[0].issue_count, None);
7338 assert_eq!(result.items[0].unresolved_issue_count, Some(4));
7339 }
7340
7341 #[tokio::test]
7342 async fn list_project_versions_orders_unreleased_first_then_recent() {
7343 let server = MockServer::start();
7347 server.mock(|when, then| {
7348 when.method(GET).path("/project/PROJ/versions");
7349 then.status(200).json_body(serde_json::json!([
7350 version_dto("1", "9.10.0", Some("2026-04-01"), true, false),
7351 version_dto("2", "10.0.0", Some("2026-04-02"), false, false),
7352 version_dto("3", "next", None, false, false),
7353 version_dto("4", "1.0.0", Some("2024-01-01"), true, true),
7354 ]));
7355 });
7356
7357 let client = create_client(&server);
7358 let result = client
7359 .list_project_versions(ListProjectVersionsParams {
7360 project: "PROJ".into(),
7361 released: None,
7362 archived: None,
7363 limit: None,
7364 include_issue_count: false,
7365 })
7366 .await
7367 .unwrap();
7368 let names: Vec<_> = result.items.iter().map(|v| v.name.as_str()).collect();
7371 assert_eq!(names, vec!["next", "10.0.0", "9.10.0", "1.0.0"]);
7372 }
7373
7374 #[tokio::test]
7375 async fn list_project_versions_pagination_reflects_truncation() {
7376 let server = MockServer::start();
7379 server.mock(|when, then| {
7380 when.method(GET).path("/project/PROJ/versions");
7381 then.status(200).json_body(serde_json::json!([
7382 version_dto("1", "v1", Some("2024-01-01"), true, false),
7383 version_dto("2", "v2", Some("2025-01-01"), true, false),
7384 version_dto("3", "v3", Some("2026-01-01"), true, false),
7385 ]));
7386 });
7387
7388 let client = create_client(&server);
7389 let result = client
7390 .list_project_versions(ListProjectVersionsParams {
7391 project: "PROJ".into(),
7392 released: None,
7393 archived: None,
7394 limit: Some(2),
7395 include_issue_count: false,
7396 })
7397 .await
7398 .unwrap();
7399 let p = result.pagination.expect("pagination must be set");
7400 assert_eq!(p.total, Some(3));
7401 assert_eq!(p.limit, 2);
7402 assert!(p.has_more);
7403
7404 let server2 = MockServer::start();
7406 server2.mock(|when, then| {
7407 when.method(GET).path("/project/PROJ/versions");
7408 then.status(200).json_body(serde_json::json!([version_dto(
7409 "1",
7410 "v1",
7411 Some("2024-01-01"),
7412 true,
7413 false
7414 ),]));
7415 });
7416 let client2 = create_client(&server2);
7417 let result2 = client2
7418 .list_project_versions(ListProjectVersionsParams {
7419 project: "PROJ".into(),
7420 released: None,
7421 archived: None,
7422 limit: Some(20),
7423 include_issue_count: false,
7424 })
7425 .await
7426 .unwrap();
7427 let p2 = result2.pagination.unwrap();
7428 assert_eq!(p2.total, Some(1));
7429 assert!(!p2.has_more);
7430 }
7431
7432 #[test]
7433 fn compare_version_names_handles_semver_and_alpha() {
7434 use std::cmp::Ordering;
7435 assert_eq!(compare_version_names("10.0.0", "9.10.0"), Ordering::Greater);
7436 assert_eq!(compare_version_names("1.0.0", "1.0.0"), Ordering::Equal);
7437 assert_eq!(compare_version_names("1.0.10", "1.0.2"), Ordering::Greater);
7438 assert_eq!(compare_version_names("1.0.0-rc1", "1.0.0"), Ordering::Less);
7440 let _ = compare_version_names("Sprint 42 cleanup", "Sprint 9 cleanup");
7443 }
7444
7445 #[tokio::test]
7446 async fn upsert_project_version_creates_when_missing() {
7447 let server = MockServer::start();
7448 server.mock(|when, then| {
7450 when.method(GET).path("/project/PROJ/versions");
7451 then.status(200).json_body(serde_json::json!([version_dto(
7452 "99",
7453 "1.0.0",
7454 Some("2025-01-01"),
7455 true,
7456 false
7457 ),]));
7458 });
7459 server.mock(|when, then| {
7461 when.method(POST)
7462 .path("/version")
7463 .body_includes("\"name\":\"3.18.0\"")
7464 .body_includes("\"project\":\"PROJ\"")
7465 .body_includes("\"description\":\"Release notes draft\"");
7466 then.status(201).json_body(serde_json::json!({
7467 "id": "10500",
7468 "name": "3.18.0",
7469 "project": "PROJ",
7470 "description": "Release notes draft",
7471 "released": false,
7472 "archived": false,
7473 }));
7474 });
7475
7476 let client = create_client(&server);
7477 let v = client
7478 .upsert_project_version(UpsertProjectVersionInput {
7479 project: "PROJ".into(),
7480 name: "3.18.0".into(),
7481 description: Some("Release notes draft".into()),
7482 start_date: None,
7483 release_date: None,
7484 released: None,
7485 archived: None,
7486 })
7487 .await
7488 .unwrap();
7489 assert_eq!(v.id, "10500");
7490 assert_eq!(v.name, "3.18.0");
7491 assert_eq!(v.description.as_deref(), Some("Release notes draft"));
7492 }
7493
7494 #[tokio::test]
7495 async fn upsert_project_version_updates_when_present() {
7496 let server = MockServer::start();
7497 server.mock(|when, then| {
7499 when.method(GET).path("/project/PROJ/versions");
7500 then.status(200).json_body(serde_json::json!([version_dto(
7501 "777", "3.18.0", None, false, false
7502 ),]));
7503 });
7504 server.mock(|when, then| {
7506 when.method(PUT)
7507 .path("/version/777")
7508 .body_includes("\"description\":\"final notes\"")
7509 .body_includes("\"released\":true")
7510 .body_includes("\"releaseDate\":\"2026-05-01\"");
7511 then.status(200).json_body(serde_json::json!({
7512 "id": "777",
7513 "name": "3.18.0",
7514 "project": "PROJ",
7515 "description": "final notes",
7516 "releaseDate": "2026-05-01",
7517 "released": true,
7518 "archived": false,
7519 }));
7520 });
7521
7522 let client = create_client(&server);
7523 let v = client
7524 .upsert_project_version(UpsertProjectVersionInput {
7525 project: "PROJ".into(),
7526 name: "3.18.0".into(),
7527 description: Some("final notes".into()),
7528 start_date: None,
7529 release_date: Some("2026-05-01".into()),
7530 released: Some(true),
7531 archived: None,
7532 })
7533 .await
7534 .unwrap();
7535 assert_eq!(v.id, "777");
7536 assert!(v.released);
7537 assert_eq!(v.release_date.as_deref(), Some("2026-05-01"));
7538 }
7539
7540 #[tokio::test]
7541 async fn upsert_project_version_partial_update_sends_only_description() {
7542 let server = MockServer::start();
7543 server.mock(|when, then| {
7544 when.method(GET).path("/project/PROJ/versions");
7545 then.status(200).json_body(serde_json::json!([version_dto(
7546 "42",
7547 "2.0.0",
7548 Some("2026-01-01"),
7549 false,
7550 false
7551 ),]));
7552 });
7553 let put_mock = server.mock(|when, then| {
7556 when.method(PUT)
7557 .path("/version/42")
7558 .body_includes("\"description\":\"draft\"")
7559 .body_excludes("\"name\":")
7560 .body_excludes("\"released\":")
7561 .body_excludes("\"archived\":")
7562 .body_excludes("\"releaseDate\":");
7563 then.status(200).json_body(serde_json::json!({
7564 "id": "42",
7565 "name": "2.0.0",
7566 "project": "PROJ",
7567 "description": "draft",
7568 "releaseDate": "2026-01-01",
7569 "released": false,
7570 "archived": false,
7571 }));
7572 });
7573
7574 let client = create_client(&server);
7575 client
7576 .upsert_project_version(UpsertProjectVersionInput {
7577 project: "PROJ".into(),
7578 name: "2.0.0".into(),
7579 description: Some("draft".into()),
7580 start_date: None,
7581 release_date: None,
7582 released: None,
7583 archived: None,
7584 })
7585 .await
7586 .unwrap();
7587 put_mock.assert();
7588 }
7589
7590 #[tokio::test]
7591 async fn upsert_project_version_rejects_empty_name() {
7592 let server = MockServer::start();
7593 let client = create_client(&server);
7594 let err = client
7595 .upsert_project_version(UpsertProjectVersionInput {
7596 project: "PROJ".into(),
7597 name: " ".into(),
7598 ..Default::default()
7599 })
7600 .await
7601 .unwrap_err();
7602 assert!(matches!(err, devboy_core::Error::InvalidData(_)));
7603 }
7604
7605 #[tokio::test]
7606 async fn upsert_project_version_rejects_overlong_name() {
7607 let server = MockServer::start();
7611 let client = create_client(&server);
7612 let err = client
7613 .upsert_project_version(UpsertProjectVersionInput {
7614 project: "PROJ".into(),
7615 name: "x".repeat(256),
7616 ..Default::default()
7617 })
7618 .await
7619 .unwrap_err();
7620 assert!(matches!(err, devboy_core::Error::InvalidData(_)));
7621 }
7622
7623 #[test]
7624 fn duplicate_version_error_classifier_matches_jira_phrasing() {
7625 let dup1 = devboy_core::Error::Api {
7630 status: 400,
7631 message: "A version with this name already exists in this project.".into(),
7632 };
7633 let dup2 = devboy_core::Error::Api {
7634 status: 400,
7635 message: "Name is already used by another version in this project.".into(),
7636 };
7637 let unrelated = devboy_core::Error::Api {
7638 status: 400,
7639 message: "releaseDate is in the wrong format.".into(),
7640 };
7641 assert!(is_duplicate_version_error(&dup1));
7642 assert!(is_duplicate_version_error(&dup2));
7643 assert!(!is_duplicate_version_error(&unrelated));
7644 }
7645
7646 #[tokio::test]
7647 async fn upsert_project_version_propagates_non_duplicate_400() {
7648 let server = MockServer::start();
7651 server.mock(|when, then| {
7652 when.method(GET).path("/project/PROJ/versions");
7653 then.status(200).json_body(serde_json::json!([]));
7654 });
7655 server.mock(|when, then| {
7656 when.method(POST).path("/version");
7657 then.status(400).json_body(serde_json::json!({
7658 "errorMessages": ["releaseDate is in the wrong format."]
7659 }));
7660 });
7661 let client = create_client(&server);
7662 let err = client
7663 .upsert_project_version(UpsertProjectVersionInput {
7664 project: "PROJ".into(),
7665 name: "3.18.0".into(),
7666 release_date: Some("not-a-date".into()),
7667 ..Default::default()
7668 })
7669 .await
7670 .unwrap_err();
7671 assert!(matches!(err, devboy_core::Error::Api { .. }));
7673 }
7674
7675 #[tokio::test]
7676 async fn upsert_project_version_works_on_cloud_flavor() {
7677 let server = MockServer::start();
7681 server.mock(|when, then| {
7682 when.method(GET).path("/project/CLOUDPROJ/versions");
7683 then.status(200).json_body(serde_json::json!([]));
7684 });
7685 let post_mock = server.mock(|when, then| {
7686 when.method(POST)
7687 .path("/version")
7688 .body_includes("\"name\":\"4.0.0\"")
7689 .body_includes("\"project\":\"CLOUDPROJ\"");
7690 then.status(201).json_body(serde_json::json!({
7691 "id": "30001",
7692 "name": "4.0.0",
7693 "project": "CLOUDPROJ",
7694 "description": "Cloud release",
7695 "released": false,
7696 "archived": false,
7697 }));
7701 });
7702
7703 let client = create_cloud_client(&server);
7704 let v = client
7705 .upsert_project_version(UpsertProjectVersionInput {
7706 project: "CLOUDPROJ".into(),
7707 name: "4.0.0".into(),
7708 description: Some("Cloud release".into()),
7709 ..Default::default()
7710 })
7711 .await
7712 .unwrap();
7713 post_mock.assert();
7714 assert_eq!(v.id, "30001");
7715 assert_eq!(v.project, "CLOUDPROJ");
7716 assert_eq!(v.description.as_deref(), Some("Cloud release"));
7717 }
7718 }
7719}