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, JiraField,
36 JiraForestModifyResponse, JiraForestResponse, JiraIssue, JiraIssueLink, JiraIssueTypeStatuses,
37 JiraPriority, JiraProjectStatus, JiraSearchResponse, JiraStatus, JiraStructure,
38 JiraStructureListResponse, JiraStructureValuesResponse, JiraStructureView,
39 JiraStructureViewListResponse, JiraTransition, JiraTransitionsResponse, JiraUser,
40 JiraVersionDto, PriorityName, ProjectKey, TransitionId, TransitionPayload, UpdateIssueFields,
41 UpdateIssuePayload, UpdateVersionPayload,
42};
43
44#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
46#[serde(rename_all = "snake_case")]
47pub enum JiraFlavor {
48 Cloud,
50 SelfHosted,
52}
53
54pub struct JiraClient {
55 base_url: String,
56 instance_url: String,
59 project_key: String,
60 email: String,
61 token: SecretString,
62 flavor: JiraFlavor,
63 proxy_headers: Option<std::collections::HashMap<String, String>>,
64 client: reqwest::Client,
65 field_cache: tokio::sync::OnceCell<std::collections::HashMap<String, Vec<String>>>,
76}
77
78impl JiraClient {
79 pub fn new(
81 url: impl Into<String>,
82 project_key: impl Into<String>,
83 email: impl Into<String>,
84 token: SecretString,
85 ) -> Self {
86 let url = url.into();
87 let flavor = detect_flavor(&url);
88 let instance = url.trim_end_matches('/').to_string();
89 let api_base = build_api_base(&url, flavor);
90 Self {
91 base_url: api_base,
92 instance_url: instance,
93 project_key: project_key.into(),
94 email: email.into(),
95 token,
96 flavor,
97 proxy_headers: None,
98 client: reqwest::Client::builder()
99 .user_agent("devboy-tools")
100 .build()
101 .expect("Failed to create HTTP client"),
102 field_cache: tokio::sync::OnceCell::new(),
103 }
104 }
105
106 pub fn with_proxy(mut self, headers: std::collections::HashMap<String, String>) -> Self {
111 self.proxy_headers = Some(headers);
112 self
113 }
114
115 pub fn with_instance_url(mut self, url: impl Into<String>) -> Self {
118 self.instance_url = url.into().trim_end_matches('/').to_string();
119 self
120 }
121
122 pub fn with_flavor(mut self, flavor: JiraFlavor) -> Self {
126 if self.flavor != flavor {
127 let instance_url = instance_url_from_base(&self.base_url);
129 self.base_url = build_api_base(&instance_url, flavor);
130 self.flavor = flavor;
131 }
132 self
133 }
134
135 pub fn with_base_url(
138 base_url: impl Into<String>,
139 project_key: impl Into<String>,
140 email: impl Into<String>,
141 token: SecretString,
142 flavor: bool, ) -> Self {
144 let url = base_url.into().trim_end_matches('/').to_string();
145 Self {
146 instance_url: url.clone(),
147 base_url: url,
148 project_key: project_key.into(),
149 email: email.into(),
150 token,
151 flavor: if flavor {
152 JiraFlavor::Cloud
153 } else {
154 JiraFlavor::SelfHosted
155 },
156 proxy_headers: None,
157 client: reqwest::Client::builder()
158 .user_agent("devboy-tools")
159 .build()
160 .expect("Failed to create HTTP client"),
161 field_cache: tokio::sync::OnceCell::new(),
162 }
163 }
164
165 fn request(&self, method: reqwest::Method, url: &str) -> reqwest::RequestBuilder {
170 self.request_raw(method, url)
171 .header("Content-Type", "application/json")
172 }
173
174 fn request_raw(&self, method: reqwest::Method, url: &str) -> reqwest::RequestBuilder {
179 let mut builder = self.client.request(method, url);
180
181 if let Some(headers) = &self.proxy_headers {
182 for (key, value) in headers {
183 builder = builder.header(key.as_str(), value.as_str());
184 }
185 } else {
186 builder = match self.flavor {
187 JiraFlavor::Cloud => {
188 let token_value = self.token.expose_secret();
189 let credentials = base64_encode(&format!("{}:{}", self.email, token_value));
190 builder.header("Authorization", format!("Basic {}", credentials))
191 }
192 JiraFlavor::SelfHosted => {
193 let token_value = self.token.expose_secret();
194 if token_value.contains(':') {
195 let credentials = base64_encode(token_value);
196 builder.header("Authorization", format!("Basic {}", credentials))
197 } else {
198 builder.header("Authorization", format!("Bearer {}", token_value))
199 }
200 }
201 };
202 }
203 builder
204 }
205
206 async fn get<T: serde::de::DeserializeOwned>(&self, url: &str) -> Result<T> {
208 debug!(url = url, "Jira GET request");
209
210 let response = self
211 .request(reqwest::Method::GET, url)
212 .send()
213 .await
214 .map_err(|e| Error::Http(e.to_string()))?;
215
216 self.handle_response(response).await
217 }
218
219 async fn post<T: serde::de::DeserializeOwned, B: serde::Serialize>(
221 &self,
222 url: &str,
223 body: &B,
224 ) -> Result<T> {
225 debug!(url = url, "Jira POST 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 self.handle_response(response).await
235 }
236
237 async fn post_no_content<B: serde::Serialize>(&self, url: &str, body: &B) -> Result<()> {
239 debug!(url = url, "Jira POST (no content) request");
240
241 let response = self
242 .request(reqwest::Method::POST, url)
243 .json(body)
244 .send()
245 .await
246 .map_err(|e| Error::Http(e.to_string()))?;
247
248 let status = response.status();
249 if !status.is_success() {
250 let status_code = status.as_u16();
251 let message = response.text().await.unwrap_or_default();
252 warn!(
253 status = status_code,
254 message = message,
255 "Jira API error response"
256 );
257 return Err(Error::from_status(status_code, message));
258 }
259
260 Ok(())
261 }
262
263 async fn put_with_response<T: serde::de::DeserializeOwned, B: serde::Serialize>(
269 &self,
270 url: &str,
271 body: &B,
272 ) -> Result<T> {
273 debug!(url = url, "Jira PUT request (typed response)");
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 self.handle_response(response).await
283 }
284
285 async fn put<B: serde::Serialize>(&self, url: &str, body: &B) -> Result<()> {
287 debug!(url = url, "Jira PUT request");
288
289 let response = self
290 .request(reqwest::Method::PUT, url)
291 .json(body)
292 .send()
293 .await
294 .map_err(|e| Error::Http(e.to_string()))?;
295
296 let status = response.status();
297 if !status.is_success() {
298 let status_code = status.as_u16();
299 let message = response.text().await.unwrap_or_default();
300 warn!(
301 status = status_code,
302 message = message,
303 "Jira API error response"
304 );
305 return Err(Error::from_status(status_code, message));
306 }
307
308 Ok(())
309 }
310
311 async fn handle_response<T: serde::de::DeserializeOwned>(
313 &self,
314 response: reqwest::Response,
315 ) -> Result<T> {
316 let status = response.status();
317
318 if !status.is_success() {
319 let status_code = status.as_u16();
320 let message = response.text().await.unwrap_or_default();
321 warn!(
322 status = status_code,
323 message = message,
324 "Jira API error response"
325 );
326 return Err(Error::from_status(status_code, message));
327 }
328
329 let body = response
330 .text()
331 .await
332 .map_err(|e| Error::InvalidData(format!("Failed to read response body: {}", e)))?;
333
334 serde_json::from_str::<T>(&body).map_err(|e| {
335 let preview = if body.len() > 500 {
337 let end = safe_char_boundary(&body, 500);
338 format!("{}...(truncated, total {} bytes)", &body[..end], body.len())
339 } else {
340 body.clone()
341 };
342 warn!(
343 error = %e,
344 body_preview = preview,
345 "Failed to parse Jira response"
346 );
347 let preview = if body.len() > 300 {
348 let end = safe_char_boundary(&body, 300);
349 format!("{}...(truncated)", &body[..end])
350 } else {
351 body.clone()
352 };
353 Error::InvalidData(format!(
354 "Failed to parse response: {}. Response preview: {}",
355 e, preview
356 ))
357 })
358 }
359
360 async fn transition_issue(&self, key: &str, target_status: &str) -> Result<()> {
369 let url = format!("{}/issue/{}/transitions", self.base_url, key);
370 let transitions: JiraTransitionsResponse = self.get(&url).await?;
371
372 let transition = transitions
374 .transitions
375 .iter()
376 .find(|t| t.to.name.eq_ignore_ascii_case(target_status))
377 .or_else(|| {
378 transitions
380 .transitions
381 .iter()
382 .find(|t| t.name.eq_ignore_ascii_case(target_status))
383 });
384
385 let transition = if let Some(t) = transition {
386 t
387 } else {
388 self.find_transition_by_project_statuses(target_status, &transitions)
390 .await?
391 .ok_or_else(|| {
392 let available: Vec<String> = transitions
393 .transitions
394 .iter()
395 .map(|t| {
396 let cat =
397 t.to.status_category
398 .as_ref()
399 .map(|sc| sc.key.as_str())
400 .unwrap_or("?");
401 format!("{} [{}]", t.to.name, cat)
402 })
403 .collect();
404 Error::InvalidData(format!(
405 "No transition to status '{}' found for issue {}. Available: {:?}",
406 target_status, key, available
407 ))
408 })?
409 };
410
411 let payload = TransitionPayload {
412 transition: TransitionId {
413 id: transition.id.clone(),
414 },
415 };
416
417 let post_url = format!("{}/issue/{}/transitions", self.base_url, key);
418 debug!(
419 issue = key,
420 transition_id = transition.id,
421 target = target_status,
422 "Transitioning issue"
423 );
424
425 let response = self
426 .request(reqwest::Method::POST, &post_url)
427 .json(&payload)
428 .send()
429 .await
430 .map_err(|e| Error::Http(e.to_string()))?;
431
432 let status = response.status();
433 if !status.is_success() {
434 let status_code = status.as_u16();
435 let message = response.text().await.unwrap_or_default();
436 return Err(Error::from_status(status_code, message));
437 }
438
439 Ok(())
440 }
441
442 async fn find_transition_by_project_statuses<'a>(
450 &self,
451 target_status: &str,
452 transitions: &'a JiraTransitionsResponse,
453 ) -> Result<Option<&'a JiraTransition>> {
454 let project_statuses = self.get_project_statuses().await.unwrap_or_default();
455
456 if project_statuses.is_empty() {
457 let category_key = generic_status_to_category(target_status);
459 return Ok(category_key.and_then(|cat| {
460 transitions.transitions.iter().find(|t| {
461 t.to.status_category
462 .as_ref()
463 .is_some_and(|sc| sc.key == cat)
464 })
465 }));
466 }
467
468 let matching_status = project_statuses
470 .iter()
471 .find(|s| s.name.eq_ignore_ascii_case(target_status));
472
473 if let Some(status) = matching_status {
474 if let Some(t) = transitions
476 .transitions
477 .iter()
478 .find(|t| t.to.name.eq_ignore_ascii_case(&status.name))
479 {
480 return Ok(Some(t));
481 }
482 }
483
484 if let Some(category_key) = generic_status_to_category(target_status) {
487 let category_status_names: Vec<&str> = project_statuses
489 .iter()
490 .filter(|s| {
491 s.status_category
492 .as_ref()
493 .is_some_and(|sc| sc.key == category_key)
494 })
495 .map(|s| s.name.as_str())
496 .collect();
497
498 debug!(
499 target = target_status,
500 category = category_key,
501 statuses = ?category_status_names,
502 "Resolved category to project statuses"
503 );
504
505 for status_name in &category_status_names {
507 if let Some(t) = transitions
508 .transitions
509 .iter()
510 .find(|t| t.to.name.eq_ignore_ascii_case(status_name))
511 {
512 return Ok(Some(t));
513 }
514 }
515
516 return Ok(transitions.transitions.iter().find(|t| {
518 t.to.status_category
519 .as_ref()
520 .is_some_and(|sc| sc.key == category_key)
521 }));
522 }
523
524 Ok(None)
525 }
526
527 async fn get_project_statuses(&self) -> Result<Vec<JiraProjectStatus>> {
532 let url = format!("{}/project/{}/statuses", self.base_url, self.project_key);
533 let issue_type_statuses: Vec<JiraIssueTypeStatuses> = self.get(&url).await?;
534
535 let mut seen = std::collections::HashSet::new();
536 let mut statuses = Vec::new();
537
538 for its in &issue_type_statuses {
539 for status in &its.statuses {
540 let name_lower = status.name.to_lowercase();
541 if seen.insert(name_lower) {
542 statuses.push(status.clone());
543 }
544 }
545 }
546
547 debug!(
548 project = self.project_key,
549 count = statuses.len(),
550 "Fetched project statuses"
551 );
552
553 Ok(statuses)
554 }
555
556 fn structure_url(&self, endpoint: &str) -> String {
568 let root = instance_url_from_base(&self.base_url);
569 format!("{}/rest/structure/2.0{}", root, endpoint)
570 }
571
572 async fn structure_get<T: serde::de::DeserializeOwned>(&self, endpoint: &str) -> Result<T> {
574 let url = self.structure_url(endpoint);
575 debug!(url = %url, "Jira Structure GET");
576 let response = self
577 .request(reqwest::Method::GET, &url)
578 .send()
579 .await
580 .map_err(|e| Error::Http(e.to_string()))?;
581 handle_structure_response(response).await
582 }
583
584 async fn structure_post<T: serde::de::DeserializeOwned, B: serde::Serialize>(
586 &self,
587 endpoint: &str,
588 body: &B,
589 ) -> Result<T> {
590 let url = self.structure_url(endpoint);
591 debug!(url = %url, "Jira Structure POST");
592 let response = self
593 .request(reqwest::Method::POST, &url)
594 .json(body)
595 .send()
596 .await
597 .map_err(|e| Error::Http(e.to_string()))?;
598 handle_structure_response(response).await
599 }
600
601 async fn structure_put<T: serde::de::DeserializeOwned, B: serde::Serialize>(
603 &self,
604 endpoint: &str,
605 body: &B,
606 ) -> Result<T> {
607 let url = self.structure_url(endpoint);
608 debug!(url = %url, "Jira Structure PUT");
609 let response = self
610 .request(reqwest::Method::PUT, &url)
611 .json(body)
612 .send()
613 .await
614 .map_err(|e| Error::Http(e.to_string()))?;
615 handle_structure_response(response).await
616 }
617
618 fn agile_url(&self, endpoint: &str) -> String {
621 let root = instance_url_from_base(&self.base_url);
622 format!("{}/rest/agile/1.0{}", root, endpoint)
623 }
624
625 async fn agile_get<T: serde::de::DeserializeOwned>(&self, endpoint: &str) -> Result<T> {
631 let url = self.agile_url(endpoint);
632 debug!(url = %url, "Jira Agile GET");
633 self.get(&url).await
634 }
635
636 async fn agile_post_void<B: serde::Serialize>(&self, endpoint: &str, body: &B) -> Result<()> {
640 let url = self.agile_url(endpoint);
641 debug!(url = %url, "Jira Agile POST");
642 self.post_no_content(&url, body).await
643 }
644
645 async fn structure_delete_request(&self, endpoint: &str) -> Result<()> {
647 let url = self.structure_url(endpoint);
648 debug!(url = %url, "Jira Structure DELETE");
649 let response = self
650 .request(reqwest::Method::DELETE, &url)
651 .send()
652 .await
653 .map_err(|e| Error::Http(e.to_string()))?;
654 let status = response.status();
655 if !status.is_success() {
656 let (content_type, body) = read_structure_error_body(response).await;
657 return Err(structure_error_from_status(
658 status.as_u16(),
659 &content_type,
660 body,
661 ));
662 }
663 Ok(())
664 }
665
666 pub async fn list_structures_for_metadata(
682 &self,
683 ) -> Result<Vec<crate::metadata::JiraStructureRef>> {
684 match self
685 .structure_get::<crate::types::JiraStructureListResponse>("/structure")
686 .await
687 {
688 Ok(resp) => Ok(resp
689 .structures
690 .into_iter()
691 .map(|s| crate::metadata::JiraStructureRef {
692 id: s.id,
693 name: s.name,
694 description: s.description,
695 })
696 .collect()),
697 Err(Error::NotFound(_)) => Ok(vec![]),
700 Err(other) => Err(other),
701 }
702 }
703
704 pub async fn fetch_fields(&self) -> Result<Vec<JiraField>> {
720 let url = format!("{}/field", self.base_url);
721 self.get(&url).await
722 }
723
724 pub async fn resolve_field_id_by_name(&self, name: &str) -> Result<Option<String>> {
738 let cache = self
739 .field_cache
740 .get_or_try_init(|| async {
741 let fields = self.fetch_fields().await?;
742 let mut map: std::collections::HashMap<String, Vec<String>> =
743 std::collections::HashMap::new();
744 for f in fields {
745 map.entry(f.name).or_default().push(f.id);
746 }
747 Ok::<_, Error>(map)
748 })
749 .await?;
750 match cache.get(name).map(Vec::as_slice) {
751 None | Some([]) => Ok(None),
752 Some([id]) => Ok(Some(id.clone())),
753 Some(ids) => Err(Error::InvalidData(format!(
754 "Jira field name `{name}` is ambiguous on this instance — \
755 {n} fields share this name (ids: {joined}). \
756 Use `get_custom_fields` to inspect each field's schema, \
757 then disambiguate by passing the desired id explicitly via \
758 `customFields: {{ \"<id>\": <value> }}`.",
759 n = ids.len(),
760 joined = ids.join(", "),
761 ))),
762 }
763 }
764
765 async fn resolve_well_known_field_id(&self, name: &str) -> Result<String> {
772 self.resolve_field_id_by_name(name).await?.ok_or_else(|| {
773 Error::InvalidData(format!(
774 "Jira field `{name}` not found on this instance. \
775 Use `get_custom_fields` to list available fields, or \
776 pass it explicitly via `customFields` with the right id."
777 ))
778 })
779 }
780
781 async fn read_epic_link_key(&self, issue: &JiraIssue) -> Result<Option<String>> {
788 let cf_id = match self.resolve_field_id_by_name("Epic Link").await? {
789 Some(id) => id,
790 None => return Ok(None),
791 };
792 Ok(issue
793 .fields
794 .extras
795 .get(&cf_id)
796 .and_then(|v| v.as_str())
797 .filter(|s| !s.is_empty())
798 .map(str::to_string))
799 }
800
801 async fn read_epic_description_fallback(&self, issue: &JiraIssue) -> Result<Option<String>> {
813 let is_epic = issue
814 .fields
815 .issuetype
816 .as_ref()
817 .is_some_and(|t| t.name.eq_ignore_ascii_case("Epic"));
818 if !is_epic {
819 return Ok(None);
820 }
821 let cf_id = match self.resolve_field_id_by_name("Epic Description").await? {
822 Some(id) => id,
823 None => return Ok(None),
824 };
825 let raw = match issue.fields.extras.get(&cf_id) {
826 Some(value) => value,
827 None => return Ok(None),
828 };
829 Ok(read_description(&Some(raw.clone()), self.flavor))
830 }
831
832 async fn inject_well_known_customfields(
839 &self,
840 payload: &mut serde_json::Value,
841 epic_key: &Option<String>,
842 epic_name: &Option<String>,
843 ) -> Result<()> {
844 if epic_key.is_none() && epic_name.is_none() {
845 return Ok(());
846 }
847 let fields = payload
848 .get_mut("fields")
849 .and_then(|f| f.as_object_mut())
850 .ok_or_else(|| {
851 Error::InvalidData("payload missing top-level `fields` object".into())
852 })?;
853
854 if let Some(value) = epic_key {
855 let id = self.resolve_well_known_field_id("Epic Link").await?;
856 fields.insert(id, serde_json::json!(value));
857 }
858 if let Some(value) = epic_name {
859 let id = self.resolve_well_known_field_id("Epic Name").await?;
860 fields.insert(id, serde_json::json!(value));
861 }
862 Ok(())
863 }
864
865 pub async fn load_default_metadata(
882 &self,
883 strategy: crate::metadata::MetadataLoadStrategy,
884 ) -> Result<crate::metadata::JiraMetadata> {
885 use crate::metadata::{MAX_ENRICHMENT_PROJECTS, MetadataLoadStrategy};
886
887 match strategy {
888 MetadataLoadStrategy::Configured(keys) => {
889 let effective_keys: Vec<String> = if keys.len() > MAX_ENRICHMENT_PROJECTS {
895 tracing::warn!(
896 requested = keys.len(),
897 cap = MAX_ENRICHMENT_PROJECTS,
898 "Configured project list exceeds enrichment cap; \
899 truncating to the first {} — narrow the list to \
900 silence this warning.",
901 MAX_ENRICHMENT_PROJECTS
902 );
903 keys.into_iter().take(MAX_ENRICHMENT_PROJECTS).collect()
904 } else {
905 keys
906 };
907
908 let mut projects = std::collections::HashMap::new();
909 for key in effective_keys {
910 let project_meta = self.build_project_metadata(&key).await?;
911 projects.insert(key, project_meta);
912 }
913 Ok(crate::metadata::JiraMetadata {
914 flavor: match self.flavor {
915 JiraFlavor::Cloud => crate::metadata::JiraFlavor::Cloud,
916 JiraFlavor::SelfHosted => crate::metadata::JiraFlavor::SelfHosted,
917 },
918 projects,
919 structures: vec![],
920 })
921 }
922 MetadataLoadStrategy::All => {
923 let keys: Vec<String> = match self.flavor {
929 JiraFlavor::Cloud => {
930 let mut collected: Vec<String> = Vec::new();
931 let mut start_at: u32 = 0;
932 let page_size: u32 = (MAX_ENRICHMENT_PROJECTS as u32) + 1;
933 loop {
934 let url = format!(
935 "{}/project/search?startAt={}&maxResults={}",
936 self.base_url, start_at, page_size
937 );
938 let raw: serde_json::Value = self.get(&url).await?;
939 let page_values = raw
940 .get("values")
941 .and_then(|v| v.as_array())
942 .cloned()
943 .unwrap_or_default();
944 let page_len = page_values.len();
945 for v in page_values {
946 if let Some(k) = v.get("key").and_then(|k| k.as_str()) {
947 collected.push(k.to_string());
948 }
949 }
950 let is_last_default = page_len < page_size as usize;
951 let is_last = raw
952 .get("isLast")
953 .and_then(|v| v.as_bool())
954 .unwrap_or(is_last_default);
955 if is_last || page_len == 0 || collected.len() > MAX_ENRICHMENT_PROJECTS
956 {
957 break;
958 }
959 start_at += page_len as u32;
960 }
961 collected
962 }
963 JiraFlavor::SelfHosted => {
964 let url = format!("{}/project", self.base_url);
965 let raw: Vec<serde_json::Value> = self.get(&url).await?;
966 raw.iter()
967 .filter_map(|v| {
968 v.get("key").and_then(|k| k.as_str()).map(str::to_string)
969 })
970 .collect()
971 }
972 };
973
974 if keys.len() > MAX_ENRICHMENT_PROJECTS {
975 return Err(Error::InvalidData(format!(
980 "Jira instance has {} accessible projects, more than the \
981 enrichment cap of {}. Switch to \
982 `MetadataLoadStrategy::MyProjects` (recently-touched) or \
983 `RecentActivity {{ days }}` (recent issue activity) or \
984 `Configured(vec![...])` to narrow the selection.",
985 keys.len(),
986 MAX_ENRICHMENT_PROJECTS,
987 )));
988 }
989
990 let mut projects = std::collections::HashMap::new();
991 for key in keys {
992 let project_meta = self.build_project_metadata(&key).await?;
993 projects.insert(key, project_meta);
994 }
995 Ok(crate::metadata::JiraMetadata {
996 flavor: match self.flavor {
997 JiraFlavor::Cloud => crate::metadata::JiraFlavor::Cloud,
998 JiraFlavor::SelfHosted => crate::metadata::JiraFlavor::SelfHosted,
999 },
1000 projects,
1001 structures: vec![],
1002 })
1003 }
1004 MetadataLoadStrategy::MyProjects => {
1005 let keys: Vec<String> = match self.flavor {
1010 JiraFlavor::Cloud => {
1011 let url = format!(
1012 "{}/project/search?recent={}",
1013 self.base_url, MAX_ENRICHMENT_PROJECTS
1014 );
1015 let raw: serde_json::Value = self.get(&url).await?;
1016 raw.get("values")
1017 .and_then(|v| v.as_array())
1018 .map(|arr| {
1019 arr.iter()
1020 .filter_map(|v| {
1021 v.get("key").and_then(|k| k.as_str()).map(str::to_string)
1022 })
1023 .collect()
1024 })
1025 .unwrap_or_default()
1026 }
1027 JiraFlavor::SelfHosted => {
1028 let url = format!(
1029 "{}/project?recent={}",
1030 self.base_url, MAX_ENRICHMENT_PROJECTS
1031 );
1032 let raw: Vec<serde_json::Value> = self.get(&url).await?;
1033 raw.iter()
1034 .filter_map(|v| {
1035 v.get("key").and_then(|k| k.as_str()).map(str::to_string)
1036 })
1037 .collect()
1038 }
1039 };
1040
1041 let mut projects = std::collections::HashMap::new();
1042 for key in keys.into_iter().take(MAX_ENRICHMENT_PROJECTS) {
1043 let project_meta = self.build_project_metadata(&key).await?;
1044 projects.insert(key, project_meta);
1045 }
1046 Ok(crate::metadata::JiraMetadata {
1047 flavor: match self.flavor {
1048 JiraFlavor::Cloud => crate::metadata::JiraFlavor::Cloud,
1049 JiraFlavor::SelfHosted => crate::metadata::JiraFlavor::SelfHosted,
1050 },
1051 projects,
1052 structures: vec![],
1053 })
1054 }
1055 MetadataLoadStrategy::RecentActivity { days } => {
1056 let jql = format!("updated >= -{days}d ORDER BY updated DESC");
1062 let url = format!("{}/search", self.base_url);
1063 let response = self
1068 .request(reqwest::Method::GET, &url)
1069 .query(&[
1070 ("jql", jql.as_str()),
1071 ("fields", "project"),
1072 ("maxResults", "100"),
1073 ])
1074 .send()
1075 .await
1076 .map_err(|e| Error::Http(e.to_string()))?;
1077 let response: serde_json::Value = self.handle_response(response).await?;
1078
1079 let mut seen = std::collections::HashSet::new();
1080 let mut keys: Vec<String> = Vec::new();
1081 if let Some(issues) = response.get("issues").and_then(|v| v.as_array()) {
1082 for issue in issues {
1083 if let Some(project_key) = issue
1084 .pointer("/fields/project/key")
1085 .and_then(|v| v.as_str())
1086 && seen.insert(project_key.to_string())
1087 {
1088 keys.push(project_key.to_string());
1089 if keys.len() >= MAX_ENRICHMENT_PROJECTS {
1090 break;
1091 }
1092 }
1093 }
1094 }
1095
1096 let mut projects = std::collections::HashMap::new();
1097 for key in keys {
1098 let project_meta = self.build_project_metadata(&key).await?;
1099 projects.insert(key, project_meta);
1100 }
1101 Ok(crate::metadata::JiraMetadata {
1102 flavor: match self.flavor {
1103 JiraFlavor::Cloud => crate::metadata::JiraFlavor::Cloud,
1104 JiraFlavor::SelfHosted => crate::metadata::JiraFlavor::SelfHosted,
1105 },
1106 projects,
1107 structures: vec![],
1108 })
1109 }
1110 }
1111 }
1112
1113 pub async fn build_project_metadata(
1133 &self,
1134 project_key: &str,
1135 ) -> Result<crate::metadata::JiraProjectMetadata> {
1136 let project_url = format!("{}/project/{}", self.base_url, project_key);
1137 let project_value: serde_json::Value = self.get(&project_url).await?;
1138 let issue_types: Vec<crate::metadata::JiraIssueType> = project_value
1139 .get("issueTypes")
1140 .and_then(|v| v.as_array())
1141 .map(|arr| {
1142 arr.iter()
1143 .filter_map(|it| {
1144 Some(crate::metadata::JiraIssueType {
1145 id: it.get("id")?.as_str()?.to_string(),
1146 name: it.get("name")?.as_str()?.to_string(),
1147 subtask: it.get("subtask").and_then(|v| v.as_bool()).unwrap_or(false),
1148 })
1149 })
1150 .collect()
1151 })
1152 .unwrap_or_default();
1153
1154 let comp_url = format!("{}/project/{}/components", self.base_url, project_key);
1155 let comp_raw: Vec<serde_json::Value> = self.get(&comp_url).await?;
1156 let components: Vec<crate::metadata::JiraComponent> = comp_raw
1157 .into_iter()
1158 .filter_map(|v| {
1159 Some(crate::metadata::JiraComponent {
1160 id: v.get("id")?.as_str()?.to_string(),
1161 name: v.get("name")?.as_str()?.to_string(),
1162 })
1163 })
1164 .collect();
1165
1166 let prio_url = format!("{}/priority", self.base_url);
1167 let prio_raw: Vec<serde_json::Value> = self.get(&prio_url).await?;
1168 let priorities: Vec<crate::metadata::JiraPriority> = prio_raw
1169 .into_iter()
1170 .filter_map(|v| {
1171 Some(crate::metadata::JiraPriority {
1172 id: v.get("id")?.as_str()?.to_string(),
1173 name: v.get("name")?.as_str()?.to_string(),
1174 })
1175 })
1176 .collect();
1177
1178 let lt_url = format!("{}/issueLinkType", self.base_url);
1179 let lt_raw: serde_json::Value = self.get(<_url).await?;
1180 let link_types: Vec<crate::metadata::JiraLinkType> = lt_raw
1181 .get("issueLinkTypes")
1182 .and_then(|v| v.as_array())
1183 .map(|arr| {
1184 arr.iter()
1185 .filter_map(|v| {
1186 Some(crate::metadata::JiraLinkType {
1187 id: v.get("id")?.as_str()?.to_string(),
1188 name: v.get("name")?.as_str()?.to_string(),
1189 outward: v
1190 .get("outward")
1191 .and_then(|s| s.as_str())
1192 .map(str::to_string),
1193 inward: v.get("inward").and_then(|s| s.as_str()).map(str::to_string),
1194 })
1195 })
1196 .collect()
1197 })
1198 .unwrap_or_default();
1199
1200 let fields = self.fetch_fields().await?;
1201 let custom_fields: Vec<crate::metadata::JiraCustomField> = fields
1202 .into_iter()
1203 .filter(|f| f.custom)
1204 .map(|f| {
1205 let field_type = infer_jira_field_type(f.schema.as_ref());
1206 crate::metadata::JiraCustomField {
1207 id: f.id,
1208 name: f.name,
1209 field_type,
1210 required: false,
1211 options: vec![],
1212 }
1213 })
1214 .collect();
1215
1216 Ok(crate::metadata::JiraProjectMetadata {
1217 issue_types,
1218 components,
1219 priorities,
1220 link_types,
1221 custom_fields,
1222 })
1223 }
1224}
1225
1226fn infer_jira_field_type(
1232 schema: Option<&crate::types::JiraFieldSchema>,
1233) -> crate::metadata::JiraFieldType {
1234 use crate::metadata::JiraFieldType;
1235 let schema = match schema {
1236 Some(s) => s,
1237 None => return JiraFieldType::Any,
1238 };
1239 match schema.field_type.as_deref() {
1240 Some("array") => JiraFieldType::Array,
1241 Some("number") => JiraFieldType::Number,
1242 Some("string") => JiraFieldType::String,
1243 Some("date") => JiraFieldType::Date,
1244 Some("datetime") => JiraFieldType::DateTime,
1245 Some("option") => JiraFieldType::Option,
1246 _ => JiraFieldType::Any,
1247 }
1248}
1249
1250const 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";
1255
1256fn looks_like_html(content_type: &str, body: &str) -> bool {
1262 let ct = content_type.to_ascii_lowercase();
1263 if ct.contains("text/html") || ct.contains("application/xml") || ct.contains("text/xml") {
1264 return true;
1265 }
1266 let head = body.trim_start();
1267 head.starts_with("<!DOCTYPE")
1268 || head.starts_with("<!doctype")
1269 || head.starts_with("<html")
1270 || head.starts_with("<HTML")
1271 || head.starts_with("<?xml")
1272}
1273
1274async fn read_structure_error_body(response: reqwest::Response) -> (String, String) {
1277 let content_type = response
1278 .headers()
1279 .get(reqwest::header::CONTENT_TYPE)
1280 .and_then(|v| v.to_str().ok())
1281 .unwrap_or("")
1282 .to_string();
1283 let body = response.text().await.unwrap_or_default();
1284 (content_type, body)
1285}
1286
1287fn structure_error_from_status(status: u16, content_type: &str, body: String) -> Error {
1299 let html = looks_like_html(content_type, &body);
1300
1301 if status == 404 && html {
1302 return Error::from_status(
1303 status,
1304 format!("Structure API endpoint not found (HTTP 404). {STRUCTURE_PLUGIN_HINT}"),
1305 );
1306 }
1307
1308 if html {
1309 return Error::from_status(
1310 status,
1311 format!(
1312 "Jira returned a non-JSON (HTML/XML) response for a Structure API call (HTTP {status}). {STRUCTURE_PLUGIN_HINT}"
1313 ),
1314 );
1315 }
1316
1317 let trimmed = if body.len() > 500 {
1318 let end = safe_char_boundary(&body, 500);
1319 format!("{}...(truncated, total {} bytes)", &body[..end], body.len())
1320 } else {
1321 body
1322 };
1323 Error::from_status(status, trimmed)
1324}
1325
1326fn structure_parse_preview(content_type: &str, body: &str) -> String {
1331 if looks_like_html(content_type, body) {
1332 format!(
1333 "<{} bytes of HTML/XML redacted — non-JSON body indicates a non-Structure endpoint or missing plugin>",
1334 body.len()
1335 )
1336 } else if body.len() > 300 {
1337 let end = safe_char_boundary(body, 300);
1338 format!("{}...(truncated, total {} bytes)", &body[..end], body.len())
1339 } else {
1340 body.to_string()
1341 }
1342}
1343
1344async fn handle_structure_response<T: serde::de::DeserializeOwned>(
1349 response: reqwest::Response,
1350) -> Result<T> {
1351 let status = response.status();
1352
1353 if !status.is_success() {
1354 let (content_type, body) = read_structure_error_body(response).await;
1355 warn!(
1356 status = status.as_u16(),
1357 content_type = %content_type,
1358 body_len = body.len(),
1359 "Jira Structure API error response"
1360 );
1361 return Err(structure_error_from_status(
1362 status.as_u16(),
1363 &content_type,
1364 body,
1365 ));
1366 }
1367
1368 let content_type = response
1372 .headers()
1373 .get(reqwest::header::CONTENT_TYPE)
1374 .and_then(|v| v.to_str().ok())
1375 .unwrap_or("")
1376 .to_string();
1377
1378 let body = response.text().await.map_err(|e| {
1379 Error::InvalidData(format!("Failed to read Structure response body: {}", e))
1380 })?;
1381
1382 serde_json::from_str::<T>(&body).map_err(|e| {
1383 let preview = structure_parse_preview(&content_type, &body);
1384 warn!(
1385 error = %e,
1386 body_preview = preview,
1387 content_type = %content_type,
1388 "Failed to parse Jira Structure response"
1389 );
1390 Error::InvalidData(format!(
1391 "Failed to parse Jira Structure response: {}. Body preview: {}",
1392 e, preview
1393 ))
1394 })
1395}
1396
1397fn detect_flavor(url: &str) -> JiraFlavor {
1403 if url.contains(".atlassian.net") {
1404 JiraFlavor::Cloud
1405 } else {
1406 JiraFlavor::SelfHosted
1407 }
1408}
1409
1410fn build_api_base(url: &str, flavor: JiraFlavor) -> String {
1412 let base = url.trim_end_matches('/');
1413 match flavor {
1414 JiraFlavor::Cloud => format!("{}/rest/api/3", base),
1415 JiraFlavor::SelfHosted => format!("{}/rest/api/2", base),
1416 }
1417}
1418
1419fn base64_encode(input: &str) -> String {
1421 const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
1422 let bytes = input.as_bytes();
1423 let mut result = String::new();
1424
1425 for chunk in bytes.chunks(3) {
1426 let b0 = chunk[0] as u32;
1427 let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
1428 let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
1429
1430 let triple = (b0 << 16) | (b1 << 8) | b2;
1431
1432 result.push(CHARSET[((triple >> 18) & 0x3F) as usize] as char);
1433 result.push(CHARSET[((triple >> 12) & 0x3F) as usize] as char);
1434
1435 if chunk.len() > 1 {
1436 result.push(CHARSET[((triple >> 6) & 0x3F) as usize] as char);
1437 } else {
1438 result.push('=');
1439 }
1440
1441 if chunk.len() > 2 {
1442 result.push(CHARSET[(triple & 0x3F) as usize] as char);
1443 } else {
1444 result.push('=');
1445 }
1446 }
1447
1448 result
1449}
1450
1451fn text_to_adf(text: &str) -> serde_json::Value {
1459 if text.is_empty() {
1460 return serde_json::json!({
1461 "version": 1,
1462 "type": "doc",
1463 "content": [{
1464 "type": "paragraph",
1465 "content": []
1466 }]
1467 });
1468 }
1469
1470 let paragraphs: Vec<&str> = text.split("\n\n").collect();
1471 let content: Vec<serde_json::Value> = paragraphs
1472 .iter()
1473 .map(|para| {
1474 let lines: Vec<&str> = para.split('\n').collect();
1475 let mut inline_content: Vec<serde_json::Value> = Vec::new();
1476
1477 for (i, line) in lines.iter().enumerate() {
1478 if i > 0 {
1479 inline_content.push(serde_json::json!({ "type": "hardBreak" }));
1480 }
1481 if !line.is_empty() {
1482 inline_content.push(serde_json::json!({
1483 "type": "text",
1484 "text": *line
1485 }));
1486 }
1487 }
1488
1489 serde_json::json!({
1490 "type": "paragraph",
1491 "content": inline_content
1492 })
1493 })
1494 .collect();
1495
1496 serde_json::json!({
1497 "version": 1,
1498 "type": "doc",
1499 "content": content
1500 })
1501}
1502
1503fn adf_to_text(value: &serde_json::Value) -> String {
1508 match value {
1509 serde_json::Value::String(s) => s.clone(),
1510 serde_json::Value::Object(obj) => {
1511 let doc_type = obj.get("type").and_then(|t| t.as_str());
1512
1513 if doc_type == Some("text") {
1515 return obj
1516 .get("text")
1517 .and_then(|t| t.as_str())
1518 .unwrap_or("")
1519 .to_string();
1520 }
1521
1522 if doc_type == Some("hardBreak") {
1524 return "\n".to_string();
1525 }
1526
1527 if let Some(content) = obj.get("content").and_then(|c| c.as_array()) {
1529 let texts: Vec<String> = content.iter().map(adf_to_text).collect();
1530 let joined = texts.join("");
1531
1532 if doc_type == Some("paragraph") {
1534 return joined;
1535 }
1536 if doc_type == Some("doc") {
1537 let para_texts: Vec<String> = content
1539 .iter()
1540 .map(adf_to_text)
1541 .filter(|s| !s.is_empty())
1542 .collect();
1543 return para_texts.join("\n\n");
1544 }
1545
1546 return joined;
1547 }
1548
1549 String::new()
1550 }
1551 serde_json::Value::Null => String::new(),
1552 other => other.to_string(),
1553 }
1554}
1555
1556fn read_description(value: &Option<serde_json::Value>, flavor: JiraFlavor) -> Option<String> {
1558 let value = value.as_ref()?;
1559 match value {
1560 serde_json::Value::Null => None,
1561 serde_json::Value::String(s) => {
1562 if s.is_empty() {
1563 None
1564 } else {
1565 Some(s.clone())
1566 }
1567 }
1568 _ => {
1569 if flavor == JiraFlavor::Cloud {
1570 let text = adf_to_text(value);
1571 if text.is_empty() { None } else { Some(text) }
1572 } else {
1573 Some(value.to_string())
1575 }
1576 }
1577 }
1578}
1579
1580fn read_comment_body(value: &Option<serde_json::Value>, flavor: JiraFlavor) -> String {
1582 match value {
1583 Some(serde_json::Value::String(s)) => s.clone(),
1584 Some(serde_json::Value::Null) | None => String::new(),
1585 Some(v) => {
1586 if flavor == JiraFlavor::Cloud {
1587 adf_to_text(v)
1588 } else {
1589 v.to_string()
1590 }
1591 }
1592 }
1593}
1594
1595fn map_user(jira_user: Option<&JiraUser>) -> Option<User> {
1600 jira_user.map(|u| {
1601 let id = u
1602 .account_id
1603 .clone()
1604 .or_else(|| u.name.clone())
1605 .unwrap_or_default();
1606 let username = u
1607 .name
1608 .clone()
1609 .or_else(|| u.account_id.clone())
1610 .unwrap_or_default();
1611 User {
1612 id,
1613 username,
1614 name: u.display_name.clone(),
1615 email: u.email_address.clone(),
1616 avatar_url: None,
1617 }
1618 })
1619}
1620
1621fn map_priority(jira_priority: Option<&JiraPriority>) -> Option<String> {
1622 jira_priority.map(|p| match p.name.to_lowercase().as_str() {
1623 "highest" | "critical" | "blocker" => "urgent".to_string(),
1624 "high" => "high".to_string(),
1625 "medium" => "normal".to_string(),
1626 "low" => "low".to_string(),
1627 "lowest" | "trivial" => "low".to_string(),
1628 other => other.to_string(),
1629 })
1630}
1631
1632fn map_state(status: Option<&JiraStatus>) -> String {
1633 status
1634 .map(|s| s.name.clone())
1635 .unwrap_or_else(|| "unknown".to_string())
1636}
1637
1638fn parse_jira_key(key: &str) -> &str {
1641 key.strip_prefix("jira#").unwrap_or(key)
1642}
1643
1644fn jira_link_type_name_and_reversed(link_type: &str) -> (&str, bool) {
1645 match link_type {
1646 "blocks" => ("Blocks", false),
1647 "blocked_by" => ("Blocks", true),
1648 "relates_to" => ("Relates", false),
1649 "duplicates" => ("Duplicate", false),
1650 "duplicated_by" => ("Duplicate", true),
1651 "clones" => ("Cloners", false),
1652 "cloned_by" => ("Cloners", true),
1653 "causes" => ("Causes", false),
1654 "caused_by" => ("Causes", true),
1655 "implements" => ("Implements", false),
1656 "implemented_by" => ("Implements", true),
1657 "created_by" => ("Created By", true),
1658 "creates" => ("Created By", false),
1659 other => (other, false),
1660 }
1661}
1662
1663fn jira_link_type_allows_either_direction(_link_type: &str, link_type_name: &str) -> bool {
1676 link_type_name == "Relates"
1677}
1678
1679fn jira_link_matches_target(
1680 link: &JiraIssueLink,
1681 link_type_name: &str,
1682 target_key: &str,
1683 reversed: bool,
1684 allow_either_direction: bool,
1685) -> bool {
1686 if link.link_type.name != link_type_name {
1687 return false;
1688 }
1689
1690 let outward_key = link.outward_issue.as_ref().map(|issue| issue.key.as_str());
1691 let inward_key = link.inward_issue.as_ref().map(|issue| issue.key.as_str());
1692
1693 let allow_either_direction = match (
1700 link.link_type.inward.as_deref(),
1701 link.link_type.outward.as_deref(),
1702 ) {
1703 (Some(inward), Some(outward)) => inward.eq_ignore_ascii_case(outward),
1704 _ => allow_either_direction,
1706 };
1707
1708 if allow_either_direction {
1709 outward_key == Some(target_key) || inward_key == Some(target_key)
1710 } else if reversed {
1711 inward_key == Some(target_key)
1712 } else {
1713 outward_key == Some(target_key)
1714 }
1715}
1716
1717fn map_issue(issue: &JiraIssue, flavor: JiraFlavor, instance_url: &str) -> Issue {
1718 let custom_fields: std::collections::HashMap<String, devboy_core::CustomFieldValue> = issue
1727 .fields
1728 .extras
1729 .iter()
1730 .filter(|(k, v)| k.starts_with("customfield_") && !v.is_null())
1731 .map(|(k, v)| {
1732 (
1733 k.clone(),
1734 devboy_core::CustomFieldValue {
1735 name: None,
1736 value: v.clone(),
1737 display: None, },
1739 )
1740 })
1741 .collect();
1742 Issue {
1743 custom_fields,
1744 key: format!("jira#{}", issue.key),
1745 title: issue.fields.summary.clone().unwrap_or_default(),
1746 description: read_description(&issue.fields.description, flavor),
1747 state: map_state(issue.fields.status.as_ref()),
1748 status: None, status_category: None,
1750 source: "jira".to_string(),
1751 priority: map_priority(issue.fields.priority.as_ref()),
1752 labels: issue.fields.labels.clone(),
1753 author: map_user(issue.fields.reporter.as_ref()),
1754 assignees: issue
1755 .fields
1756 .assignee
1757 .as_ref()
1758 .map(|a| vec![map_user(Some(a)).unwrap()])
1759 .unwrap_or_default(),
1760 url: Some(format!("{}/browse/{}", instance_url, issue.key)),
1761 created_at: issue.fields.created.clone(),
1762 updated_at: issue.fields.updated.clone(),
1763 attachments_count: if issue.fields.attachment.is_empty() {
1764 None
1765 } else {
1766 Some(issue.fields.attachment.len() as u32)
1767 },
1768 parent: None,
1769 subtasks: vec![],
1770 }
1771}
1772
1773fn map_relations(issue: &JiraIssue, flavor: JiraFlavor, instance_url: &str) -> IssueRelations {
1774 let mut relations = IssueRelations::default();
1775
1776 if let Some(parent) = &issue.fields.parent {
1778 relations.parent = Some(map_issue(parent, flavor, instance_url));
1779 }
1780
1781 relations.subtasks = issue
1783 .fields
1784 .subtasks
1785 .iter()
1786 .map(|s| map_issue(s, flavor, instance_url))
1787 .collect();
1788
1789 for link in &issue.fields.issuelinks {
1791 let link_name = &link.link_type.name;
1792
1793 let outward_lower = link.link_type.outward.as_deref().map(str::to_lowercase);
1794 let inward_lower = link.link_type.inward.as_deref().map(str::to_lowercase);
1795
1796 if let Some(outward) = &link.outward_issue {
1797 let mapped = map_issue(outward, flavor, instance_url);
1798 let issue_link = IssueLink {
1799 issue: mapped,
1800 link_type: link_name.clone(),
1801 };
1802
1803 match outward_lower.as_deref() {
1804 Some(s) if s.contains("block") => relations.blocks.push(issue_link),
1805 Some(s) if s.contains("duplicate") => relations.duplicates.push(issue_link),
1806 _ => relations.related_to.push(issue_link),
1807 }
1808 }
1809
1810 if let Some(inward) = &link.inward_issue {
1811 let mapped = map_issue(inward, flavor, instance_url);
1812 let issue_link = IssueLink {
1813 issue: mapped,
1814 link_type: link_name.clone(),
1815 };
1816
1817 match inward_lower.as_deref() {
1818 Some(s) if s.contains("block") => relations.blocked_by.push(issue_link),
1819 Some(s) if s.contains("duplicate") => relations.duplicates.push(issue_link),
1820 _ => relations.related_to.push(issue_link),
1821 }
1822 }
1823 }
1824
1825 relations
1826}
1827
1828fn map_comment(jira_comment: &JiraComment, flavor: JiraFlavor) -> Comment {
1829 Comment {
1830 id: jira_comment.id.clone(),
1831 body: read_comment_body(&jira_comment.body, flavor),
1832 author: map_user(jira_comment.author.as_ref()),
1833 created_at: jira_comment.created.clone(),
1834 updated_at: jira_comment.updated.clone(),
1835 position: None,
1836 }
1837}
1838
1839fn map_jira_attachment(raw: &JiraAttachment) -> AssetMeta {
1841 let filename = raw
1846 .filename
1847 .clone()
1848 .unwrap_or_else(|| format!("attachment-{}", raw.id));
1849 let author = raw
1850 .author
1851 .as_ref()
1852 .and_then(|u| map_user(Some(u)))
1853 .map(|u| u.name.unwrap_or(u.username));
1854
1855 AssetMeta {
1856 id: raw.id.clone(),
1857 filename,
1858 mime_type: raw.mime_type.clone(),
1859 size: raw.size,
1860 url: raw.content.clone(),
1861 created_at: raw.created.clone(),
1862 author,
1863 cached: false,
1864 local_path: None,
1865 checksum_sha256: None,
1866 analysis: None,
1867 }
1868}
1869
1870fn priority_to_jira(priority: &str) -> String {
1872 match priority {
1873 "urgent" => "Highest".to_string(),
1874 "high" => "High".to_string(),
1875 "normal" => "Medium".to_string(),
1876 "low" => "Low".to_string(),
1877 other => other.to_string(),
1878 }
1879}
1880
1881fn escape_jql(value: &str) -> String {
1889 value.replace('\\', "\\\\").replace('"', "\\\"")
1890}
1891
1892fn merge_custom_fields_into_payload<T: serde::Serialize>(
1897 payload: T,
1898 custom_fields: &Option<serde_json::Value>,
1899) -> Result<(serde_json::Value, usize)> {
1900 let mut value = serde_json::to_value(payload)
1901 .map_err(|e| Error::InvalidData(format!("failed to serialize issue payload: {e}")))?;
1902 let mut merged_count = 0;
1903 if let Some(serde_json::Value::Object(cf)) = custom_fields
1904 && let Some(fields) = value.get_mut("fields").and_then(|f| f.as_object_mut())
1905 {
1906 for (k, v) in cf {
1907 if k.starts_with("customfield_") {
1908 fields.insert(k.clone(), v.clone());
1909 merged_count += 1;
1910 } else {
1911 tracing::warn!(field = %k, "Skipping non-custom field in customFields (expected customfield_* prefix)");
1912 }
1913 }
1914 }
1915 Ok((value, merged_count))
1916}
1917
1918fn has_project_clause(jql: &str) -> bool {
1922 let lower = jql.to_lowercase();
1923 let bytes = lower.as_bytes();
1924 let keyword = b"project";
1925 let mut in_quote = false;
1926 let mut i = 0;
1927
1928 while i < bytes.len() {
1929 if bytes[i] == b'\\' && in_quote && i + 1 < bytes.len() {
1931 i += 2; continue;
1933 }
1934 if bytes[i] == b'"' {
1935 in_quote = !in_quote;
1936 i += 1;
1937 continue;
1938 }
1939 if in_quote {
1940 i += 1;
1941 continue;
1942 }
1943
1944 if i + keyword.len() <= bytes.len() && &bytes[i..i + keyword.len()] == keyword {
1946 if i > 0 && (bytes[i - 1].is_ascii_alphanumeric() || bytes[i - 1] == b'_') {
1948 i += 1;
1949 continue;
1950 }
1951 let after = &lower[i + keyword.len()..];
1953 let trimmed = after.trim_start();
1954 if trimmed.starts_with("!=")
1955 || trimmed.starts_with("not in ")
1956 || trimmed.starts_with("not in(")
1957 || trimmed.starts_with('=')
1958 || trimmed.starts_with('~')
1959 || trimmed.starts_with("in ")
1960 || trimmed.starts_with("in(")
1961 {
1962 return true;
1963 }
1964 }
1965 i += 1;
1966 }
1967 false
1968}
1969
1970fn generic_status_to_category(status: &str) -> Option<&'static str> {
1973 match status.to_lowercase().as_str() {
1974 "closed" | "done" | "resolved" | "canceled" | "cancelled" => Some("done"),
1975 "open" | "new" | "todo" | "to do" | "reopen" | "reopened" => Some("new"),
1976 "in_progress" | "in progress" | "in-progress" => Some("indeterminate"),
1977 _ => None,
1978 }
1979}
1980
1981fn has_unquoted_keyword(jql: &str, keyword: &str) -> bool {
1983 let lower = jql.to_lowercase();
1984 let kw = keyword.to_lowercase();
1985 let kw_bytes = kw.as_bytes();
1986 let bytes = lower.as_bytes();
1987 let mut in_quote = false;
1988 let mut i = 0;
1989
1990 while i < bytes.len() {
1991 if bytes[i] == b'\\' && in_quote && i + 1 < bytes.len() {
1992 i += 2;
1993 continue;
1994 }
1995 if bytes[i] == b'"' {
1996 in_quote = !in_quote;
1997 i += 1;
1998 continue;
1999 }
2000 if !in_quote
2001 && i + kw_bytes.len() <= bytes.len()
2002 && bytes[i..i + kw_bytes.len()] == *kw_bytes
2003 {
2004 return true;
2005 }
2006 i += 1;
2007 }
2008 false
2009}
2010
2011fn instance_url_from_base(base_url: &str) -> String {
2013 base_url
2014 .trim_end_matches("/rest/api/3")
2015 .trim_end_matches("/rest/api/2")
2016 .to_string()
2017}
2018
2019fn build_forest_tree(
2029 rows: &[crate::types::JiraForestRow],
2030 depths: &[u32],
2031) -> Result<Vec<StructureNode>> {
2032 if rows.len() != depths.len() {
2033 return Err(Error::InvalidData(format!(
2034 "Structure forest response has {} rows but {} depths",
2035 rows.len(),
2036 depths.len()
2037 )));
2038 }
2039 let mut roots: Vec<StructureNode> = Vec::new();
2040 let mut stack: Vec<StructureNode> = Vec::new();
2041
2042 for (row, depth) in rows.iter().zip(depths.iter()) {
2043 let depth = *depth as usize;
2044 let node = StructureNode {
2045 row_id: row.id,
2046 item_id: row.item_id.clone(),
2047 item_type: row.item_type.clone(),
2048 children: Vec::new(),
2049 };
2050
2051 while stack.len() > depth {
2053 let child = stack.pop().expect("stack.len() > depth > 0");
2054 if let Some(parent) = stack.last_mut() {
2055 parent.children.push(child);
2056 } else {
2057 roots.push(child);
2058 }
2059 }
2060
2061 stack.push(node);
2062 }
2063
2064 while let Some(child) = stack.pop() {
2066 if let Some(parent) = stack.last_mut() {
2067 parent.children.push(child);
2068 } else {
2069 roots.push(child);
2070 }
2071 }
2072
2073 Ok(roots)
2074}
2075
2076fn map_structure_view(view: crate::types::JiraStructureView) -> StructureView {
2078 StructureView {
2079 id: view.id,
2080 name: view.name,
2081 structure_id: view.structure_id,
2082 columns: view
2083 .columns
2084 .into_iter()
2085 .map(|c| StructureViewColumn {
2086 id: c.id,
2087 field: c.field,
2088 formula: c.formula,
2089 width: c.width,
2090 })
2091 .collect(),
2092 group_by: view.group_by,
2093 sort_by: view.sort_by,
2094 filter: view.filter,
2095 }
2096}
2097
2098#[async_trait]
2103impl IssueProvider for JiraClient {
2104 async fn get_issues(&self, filter: IssueFilter) -> Result<ProviderResult<Issue>> {
2105 let limit = filter.limit.unwrap_or(20);
2106 if limit == 0 {
2107 return Ok(vec![].into());
2108 }
2109 let offset = filter.offset.unwrap_or(0);
2110
2111 let effective_project = filter
2114 .project_key
2115 .as_deref()
2116 .filter(|k| !k.trim().is_empty())
2117 .unwrap_or(&self.project_key);
2118
2119 let escaped_project = escape_jql(effective_project);
2121 let jql = if let Some(native) = &filter.native_query
2122 && !native.trim().is_empty()
2123 {
2124 if has_project_clause(native) {
2127 native.clone()
2128 } else if native.trim_start().to_lowercase().starts_with("order by") {
2129 format!("project = \"{}\" {}", escaped_project, native)
2130 } else {
2131 format!("project = \"{}\" AND {}", escaped_project, native)
2132 }
2133 } else {
2134 let mut jql_parts: Vec<String> = vec![format!("project = \"{}\"", escaped_project)];
2135
2136 if let Some(state) = &filter.state {
2138 match state.as_str() {
2139 "open" | "opened" => {
2140 jql_parts.push("statusCategory != Done".to_string());
2141 }
2142 "closed" | "done" => {
2143 jql_parts.push("statusCategory = Done".to_string());
2144 }
2145 "all" => {} other => {
2147 jql_parts.push(format!("status = \"{}\"", escape_jql(other)));
2149 }
2150 }
2151 }
2152
2153 if let Some(search) = &filter.search {
2154 jql_parts.push(format!("summary ~ \"{}\"", escape_jql(search)));
2155 }
2156
2157 if let Some(labels) = &filter.labels {
2158 for label in labels {
2159 jql_parts.push(format!("labels = \"{}\"", escape_jql(label)));
2160 }
2161 }
2162
2163 if let Some(assignee) = &filter.assignee {
2164 jql_parts.push(format!("assignee = \"{}\"", escape_jql(assignee)));
2165 }
2166
2167 jql_parts.join(" AND ")
2168 };
2169
2170 let order_by = match filter.sort_by.as_deref() {
2172 Some("created_at" | "created") => "created",
2173 Some("priority") => "priority",
2174 _ => "updated",
2175 };
2176 let order = match filter.sort_order.as_deref() {
2177 Some("asc") => "ASC",
2178 _ => "DESC",
2179 };
2180 let has_order_by = has_unquoted_keyword(&jql, "order by");
2181 let jql_with_order = if has_order_by {
2182 jql
2183 } else {
2184 format!("{} ORDER BY {} {}", jql, order_by, order)
2185 };
2186
2187 let instance_url = &self.instance_url;
2188
2189 match self.flavor {
2190 JiraFlavor::Cloud => {
2191 let url = format!("{}/search/jql", self.base_url);
2193
2194 let mut all_issues: Vec<Issue> = Vec::new();
2195 let mut next_page_token: Option<String> = None;
2196 let total_needed = offset.saturating_add(limit);
2197 let mut fetched_count = 0u32;
2198
2199 let fields = "summary,description,status,priority,assignee,reporter,labels,created,updated,parent,subtasks,issuetype,*navigable".to_string();
2203
2204 loop {
2205 let mut params: Vec<(&str, String)> = vec![
2206 ("jql", jql_with_order.clone()),
2207 ("maxResults", std::cmp::min(limit, 50).to_string()),
2208 ("fields", fields.clone()),
2209 ];
2210
2211 if let Some(token) = &next_page_token {
2212 params.push(("nextPageToken", token.clone()));
2213 }
2214
2215 let param_refs: Vec<(&str, &str)> =
2216 params.iter().map(|(k, v)| (*k, v.as_str())).collect();
2217
2218 debug!(url = url, params = ?param_refs, "Jira Cloud search");
2219
2220 let response = self
2221 .request(reqwest::Method::GET, &url)
2222 .query(¶m_refs)
2223 .send()
2224 .await
2225 .map_err(|e| Error::Http(e.to_string()))?;
2226
2227 let search_resp: JiraCloudSearchResponse =
2228 self.handle_response(response).await?;
2229
2230 let page_len = search_resp.issues.len() as u32;
2231 for issue in &search_resp.issues {
2232 if fetched_count >= offset && all_issues.len() < limit as usize {
2233 let mut mapped = map_issue(issue, self.flavor, instance_url);
2234 if mapped.description.as_deref().is_none_or(str::is_empty)
2235 && let Some(epic_desc) =
2236 self.read_epic_description_fallback(issue).await?
2237 {
2238 mapped.description = Some(epic_desc);
2239 }
2240 all_issues.push(mapped);
2241 }
2242 fetched_count += 1;
2243 }
2244
2245 if all_issues.len() >= limit as usize {
2246 break;
2247 }
2248
2249 match search_resp.next_page_token {
2250 Some(token) if page_len > 0 && fetched_count < total_needed => {
2251 next_page_token = Some(token);
2252 }
2253 _ => break,
2254 }
2255 }
2256
2257 let mut result = ProviderResult::new(all_issues);
2258 result.pagination = Some(devboy_core::Pagination {
2259 offset,
2260 limit,
2261 total: None, has_more: next_page_token.is_some(),
2263 next_cursor: next_page_token,
2264 });
2265 result.sort_info = Some(devboy_core::SortInfo {
2266 sort_by: Some(order_by.into()),
2267 sort_order: match order {
2268 "ASC" => devboy_core::SortOrder::Asc,
2269 _ => devboy_core::SortOrder::Desc,
2270 },
2271 available_sorts: vec!["created".into(), "updated".into(), "priority".into()],
2272 });
2273 Ok(result)
2274 }
2275 JiraFlavor::SelfHosted => {
2276 let url = format!("{}/search", self.base_url);
2278
2279 let params: Vec<(&str, String)> = vec![
2280 ("jql", jql_with_order),
2281 ("startAt", offset.to_string()),
2282 ("maxResults", limit.to_string()),
2283 ("fields", "summary,description,status,priority,assignee,reporter,labels,created,updated,parent,subtasks,issuetype,*navigable".to_string()),
2284 ];
2285
2286 let param_refs: Vec<(&str, &str)> =
2287 params.iter().map(|(k, v)| (*k, v.as_str())).collect();
2288
2289 debug!(url = url, params = ?param_refs, "Jira Self-Hosted search");
2290
2291 let response = self
2292 .request(reqwest::Method::GET, &url)
2293 .query(¶m_refs)
2294 .send()
2295 .await
2296 .map_err(|e| Error::Http(e.to_string()))?;
2297
2298 let search_resp: JiraSearchResponse = self.handle_response(response).await?;
2299
2300 let total = search_resp.total;
2301 let has_more = match (total, search_resp.start_at, search_resp.max_results) {
2302 (Some(t), Some(s), Some(m)) => s + m < t,
2303 _ => false,
2304 };
2305
2306 let mut issues: Vec<Issue> = Vec::with_capacity(search_resp.issues.len());
2307 for raw in &search_resp.issues {
2308 let mut mapped = map_issue(raw, self.flavor, instance_url);
2309 if mapped.description.as_deref().is_none_or(str::is_empty)
2310 && let Some(epic_desc) = self.read_epic_description_fallback(raw).await?
2311 {
2312 mapped.description = Some(epic_desc);
2313 }
2314 issues.push(mapped);
2315 }
2316
2317 let mut result = ProviderResult::new(issues);
2318 result.pagination = Some(devboy_core::Pagination {
2319 offset,
2320 limit,
2321 total,
2322 has_more,
2323 next_cursor: None,
2324 });
2325 result.sort_info = Some(devboy_core::SortInfo {
2326 sort_by: Some(order_by.into()),
2327 sort_order: match order {
2328 "ASC" => devboy_core::SortOrder::Asc,
2329 _ => devboy_core::SortOrder::Desc,
2330 },
2331 available_sorts: vec!["created".into(), "updated".into(), "priority".into()],
2332 });
2333 Ok(result)
2334 }
2335 }
2336 }
2337
2338 async fn get_issue(&self, key: &str) -> Result<Issue> {
2339 let jira_key = parse_jira_key(key);
2340 let url = format!("{}/issue/{}", self.base_url, jira_key);
2341 let issue: JiraIssue = self.get(&url).await?;
2342 let mut mapped = map_issue(&issue, self.flavor, &self.instance_url);
2343 if mapped.description.as_deref().is_none_or(str::is_empty)
2344 && let Some(epic_desc) = self.read_epic_description_fallback(&issue).await?
2345 {
2346 mapped.description = Some(epic_desc);
2347 }
2348 Ok(mapped)
2349 }
2350
2351 async fn create_issue(&self, input: CreateIssueInput) -> Result<Issue> {
2352 let description = input.description.map(|d| {
2353 if self.flavor == JiraFlavor::Cloud {
2354 text_to_adf(&d)
2355 } else {
2356 serde_json::Value::String(d)
2357 }
2358 });
2359
2360 let labels = if input.labels.is_empty() {
2361 None
2362 } else {
2363 Some(input.labels)
2364 };
2365 let has_labels = labels.is_some();
2366
2367 let priority = input.priority.as_deref().map(|p| PriorityName {
2368 name: priority_to_jira(p),
2369 });
2370
2371 let assignee = input.assignees.first().map(|a| {
2372 if self.flavor == JiraFlavor::Cloud {
2373 serde_json::json!({ "accountId": a })
2374 } else {
2375 serde_json::json!({ "name": a })
2376 }
2377 });
2378
2379 let effective_project = input.project_id.unwrap_or_else(|| self.project_key.clone());
2380 let effective_issue_type = input.issue_type.unwrap_or_else(|| "Task".to_string());
2381
2382 let components = if input.components.is_empty() {
2384 None
2385 } else {
2386 Some(
2387 input
2388 .components
2389 .into_iter()
2390 .map(|name| crate::types::ComponentRef { name })
2391 .collect(),
2392 )
2393 };
2394
2395 let fix_versions = if input.fix_versions.is_empty() {
2396 None
2397 } else {
2398 Some(
2399 input
2400 .fix_versions
2401 .into_iter()
2402 .map(|name| crate::types::VersionRef { name })
2403 .collect(),
2404 )
2405 };
2406
2407 let payload = CreateIssuePayload {
2408 fields: CreateIssueFields {
2409 project: ProjectKey {
2410 key: effective_project,
2411 },
2412 summary: input.title,
2413 issuetype: IssueType {
2414 name: effective_issue_type,
2415 },
2416 description,
2417 labels,
2418 priority,
2419 assignee,
2420 components,
2421 fix_versions,
2422 parent: input.parent.map(|key| crate::types::IssueKeyRef { key }),
2423 },
2424 };
2425
2426 let (mut payload, _) = merge_custom_fields_into_payload(payload, &input.custom_fields)?;
2427
2428 self.inject_well_known_customfields(&mut payload, &input.epic_key, &input.epic_name)
2429 .await?;
2430
2431 let sprint_id = input.sprint_id;
2438 let url = format!("{}/issue", self.base_url);
2439 let create_result: std::result::Result<CreateIssueResponse, Error> =
2440 self.post(&url, &payload).await;
2441
2442 let create_resp = match create_result {
2443 Ok(resp) => resp,
2444 Err(e)
2445 if has_labels
2446 && e.to_string().contains("labels")
2447 && e.to_string().contains("not on the appropriate screen") =>
2448 {
2449 tracing::warn!("Create issue failed with labels, retrying without: {e}");
2453 let saved_labels = payload
2454 .get_mut("fields")
2455 .and_then(|f| f.as_object_mut())
2456 .and_then(|f| f.remove("labels"));
2457 let resp: CreateIssueResponse = self.post(&url, &payload).await?;
2458
2459 if let Some(lbl_value) = saved_labels
2461 && let Ok(lbl) = serde_json::from_value::<Vec<String>>(lbl_value)
2462 {
2463 let update = UpdateIssueInput {
2464 labels: Some(lbl),
2465 ..Default::default()
2466 };
2467 if let Err(e) = self.update_issue(&resp.key, update).await {
2468 tracing::warn!("Failed to set labels after create: {e}");
2469 }
2470 }
2471 resp
2472 }
2473 Err(e) => return Err(e),
2474 };
2475
2476 if let Some(sid) = sprint_id
2478 && sid > 0
2479 {
2480 self.assign_to_sprint(devboy_core::AssignToSprintInput {
2481 sprint_id: sid as u64,
2482 issue_keys: vec![create_resp.key.clone()],
2483 })
2484 .await?;
2485 }
2486
2487 self.get_issue(&create_resp.key).await
2489 }
2490
2491 async fn update_issue(&self, key: &str, input: UpdateIssueInput) -> Result<Issue> {
2492 let jira_key = parse_jira_key(key);
2493
2494 let description = input.description.map(|d| {
2495 if self.flavor == JiraFlavor::Cloud {
2496 text_to_adf(&d)
2497 } else {
2498 serde_json::Value::String(d)
2499 }
2500 });
2501
2502 let priority = input.priority.as_deref().map(|p| PriorityName {
2503 name: priority_to_jira(p),
2504 });
2505
2506 let assignee = input.assignees.as_ref().and_then(|a| {
2507 a.first().map(|username| {
2508 if self.flavor == JiraFlavor::Cloud {
2509 serde_json::json!({ "accountId": username })
2510 } else {
2511 serde_json::json!({ "name": username })
2512 }
2513 })
2514 });
2515
2516 let labels = input.labels;
2517
2518 let components = input.components.map(|ids| {
2520 ids.into_iter()
2521 .map(|name| crate::types::ComponentRef { name })
2522 .collect()
2523 });
2524 let has_components = components.is_some();
2525
2526 let fix_versions = input.fix_versions.map(|names| {
2528 names
2529 .into_iter()
2530 .map(|name| crate::types::VersionRef { name })
2531 .collect()
2532 });
2533 let has_fix_versions = fix_versions.is_some();
2534
2535 let fields = UpdateIssueFields {
2536 summary: input.title,
2537 description,
2538 labels,
2539 priority,
2540 assignee,
2541 components,
2542 fix_versions,
2543 };
2544
2545 let has_custom_fields = input.custom_fields.as_ref().is_some_and(|v| {
2546 v.as_object()
2547 .is_some_and(|obj| obj.keys().any(|k| k.starts_with("customfield_")))
2548 });
2549
2550 let sprint_id = input.sprint_id;
2554 let has_epic_fields = input.epic_key.is_some() || input.epic_name.is_some();
2555
2556 let has_field_updates = fields.summary.is_some()
2558 || fields.description.is_some()
2559 || fields.labels.is_some()
2560 || fields.priority.is_some()
2561 || fields.assignee.is_some()
2562 || has_components
2563 || has_fix_versions
2564 || has_custom_fields
2565 || has_epic_fields;
2566
2567 if has_field_updates {
2568 let url = format!("{}/issue/{}", self.base_url, jira_key);
2569 let payload = UpdateIssuePayload { fields };
2570 let (mut payload, _) = merge_custom_fields_into_payload(payload, &input.custom_fields)?;
2571 self.inject_well_known_customfields(&mut payload, &input.epic_key, &input.epic_name)
2572 .await?;
2573 self.put(&url, &payload).await?;
2574 }
2575
2576 if let Some(sid) = sprint_id
2577 && sid > 0
2578 {
2579 self.assign_to_sprint(devboy_core::AssignToSprintInput {
2580 sprint_id: sid as u64,
2581 issue_keys: vec![jira_key.to_string()],
2582 })
2583 .await?;
2584 }
2585
2586 if let Some(state) = &input.state {
2588 self.transition_issue(jira_key, state).await?;
2589 }
2590
2591 self.get_issue(jira_key).await
2593 }
2594
2595 async fn get_comments(&self, issue_key: &str) -> Result<ProviderResult<Comment>> {
2596 let jira_key = parse_jira_key(issue_key);
2597 let url = format!("{}/issue/{}/comment", self.base_url, jira_key);
2598 let response: JiraCommentsResponse = self.get(&url).await?;
2599 Ok(response
2600 .comments
2601 .iter()
2602 .map(|c| map_comment(c, self.flavor))
2603 .collect::<Vec<_>>()
2604 .into())
2605 }
2606
2607 async fn add_comment(&self, issue_key: &str, body: &str) -> Result<Comment> {
2608 let jira_key = parse_jira_key(issue_key);
2609 let comment_body = if self.flavor == JiraFlavor::Cloud {
2610 text_to_adf(body)
2611 } else {
2612 serde_json::Value::String(body.to_string())
2613 };
2614
2615 let payload = AddCommentPayload { body: comment_body };
2616
2617 let url = format!("{}/issue/{}/comment", self.base_url, jira_key);
2618 let jira_comment: JiraComment = self.post(&url, &payload).await?;
2619 Ok(map_comment(&jira_comment, self.flavor))
2620 }
2621
2622 async fn get_statuses(&self) -> Result<ProviderResult<IssueStatus>> {
2623 let project_statuses = self.get_project_statuses().await?;
2624
2625 let statuses: Vec<IssueStatus> = project_statuses
2626 .iter()
2627 .enumerate()
2628 .map(|(idx, s)| {
2629 let category = s
2630 .status_category
2631 .as_ref()
2632 .map(|sc| match sc.key.as_str() {
2633 "new" => "open".to_string(),
2634 "indeterminate" => "in_progress".to_string(),
2635 "done" => "done".to_string(),
2636 other => other.to_string(),
2637 })
2638 .unwrap_or_else(|| "custom".to_string());
2639
2640 IssueStatus {
2641 id: s.id.clone().unwrap_or_else(|| s.name.clone()),
2642 name: s.name.clone(),
2643 category,
2644 color: None,
2645 order: Some(idx as u32),
2646 }
2647 })
2648 .collect();
2649
2650 Ok(statuses.into())
2651 }
2652
2653 async fn get_users(&self, options: GetUsersOptions) -> Result<ProviderResult<User>> {
2654 let start_at = options.start_at.unwrap_or(0);
2655 let max_results = options.max_results.unwrap_or(50);
2656
2657 let url = if let Some(ref project_key) = options.project_key {
2659 format!(
2660 "{}/user/assignable/search?project={}&startAt={}&maxResults={}",
2661 self.base_url, project_key, start_at, max_results
2662 )
2663 } else {
2664 let query = options.search.as_deref().unwrap_or("");
2665 match self.flavor {
2666 JiraFlavor::Cloud => format!(
2667 "{}/user/search?query={}&startAt={}&maxResults={}",
2668 self.base_url, query, start_at, max_results
2669 ),
2670 JiraFlavor::SelfHosted => format!(
2671 "{}/user/search?username={}&startAt={}&maxResults={}",
2672 self.base_url,
2673 if query.is_empty() { "." } else { query },
2674 start_at,
2675 max_results
2676 ),
2677 }
2678 };
2679
2680 let jira_users: Vec<JiraUser> = self.get(&url).await?;
2681
2682 let users: Vec<User> = jira_users
2683 .iter()
2684 .map(|u| map_user(Some(u)).unwrap_or_default())
2685 .collect();
2686
2687 Ok(users.into())
2688 }
2689
2690 async fn link_issues(&self, source_key: &str, target_key: &str, link_type: &str) -> Result<()> {
2691 let source_jira_key = parse_jira_key(source_key).to_string();
2692 let target_jira_key = parse_jira_key(target_key).to_string();
2693
2694 let (link_type_name, reversed) = jira_link_type_name_and_reversed(link_type);
2698 let (outward_key, inward_key) = if reversed {
2699 (target_jira_key, source_jira_key)
2700 } else {
2701 (source_jira_key, target_jira_key)
2702 };
2703
2704 let payload = CreateIssueLinkPayload {
2705 link_type: IssueLinkTypeName {
2706 name: link_type_name.to_string(),
2707 },
2708 outward_issue: IssueKeyRef { key: outward_key },
2709 inward_issue: IssueKeyRef { key: inward_key },
2710 };
2711
2712 let url = format!("{}/issueLink", self.base_url);
2713 self.post_no_content(&url, &payload).await?;
2714
2715 Ok(())
2716 }
2717
2718 async fn unlink_issues(
2719 &self,
2720 source_key: &str,
2721 target_key: &str,
2722 link_type: &str,
2723 ) -> Result<()> {
2724 let source_jira_key = parse_jira_key(source_key).to_string();
2725 let target_jira_key = parse_jira_key(target_key).to_string();
2726 let (link_type_name, reversed) = jira_link_type_name_and_reversed(link_type);
2727 let allow_either_direction =
2728 jira_link_type_allows_either_direction(link_type, link_type_name);
2729
2730 let url = format!(
2731 "{}/issue/{}?fields=issuelinks",
2732 self.base_url, source_jira_key
2733 );
2734 let issue: JiraIssue = self.get(&url).await?;
2735
2736 let link = issue
2737 .fields
2738 .issuelinks
2739 .iter()
2740 .find(|link| {
2741 jira_link_matches_target(
2742 link,
2743 link_type_name,
2744 &target_jira_key,
2745 reversed,
2746 allow_either_direction,
2747 )
2748 })
2749 .ok_or_else(|| {
2750 let reversed_exists = !allow_either_direction
2754 && issue.fields.issuelinks.iter().any(|link| {
2755 jira_link_matches_target(
2756 link,
2757 link_type_name,
2758 &target_jira_key,
2759 !reversed,
2760 false,
2761 )
2762 });
2763 if reversed_exists {
2764 return Error::NotFound(format!(
2765 "Jira link not found: {source_jira_key} -> {target_jira_key} \
2766 ({link_type_name}) — a link of this type exists in the opposite \
2767 direction. Re-run with the source and target swapped, or with the \
2768 reversed alias, if that is the one you meant to remove."
2769 ));
2770 }
2771 Error::NotFound(format!(
2772 "Jira link not found: {} -> {} ({})",
2773 source_jira_key, target_jira_key, link_type_name
2774 ))
2775 })?;
2776
2777 let link_id = link.id.as_deref().ok_or_else(|| {
2778 Error::InvalidData(format!(
2779 "Jira link {} -> {} ({}) is missing id",
2780 source_jira_key, target_jira_key, link_type_name
2781 ))
2782 })?;
2783
2784 let delete_url = format!("{}/issueLink/{}", self.base_url, link_id);
2785 let response = self
2786 .request(reqwest::Method::DELETE, &delete_url)
2787 .send()
2788 .await
2789 .map_err(|e| Error::Http(e.to_string()))?;
2790
2791 let status = response.status();
2792 if !status.is_success() {
2793 let message = response.text().await.unwrap_or_default();
2794 return Err(Error::from_status(status.as_u16(), message));
2795 }
2796
2797 Ok(())
2798 }
2799
2800 async fn get_issue_relations(&self, issue_key: &str) -> Result<IssueRelations> {
2801 let jira_key = parse_jira_key(issue_key);
2802 let url = format!(
2805 "{}/issue/{}?fields=parent,subtasks,issuelinks,summary,status,priority,issuetype,*navigable",
2806 self.base_url, jira_key
2807 );
2808 let issue: JiraIssue = self.get(&url).await?;
2809 let mut relations = map_relations(&issue, self.flavor, &self.instance_url);
2810 if relations.parent.is_none()
2814 && relations.epic_key.is_none()
2815 && let Some(epic_key) = self.read_epic_link_key(&issue).await?
2816 {
2817 relations.epic_key = Some(epic_key);
2818 }
2819 Ok(relations)
2820 }
2821
2822 async fn upload_attachment(
2823 &self,
2824 issue_key: &str,
2825 filename: &str,
2826 data: &[u8],
2827 ) -> Result<String> {
2828 let jira_key = parse_jira_key(issue_key);
2829 let url = format!("{}/issue/{}/attachments", self.base_url, jira_key);
2830
2831 let part = reqwest::multipart::Part::bytes(data.to_vec())
2832 .file_name(filename.to_string())
2833 .mime_str("application/octet-stream")
2834 .map_err(|e| Error::Http(format!("failed to build multipart: {e}")))?;
2835 let form = reqwest::multipart::Form::new().part("file", part);
2836
2837 let response = self
2841 .request_raw(reqwest::Method::POST, &url)
2842 .header("X-Atlassian-Token", "no-check")
2845 .multipart(form)
2846 .send()
2847 .await
2848 .map_err(|e| Error::Http(e.to_string()))?;
2849
2850 let status = response.status();
2851 if !status.is_success() {
2852 let message = response.text().await.unwrap_or_default();
2853 return Err(Error::from_status(status.as_u16(), message));
2854 }
2855
2856 let attachments: Vec<JiraAttachment> = response
2858 .json()
2859 .await
2860 .map_err(|e| Error::InvalidData(format!("failed to parse attachment response: {e}")))?;
2861 let url = attachments
2862 .into_iter()
2863 .next()
2864 .and_then(|a| a.content)
2865 .filter(|u| !u.is_empty())
2866 .ok_or_else(|| {
2867 Error::InvalidData(
2868 "Jira upload returned no attachment with a content URL".to_string(),
2869 )
2870 })?;
2871 Ok(url)
2872 }
2873
2874 async fn get_issue_attachments(&self, issue_key: &str) -> Result<Vec<AssetMeta>> {
2875 let jira_key = parse_jira_key(issue_key);
2876 let url = format!("{}/issue/{}?fields=attachment", self.base_url, jira_key);
2877 let issue: JiraIssue = self.get(&url).await?;
2878 Ok(issue
2879 .fields
2880 .attachment
2881 .iter()
2882 .map(map_jira_attachment)
2883 .collect())
2884 }
2885
2886 async fn download_attachment(&self, _issue_key: &str, asset_id: &str) -> Result<Vec<u8>> {
2887 let url = match self.flavor {
2891 JiraFlavor::Cloud => {
2892 format!("{}/attachment/content/{}", self.base_url, asset_id)
2893 }
2894 JiraFlavor::SelfHosted => {
2895 let meta_url = format!("{}/attachment/{}", self.base_url, asset_id);
2896 let meta: serde_json::Value = self.get(&meta_url).await?;
2897 meta.get("content")
2898 .and_then(|v| v.as_str())
2899 .ok_or_else(|| {
2900 Error::InvalidData(format!(
2901 "attachment {asset_id} metadata has no content URL"
2902 ))
2903 })?
2904 .to_string()
2905 }
2906 };
2907 let response = self
2908 .request(reqwest::Method::GET, &url)
2909 .send()
2910 .await
2911 .map_err(|e| Error::Http(e.to_string()))?;
2912
2913 let status = response.status();
2914 if !status.is_success() {
2915 let message = response.text().await.unwrap_or_default();
2916 return Err(Error::from_status(status.as_u16(), message));
2917 }
2918
2919 let bytes = response
2920 .bytes()
2921 .await
2922 .map_err(|e| Error::Http(format!("failed to read attachment bytes: {e}")))?;
2923 Ok(bytes.to_vec())
2924 }
2925
2926 async fn delete_attachment(&self, _issue_key: &str, asset_id: &str) -> Result<()> {
2927 let url = format!("{}/attachment/{}", self.base_url, asset_id);
2929 let response = self
2930 .request(reqwest::Method::DELETE, &url)
2931 .send()
2932 .await
2933 .map_err(|e| Error::Http(e.to_string()))?;
2934
2935 let status = response.status();
2936 if !status.is_success() {
2937 let message = response.text().await.unwrap_or_default();
2938 return Err(Error::from_status(status.as_u16(), message));
2939 }
2940 Ok(())
2941 }
2942
2943 fn asset_capabilities(&self) -> AssetCapabilities {
2944 AssetCapabilities {
2946 issue: ContextCapabilities {
2947 upload: true,
2948 download: true,
2949 delete: true,
2950 list: true,
2951 max_file_size: None,
2952 allowed_types: Vec::new(),
2953 },
2954 ..Default::default()
2955 }
2956 }
2957
2958 async fn get_structures(&self) -> Result<ProviderResult<Structure>> {
2961 let resp: JiraStructureListResponse = self.structure_get("/structure").await?;
2962 let items: Vec<Structure> = resp
2963 .structures
2964 .into_iter()
2965 .map(|s| Structure {
2966 id: s.id,
2967 name: s.name,
2968 description: s.description,
2969 })
2970 .collect();
2971 Ok(items.into())
2972 }
2973
2974 async fn get_structure_forest(
2975 &self,
2976 structure_id: u64,
2977 options: GetForestOptions,
2978 ) -> Result<StructureForest> {
2979 let mut spec = serde_json::Map::new();
2980 if let Some(offset) = options.offset {
2981 spec.insert("offset".into(), serde_json::json!(offset));
2982 }
2983 if let Some(limit) = options.limit {
2984 spec.insert("limit".into(), serde_json::json!(limit));
2985 }
2986
2987 let resp: JiraForestResponse = self
2988 .structure_post(
2989 &format!("/forest/{}/spec", structure_id),
2990 &serde_json::Value::Object(spec),
2991 )
2992 .await?;
2993
2994 let tree = build_forest_tree(&resp.rows, &resp.depths)?;
2995
2996 Ok(StructureForest {
2997 version: resp.version,
2998 structure_id,
2999 tree,
3000 total_count: resp.total_count,
3001 })
3002 }
3003
3004 async fn add_structure_rows(
3005 &self,
3006 structure_id: u64,
3007 input: AddStructureRowsInput,
3008 ) -> Result<ForestModifyResult> {
3009 let mut payload = serde_json::json!({
3010 "rows": input.items.iter().map(|i| {
3011 let mut row = serde_json::json!({"itemId": i.item_id});
3012 if let Some(ref t) = i.item_type {
3013 row["itemType"] = serde_json::json!(t);
3014 }
3015 row
3016 }).collect::<Vec<_>>()
3017 });
3018 if let Some(under) = input.under {
3019 payload["under"] = serde_json::json!(under);
3020 }
3021 if let Some(after) = input.after {
3022 payload["after"] = serde_json::json!(after);
3023 }
3024 if let Some(version) = input.forest_version {
3025 payload["forestVersion"] = serde_json::json!(version);
3026 }
3027
3028 let resp: JiraForestModifyResponse = self
3029 .structure_put(&format!("/forest/{}/item", structure_id), &payload)
3030 .await
3031 .map_err(|e| {
3032 if matches!(&e, Error::Api { status, .. } if *status == 409) {
3033 Error::Api {
3034 status: 409,
3035 message: "Forest version conflict. The structure was modified concurrently. Retry with the latest version.".to_string(),
3036 }
3037 } else {
3038 e
3039 }
3040 })?;
3041
3042 Ok(ForestModifyResult {
3043 version: resp.version,
3044 affected_count: input.items.len(),
3045 })
3046 }
3047
3048 async fn move_structure_rows(
3049 &self,
3050 structure_id: u64,
3051 input: MoveStructureRowsInput,
3052 ) -> Result<ForestModifyResult> {
3053 let mut payload = serde_json::json!({
3054 "rowIds": input.row_ids
3055 });
3056 if let Some(under) = input.under {
3057 payload["under"] = serde_json::json!(under);
3058 }
3059 if let Some(after) = input.after {
3060 payload["after"] = serde_json::json!(after);
3061 }
3062 if let Some(version) = input.forest_version {
3063 payload["forestVersion"] = serde_json::json!(version);
3064 }
3065
3066 let resp: JiraForestModifyResponse = self
3067 .structure_post(&format!("/forest/{}/move", structure_id), &payload)
3068 .await
3069 .map_err(|e| {
3070 if matches!(&e, Error::Api { status, .. } if *status == 409) {
3071 Error::Api {
3072 status: 409,
3073 message: "Forest version conflict. Retry with the latest version."
3074 .to_string(),
3075 }
3076 } else {
3077 e
3078 }
3079 })?;
3080
3081 Ok(ForestModifyResult {
3082 version: resp.version,
3083 affected_count: input.row_ids.len(),
3084 })
3085 }
3086
3087 async fn remove_structure_row(&self, structure_id: u64, row_id: u64) -> Result<()> {
3088 self.structure_delete_request(&format!("/forest/{}/item/{}", structure_id, row_id))
3089 .await
3090 }
3091
3092 async fn get_structure_values(
3093 &self,
3094 input: GetStructureValuesInput,
3095 ) -> Result<StructureValues> {
3096 let columns: Vec<serde_json::Value> = input
3097 .columns
3098 .iter()
3099 .map(|c| {
3100 let mut col = serde_json::Map::new();
3101 if let Some(ref id) = c.id {
3102 col.insert("id".into(), serde_json::json!(id));
3103 }
3104 if let Some(ref field) = c.field {
3105 col.insert("field".into(), serde_json::json!(field));
3106 }
3107 if let Some(ref formula) = c.formula {
3108 col.insert("formula".into(), serde_json::json!(formula));
3109 }
3110 serde_json::Value::Object(col)
3111 })
3112 .collect();
3113
3114 let payload = serde_json::json!({
3115 "structureId": input.structure_id,
3116 "rows": input.rows,
3117 "columns": columns,
3118 });
3119
3120 let resp: JiraStructureValuesResponse = self.structure_post("/value", &payload).await?;
3121
3122 let mut row_map: std::collections::BTreeMap<u64, Vec<StructureColumnValue>> =
3127 std::collections::BTreeMap::new();
3128 for entry in resp.values {
3129 let column = entry.column_id.ok_or_else(|| {
3130 Error::InvalidData(format!(
3131 "Structure value for row {} is missing `columnId`",
3132 entry.row_id
3133 ))
3134 })?;
3135 row_map
3136 .entry(entry.row_id)
3137 .or_default()
3138 .push(StructureColumnValue {
3139 column,
3140 value: entry.value,
3141 });
3142 }
3143
3144 let values = row_map
3145 .into_iter()
3146 .map(|(row_id, columns)| StructureRowValues { row_id, columns })
3147 .collect();
3148
3149 Ok(StructureValues {
3150 structure_id: input.structure_id,
3151 values,
3152 })
3153 }
3154
3155 async fn get_structure_views(
3156 &self,
3157 structure_id: u64,
3158 view_id: Option<u64>,
3159 ) -> Result<Vec<StructureView>> {
3160 if let Some(id) = view_id {
3161 let view: JiraStructureView = self.structure_get(&format!("/view/{}", id)).await?;
3162 if view.structure_id != structure_id {
3168 return Err(Error::InvalidData(format!(
3169 "view {id} belongs to structure {} but {structure_id} was requested",
3170 view.structure_id
3171 )));
3172 }
3173 Ok(vec![map_structure_view(view)])
3174 } else {
3175 let resp: JiraStructureViewListResponse = self
3176 .structure_get(&format!("/view?structureId={}", structure_id))
3177 .await?;
3178 Ok(resp.views.into_iter().map(map_structure_view).collect())
3179 }
3180 }
3181
3182 async fn save_structure_view(&self, input: SaveStructureViewInput) -> Result<StructureView> {
3183 let columns: Option<Vec<serde_json::Value>> = input.columns.as_ref().map(|cols| {
3184 cols.iter()
3185 .map(|c| {
3186 let mut col = serde_json::Map::new();
3187 if let Some(ref field) = c.field {
3188 col.insert("field".into(), serde_json::json!(field));
3189 }
3190 if let Some(ref formula) = c.formula {
3191 col.insert("formula".into(), serde_json::json!(formula));
3192 }
3193 if let Some(width) = c.width {
3194 col.insert("width".into(), serde_json::json!(width));
3195 }
3196 serde_json::Value::Object(col)
3197 })
3198 .collect()
3199 });
3200
3201 let mut payload = serde_json::json!({
3202 "structureId": input.structure_id,
3203 "name": input.name,
3204 });
3205 if let Some(cols) = columns {
3206 payload["columns"] = serde_json::json!(cols);
3207 }
3208 if let Some(ref g) = input.group_by {
3209 payload["groupBy"] = serde_json::json!(g);
3210 }
3211 if let Some(ref s) = input.sort_by {
3212 payload["sortBy"] = serde_json::json!(s);
3213 }
3214 if let Some(ref f) = input.filter {
3215 payload["filter"] = serde_json::json!(f);
3216 }
3217
3218 let view: JiraStructureView = if let Some(id) = input.id {
3219 self.structure_put(&format!("/view/{}", id), &payload)
3220 .await?
3221 } else {
3222 self.structure_post("/view", &payload).await?
3223 };
3224
3225 Ok(map_structure_view(view))
3226 }
3227
3228 async fn create_structure(&self, input: CreateStructureInput) -> Result<Structure> {
3229 let mut payload = serde_json::json!({"name": input.name});
3230 if let Some(ref desc) = input.description {
3231 payload["description"] = serde_json::json!(desc);
3232 }
3233 let s: JiraStructure = self.structure_post("/structure", &payload).await?;
3234 Ok(Structure {
3235 id: s.id,
3236 name: s.name,
3237 description: s.description,
3238 })
3239 }
3240
3241 async fn get_structure_generators(
3244 &self,
3245 structure_id: u64,
3246 ) -> Result<ProviderResult<devboy_core::StructureGenerator>> {
3247 #[derive(serde::Deserialize)]
3248 struct Resp {
3249 #[serde(default)]
3250 generators: Vec<RawGenerator>,
3251 }
3252 #[derive(serde::Deserialize)]
3253 struct RawGenerator {
3254 id: String,
3255 #[serde(rename = "type")]
3256 generator_type: String,
3257 #[serde(default)]
3258 spec: serde_json::Value,
3259 }
3260 let resp: Resp = self
3261 .structure_get(&format!("/structure/{}/generator", structure_id))
3262 .await?;
3263 let items: Vec<devboy_core::StructureGenerator> = resp
3264 .generators
3265 .into_iter()
3266 .map(|g| devboy_core::StructureGenerator {
3267 id: g.id,
3268 generator_type: g.generator_type,
3269 spec: g.spec,
3270 })
3271 .collect();
3272 Ok(items.into())
3273 }
3274
3275 async fn add_structure_generator(
3276 &self,
3277 input: devboy_core::AddStructureGeneratorInput,
3278 ) -> Result<devboy_core::StructureGenerator> {
3279 #[derive(serde::Deserialize)]
3282 struct Resp {
3283 id: String,
3284 #[serde(rename = "type")]
3285 generator_type: String,
3286 #[serde(default)]
3287 spec: serde_json::Value,
3288 }
3289 let body = serde_json::json!({
3290 "type": input.generator_type,
3291 "spec": input.spec,
3292 });
3293 let resp: Resp = self
3294 .structure_post(
3295 &format!("/structure/{}/generator", input.structure_id),
3296 &body,
3297 )
3298 .await?;
3299 Ok(devboy_core::StructureGenerator {
3300 id: resp.id,
3301 generator_type: resp.generator_type,
3302 spec: resp.spec,
3303 })
3304 }
3305
3306 async fn sync_structure_generator(
3307 &self,
3308 input: devboy_core::SyncStructureGeneratorInput,
3309 ) -> Result<()> {
3310 let body = serde_json::json!({});
3311 let _: serde_json::Value = self
3312 .structure_post(
3313 &format!(
3314 "/structure/{}/generator/{}/sync",
3315 input.structure_id, input.generator_id
3316 ),
3317 &body,
3318 )
3319 .await?;
3320 Ok(())
3321 }
3322
3323 async fn delete_structure(&self, structure_id: u64) -> Result<()> {
3326 self.structure_delete_request(&format!("/structure/{}", structure_id))
3327 .await
3328 }
3329
3330 async fn update_structure_automation(
3331 &self,
3332 input: devboy_core::UpdateStructureAutomationInput,
3333 ) -> Result<()> {
3334 let endpoint = match input.automation_id.as_deref() {
3337 Some(aid) => format!("/structure/{}/automation/{}", input.structure_id, aid),
3338 None => format!("/structure/{}/automation", input.structure_id),
3339 };
3340 let _: serde_json::Value = self.structure_put(&endpoint, &input.config).await?;
3341 Ok(())
3342 }
3343
3344 async fn trigger_structure_automation(&self, structure_id: u64) -> Result<()> {
3345 let body = serde_json::json!({});
3346 let _: serde_json::Value = self
3347 .structure_post(
3348 &format!("/structure/{}/automation/run", structure_id),
3349 &body,
3350 )
3351 .await?;
3352 Ok(())
3353 }
3354
3355 async fn get_board_sprints(
3358 &self,
3359 board_id: u64,
3360 state: devboy_core::SprintState,
3361 ) -> Result<ProviderResult<devboy_core::Sprint>> {
3362 #[derive(serde::Deserialize)]
3366 #[serde(rename_all = "camelCase")]
3367 struct Resp {
3368 #[serde(default)]
3369 is_last: bool,
3370 #[serde(default)]
3371 values: Vec<devboy_core::Sprint>,
3372 }
3373 const MAX_SPRINTS: usize = 5_000;
3376 const PAGE_SIZE: u32 = 50;
3377
3378 let state_param = state
3379 .as_query_value()
3380 .map(|s| format!("&state={}", s))
3381 .unwrap_or_default();
3382
3383 let mut sprints: Vec<devboy_core::Sprint> = Vec::new();
3384 let mut start_at: u32 = 0;
3385 loop {
3386 let endpoint = format!(
3387 "/board/{}/sprint?startAt={}&maxResults={}{}",
3388 board_id, start_at, PAGE_SIZE, state_param
3389 );
3390 let resp: Resp = self.agile_get(&endpoint).await?;
3391 let fetched = resp.values.len() as u32;
3392 sprints.extend(resp.values);
3393 if resp.is_last || fetched == 0 || sprints.len() >= MAX_SPRINTS {
3394 break;
3395 }
3396 start_at += fetched;
3397 }
3398 Ok(sprints.into())
3399 }
3400
3401 async fn assign_to_sprint(&self, input: devboy_core::AssignToSprintInput) -> Result<()> {
3402 let issues: Vec<String> = input
3405 .issue_keys
3406 .into_iter()
3407 .map(|k| parse_jira_key(&k).to_string())
3408 .collect();
3409 let body = serde_json::json!({ "issues": issues });
3410 self.agile_post_void(&format!("/sprint/{}/issue", input.sprint_id), &body)
3411 .await
3412 }
3413
3414 async fn list_project_versions(
3417 &self,
3418 params: ListProjectVersionsParams,
3419 ) -> Result<ProviderResult<ProjectVersion>> {
3420 let project_key = if params.project.is_empty() {
3421 self.project_key.clone()
3422 } else {
3423 params.project
3424 };
3425
3426 let mut url = format!("{}/project/{}/versions", self.base_url, project_key);
3437 if params.include_issue_count && self.flavor == JiraFlavor::Cloud {
3438 url.push_str("?expand=issuesstatus");
3439 }
3440
3441 let dtos: Vec<JiraVersionDto> = self.get(&url).await?;
3442
3443 let mut versions: Vec<ProjectVersion> = dtos
3444 .into_iter()
3445 .map(|dto| jira_version_to_project_version(dto, &project_key))
3446 .collect();
3447
3448 if let Some(want_released) = params.released {
3449 versions.retain(|v| v.released == want_released);
3450 }
3451 if let Some(want_archived) = params.archived {
3452 versions.retain(|v| v.archived == want_archived);
3453 }
3454
3455 versions.sort_by(|a, b| {
3465 use std::cmp::Ordering;
3466 let group = a.released.cmp(&b.released);
3467 if group != Ordering::Equal {
3468 return group;
3469 }
3470 let undated_first = !a.released;
3473 let date = match (&a.release_date, &b.release_date) {
3474 (Some(a_d), Some(b_d)) => b_d.cmp(a_d),
3475 (None, None) => Ordering::Equal,
3476 (None, Some(_)) if undated_first => Ordering::Less,
3477 (None, Some(_)) => Ordering::Greater,
3478 (Some(_), None) if undated_first => Ordering::Greater,
3479 (Some(_), None) => Ordering::Less,
3480 };
3481 date.then_with(|| compare_version_names(&b.name, &a.name))
3482 });
3483
3484 let total_after_filter = versions.len() as u32;
3485 let limit_applied = params.limit.unwrap_or(total_after_filter);
3486 if (limit_applied as usize) < versions.len() {
3487 versions.truncate(limit_applied as usize);
3488 }
3489
3490 let pagination = devboy_core::Pagination {
3495 offset: 0,
3496 limit: limit_applied,
3497 total: Some(total_after_filter),
3498 has_more: (versions.len() as u32) < total_after_filter,
3499 next_cursor: None,
3500 };
3501
3502 Ok(ProviderResult::new(versions).with_pagination(pagination))
3503 }
3504
3505 async fn upsert_project_version(
3506 &self,
3507 input: UpsertProjectVersionInput,
3508 ) -> Result<ProjectVersion> {
3509 let trimmed_name = input.name.trim().to_string();
3510 if trimmed_name.is_empty() {
3511 return Err(Error::InvalidData(
3512 "upsert_project_version: name must not be empty".into(),
3513 ));
3514 }
3515 if trimmed_name.chars().count() > 255 {
3518 return Err(Error::InvalidData(
3519 "upsert_project_version: name must be ≤ 255 characters".into(),
3520 ));
3521 }
3522 let project_key = if input.project.is_empty() {
3523 self.project_key.clone()
3524 } else {
3525 input.project.clone()
3526 };
3527
3528 let update_payload = UpdateVersionPayload {
3529 name: None,
3530 description: input.description.clone(),
3531 start_date: input.start_date.clone(),
3532 release_date: input.release_date.clone(),
3533 released: input.released,
3534 archived: input.archived,
3535 };
3536 let create_payload = CreateVersionPayload {
3537 name: trimmed_name.clone(),
3538 project: Some(project_key.clone()),
3539 project_id: None,
3540 description: input.description,
3541 start_date: input.start_date,
3542 release_date: input.release_date,
3543 released: input.released,
3544 archived: input.archived,
3545 };
3546
3547 let list_url = format!("{}/project/{}/versions", self.base_url, project_key);
3552 let dtos: Vec<JiraVersionDto> = self.get(&list_url).await?;
3553 let existing = dtos.into_iter().find(|d| d.name == trimmed_name);
3554
3555 let dto: JiraVersionDto = match existing {
3556 Some(existing) => {
3557 self.put_with_response(
3558 &format!("{}/version/{}", self.base_url, existing.id),
3559 &update_payload,
3560 )
3561 .await?
3562 }
3563 None => {
3564 match self
3570 .post::<JiraVersionDto, _>(
3571 &format!("{}/version", self.base_url),
3572 &create_payload,
3573 )
3574 .await
3575 {
3576 Ok(dto) => dto,
3577 Err(e) if is_duplicate_version_error(&e) => {
3578 let dtos: Vec<JiraVersionDto> = self.get(&list_url).await?;
3579 let recovered = dtos
3580 .into_iter()
3581 .find(|d| d.name == trimmed_name)
3582 .ok_or_else(|| {
3583 Error::InvalidData(format!(
3584 "upsert_project_version: create rejected as duplicate but version '{trimmed_name}' is not in the project list"
3585 ))
3586 })?;
3587 self.put_with_response(
3588 &format!("{}/version/{}", self.base_url, recovered.id),
3589 &update_payload,
3590 )
3591 .await?
3592 }
3593 Err(e) => return Err(e),
3594 }
3595 }
3596 };
3597
3598 Ok(jira_version_to_project_version(dto, &project_key))
3599 }
3600
3601 async fn list_custom_fields(
3602 &self,
3603 params: devboy_core::ListCustomFieldsParams,
3604 ) -> Result<ProviderResult<devboy_core::CustomFieldDescriptor>> {
3605 let _ = (¶ms.project, ¶ms.issue_type);
3610
3611 let fields = self.fetch_fields().await?;
3612 let limit = params.limit.unwrap_or(50).min(200);
3613 let needle = params.search.as_deref().map(str::to_lowercase);
3614
3615 let mut descriptors: Vec<devboy_core::CustomFieldDescriptor> = fields
3616 .into_iter()
3617 .filter(|f| f.custom)
3618 .filter(|f| match &needle {
3619 Some(n) => f.name.to_lowercase().contains(n),
3620 None => true,
3621 })
3622 .map(|f| {
3623 let field_type = f
3624 .schema
3625 .as_ref()
3626 .and_then(|s| s.field_type.clone())
3627 .unwrap_or_default();
3628 let native = f.schema.as_ref().and_then(|s| serde_json::to_value(s).ok());
3629 devboy_core::CustomFieldDescriptor {
3630 id: f.id,
3631 name: f.name,
3632 field_type,
3633 description: None,
3634 native,
3635 }
3636 })
3637 .collect();
3638
3639 descriptors.sort_by(|a, b| a.name.cmp(&b.name));
3640
3641 let total_after_filter = descriptors.len() as u32;
3642 if (limit as usize) < descriptors.len() {
3643 descriptors.truncate(limit as usize);
3644 }
3645
3646 let pagination = devboy_core::Pagination {
3647 offset: 0,
3648 limit,
3649 total: Some(total_after_filter),
3650 has_more: (descriptors.len() as u32) < total_after_filter,
3651 next_cursor: None,
3652 };
3653
3654 Ok(ProviderResult::new(descriptors).with_pagination(pagination))
3655 }
3656
3657 fn provider_name(&self) -> &'static str {
3658 "jira"
3659 }
3660}
3661
3662fn is_duplicate_version_error(e: &Error) -> bool {
3672 let lowered = e.to_string().to_lowercase();
3673 lowered.contains("already exists") || lowered.contains("already used")
3674}
3675
3676fn compare_version_names(a: &str, b: &str) -> std::cmp::Ordering {
3684 fn tokens(s: &str) -> Vec<(bool, &str)> {
3685 let mut out = Vec::new();
3686 let mut start = 0;
3687 let mut last_digit: Option<bool> = None;
3688 for (i, ch) in s.char_indices() {
3689 let is_digit = ch.is_ascii_digit();
3690 match last_digit {
3691 Some(prev) if prev != is_digit => {
3692 out.push((prev, &s[start..i]));
3693 start = i;
3694 }
3695 _ => {}
3696 }
3697 last_digit = Some(is_digit);
3698 }
3699 if let Some(prev) = last_digit {
3700 out.push((prev, &s[start..]));
3701 }
3702 out
3703 }
3704
3705 let a_toks = tokens(a);
3706 let b_toks = tokens(b);
3707 for (ax, bx) in a_toks.iter().zip(b_toks.iter()) {
3708 let cmp = match (ax, bx) {
3709 ((true, ad), (true, bd)) => {
3710 let an = ad.trim_start_matches('0');
3713 let bn = bd.trim_start_matches('0');
3714 an.len().cmp(&bn.len()).then_with(|| an.cmp(bn))
3715 }
3716 ((false, at), (false, bt)) => at.cmp(bt),
3717 ((true, _), (false, _)) => std::cmp::Ordering::Greater,
3720 ((false, _), (true, _)) => std::cmp::Ordering::Less,
3721 };
3722 if cmp != std::cmp::Ordering::Equal {
3723 return cmp;
3724 }
3725 }
3726 match a_toks.len().cmp(&b_toks.len()) {
3731 std::cmp::Ordering::Equal => std::cmp::Ordering::Equal,
3732 std::cmp::Ordering::Greater => {
3733 let next = a_toks[b_toks.len()].1;
3734 if next.starts_with('-') || next.starts_with('+') {
3735 std::cmp::Ordering::Less
3736 } else {
3737 std::cmp::Ordering::Greater
3738 }
3739 }
3740 std::cmp::Ordering::Less => {
3741 let next = b_toks[a_toks.len()].1;
3742 if next.starts_with('-') || next.starts_with('+') {
3743 std::cmp::Ordering::Greater
3744 } else {
3745 std::cmp::Ordering::Less
3746 }
3747 }
3748 }
3749}
3750
3751fn jira_version_to_project_version(dto: JiraVersionDto, project_fallback: &str) -> ProjectVersion {
3752 let issue_count = dto
3760 .issues_status_for_fix_version
3761 .as_ref()
3762 .map(|c| c.total());
3763 let unresolved_issue_count = dto.issues_unresolved_count;
3764
3765 ProjectVersion {
3766 id: dto.id,
3767 project: dto.project.unwrap_or_else(|| project_fallback.to_string()),
3768 name: dto.name,
3769 description: dto.description.filter(|d| !d.is_empty()),
3770 start_date: dto.start_date.filter(|d| !d.is_empty()),
3771 release_date: dto.release_date.filter(|d| !d.is_empty()),
3772 released: dto.released,
3773 archived: dto.archived,
3774 overdue: dto.overdue,
3775 issue_count,
3776 unresolved_issue_count,
3777 source: "jira".to_string(),
3778 }
3779}
3780
3781#[async_trait]
3782impl MergeRequestProvider for JiraClient {
3783 fn provider_name(&self) -> &'static str {
3784 "jira"
3785 }
3786}
3787
3788#[async_trait]
3789impl PipelineProvider for JiraClient {
3790 fn provider_name(&self) -> &'static str {
3791 "jira"
3792 }
3793}
3794
3795#[async_trait]
3796impl Provider for JiraClient {
3797 async fn get_current_user(&self) -> Result<User> {
3798 let url = format!("{}/myself", self.base_url);
3799 let jira_user: JiraUser = self.get(&url).await?;
3800 Ok(map_user(Some(&jira_user)).unwrap_or_default())
3801 }
3802}
3803
3804#[async_trait]
3807impl devboy_core::UserProvider for JiraClient {
3808 fn provider_name(&self) -> &'static str {
3809 "jira"
3810 }
3811
3812 async fn get_user_profile(&self, user_id: &str) -> Result<User> {
3813 let url = match self.flavor {
3814 JiraFlavor::Cloud => format!("{}/user?accountId={}", self.base_url, user_id),
3815 JiraFlavor::SelfHosted => format!("{}/user?username={}", self.base_url, user_id),
3816 };
3817 let jira_user: JiraUser = self.get(&url).await?;
3818 map_user(Some(&jira_user))
3819 .ok_or_else(|| Error::InvalidData("Jira /user returned no user".to_string()))
3820 }
3821
3822 async fn lookup_user_by_email(&self, email: &str) -> Result<Option<User>> {
3823 let url = match self.flavor {
3827 JiraFlavor::Cloud => format!("{}/user/search?query={}", self.base_url, email),
3828 JiraFlavor::SelfHosted => {
3829 format!("{}/user/search?username={}", self.base_url, email)
3830 }
3831 };
3832 let users: Vec<JiraUser> = self.get(&url).await?;
3833 Ok(users.into_iter().find_map(|u| map_user(Some(&u))))
3834 }
3835}
3836
3837#[cfg(test)]
3842mod tests {
3843 use super::*;
3844 use crate::types::*;
3845 use devboy_core::{CreateCommentInput, MrFilter};
3846
3847 fn token(s: &str) -> SecretString {
3848 SecretString::from(s.to_string())
3849 }
3850
3851 #[test]
3856 fn structure_install_hint_is_single_well_spaced_line() {
3857 assert!(
3860 !STRUCTURE_PLUGIN_HINT.contains(" "),
3861 "hint contains consecutive spaces: {STRUCTURE_PLUGIN_HINT:?}"
3862 );
3863 assert!(!STRUCTURE_PLUGIN_HINT.contains('\n'));
3864 assert!(STRUCTURE_PLUGIN_HINT.contains("marketplace.atlassian.com"));
3865 }
3866
3867 #[test]
3868 fn structure_404_with_html_returns_soft_endpoint_hint() {
3869 let html = "<!DOCTYPE html><html><body>Oops, you've found a dead link.</body></html>";
3870 let err = structure_error_from_status(404, "text/html;charset=UTF-8", html.into());
3871 let msg = err.to_string();
3872 assert!(!msg.contains("<!DOCTYPE"), "HTML leaked into error: {msg}");
3873 assert!(
3876 msg.contains("endpoint not found"),
3877 "expected soft 'endpoint not found' wording: {msg}"
3878 );
3879 assert!(
3880 msg.contains("may not be installed"),
3881 "expected soft install-hint wording: {msg}"
3882 );
3883 assert!(
3884 msg.contains("marketplace.atlassian.com"),
3885 "missing marketplace link: {msg}"
3886 );
3887 }
3888
3889 #[test]
3890 fn structure_500_with_html_strips_body() {
3891 let html = "<html><body>".to_string() + &"x".repeat(20_000) + "</body></html>";
3892 let err = structure_error_from_status(500, "text/html", html);
3893 let msg = err.to_string();
3894 assert!(
3895 !msg.contains("xxxx"),
3896 "raw HTML body leaked: {}",
3897 &msg[..msg.len().min(400)]
3898 );
3899 assert!(
3900 msg.contains("non-JSON"),
3901 "missing short status message: {msg}"
3902 );
3903 }
3904
3905 #[test]
3906 fn structure_json_error_is_forwarded_verbatim() {
3907 let body = r#"{"errorMessages":["Invalid forestVersion"],"errors":{}}"#;
3908 let err = structure_error_from_status(409, "application/json", body.into());
3909 let msg = err.to_string();
3910 assert!(
3911 msg.contains("Invalid forestVersion"),
3912 "JSON body dropped: {msg}"
3913 );
3914 }
3915
3916 #[test]
3917 fn structure_long_text_body_is_truncated() {
3918 let body = "plain text ".repeat(200); let err = structure_error_from_status(400, "text/plain", body);
3920 let msg = err.to_string();
3921 assert!(
3922 msg.contains("truncated"),
3923 "truncation marker missing: {msg}"
3924 );
3925 }
3926
3927 #[test]
3928 fn structure_html_detected_by_body_when_content_type_missing() {
3929 assert!(looks_like_html("", "<!DOCTYPE html><html>..."));
3930 assert!(looks_like_html("", "<html lang=\"en\">"));
3931 assert!(!looks_like_html("", " {\"ok\":true}"));
3932 assert!(!looks_like_html("application/json", "{\"ok\":true}"));
3933 }
3934
3935 #[test]
3936 fn structure_html_detected_by_content_type_only() {
3937 assert!(looks_like_html("text/html; charset=UTF-8", ""));
3938 assert!(looks_like_html("Text/HTML", ""));
3939 }
3940
3941 #[test]
3942 fn structure_xml_body_treated_as_non_json() {
3943 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>"#;
3945 assert!(looks_like_html("application/xml", xml));
3946 assert!(looks_like_html("", xml));
3947 let err = structure_error_from_status(404, "application/xml", xml.into());
3948 let msg = err.to_string();
3949 assert!(!msg.contains("<?xml"), "XML leaked into error: {msg}");
3950 assert!(
3951 msg.contains("endpoint not found"),
3952 "expected soft wording: {msg}"
3953 );
3954 }
3955
3956 #[test]
3957 fn structure_parse_preview_redacts_html_body() {
3958 let html = r#"<!DOCTYPE html><html><head><title>Login</title></head><body><form>…</form></body></html>"#;
3959 let preview = structure_parse_preview("text/html; charset=UTF-8", html);
3960 assert!(
3961 !preview.contains("<!DOCTYPE"),
3962 "HTML leaked into parse preview: {preview}"
3963 );
3964 assert!(
3965 !preview.contains("<html"),
3966 "HTML leaked into parse preview: {preview}"
3967 );
3968 assert!(
3969 preview.contains("redacted"),
3970 "expected redaction marker: {preview}"
3971 );
3972 assert!(
3973 preview.contains(&format!("{}", html.len())),
3974 "expected byte count in preview: {preview}"
3975 );
3976 }
3977
3978 #[test]
3979 fn structure_parse_preview_redacts_xml_body() {
3980 let xml = r#"<?xml version="1.0"?><status><code>200</code></status>"#;
3981 let preview = structure_parse_preview("application/xml", xml);
3982 assert!(!preview.contains("<?xml"), "XML leaked: {preview}");
3983 assert!(preview.contains("redacted"));
3984 }
3985
3986 #[test]
3987 fn structure_parse_preview_keeps_short_json_body_verbatim() {
3988 let body = r#"{"broken":"response"#; let preview = structure_parse_preview("application/json", body);
3990 assert_eq!(preview, body);
3991 }
3992
3993 #[test]
3994 fn structure_parse_preview_truncates_long_non_markup_body() {
3995 let body = "a".repeat(2000);
3996 let preview = structure_parse_preview("text/plain", &body);
3997 assert!(preview.contains("truncated"));
3998 assert!(preview.len() < body.len());
3999 }
4000
4001 #[test]
4006 fn test_flavor_detection_cloud() {
4007 assert_eq!(
4008 detect_flavor("https://company.atlassian.net"),
4009 JiraFlavor::Cloud
4010 );
4011 assert_eq!(
4012 detect_flavor("https://myorg.atlassian.net/"),
4013 JiraFlavor::Cloud
4014 );
4015 }
4016
4017 #[test]
4018 fn test_flavor_detection_self_hosted() {
4019 assert_eq!(
4020 detect_flavor("https://jira.company.com"),
4021 JiraFlavor::SelfHosted
4022 );
4023 assert_eq!(
4024 detect_flavor("https://jira.corp.internal"),
4025 JiraFlavor::SelfHosted
4026 );
4027 assert_eq!(
4028 detect_flavor("http://localhost:8080"),
4029 JiraFlavor::SelfHosted
4030 );
4031 }
4032
4033 #[test]
4038 fn test_api_url_cloud() {
4039 assert_eq!(
4040 build_api_base("https://company.atlassian.net", JiraFlavor::Cloud),
4041 "https://company.atlassian.net/rest/api/3"
4042 );
4043 }
4044
4045 #[test]
4046 fn test_api_url_self_hosted() {
4047 assert_eq!(
4048 build_api_base("https://jira.company.com", JiraFlavor::SelfHosted),
4049 "https://jira.company.com/rest/api/2"
4050 );
4051 }
4052
4053 #[test]
4054 fn test_api_url_strips_trailing_slash() {
4055 assert_eq!(
4056 build_api_base("https://company.atlassian.net/", JiraFlavor::Cloud),
4057 "https://company.atlassian.net/rest/api/3"
4058 );
4059 }
4060
4061 #[test]
4066 fn test_auth_header_cloud() {
4067 let client = JiraClient::with_base_url(
4068 "http://localhost",
4069 "PROJ",
4070 "user@example.com",
4071 token("api-token-123"),
4072 true,
4073 );
4074 let expected = base64_encode("user@example.com:api-token-123");
4076 let req = client.request(reqwest::Method::GET, "http://localhost/test");
4077 let built = req.build().unwrap();
4078 let auth = built
4079 .headers()
4080 .get("Authorization")
4081 .unwrap()
4082 .to_str()
4083 .unwrap();
4084 assert_eq!(auth, format!("Basic {}", expected));
4085 }
4086
4087 #[test]
4088 fn test_auth_header_self_hosted_bearer() {
4089 let client = JiraClient::with_base_url(
4090 "http://localhost",
4091 "PROJ",
4092 "user@example.com",
4093 token("personal-access-token"),
4094 false,
4095 );
4096 let req = client.request(reqwest::Method::GET, "http://localhost/test");
4097 let built = req.build().unwrap();
4098 let auth = built
4099 .headers()
4100 .get("Authorization")
4101 .unwrap()
4102 .to_str()
4103 .unwrap();
4104 assert_eq!(auth, "Bearer personal-access-token");
4105 }
4106
4107 #[test]
4108 fn test_auth_header_self_hosted_basic() {
4109 let client = JiraClient::with_base_url(
4110 "http://localhost",
4111 "PROJ",
4112 "user@example.com",
4113 token("user:password"),
4114 false,
4115 );
4116 let expected = base64_encode("user:password");
4117 let req = client.request(reqwest::Method::GET, "http://localhost/test");
4118 let built = req.build().unwrap();
4119 let auth = built
4120 .headers()
4121 .get("Authorization")
4122 .unwrap()
4123 .to_str()
4124 .unwrap();
4125 assert_eq!(auth, format!("Basic {}", expected));
4126 }
4127
4128 #[test]
4133 fn test_base64_encode() {
4134 assert_eq!(base64_encode("hello"), "aGVsbG8=");
4135 assert_eq!(base64_encode("user:pass"), "dXNlcjpwYXNz");
4136 assert_eq!(base64_encode(""), "");
4137 assert_eq!(base64_encode("a"), "YQ==");
4138 assert_eq!(base64_encode("ab"), "YWI=");
4139 assert_eq!(base64_encode("abc"), "YWJj");
4140 }
4141
4142 #[test]
4147 fn test_text_to_adf_simple() {
4148 let adf = text_to_adf("Hello world");
4149 assert_eq!(adf["type"], "doc");
4150 assert_eq!(adf["version"], 1);
4151 let content = adf["content"].as_array().unwrap();
4152 assert_eq!(content.len(), 1);
4153 assert_eq!(content[0]["type"], "paragraph");
4154 let inline = content[0]["content"].as_array().unwrap();
4155 assert_eq!(inline.len(), 1);
4156 assert_eq!(inline[0]["text"], "Hello world");
4157 }
4158
4159 #[test]
4160 fn test_text_to_adf_multi_paragraph() {
4161 let adf = text_to_adf("First paragraph\n\nSecond paragraph");
4162 let content = adf["content"].as_array().unwrap();
4163 assert_eq!(content.len(), 2);
4164 assert_eq!(content[0]["content"][0]["text"], "First paragraph");
4165 assert_eq!(content[1]["content"][0]["text"], "Second paragraph");
4166 }
4167
4168 #[test]
4169 fn test_text_to_adf_with_line_breaks() {
4170 let adf = text_to_adf("Line 1\nLine 2\nLine 3");
4171 let content = adf["content"].as_array().unwrap();
4172 assert_eq!(content.len(), 1);
4173 let inline = content[0]["content"].as_array().unwrap();
4174 assert_eq!(inline.len(), 5);
4176 assert_eq!(inline[0]["text"], "Line 1");
4177 assert_eq!(inline[1]["type"], "hardBreak");
4178 assert_eq!(inline[2]["text"], "Line 2");
4179 assert_eq!(inline[3]["type"], "hardBreak");
4180 assert_eq!(inline[4]["text"], "Line 3");
4181 }
4182
4183 #[test]
4184 fn test_text_to_adf_empty() {
4185 let adf = text_to_adf("");
4186 assert_eq!(adf["type"], "doc");
4187 let content = adf["content"].as_array().unwrap();
4188 assert_eq!(content.len(), 1);
4189 assert_eq!(content[0]["type"], "paragraph");
4190 assert!(content[0]["content"].as_array().unwrap().is_empty());
4191 }
4192
4193 #[test]
4194 fn test_adf_to_text_simple() {
4195 let adf = serde_json::json!({
4196 "version": 1,
4197 "type": "doc",
4198 "content": [{
4199 "type": "paragraph",
4200 "content": [{
4201 "type": "text",
4202 "text": "Hello world"
4203 }]
4204 }]
4205 });
4206 assert_eq!(adf_to_text(&adf), "Hello world");
4207 }
4208
4209 #[test]
4210 fn test_adf_to_text_multi() {
4211 let adf = serde_json::json!({
4212 "version": 1,
4213 "type": "doc",
4214 "content": [
4215 {
4216 "type": "paragraph",
4217 "content": [{
4218 "type": "text",
4219 "text": "First"
4220 }]
4221 },
4222 {
4223 "type": "paragraph",
4224 "content": [{
4225 "type": "text",
4226 "text": "Second"
4227 }]
4228 }
4229 ]
4230 });
4231 assert_eq!(adf_to_text(&adf), "First\n\nSecond");
4232 }
4233
4234 #[test]
4235 fn test_adf_to_text_with_hardbreak() {
4236 let adf = serde_json::json!({
4237 "version": 1,
4238 "type": "doc",
4239 "content": [{
4240 "type": "paragraph",
4241 "content": [
4242 {"type": "text", "text": "Line 1"},
4243 {"type": "hardBreak"},
4244 {"type": "text", "text": "Line 2"}
4245 ]
4246 }]
4247 });
4248 assert_eq!(adf_to_text(&adf), "Line 1\nLine 2");
4249 }
4250
4251 #[test]
4252 fn test_adf_to_text_empty() {
4253 let adf = serde_json::json!({
4254 "version": 1,
4255 "type": "doc",
4256 "content": []
4257 });
4258 assert_eq!(adf_to_text(&adf), "");
4259 }
4260
4261 #[test]
4262 fn test_adf_to_text_non_adf_string() {
4263 let value = serde_json::Value::String("plain text".to_string());
4264 assert_eq!(adf_to_text(&value), "plain text");
4265 }
4266
4267 #[test]
4268 fn test_adf_to_text_null() {
4269 assert_eq!(adf_to_text(&serde_json::Value::Null), "");
4270 }
4271
4272 fn sample_jira_user_cloud() -> JiraUser {
4277 JiraUser {
4278 account_id: Some("5b10a2844c20165700ede21g".to_string()),
4279 name: None,
4280 display_name: Some("John Doe".to_string()),
4281 email_address: Some("john@example.com".to_string()),
4282 }
4283 }
4284
4285 fn sample_jira_user_self_hosted() -> JiraUser {
4286 JiraUser {
4287 account_id: None,
4288 name: Some("jdoe".to_string()),
4289 display_name: Some("John Doe".to_string()),
4290 email_address: Some("john@example.com".to_string()),
4291 }
4292 }
4293
4294 #[test]
4295 fn test_map_user_cloud() {
4296 let user = map_user(Some(&sample_jira_user_cloud())).unwrap();
4297 assert_eq!(user.id, "5b10a2844c20165700ede21g");
4298 assert_eq!(user.username, "5b10a2844c20165700ede21g");
4299 assert_eq!(user.name, Some("John Doe".to_string()));
4300 assert_eq!(user.email, Some("john@example.com".to_string()));
4301 }
4302
4303 #[test]
4304 fn test_map_user_self_hosted() {
4305 let user = map_user(Some(&sample_jira_user_self_hosted())).unwrap();
4306 assert_eq!(user.id, "jdoe");
4307 assert_eq!(user.username, "jdoe");
4308 assert_eq!(user.name, Some("John Doe".to_string()));
4309 }
4310
4311 #[test]
4312 fn test_map_user_none() {
4313 assert!(map_user(None).is_none());
4314 }
4315
4316 #[test]
4317 fn test_map_priority() {
4318 let make_priority = |name: &str| JiraPriority {
4319 name: name.to_string(),
4320 };
4321
4322 assert_eq!(
4323 map_priority(Some(&make_priority("Highest"))),
4324 Some("urgent".to_string())
4325 );
4326 assert_eq!(
4327 map_priority(Some(&make_priority("High"))),
4328 Some("high".to_string())
4329 );
4330 assert_eq!(
4331 map_priority(Some(&make_priority("Medium"))),
4332 Some("normal".to_string())
4333 );
4334 assert_eq!(
4335 map_priority(Some(&make_priority("Low"))),
4336 Some("low".to_string())
4337 );
4338 assert_eq!(
4339 map_priority(Some(&make_priority("Lowest"))),
4340 Some("low".to_string())
4341 );
4342 assert_eq!(
4343 map_priority(Some(&make_priority("Blocker"))),
4344 Some("urgent".to_string())
4345 );
4346 assert_eq!(map_priority(None), None);
4347 }
4348
4349 #[test]
4350 fn test_map_issue() {
4351 let issue = JiraIssue {
4352 id: "10001".to_string(),
4353 key: "PROJ-123".to_string(),
4354 fields: JiraIssueFields {
4355 summary: Some("Fix login bug".to_string()),
4356 description: Some(serde_json::Value::String(
4357 "Login fails on mobile".to_string(),
4358 )),
4359 status: Some(JiraStatus {
4360 name: "In Progress".to_string(),
4361 status_category: None,
4362 }),
4363 priority: Some(JiraPriority {
4364 name: "High".to_string(),
4365 }),
4366 assignee: Some(sample_jira_user_self_hosted()),
4367 reporter: Some(JiraUser {
4368 account_id: None,
4369 name: Some("reporter".to_string()),
4370 display_name: Some("Reporter".to_string()),
4371 email_address: None,
4372 }),
4373 labels: vec!["bug".to_string(), "mobile".to_string()],
4374 created: Some("2024-01-01T10:00:00.000+0000".to_string()),
4375 updated: Some("2024-01-02T15:30:00.000+0000".to_string()),
4376 parent: None,
4377 subtasks: vec![],
4378 issuelinks: vec![],
4379 attachment: vec![],
4380 issuetype: None,
4381 extras: std::collections::HashMap::new(),
4382 },
4383 };
4384
4385 let mapped = map_issue(&issue, JiraFlavor::SelfHosted, "https://jira.example.com");
4386 assert_eq!(mapped.key, "jira#PROJ-123");
4387 assert_eq!(mapped.title, "Fix login bug");
4388 assert_eq!(
4389 mapped.description,
4390 Some("Login fails on mobile".to_string())
4391 );
4392 assert_eq!(mapped.state, "In Progress");
4393 assert_eq!(mapped.source, "jira");
4394 assert_eq!(mapped.priority, Some("high".to_string()));
4395 assert_eq!(mapped.labels, vec!["bug", "mobile"]);
4396 assert_eq!(mapped.assignees.len(), 1);
4397 assert_eq!(mapped.assignees[0].username, "jdoe");
4398 assert!(mapped.author.is_some());
4399 assert_eq!(mapped.author.unwrap().username, "reporter");
4400 assert_eq!(
4401 mapped.url,
4402 Some("https://jira.example.com/browse/PROJ-123".to_string())
4403 );
4404 assert_eq!(
4405 mapped.created_at,
4406 Some("2024-01-01T10:00:00.000+0000".to_string())
4407 );
4408 }
4409
4410 #[test]
4411 fn test_map_issue_cloud_adf_description() {
4412 let adf_desc = serde_json::json!({
4413 "version": 1,
4414 "type": "doc",
4415 "content": [{
4416 "type": "paragraph",
4417 "content": [{
4418 "type": "text",
4419 "text": "ADF description"
4420 }]
4421 }]
4422 });
4423
4424 let issue = JiraIssue {
4425 id: "10001".to_string(),
4426 key: "PROJ-1".to_string(),
4427 fields: JiraIssueFields {
4428 summary: Some("Test".to_string()),
4429 description: Some(adf_desc),
4430 status: None,
4431 priority: None,
4432 assignee: None,
4433 reporter: None,
4434 labels: vec![],
4435 created: None,
4436 updated: None,
4437 parent: None,
4438 subtasks: vec![],
4439 issuelinks: vec![],
4440 attachment: vec![],
4441 issuetype: None,
4442 extras: std::collections::HashMap::new(),
4443 },
4444 };
4445
4446 let mapped = map_issue(&issue, JiraFlavor::Cloud, "https://test.atlassian.net");
4447 assert_eq!(mapped.description, Some("ADF description".to_string()));
4448 }
4449
4450 #[test]
4451 fn test_map_issue_self_hosted_plain_description() {
4452 let issue = JiraIssue {
4453 id: "10001".to_string(),
4454 key: "PROJ-1".to_string(),
4455 fields: JiraIssueFields {
4456 summary: Some("Test".to_string()),
4457 description: Some(serde_json::Value::String("Plain text desc".to_string())),
4458 status: None,
4459 priority: None,
4460 assignee: None,
4461 reporter: None,
4462 labels: vec![],
4463 created: None,
4464 updated: None,
4465 parent: None,
4466 subtasks: vec![],
4467 issuelinks: vec![],
4468 attachment: vec![],
4469 issuetype: None,
4470 extras: std::collections::HashMap::new(),
4471 },
4472 };
4473
4474 let mapped = map_issue(&issue, JiraFlavor::SelfHosted, "https://jira.example.com");
4475 assert_eq!(mapped.description, Some("Plain text desc".to_string()));
4476 }
4477
4478 #[test]
4479 fn test_map_comment() {
4480 let comment = JiraComment {
4481 id: "100".to_string(),
4482 body: Some(serde_json::Value::String("Nice work!".to_string())),
4483 author: Some(sample_jira_user_self_hosted()),
4484 created: Some("2024-01-01T10:00:00.000+0000".to_string()),
4485 updated: Some("2024-01-01T11:00:00.000+0000".to_string()),
4486 };
4487
4488 let mapped = map_comment(&comment, JiraFlavor::SelfHosted);
4489 assert_eq!(mapped.id, "100");
4490 assert_eq!(mapped.body, "Nice work!");
4491 assert!(mapped.author.is_some());
4492 assert_eq!(mapped.author.unwrap().username, "jdoe");
4493 }
4494
4495 #[test]
4496 fn test_map_comment_cloud_adf() {
4497 let adf_body = serde_json::json!({
4498 "version": 1,
4499 "type": "doc",
4500 "content": [{
4501 "type": "paragraph",
4502 "content": [{
4503 "type": "text",
4504 "text": "ADF comment"
4505 }]
4506 }]
4507 });
4508
4509 let comment = JiraComment {
4510 id: "200".to_string(),
4511 body: Some(adf_body),
4512 author: None,
4513 created: None,
4514 updated: None,
4515 };
4516
4517 let mapped = map_comment(&comment, JiraFlavor::Cloud);
4518 assert_eq!(mapped.body, "ADF comment");
4519 }
4520
4521 #[test]
4526 fn test_provider_name() {
4527 let client = JiraClient::with_base_url(
4528 "http://localhost",
4529 "PROJ",
4530 "user@example.com",
4531 token("token"),
4532 false,
4533 );
4534 assert_eq!(IssueProvider::provider_name(&client), "jira");
4535 assert_eq!(MergeRequestProvider::provider_name(&client), "jira");
4536 }
4537
4538 #[test]
4543 fn test_generic_status_to_category() {
4544 assert_eq!(generic_status_to_category("closed"), Some("done"));
4546 assert_eq!(generic_status_to_category("done"), Some("done"));
4547 assert_eq!(generic_status_to_category("resolved"), Some("done"));
4548 assert_eq!(generic_status_to_category("canceled"), Some("done"));
4549 assert_eq!(generic_status_to_category("cancelled"), Some("done"));
4550 assert_eq!(generic_status_to_category("CLOSED"), Some("done"));
4551
4552 assert_eq!(generic_status_to_category("open"), Some("new"));
4554 assert_eq!(generic_status_to_category("new"), Some("new"));
4555 assert_eq!(generic_status_to_category("todo"), Some("new"));
4556 assert_eq!(generic_status_to_category("to do"), Some("new"));
4557 assert_eq!(generic_status_to_category("reopen"), Some("new"));
4558 assert_eq!(generic_status_to_category("reopened"), Some("new"));
4559
4560 assert_eq!(
4562 generic_status_to_category("in_progress"),
4563 Some("indeterminate")
4564 );
4565 assert_eq!(
4566 generic_status_to_category("in progress"),
4567 Some("indeterminate")
4568 );
4569 assert_eq!(
4570 generic_status_to_category("in-progress"),
4571 Some("indeterminate")
4572 );
4573
4574 assert_eq!(generic_status_to_category("custom status"), None);
4576 assert_eq!(generic_status_to_category("review"), None);
4577 }
4578
4579 #[test]
4580 fn test_priority_to_jira() {
4581 assert_eq!(priority_to_jira("urgent"), "Highest");
4582 assert_eq!(priority_to_jira("high"), "High");
4583 assert_eq!(priority_to_jira("normal"), "Medium");
4584 assert_eq!(priority_to_jira("low"), "Low");
4585 assert_eq!(priority_to_jira("custom"), "custom");
4586 }
4587
4588 #[test]
4593 fn test_instance_url_from_base() {
4594 assert_eq!(
4595 instance_url_from_base("https://company.atlassian.net/rest/api/3"),
4596 "https://company.atlassian.net"
4597 );
4598 assert_eq!(
4599 instance_url_from_base("https://jira.corp.com/rest/api/2"),
4600 "https://jira.corp.com"
4601 );
4602 assert_eq!(
4603 instance_url_from_base("http://localhost:8080"),
4604 "http://localhost:8080"
4605 );
4606 }
4607
4608 mod integration {
4613 use super::*;
4614 use httpmock::prelude::*;
4615
4616 fn token(s: &str) -> SecretString {
4617 SecretString::from(s.to_string())
4618 }
4619
4620 fn create_self_hosted_client(server: &MockServer) -> JiraClient {
4621 JiraClient::with_base_url(
4622 server.base_url(),
4623 "PROJ",
4624 "user@example.com",
4625 token("pat-token"),
4626 false,
4627 )
4628 }
4629
4630 fn create_cloud_client(server: &MockServer) -> JiraClient {
4631 JiraClient::with_base_url(
4632 server.base_url(),
4633 "PROJ",
4634 "user@example.com",
4635 token("api-token"),
4636 true,
4637 )
4638 }
4639
4640 fn sample_issue_json() -> serde_json::Value {
4641 serde_json::json!({
4642 "id": "10001",
4643 "key": "PROJ-1",
4644 "fields": {
4645 "summary": "Fix login bug",
4646 "description": "Login fails on mobile",
4647 "status": {"name": "Open"},
4648 "priority": {"name": "High"},
4649 "assignee": {
4650 "name": "jdoe",
4651 "displayName": "John Doe",
4652 "emailAddress": "john@example.com"
4653 },
4654 "reporter": {
4655 "name": "reporter",
4656 "displayName": "Reporter"
4657 },
4658 "labels": ["bug"],
4659 "created": "2024-01-01T10:00:00.000+0000",
4660 "updated": "2024-01-02T15:30:00.000+0000"
4661 }
4662 })
4663 }
4664
4665 fn sample_cloud_issue_json() -> serde_json::Value {
4666 serde_json::json!({
4667 "id": "10001",
4668 "key": "PROJ-1",
4669 "fields": {
4670 "summary": "Fix login bug",
4671 "description": {
4672 "version": 1,
4673 "type": "doc",
4674 "content": [{
4675 "type": "paragraph",
4676 "content": [{
4677 "type": "text",
4678 "text": "Login fails on mobile"
4679 }]
4680 }]
4681 },
4682 "status": {"name": "Open"},
4683 "priority": {"name": "High"},
4684 "assignee": {
4685 "accountId": "5b10a2844c20165700ede21g",
4686 "displayName": "John Doe",
4687 "emailAddress": "john@example.com"
4688 },
4689 "reporter": {
4690 "accountId": "5b10a284reporter",
4691 "displayName": "Reporter"
4692 },
4693 "labels": ["bug"],
4694 "created": "2024-01-01T10:00:00.000+0000",
4695 "updated": "2024-01-02T15:30:00.000+0000"
4696 }
4697 })
4698 }
4699
4700 #[tokio::test]
4705 async fn test_get_issues() {
4706 let server = MockServer::start();
4707
4708 server.mock(|when, then| {
4709 when.method(GET).path("/search").query_param_exists("jql");
4710 then.status(200).json_body(serde_json::json!({
4711 "issues": [sample_issue_json()],
4712 "startAt": 0,
4713 "maxResults": 20,
4714 "total": 1
4715 }));
4716 });
4717
4718 let client = create_self_hosted_client(&server);
4719 let issues = client
4720 .get_issues(IssueFilter::default())
4721 .await
4722 .unwrap()
4723 .items;
4724
4725 assert_eq!(issues.len(), 1);
4726 assert_eq!(issues[0].key, "jira#PROJ-1");
4727 assert_eq!(issues[0].title, "Fix login bug");
4728 assert_eq!(issues[0].source, "jira");
4729 assert_eq!(issues[0].priority, Some("high".to_string()));
4730 assert_eq!(
4731 issues[0].description,
4732 Some("Login fails on mobile".to_string())
4733 );
4734 }
4735
4736 #[tokio::test]
4737 async fn test_get_issues_with_filters() {
4738 let server = MockServer::start();
4739
4740 server.mock(|when, then| {
4741 when.method(GET)
4742 .path("/search")
4743 .query_param_includes("jql", "labels = \"bug\"")
4744 .query_param_includes("jql", "assignee = \"jdoe\"");
4745 then.status(200).json_body(serde_json::json!({
4746 "issues": [sample_issue_json()],
4747 "startAt": 0,
4748 "maxResults": 20,
4749 "total": 1
4750 }));
4751 });
4752
4753 let client = create_self_hosted_client(&server);
4754 let issues = client
4755 .get_issues(IssueFilter {
4756 labels: Some(vec!["bug".to_string()]),
4757 assignee: Some("jdoe".to_string()),
4758 ..Default::default()
4759 })
4760 .await
4761 .unwrap()
4762 .items;
4763
4764 assert_eq!(issues.len(), 1);
4765 }
4766
4767 #[tokio::test]
4768 async fn test_get_issues_pagination() {
4769 let server = MockServer::start();
4770
4771 server.mock(|when, then| {
4772 when.method(GET)
4773 .path("/search")
4774 .query_param("startAt", "5")
4775 .query_param("maxResults", "10");
4776 then.status(200).json_body(serde_json::json!({
4777 "issues": [sample_issue_json()],
4778 "startAt": 5,
4779 "maxResults": 10,
4780 "total": 20
4781 }));
4782 });
4783
4784 let client = create_self_hosted_client(&server);
4785 let issues = client
4786 .get_issues(IssueFilter {
4787 offset: Some(5),
4788 limit: Some(10),
4789 ..Default::default()
4790 })
4791 .await
4792 .unwrap()
4793 .items;
4794
4795 assert_eq!(issues.len(), 1);
4796 }
4797
4798 #[tokio::test]
4799 async fn test_get_issues_project_key_override() {
4800 let server = MockServer::start();
4801
4802 server.mock(|when, then| {
4803 when.method(GET)
4804 .path("/search")
4805 .query_param_includes("jql", "project = \"OTHER\"");
4806 then.status(200).json_body(serde_json::json!({
4807 "issues": [sample_issue_json()],
4808 "startAt": 0,
4809 "maxResults": 20,
4810 "total": 1
4811 }));
4812 });
4813
4814 let client = create_self_hosted_client(&server);
4815 let issues = client
4816 .get_issues(IssueFilter {
4817 project_key: Some("OTHER".to_string()),
4818 ..Default::default()
4819 })
4820 .await
4821 .unwrap()
4822 .items;
4823
4824 assert_eq!(issues.len(), 1);
4825 }
4826
4827 #[tokio::test]
4828 async fn test_get_issues_native_query_passthrough() {
4829 let server = MockServer::start();
4830
4831 server.mock(|when, then| {
4832 when.method(GET)
4833 .path("/search")
4834 .query_param_includes("jql", "project = \"CUSTOM\" AND fixVersion = \"1.0\"");
4835 then.status(200).json_body(serde_json::json!({
4836 "issues": [sample_issue_json()],
4837 "startAt": 0,
4838 "maxResults": 20,
4839 "total": 1
4840 }));
4841 });
4842
4843 let client = create_self_hosted_client(&server);
4844 let issues = client
4845 .get_issues(IssueFilter {
4846 native_query: Some("project = \"CUSTOM\" AND fixVersion = \"1.0\"".to_string()),
4847 ..Default::default()
4848 })
4849 .await
4850 .unwrap()
4851 .items;
4852
4853 assert_eq!(issues.len(), 1);
4854 }
4855
4856 #[tokio::test]
4857 async fn test_get_issues_native_query_auto_injects_project() {
4858 let server = MockServer::start();
4859
4860 server.mock(|when, then| {
4863 when.method(GET)
4864 .path("/search")
4865 .query_param_includes("jql", "project = \"PROJ\" AND fixVersion = \"2.0\"");
4866 then.status(200).json_body(serde_json::json!({
4867 "issues": [sample_issue_json()],
4868 "startAt": 0,
4869 "maxResults": 20,
4870 "total": 1
4871 }));
4872 });
4873
4874 let client = create_self_hosted_client(&server);
4875 let issues = client
4876 .get_issues(IssueFilter {
4877 native_query: Some("fixVersion = \"2.0\"".to_string()),
4878 ..Default::default()
4879 })
4880 .await
4881 .unwrap()
4882 .items;
4883
4884 assert_eq!(issues.len(), 1);
4885 }
4886
4887 #[tokio::test]
4888 async fn test_get_issues_native_query_with_project_in() {
4889 let server = MockServer::start();
4890
4891 server.mock(|when, then| {
4893 when.method(GET)
4894 .path("/search")
4895 .query_param_includes("jql", "project IN (\"A\", \"B\") AND status = \"Open\"");
4896 then.status(200).json_body(serde_json::json!({
4897 "issues": [sample_issue_json()],
4898 "startAt": 0,
4899 "maxResults": 20,
4900 "total": 1
4901 }));
4902 });
4903
4904 let client = create_self_hosted_client(&server);
4905 let issues = client
4906 .get_issues(IssueFilter {
4907 native_query: Some(
4908 "project IN (\"A\", \"B\") AND status = \"Open\"".to_string(),
4909 ),
4910 ..Default::default()
4911 })
4912 .await
4913 .unwrap()
4914 .items;
4915
4916 assert_eq!(issues.len(), 1);
4917 }
4918
4919 #[tokio::test]
4920 async fn test_get_issues_project_key_with_native_query() {
4921 let server = MockServer::start();
4922
4923 server.mock(|when, then| {
4926 when.method(GET)
4927 .path("/search")
4928 .query_param_includes("jql", "project = \"OVERRIDE\" AND sprint = 42");
4929 then.status(200).json_body(serde_json::json!({
4930 "issues": [sample_issue_json()],
4931 "startAt": 0,
4932 "maxResults": 20,
4933 "total": 1
4934 }));
4935 });
4936
4937 let client = create_self_hosted_client(&server); let issues = client
4939 .get_issues(IssueFilter {
4940 project_key: Some("OVERRIDE".to_string()),
4941 native_query: Some("sprint = 42".to_string()),
4942 ..Default::default()
4943 })
4944 .await
4945 .unwrap()
4946 .items;
4947
4948 assert_eq!(issues.len(), 1);
4949 }
4950
4951 #[tokio::test]
4952 async fn test_get_issues_empty_native_query_falls_back() {
4953 let server = MockServer::start();
4954
4955 server.mock(|when, then| {
4957 when.method(GET)
4958 .path("/search")
4959 .query_param_includes("jql", "project = \"PROJ\"");
4960 then.status(200).json_body(serde_json::json!({
4961 "issues": [sample_issue_json()],
4962 "startAt": 0,
4963 "maxResults": 20,
4964 "total": 1
4965 }));
4966 });
4967
4968 let client = create_self_hosted_client(&server);
4969 let issues = client
4970 .get_issues(IssueFilter {
4971 native_query: Some("".to_string()),
4972 ..Default::default()
4973 })
4974 .await
4975 .unwrap()
4976 .items;
4977
4978 assert_eq!(issues.len(), 1);
4979 }
4980
4981 #[tokio::test]
4982 async fn test_get_issues_native_query_order_by_only() {
4983 let server = MockServer::start();
4984
4985 server.mock(|when, then| {
4988 when.method(GET)
4989 .path("/search")
4990 .query_param_includes("jql", "project = \"PROJ\" ORDER BY created ASC");
4991 then.status(200).json_body(serde_json::json!({
4992 "issues": [sample_issue_json()],
4993 "startAt": 0,
4994 "maxResults": 20,
4995 "total": 1
4996 }));
4997 });
4998
4999 let client = create_self_hosted_client(&server);
5000 let issues = client
5001 .get_issues(IssueFilter {
5002 native_query: Some("ORDER BY created ASC".to_string()),
5003 ..Default::default()
5004 })
5005 .await
5006 .unwrap()
5007 .items;
5008
5009 assert_eq!(issues.len(), 1);
5010 }
5011
5012 #[tokio::test]
5013 async fn test_get_issue() {
5014 let server = MockServer::start();
5015
5016 server.mock(|when, then| {
5017 when.method(GET).path("/issue/PROJ-1");
5018 then.status(200).json_body(sample_issue_json());
5019 });
5020
5021 let client = create_self_hosted_client(&server);
5022 let issue = client.get_issue("jira#PROJ-1").await.unwrap();
5023
5024 assert_eq!(issue.key, "jira#PROJ-1");
5025 assert_eq!(issue.title, "Fix login bug");
5026 }
5027
5028 #[tokio::test]
5029 async fn test_create_issue() {
5030 let server = MockServer::start();
5031
5032 server.mock(|when, then| {
5033 when.method(POST)
5034 .path("/issue")
5035 .body_includes("\"summary\":\"New task\"");
5036 then.status(201).json_body(serde_json::json!({
5037 "id": "10002",
5038 "key": "PROJ-2"
5039 }));
5040 });
5041
5042 server.mock(|when, then| {
5043 when.method(GET).path("/issue/PROJ-2");
5044 then.status(200).json_body(serde_json::json!({
5045 "id": "10002",
5046 "key": "PROJ-2",
5047 "fields": {
5048 "summary": "New task",
5049 "status": {"name": "Open"},
5050 "labels": [],
5051 "created": "2024-01-03T10:00:00.000+0000"
5052 }
5053 }));
5054 });
5055
5056 let client = create_self_hosted_client(&server);
5057 let issue = client
5058 .create_issue(CreateIssueInput {
5059 title: "New task".to_string(),
5060 description: Some("Task description".to_string()),
5061 ..Default::default()
5062 })
5063 .await
5064 .unwrap();
5065
5066 assert_eq!(issue.key, "jira#PROJ-2");
5067 assert_eq!(issue.title, "New task");
5068 }
5069
5070 #[tokio::test]
5071 async fn test_create_issue_with_project_id_override() {
5072 let server = MockServer::start();
5073
5074 server.mock(|when, then| {
5076 when.method(POST)
5077 .path("/issue")
5078 .body_includes("\"key\":\"OTHER\"");
5079 then.status(201).json_body(serde_json::json!({
5080 "id": "10003",
5081 "key": "OTHER-1"
5082 }));
5083 });
5084
5085 server.mock(|when, then| {
5086 when.method(GET).path("/issue/OTHER-1");
5087 then.status(200).json_body(serde_json::json!({
5088 "id": "10003",
5089 "key": "OTHER-1",
5090 "fields": {
5091 "summary": "Task in other project",
5092 "status": {"name": "Open"},
5093 "labels": [],
5094 "created": "2024-01-03T10:00:00.000+0000"
5095 }
5096 }));
5097 });
5098
5099 let client = create_self_hosted_client(&server); let issue = client
5101 .create_issue(CreateIssueInput {
5102 title: "Task in other project".to_string(),
5103 project_id: Some("OTHER".to_string()),
5104 ..Default::default()
5105 })
5106 .await
5107 .unwrap();
5108
5109 assert_eq!(issue.key, "jira#OTHER-1");
5110 }
5111
5112 #[tokio::test]
5113 async fn test_create_issue_with_issue_type() {
5114 let server = MockServer::start();
5115
5116 server.mock(|when, then| {
5118 when.method(POST)
5119 .path("/issue")
5120 .body_includes("\"name\":\"Bug\"");
5121 then.status(201).json_body(serde_json::json!({
5122 "id": "10004",
5123 "key": "PROJ-3"
5124 }));
5125 });
5126
5127 server.mock(|when, then| {
5128 when.method(GET).path("/issue/PROJ-3");
5129 then.status(200).json_body(serde_json::json!({
5130 "id": "10004",
5131 "key": "PROJ-3",
5132 "fields": {
5133 "summary": "Bug report",
5134 "status": {"name": "Open"},
5135 "labels": [],
5136 "created": "2024-01-03T10:00:00.000+0000"
5137 }
5138 }));
5139 });
5140
5141 let client = create_self_hosted_client(&server);
5142 let issue = client
5143 .create_issue(CreateIssueInput {
5144 title: "Bug report".to_string(),
5145 issue_type: Some("Bug".to_string()),
5146 ..Default::default()
5147 })
5148 .await
5149 .unwrap();
5150
5151 assert_eq!(issue.key, "jira#PROJ-3");
5152 }
5153
5154 #[tokio::test]
5155 async fn test_create_issue_with_custom_fields() {
5156 let server = MockServer::start();
5157
5158 server.mock(|when, then| {
5160 when.method(POST)
5161 .path("/issue")
5162 .body_includes("\"customfield_10001\":8")
5163 .body_includes("\"customfield_10002\":\"goal-a\"");
5164 then.status(201).json_body(serde_json::json!({
5165 "id": "10005",
5166 "key": "PROJ-5"
5167 }));
5168 });
5169
5170 server.mock(|when, then| {
5171 when.method(GET).path("/issue/PROJ-5");
5172 then.status(200).json_body(serde_json::json!({
5173 "id": "10005",
5174 "key": "PROJ-5",
5175 "fields": {
5176 "summary": "With custom fields",
5177 "status": {"name": "Open"},
5178 "labels": [],
5179 "created": "2024-01-03T10:00:00.000+0000"
5180 }
5181 }));
5182 });
5183
5184 let client = create_self_hosted_client(&server);
5185 let issue = client
5186 .create_issue(CreateIssueInput {
5187 title: "With custom fields".to_string(),
5188 custom_fields: Some(serde_json::json!({
5189 "customfield_10001": 8,
5190 "customfield_10002": "goal-a"
5191 })),
5192 ..Default::default()
5193 })
5194 .await
5195 .unwrap();
5196
5197 assert_eq!(issue.key, "jira#PROJ-5");
5198 }
5199
5200 #[tokio::test]
5201 async fn test_update_issue_with_custom_fields() {
5202 let server = MockServer::start();
5203
5204 server.mock(|when, then| {
5206 when.method(PUT)
5207 .path("/issue/PROJ-1")
5208 .body_includes("\"customfield_10001\":5");
5209 then.status(204);
5210 });
5211
5212 server.mock(|when, then| {
5213 when.method(GET).path("/issue/PROJ-1");
5214 then.status(200).json_body(serde_json::json!({
5215 "id": "10001",
5216 "key": "PROJ-1",
5217 "fields": {
5218 "summary": "Fix login bug",
5219 "status": {"name": "Open"},
5220 "labels": [],
5221 "created": "2024-01-01T10:00:00.000+0000"
5222 }
5223 }));
5224 });
5225
5226 let client = create_self_hosted_client(&server);
5227 let issue = client
5228 .update_issue(
5229 "PROJ-1",
5230 UpdateIssueInput {
5231 custom_fields: Some(serde_json::json!({
5232 "customfield_10001": 5
5233 })),
5234 ..Default::default()
5235 },
5236 )
5237 .await
5238 .unwrap();
5239
5240 assert_eq!(issue.key, "jira#PROJ-1");
5241 }
5242
5243 #[tokio::test]
5245 async fn test_create_issue_with_components() {
5246 let server = MockServer::start();
5247
5248 server.mock(|when, then| {
5249 when.method(POST).path("/issue").body_includes(
5250 "\"components\":[{\"name\":\"Backend\"},{\"name\":\"Frontend\"}]",
5251 );
5252 then.status(201).json_body(serde_json::json!({
5253 "id": "10010",
5254 "key": "PROJ-10"
5255 }));
5256 });
5257
5258 server.mock(|when, then| {
5259 when.method(GET).path("/issue/PROJ-10");
5260 then.status(200).json_body(serde_json::json!({
5261 "id": "10010",
5262 "key": "PROJ-10",
5263 "fields": {
5264 "summary": "With components",
5265 "status": {"name": "Open"},
5266 "labels": [],
5267 "created": "2024-01-05T10:00:00.000+0000"
5268 }
5269 }));
5270 });
5271
5272 let client = create_self_hosted_client(&server);
5273 let issue = client
5274 .create_issue(CreateIssueInput {
5275 title: "With components".to_string(),
5276 components: vec!["Backend".to_string(), "Frontend".to_string()],
5277 ..Default::default()
5278 })
5279 .await
5280 .unwrap();
5281
5282 assert_eq!(issue.key, "jira#PROJ-10");
5283 }
5284
5285 #[tokio::test]
5288 async fn test_create_issue_without_components_omits_field() {
5289 let server = MockServer::start();
5290
5291 server.mock(|when, then| {
5292 when.method(POST).path("/issue").is_true(|req| {
5293 let body = String::from_utf8_lossy(req.body().as_ref());
5294 !body.contains("\"components\"")
5295 });
5296 then.status(201).json_body(serde_json::json!({
5297 "id": "10011",
5298 "key": "PROJ-11"
5299 }));
5300 });
5301
5302 server.mock(|when, then| {
5303 when.method(GET).path("/issue/PROJ-11");
5304 then.status(200).json_body(serde_json::json!({
5305 "id": "10011",
5306 "key": "PROJ-11",
5307 "fields": {
5308 "summary": "No components",
5309 "status": {"name": "Open"},
5310 "labels": [],
5311 "created": "2024-01-05T10:00:00.000+0000"
5312 }
5313 }));
5314 });
5315
5316 let client = create_self_hosted_client(&server);
5317 let issue = client
5318 .create_issue(CreateIssueInput {
5319 title: "No components".to_string(),
5320 components: vec![],
5321 ..Default::default()
5322 })
5323 .await
5324 .unwrap();
5325
5326 assert_eq!(issue.key, "jira#PROJ-11");
5327 }
5328
5329 #[tokio::test]
5332 async fn test_create_issue_with_fix_versions() {
5333 let server = MockServer::start();
5334
5335 server.mock(|when, then| {
5336 when.method(POST)
5337 .path("/issue")
5338 .body_includes("\"fixVersions\":[{\"name\":\"3.18.0\"},{\"name\":\"3.19.0\"}]");
5339 then.status(201).json_body(serde_json::json!({
5340 "id": "10012",
5341 "key": "PROJ-12"
5342 }));
5343 });
5344
5345 server.mock(|when, then| {
5346 when.method(GET).path("/issue/PROJ-12");
5347 then.status(200).json_body(serde_json::json!({
5348 "id": "10012",
5349 "key": "PROJ-12",
5350 "fields": {
5351 "summary": "With fix versions",
5352 "status": {"name": "Open"},
5353 "labels": [],
5354 "created": "2024-01-05T10:00:00.000+0000"
5355 }
5356 }));
5357 });
5358
5359 let client = create_self_hosted_client(&server);
5360 let issue = client
5361 .create_issue(CreateIssueInput {
5362 title: "With fix versions".to_string(),
5363 fix_versions: vec!["3.18.0".to_string(), "3.19.0".to_string()],
5364 ..Default::default()
5365 })
5366 .await
5367 .unwrap();
5368
5369 assert_eq!(issue.key, "jira#PROJ-12");
5370 }
5371
5372 #[tokio::test]
5376 async fn test_create_issue_without_fix_versions_omits_field() {
5377 let server = MockServer::start();
5378
5379 server.mock(|when, then| {
5380 when.method(POST).path("/issue").is_true(|req| {
5381 let body = String::from_utf8_lossy(req.body().as_ref());
5382 !body.contains("\"fixVersions\"")
5383 });
5384 then.status(201).json_body(serde_json::json!({
5385 "id": "10013",
5386 "key": "PROJ-13"
5387 }));
5388 });
5389
5390 server.mock(|when, then| {
5391 when.method(GET).path("/issue/PROJ-13");
5392 then.status(200).json_body(serde_json::json!({
5393 "id": "10013",
5394 "key": "PROJ-13",
5395 "fields": {
5396 "summary": "No fix versions",
5397 "status": {"name": "Open"},
5398 "labels": [],
5399 "created": "2024-01-05T10:00:00.000+0000"
5400 }
5401 }));
5402 });
5403
5404 let client = create_self_hosted_client(&server);
5405 let issue = client
5406 .create_issue(CreateIssueInput {
5407 title: "No fix versions".to_string(),
5408 fix_versions: vec![],
5409 ..Default::default()
5410 })
5411 .await
5412 .unwrap();
5413
5414 assert_eq!(issue.key, "jira#PROJ-13");
5415 }
5416
5417 #[tokio::test]
5421 async fn test_create_issue_subtask_includes_parent_in_payload() {
5422 let server = MockServer::start();
5423
5424 server.mock(|when, then| {
5425 when.method(POST).path("/issue").is_true(|req| {
5426 let body = String::from_utf8_lossy(req.body().as_ref());
5427 body.contains("\"parent\":{\"key\":\"PROJ-1\"}")
5428 && body.contains("\"name\":\"Sub-task\"")
5429 });
5430 then.status(201).json_body(serde_json::json!({
5431 "id": "10010",
5432 "key": "PROJ-10"
5433 }));
5434 });
5435
5436 server.mock(|when, then| {
5437 when.method(GET).path("/issue/PROJ-10");
5438 then.status(200).json_body(serde_json::json!({
5439 "id": "10010",
5440 "key": "PROJ-10",
5441 "fields": {
5442 "summary": "Sub task work",
5443 "status": {"name": "Open"},
5444 "labels": [],
5445 "created": "2024-01-06T10:00:00.000+0000"
5446 }
5447 }));
5448 });
5449
5450 let client = create_self_hosted_client(&server);
5451 let issue = client
5452 .create_issue(CreateIssueInput {
5453 title: "Sub task work".to_string(),
5454 issue_type: Some("Sub-task".to_string()),
5455 parent: Some("PROJ-1".to_string()),
5456 ..Default::default()
5457 })
5458 .await
5459 .unwrap();
5460
5461 assert_eq!(issue.key, "jira#PROJ-10");
5462 }
5463
5464 #[tokio::test]
5468 async fn test_create_issue_without_parent_omits_field() {
5469 let server = MockServer::start();
5470
5471 server.mock(|when, then| {
5472 when.method(POST).path("/issue").is_true(|req| {
5473 let body = String::from_utf8_lossy(req.body().as_ref());
5474 !body.contains("\"parent\"")
5475 });
5476 then.status(201).json_body(serde_json::json!({
5477 "id": "10011",
5478 "key": "PROJ-11"
5479 }));
5480 });
5481
5482 server.mock(|when, then| {
5483 when.method(GET).path("/issue/PROJ-11");
5484 then.status(200).json_body(serde_json::json!({
5485 "id": "10011",
5486 "key": "PROJ-11",
5487 "fields": {
5488 "summary": "Plain task",
5489 "status": {"name": "Open"},
5490 "labels": [],
5491 "created": "2024-01-06T10:00:00.000+0000"
5492 }
5493 }));
5494 });
5495
5496 let client = create_self_hosted_client(&server);
5497 let issue = client
5498 .create_issue(CreateIssueInput {
5499 title: "Plain task".to_string(),
5500 parent: None,
5501 ..Default::default()
5502 })
5503 .await
5504 .unwrap();
5505
5506 assert_eq!(issue.key, "jira#PROJ-11");
5507 }
5508
5509 #[tokio::test]
5512 async fn test_update_issue_replaces_components() {
5513 let server = MockServer::start();
5514
5515 server.mock(|when, then| {
5516 when.method(PUT)
5517 .path("/issue/PROJ-1")
5518 .body_includes("\"components\":[{\"name\":\"Backend\"}]");
5519 then.status(204);
5520 });
5521
5522 server.mock(|when, then| {
5523 when.method(GET).path("/issue/PROJ-1");
5524 then.status(200).json_body(serde_json::json!({
5525 "id": "10001",
5526 "key": "PROJ-1",
5527 "fields": {
5528 "summary": "Updated",
5529 "status": {"name": "Open"},
5530 "labels": [],
5531 "created": "2024-01-01T10:00:00.000+0000"
5532 }
5533 }));
5534 });
5535
5536 let client = create_self_hosted_client(&server);
5537 let issue = client
5538 .update_issue(
5539 "PROJ-1",
5540 UpdateIssueInput {
5541 components: Some(vec!["Backend".to_string()]),
5542 ..Default::default()
5543 },
5544 )
5545 .await
5546 .unwrap();
5547
5548 assert_eq!(issue.key, "jira#PROJ-1");
5549 }
5550
5551 #[tokio::test]
5554 async fn test_update_issue_replaces_fix_versions() {
5555 let server = MockServer::start();
5556
5557 server.mock(|when, then| {
5558 when.method(PUT)
5559 .path("/issue/PROJ-1")
5560 .body_includes("\"fixVersions\":[{\"name\":\"3.18.0\"}]");
5561 then.status(204);
5562 });
5563
5564 server.mock(|when, then| {
5565 when.method(GET).path("/issue/PROJ-1");
5566 then.status(200).json_body(serde_json::json!({
5567 "id": "10001",
5568 "key": "PROJ-1",
5569 "fields": {
5570 "summary": "Updated",
5571 "status": {"name": "Open"},
5572 "labels": [],
5573 "created": "2024-01-01T10:00:00.000+0000"
5574 }
5575 }));
5576 });
5577
5578 let client = create_self_hosted_client(&server);
5579 let issue = client
5580 .update_issue(
5581 "PROJ-1",
5582 UpdateIssueInput {
5583 fix_versions: Some(vec!["3.18.0".to_string()]),
5584 ..Default::default()
5585 },
5586 )
5587 .await
5588 .unwrap();
5589
5590 assert_eq!(issue.key, "jira#PROJ-1");
5591 }
5592
5593 #[tokio::test]
5598 async fn test_create_issue_with_epic_sprint_epicname() {
5599 let server = MockServer::start();
5600
5601 server.mock(|when, then| {
5602 when.method(GET).path("/field");
5603 then.status(200).json_body(serde_json::json!([
5604 {"id": "customfield_10014", "name": "Epic Link", "custom": true},
5605 {"id": "customfield_10011", "name": "Epic Name", "custom": true}
5606 ]));
5607 });
5608
5609 server.mock(|when, then| {
5613 when.method(POST).path("/issue").is_true(|req| {
5614 let body = String::from_utf8_lossy(req.body().as_ref());
5615 body.contains("\"customfield_10014\":\"PROJ-1\"")
5616 && !body.contains("customfield_10020")
5617 && body.contains("\"customfield_10011\":\"Q4 platform\"")
5618 });
5619 then.status(201).json_body(serde_json::json!({
5620 "id": "10100",
5621 "key": "PROJ-100"
5622 }));
5623 });
5624
5625 server.mock(|when, then| {
5628 when.method(POST)
5629 .path("/rest/agile/1.0/sprint/42/issue")
5630 .body_includes("\"issues\":[\"PROJ-100\"]");
5631 then.status(204);
5632 });
5633
5634 server.mock(|when, then| {
5635 when.method(GET).path("/issue/PROJ-100");
5636 then.status(200).json_body(serde_json::json!({
5637 "id": "10100",
5638 "key": "PROJ-100",
5639 "fields": {
5640 "summary": "Epic with agile fields",
5641 "status": {"name": "Open"},
5642 "labels": [],
5643 "created": "2024-01-05T10:00:00.000+0000"
5644 }
5645 }));
5646 });
5647
5648 let client = create_self_hosted_client(&server);
5649 let issue = client
5650 .create_issue(CreateIssueInput {
5651 title: "Epic with agile fields".to_string(),
5652 epic_key: Some("PROJ-1".to_string()),
5653 sprint_id: Some(42),
5654 epic_name: Some("Q4 platform".to_string()),
5655 ..Default::default()
5656 })
5657 .await
5658 .unwrap();
5659
5660 assert_eq!(issue.key, "jira#PROJ-100");
5661 }
5662
5663 #[tokio::test]
5667 async fn test_create_issue_epic_key_errors_when_field_missing() {
5668 let server = MockServer::start();
5669
5670 server.mock(|when, then| {
5671 when.method(GET).path("/field");
5672 then.status(200).json_body(serde_json::json!([
5674 {"id": "summary", "name": "Summary", "custom": false},
5675 {"id": "customfield_99999", "name": "Tenant", "custom": true}
5676 ]));
5677 });
5678
5679 let client = create_self_hosted_client(&server);
5680 let err = client
5681 .create_issue(CreateIssueInput {
5682 title: "No epic link".to_string(),
5683 epic_key: Some("PROJ-1".to_string()),
5684 ..Default::default()
5685 })
5686 .await
5687 .unwrap_err();
5688
5689 let msg = err.to_string();
5690 assert!(
5691 msg.contains("Epic Link"),
5692 "missing field name in error: {msg}"
5693 );
5694 assert!(
5695 msg.contains("get_custom_fields"),
5696 "missing discovery hint: {msg}"
5697 );
5698 }
5699
5700 #[tokio::test]
5704 async fn test_update_issue_replaces_epic_key() {
5705 let server = MockServer::start();
5706
5707 server.mock(|when, then| {
5708 when.method(GET).path("/field");
5709 then.status(200).json_body(serde_json::json!([
5710 {"id": "customfield_10014", "name": "Epic Link", "custom": true}
5711 ]));
5712 });
5713
5714 server.mock(|when, then| {
5715 when.method(PUT)
5716 .path("/issue/PROJ-1")
5717 .body_includes("\"customfield_10014\":\"PROJ-50\"");
5718 then.status(204);
5719 });
5720
5721 server.mock(|when, then| {
5722 when.method(GET).path("/issue/PROJ-1");
5723 then.status(200).json_body(serde_json::json!({
5724 "id": "10001",
5725 "key": "PROJ-1",
5726 "fields": {
5727 "summary": "Reparented",
5728 "status": {"name": "Open"},
5729 "labels": [],
5730 "created": "2024-01-01T10:00:00.000+0000"
5731 }
5732 }));
5733 });
5734
5735 let client = create_self_hosted_client(&server);
5736 let issue = client
5737 .update_issue(
5738 "PROJ-1",
5739 UpdateIssueInput {
5740 epic_key: Some("PROJ-50".to_string()),
5741 ..Default::default()
5742 },
5743 )
5744 .await
5745 .unwrap();
5746
5747 assert_eq!(issue.key, "jira#PROJ-1");
5748 }
5749
5750 #[tokio::test]
5760 async fn test_load_default_metadata_then_enrich_schema_e2e() {
5761 use crate::JiraSchemaEnricher;
5762 use devboy_core::{ToolEnricher, ToolSchema};
5763 use serde_json::json;
5764
5765 let server = MockServer::start();
5766
5767 server.mock(|when, then| {
5769 when.method(GET)
5770 .path("/project")
5771 .query_param("recent", "30");
5772 then.status(200).json_body(json!([
5773 {"key": "PROJ", "name": "Platform"}
5774 ]));
5775 });
5776
5777 server.mock(|when, then| {
5778 when.method(GET).path("/project/PROJ");
5779 then.status(200).json_body(json!({
5780 "key": "PROJ",
5781 "issueTypes": [
5782 {"id": "1", "name": "Task", "subtask": false},
5783 {"id": "10000", "name": "Epic", "subtask": false}
5784 ]
5785 }));
5786 });
5787
5788 server.mock(|when, then| {
5789 when.method(GET).path("/project/PROJ/components");
5790 then.status(200).json_body(json!([
5791 {"id": "100", "name": "Backend"}
5792 ]));
5793 });
5794
5795 server.mock(|when, then| {
5796 when.method(GET).path("/priority");
5797 then.status(200).json_body(json!([
5798 {"id": "1", "name": "High"},
5799 {"id": "2", "name": "Medium"}
5800 ]));
5801 });
5802
5803 server.mock(|when, then| {
5804 when.method(GET).path("/issueLinkType");
5805 then.status(200).json_body(json!({
5806 "issueLinkTypes": [
5807 {"id": "1", "name": "Blocks", "outward": "blocks", "inward": "is blocked by"}
5808 ]
5809 }));
5810 });
5811
5812 server.mock(|when, then| {
5816 when.method(GET).path("/field");
5817 then.status(200).json_body(json!([
5818 {"id": "summary", "name": "Summary", "custom": false},
5819 {"id": "customfield_10014", "name": "Epic Link", "custom": true,
5820 "schema": {"type": "any"}},
5821 {"id": "customfield_10001", "name": "Story Points", "custom": true,
5822 "schema": {"type": "number"}}
5823 ]));
5824 });
5825
5826 let client = create_self_hosted_client(&server);
5827 let metadata = client
5828 .load_default_metadata(crate::metadata::MetadataLoadStrategy::MyProjects)
5829 .await
5830 .expect("metadata loads");
5831 assert!(metadata.projects.contains_key("PROJ"));
5832
5833 let enricher = JiraSchemaEnricher::new(metadata);
5836 let mut schema = ToolSchema::from_json(&json!({
5837 "type": "object",
5838 "properties": {
5839 "customFields": { "type": "object" },
5840 "priority": { "type": "string" },
5841 "components": { "type": "array" }
5842 }
5843 }));
5844 enricher.enrich_schema("create_issue", &mut schema);
5845
5846 assert!(
5848 schema.properties.contains_key("epicKey"),
5849 "Epic Link customfield should promote to canonical `epicKey` alias"
5850 );
5851 assert!(!schema.properties.contains_key("cf_epic_link"));
5852 assert!(
5854 schema.properties.contains_key("cf_story_points"),
5855 "Story Points should surface as cf_story_points"
5856 );
5857 let priority = schema.properties.get("priority").unwrap();
5860 assert_eq!(
5861 priority.enum_values,
5862 Some(vec!["High".into(), "Medium".into()])
5863 );
5864 let components = schema.properties.get("components").unwrap();
5865 assert_eq!(components.enum_values, Some(vec!["Backend".into()]));
5866 }
5867
5868 #[tokio::test]
5872 async fn test_load_default_metadata_recent_activity_strategy() {
5873 let server = MockServer::start();
5874
5875 server.mock(|when, then| {
5876 when.method(GET)
5877 .path("/search")
5878 .query_param_includes("jql", "updated >= -7d")
5879 .query_param("fields", "project");
5880 then.status(200).json_body(serde_json::json!({
5881 "issues": [
5882 {"key": "ACTIVE-1", "fields": {"project": {"key": "ACTIVE"}}},
5883 {"key": "ACTIVE-2", "fields": {"project": {"key": "ACTIVE"}}},
5885 {"key": "QUIET-1", "fields": {"project": {"key": "QUIET"}}}
5886 ],
5887 "total": 3
5888 }));
5889 });
5890
5891 for key in &["ACTIVE", "QUIET"] {
5892 server.mock(|when, then| {
5893 when.method(GET).path(format!("/project/{key}"));
5894 then.status(200).json_body(serde_json::json!({
5895 "key": key,
5896 "issueTypes": [{"id": "1", "name": "Task", "subtask": false}]
5897 }));
5898 });
5899 server.mock(|when, then| {
5900 when.method(GET).path(format!("/project/{key}/components"));
5901 then.status(200).json_body(serde_json::json!([]));
5902 });
5903 }
5904 server.mock(|when, then| {
5905 when.method(GET).path("/priority");
5906 then.status(200).json_body(serde_json::json!([]));
5907 });
5908 server.mock(|when, then| {
5909 when.method(GET).path("/issueLinkType");
5910 then.status(200)
5911 .json_body(serde_json::json!({"issueLinkTypes": []}));
5912 });
5913 server.mock(|when, then| {
5914 when.method(GET).path("/field");
5915 then.status(200).json_body(serde_json::json!([]));
5916 });
5917
5918 let client = create_self_hosted_client(&server);
5919 let meta = client
5920 .load_default_metadata(crate::metadata::MetadataLoadStrategy::RecentActivity {
5921 days: 7,
5922 })
5923 .await
5924 .unwrap();
5925 assert_eq!(meta.projects.len(), 2);
5927 assert!(meta.projects.contains_key("ACTIVE"));
5928 assert!(meta.projects.contains_key("QUIET"));
5929 }
5930
5931 #[tokio::test]
5933 async fn test_load_default_metadata_my_projects_self_hosted() {
5934 let server = MockServer::start();
5935
5936 server.mock(|when, then| {
5937 when.method(GET)
5938 .path("/project")
5939 .query_param("recent", "30");
5940 then.status(200).json_body(serde_json::json!([
5941 {"key": "RECENT1", "name": "Recent 1"},
5942 {"key": "RECENT2", "name": "Recent 2"}
5943 ]));
5944 });
5945
5946 for key in &["RECENT1", "RECENT2"] {
5947 server.mock(|when, then| {
5948 when.method(GET).path(format!("/project/{key}"));
5949 then.status(200).json_body(serde_json::json!({
5950 "key": key,
5951 "issueTypes": [{"id": "1", "name": "Task", "subtask": false}]
5952 }));
5953 });
5954 server.mock(|when, then| {
5955 when.method(GET).path(format!("/project/{key}/components"));
5956 then.status(200).json_body(serde_json::json!([]));
5957 });
5958 }
5959 server.mock(|when, then| {
5960 when.method(GET).path("/priority");
5961 then.status(200).json_body(serde_json::json!([]));
5962 });
5963 server.mock(|when, then| {
5964 when.method(GET).path("/issueLinkType");
5965 then.status(200)
5966 .json_body(serde_json::json!({"issueLinkTypes": []}));
5967 });
5968 server.mock(|when, then| {
5969 when.method(GET).path("/field");
5970 then.status(200).json_body(serde_json::json!([]));
5971 });
5972
5973 let client = create_self_hosted_client(&server);
5974 let meta = client
5975 .load_default_metadata(crate::metadata::MetadataLoadStrategy::MyProjects)
5976 .await
5977 .unwrap();
5978 assert_eq!(meta.projects.len(), 2);
5979 assert!(meta.projects.contains_key("RECENT1"));
5980 assert!(meta.projects.contains_key("RECENT2"));
5981 }
5982
5983 #[tokio::test]
5986 async fn test_load_default_metadata_my_projects_cloud() {
5987 let server = MockServer::start();
5988
5989 server.mock(|when, then| {
5990 when.method(GET)
5991 .path("/project/search")
5992 .query_param("recent", "30");
5993 then.status(200).json_body(serde_json::json!({
5994 "values": [
5995 {"key": "CLOUD1", "name": "Cloud Project 1"}
5996 ],
5997 "isLast": true
5998 }));
5999 });
6000
6001 server.mock(|when, then| {
6002 when.method(GET).path("/project/CLOUD1");
6003 then.status(200).json_body(serde_json::json!({
6004 "key": "CLOUD1",
6005 "issueTypes": [{"id": "1", "name": "Task", "subtask": false}]
6006 }));
6007 });
6008 server.mock(|when, then| {
6009 when.method(GET).path("/project/CLOUD1/components");
6010 then.status(200).json_body(serde_json::json!([]));
6011 });
6012 server.mock(|when, then| {
6013 when.method(GET).path("/priority");
6014 then.status(200).json_body(serde_json::json!([]));
6015 });
6016 server.mock(|when, then| {
6017 when.method(GET).path("/issueLinkType");
6018 then.status(200)
6019 .json_body(serde_json::json!({"issueLinkTypes": []}));
6020 });
6021 server.mock(|when, then| {
6022 when.method(GET).path("/field");
6023 then.status(200).json_body(serde_json::json!([]));
6024 });
6025
6026 let client = create_cloud_client(&server);
6027 let meta = client
6028 .load_default_metadata(crate::metadata::MetadataLoadStrategy::MyProjects)
6029 .await
6030 .unwrap();
6031 assert_eq!(meta.projects.len(), 1);
6032 assert!(meta.projects.contains_key("CLOUD1"));
6033 assert_eq!(meta.flavor, crate::metadata::JiraFlavor::Cloud);
6034 }
6035
6036 #[tokio::test]
6040 async fn test_load_default_metadata_all_strategy_under_cap() {
6041 let server = MockServer::start();
6042
6043 server.mock(|when, then| {
6044 when.method(GET).path("/project");
6045 then.status(200).json_body(serde_json::json!([
6046 {"key": "PROJ", "name": "Platform"},
6047 {"key": "INFRA", "name": "Infrastructure"}
6048 ]));
6049 });
6050
6051 for key in &["PROJ", "INFRA"] {
6052 server.mock(|when, then| {
6053 when.method(GET).path(format!("/project/{key}"));
6054 then.status(200).json_body(serde_json::json!({
6055 "key": key,
6056 "issueTypes": [{"id": "1", "name": "Task", "subtask": false}]
6057 }));
6058 });
6059 server.mock(|when, then| {
6060 when.method(GET).path(format!("/project/{key}/components"));
6061 then.status(200).json_body(serde_json::json!([]));
6062 });
6063 }
6064
6065 server.mock(|when, then| {
6066 when.method(GET).path("/priority");
6067 then.status(200).json_body(serde_json::json!([]));
6068 });
6069 server.mock(|when, then| {
6070 when.method(GET).path("/issueLinkType");
6071 then.status(200)
6072 .json_body(serde_json::json!({"issueLinkTypes": []}));
6073 });
6074 server.mock(|when, then| {
6075 when.method(GET).path("/field");
6076 then.status(200).json_body(serde_json::json!([]));
6077 });
6078
6079 let client = create_self_hosted_client(&server);
6080 let meta = client
6081 .load_default_metadata(crate::metadata::MetadataLoadStrategy::All)
6082 .await
6083 .unwrap();
6084 assert_eq!(meta.projects.len(), 2);
6085 assert!(meta.projects.contains_key("PROJ"));
6086 assert!(meta.projects.contains_key("INFRA"));
6087 }
6088
6089 #[tokio::test]
6093 async fn test_load_default_metadata_all_strategy_errors_over_cap() {
6094 let server = MockServer::start();
6095
6096 let projects: Vec<serde_json::Value> = (1..=31)
6098 .map(
6099 |i| serde_json::json!({"key": format!("P{i}"), "name": format!("Project {i}")}),
6100 )
6101 .collect();
6102 server.mock(|when, then| {
6103 when.method(GET).path("/project");
6104 then.status(200).json_body(serde_json::json!(projects));
6105 });
6106
6107 let client = create_self_hosted_client(&server);
6108 let err = client
6109 .load_default_metadata(crate::metadata::MetadataLoadStrategy::All)
6110 .await
6111 .unwrap_err();
6112 let msg = err.to_string();
6113 assert!(msg.contains("31"), "missing count: {msg}");
6114 assert!(msg.contains("30"), "missing cap: {msg}");
6115 assert!(
6116 msg.contains("MyProjects"),
6117 "missing alternative hint: {msg}"
6118 );
6119 assert!(
6120 msg.contains("RecentActivity"),
6121 "missing alternative hint: {msg}"
6122 );
6123 assert!(
6124 msg.contains("Configured"),
6125 "missing alternative hint: {msg}"
6126 );
6127 }
6128
6129 #[tokio::test]
6136 async fn test_load_default_metadata_configured_strategy() {
6137 let server = MockServer::start();
6138
6139 for key in &["PROJ", "INFRA"] {
6140 server.mock(|when, then| {
6141 when.method(GET).path(format!("/project/{key}"));
6142 then.status(200).json_body(serde_json::json!({
6143 "key": key,
6144 "issueTypes": [
6145 {"id": "1", "name": "Task", "subtask": false}
6146 ]
6147 }));
6148 });
6149 server.mock(|when, then| {
6150 when.method(GET).path(format!("/project/{key}/components"));
6151 then.status(200).json_body(serde_json::json!([]));
6152 });
6153 }
6154
6155 server.mock(|when, then| {
6156 when.method(GET).path("/priority");
6157 then.status(200).json_body(serde_json::json!([
6158 {"id": "1", "name": "High"}
6159 ]));
6160 });
6161 server.mock(|when, then| {
6162 when.method(GET).path("/issueLinkType");
6163 then.status(200).json_body(serde_json::json!({
6164 "issueLinkTypes": [
6165 {"id": "1", "name": "Blocks", "outward": "blocks", "inward": "is blocked by"}
6166 ]
6167 }));
6168 });
6169 server.mock(|when, then| {
6170 when.method(GET).path("/field");
6171 then.status(200).json_body(serde_json::json!([
6172 {"id": "customfield_10001", "name": "Story Points", "custom": true,
6173 "schema": {"type": "number"}}
6174 ]));
6175 });
6176
6177 let client = create_self_hosted_client(&server);
6178 let meta = client
6179 .load_default_metadata(crate::metadata::MetadataLoadStrategy::Configured(vec![
6180 "PROJ".into(),
6181 "INFRA".into(),
6182 ]))
6183 .await
6184 .unwrap();
6185
6186 assert_eq!(meta.projects.len(), 2);
6187 assert!(meta.projects.contains_key("PROJ"));
6188 assert!(meta.projects.contains_key("INFRA"));
6189 assert_eq!(meta.flavor, crate::metadata::JiraFlavor::SelfHosted);
6190 for project in meta.projects.values() {
6192 assert_eq!(project.custom_fields.len(), 1);
6193 assert_eq!(project.custom_fields[0].id, "customfield_10001");
6194 }
6195 }
6196
6197 #[tokio::test]
6204 async fn test_build_project_metadata_assembles_from_five_endpoints() {
6205 let server = MockServer::start();
6206
6207 server.mock(|when, then| {
6208 when.method(GET).path("/project/PROJ");
6209 then.status(200).json_body(serde_json::json!({
6210 "key": "PROJ",
6211 "issueTypes": [
6212 {"id": "1", "name": "Task", "subtask": false},
6213 {"id": "5", "name": "Sub-task", "subtask": true}
6214 ]
6215 }));
6216 });
6217
6218 server.mock(|when, then| {
6219 when.method(GET).path("/project/PROJ/components");
6220 then.status(200).json_body(serde_json::json!([
6221 {"id": "10", "name": "API"},
6222 {"id": "11", "name": "Frontend"}
6223 ]));
6224 });
6225
6226 server.mock(|when, then| {
6227 when.method(GET).path("/priority");
6228 then.status(200).json_body(serde_json::json!([
6229 {"id": "1", "name": "Highest"},
6230 {"id": "2", "name": "Medium"}
6231 ]));
6232 });
6233
6234 server.mock(|when, then| {
6235 when.method(GET).path("/issueLinkType");
6236 then.status(200).json_body(serde_json::json!({
6237 "issueLinkTypes": [
6238 {"id": "1", "name": "Blocks", "outward": "blocks", "inward": "is blocked by"}
6239 ]
6240 }));
6241 });
6242
6243 server.mock(|when, then| {
6244 when.method(GET).path("/field");
6245 then.status(200).json_body(serde_json::json!([
6246 {"id": "summary", "name": "Summary", "custom": false},
6247 {"id": "customfield_10001", "name": "Story Points", "custom": true,
6248 "schema": {"type": "number"}}
6249 ]));
6250 });
6251
6252 let client = create_self_hosted_client(&server);
6253 let meta = client.build_project_metadata("PROJ").await.unwrap();
6254
6255 assert_eq!(meta.issue_types.len(), 2);
6256 assert!(
6257 meta.issue_types
6258 .iter()
6259 .any(|it| it.name == "Sub-task" && it.subtask)
6260 );
6261 assert_eq!(meta.components.len(), 2);
6262 assert_eq!(meta.priorities.len(), 2);
6263 assert_eq!(meta.link_types.len(), 1);
6264 assert_eq!(meta.link_types[0].outward.as_deref(), Some("blocks"));
6265 assert_eq!(meta.custom_fields.len(), 1);
6267 assert_eq!(meta.custom_fields[0].id, "customfield_10001");
6268 assert_eq!(
6269 meta.custom_fields[0].field_type,
6270 crate::metadata::JiraFieldType::Number
6271 );
6272 }
6273
6274 #[tokio::test]
6279 async fn test_get_issue_surfaces_customfield_values() {
6280 let server = MockServer::start();
6281
6282 server.mock(|when, then| {
6283 when.method(GET).path("/issue/PROJ-1");
6284 then.status(200).json_body(serde_json::json!({
6285 "id": "10001",
6286 "key": "PROJ-1",
6287 "fields": {
6288 "summary": "Issue with cf",
6289 "issuetype": {"name": "Task"},
6290 "status": {"name": "Open"},
6291 "labels": [],
6292 "created": "2024-01-01T10:00:00.000+0000",
6293 "customfield_10999": "tenant-a",
6294 "customfield_10888": 42,
6295 "customfield_10777": null
6296 }
6297 }));
6298 });
6299
6300 let client = create_self_hosted_client(&server);
6301 let issue = client.get_issue("PROJ-1").await.unwrap();
6302 let cf1 = issue
6303 .custom_fields
6304 .get("customfield_10999")
6305 .expect("cf 10999 present");
6306 assert!(
6307 cf1.name.is_none(),
6308 "Jira mapper leaves name resolution to get_custom_fields"
6309 );
6310 assert_eq!(cf1.value, serde_json::json!("tenant-a"));
6311 let cf2 = issue
6312 .custom_fields
6313 .get("customfield_10888")
6314 .expect("cf 10888 present");
6315 assert_eq!(cf2.value, serde_json::json!(42));
6316 assert!(!issue.custom_fields.contains_key("customfield_10777"));
6318 }
6319
6320 #[tokio::test]
6324 async fn test_link_issues_implements_canonical_name() {
6325 let server = MockServer::start();
6326
6327 server.mock(|when, then| {
6328 when.method(POST)
6329 .path("/issueLink")
6330 .body_includes("\"name\":\"Implements\"")
6331 .body_includes("\"outwardIssue\":{\"key\":\"PROJ-1\"}")
6332 .body_includes("\"inwardIssue\":{\"key\":\"PROJ-2\"}");
6333 then.status(201);
6334 });
6335
6336 let client = create_self_hosted_client(&server);
6337 client
6338 .link_issues("PROJ-1", "PROJ-2", "Implements")
6339 .await
6340 .unwrap();
6341 }
6342
6343 #[tokio::test]
6345 async fn test_link_issues_causes_alias_maps_to_canonical() {
6346 let server = MockServer::start();
6347
6348 server.mock(|when, then| {
6349 when.method(POST)
6350 .path("/issueLink")
6351 .body_includes("\"name\":\"Causes\"")
6352 .body_includes("\"outwardIssue\":{\"key\":\"PROJ-1\"}")
6353 .body_includes("\"inwardIssue\":{\"key\":\"PROJ-2\"}");
6354 then.status(201);
6355 });
6356
6357 let client = create_self_hosted_client(&server);
6358 client
6359 .link_issues("PROJ-1", "PROJ-2", "causes")
6360 .await
6361 .unwrap();
6362 }
6363
6364 #[tokio::test]
6369 async fn test_link_issues_created_by_flips_direction() {
6370 let server = MockServer::start();
6371
6372 server.mock(|when, then| {
6373 when.method(POST)
6374 .path("/issueLink")
6375 .body_includes("\"name\":\"Created By\"")
6376 .body_includes("\"outwardIssue\":{\"key\":\"PROJ-2\"}")
6377 .body_includes("\"inwardIssue\":{\"key\":\"PROJ-1\"}");
6378 then.status(201);
6379 });
6380
6381 let client = create_self_hosted_client(&server);
6382 client
6383 .link_issues("PROJ-1", "PROJ-2", "created_by")
6384 .await
6385 .unwrap();
6386 }
6387
6388 #[tokio::test]
6391 async fn test_link_issues_caused_by_flips_direction() {
6392 let server = MockServer::start();
6393
6394 server.mock(|when, then| {
6395 when.method(POST)
6396 .path("/issueLink")
6397 .body_includes("\"name\":\"Causes\"")
6398 .body_includes("\"outwardIssue\":{\"key\":\"PROJ-2\"}")
6401 .body_includes("\"inwardIssue\":{\"key\":\"PROJ-1\"}");
6402 then.status(201);
6403 });
6404
6405 let client = create_self_hosted_client(&server);
6406 client
6407 .link_issues("PROJ-1", "PROJ-2", "caused_by")
6408 .await
6409 .unwrap();
6410 }
6411
6412 fn jira_issue_with_link(link: serde_json::Value) -> serde_json::Value {
6413 serde_json::json!({
6414 "id": "10001",
6415 "key": "PROJ-1",
6416 "fields": {
6417 "summary": "Source issue",
6418 "labels": [],
6419 "subtasks": [],
6420 "issuelinks": [link],
6421 "attachment": []
6422 }
6423 })
6424 }
6425
6426 #[tokio::test]
6427 async fn test_unlink_issues_blocks_deletes_matching_outward_link() {
6428 let server = MockServer::start();
6429
6430 let get_mock = server.mock(|when, then| {
6431 when.method(GET)
6432 .path("/issue/PROJ-1")
6433 .query_param("fields", "issuelinks");
6434 then.status(200).json_body(jira_issue_with_link(serde_json::json!({
6435 "id": "42",
6436 "type": { "name": "Blocks", "outward": "blocks", "inward": "is blocked by" },
6437 "outwardIssue": {
6438 "id": "10002",
6439 "key": "PROJ-2",
6440 "fields": { "summary": "Target" }
6441 }
6442 })));
6443 });
6444
6445 let delete_mock = server.mock(|when, then| {
6446 when.method(DELETE).path("/issueLink/42");
6447 then.status(204);
6448 });
6449
6450 let client = create_self_hosted_client(&server);
6451 client
6452 .unlink_issues("PROJ-1", "PROJ-2", "blocks")
6453 .await
6454 .unwrap();
6455 get_mock.assert();
6456 delete_mock.assert();
6457 }
6458
6459 #[tokio::test]
6460 async fn test_unlink_issues_blocked_by_matches_inward_link() {
6461 let server = MockServer::start();
6462
6463 let get_mock = server.mock(|when, then| {
6464 when.method(GET)
6465 .path("/issue/PROJ-1")
6466 .query_param("fields", "issuelinks");
6467 then.status(200).json_body(jira_issue_with_link(serde_json::json!({
6468 "id": "43",
6469 "type": { "name": "Blocks", "outward": "blocks", "inward": "is blocked by" },
6470 "inwardIssue": {
6471 "id": "10002",
6472 "key": "PROJ-2",
6473 "fields": { "summary": "Blocker" }
6474 }
6475 })));
6476 });
6477
6478 let delete_mock = server.mock(|when, then| {
6479 when.method(DELETE).path("/issueLink/43");
6480 then.status(204);
6481 });
6482
6483 let client = create_self_hosted_client(&server);
6484 client
6485 .unlink_issues("PROJ-1", "PROJ-2", "blocked_by")
6486 .await
6487 .unwrap();
6488 get_mock.assert();
6489 delete_mock.assert();
6490 }
6491
6492 #[tokio::test]
6493 async fn test_unlink_issues_relates_matches_either_direction() {
6494 let server = MockServer::start();
6495
6496 let get_mock = server.mock(|when, then| {
6497 when.method(GET)
6498 .path("/issue/PROJ-1")
6499 .query_param("fields", "issuelinks");
6500 then.status(200).json_body(jira_issue_with_link(serde_json::json!({
6501 "id": "44",
6502 "type": { "name": "Relates", "outward": "relates to", "inward": "relates to" },
6503 "inwardIssue": {
6504 "id": "10002",
6505 "key": "PROJ-2",
6506 "fields": { "summary": "Related" }
6507 }
6508 })));
6509 });
6510
6511 let delete_mock = server.mock(|when, then| {
6512 when.method(DELETE).path("/issueLink/44");
6513 then.status(204);
6514 });
6515
6516 let client = create_self_hosted_client(&server);
6517 client
6518 .unlink_issues("PROJ-1", "PROJ-2", "relates_to")
6519 .await
6520 .unwrap();
6521 get_mock.assert();
6522 delete_mock.assert();
6523 }
6524
6525 #[tokio::test]
6526 async fn test_unlink_issues_custom_type_does_not_delete_the_reverse_link() {
6527 let server = MockServer::start();
6531
6532 let get_mock = server.mock(|when, then| {
6533 when.method(GET)
6534 .path("/issue/PROJ-1")
6535 .query_param("fields", "issuelinks");
6536 then.status(200)
6537 .json_body(jira_issue_with_link(serde_json::json!({
6538 "id": "45",
6539 "type": { "name": "Discovered while testing" },
6540 "inwardIssue": {
6541 "id": "10002",
6542 "key": "PROJ-2",
6543 "fields": { "summary": "Target" }
6544 }
6545 })));
6546 });
6547
6548 let delete_mock = server.mock(|when, then| {
6549 when.method(DELETE).path("/issueLink/45");
6550 then.status(204);
6551 });
6552
6553 let client = create_self_hosted_client(&server);
6554 let err = client
6555 .unlink_issues("PROJ-1", "PROJ-2", "Discovered while testing")
6556 .await
6557 .unwrap_err();
6558
6559 match &err {
6560 Error::NotFound(message) => assert!(
6561 message.contains("opposite direction"),
6562 "error should point at the reversed link: {message}"
6563 ),
6564 other => panic!("unexpected error: {other:?}"),
6565 }
6566 get_mock.assert();
6567 assert_eq!(delete_mock.calls(), 0);
6569 }
6570
6571 #[tokio::test]
6572 async fn test_unlink_issues_directional_type_named_relates_is_not_symmetric() {
6573 let server = MockServer::start();
6577
6578 let get_mock = server.mock(|when, then| {
6579 when.method(GET)
6580 .path("/issue/PROJ-1")
6581 .query_param("fields", "issuelinks");
6582 then.status(200)
6583 .json_body(jira_issue_with_link(serde_json::json!({
6584 "id": "47",
6585 "type": {
6586 "name": "Relates",
6587 "inward": "is required by",
6588 "outward": "requires"
6589 },
6590 "inwardIssue": {
6591 "id": "10002",
6592 "key": "PROJ-2",
6593 "fields": { "summary": "Target" }
6594 }
6595 })));
6596 });
6597
6598 let delete_mock = server.mock(|when, then| {
6599 when.method(DELETE).path("/issueLink/47");
6600 then.status(204);
6601 });
6602
6603 let client = create_self_hosted_client(&server);
6604 client
6605 .unlink_issues("PROJ-1", "PROJ-2", "relates_to")
6606 .await
6607 .unwrap_err();
6608
6609 get_mock.assert();
6610 assert_eq!(delete_mock.calls(), 0, "the reverse link must survive");
6611 }
6612
6613 #[tokio::test]
6614 async fn test_unlink_issues_custom_type_deletes_the_matching_outward_link() {
6615 let server = MockServer::start();
6616
6617 let get_mock = server.mock(|when, then| {
6618 when.method(GET)
6619 .path("/issue/PROJ-1")
6620 .query_param("fields", "issuelinks");
6621 then.status(200)
6622 .json_body(jira_issue_with_link(serde_json::json!({
6623 "id": "46",
6624 "type": { "name": "Discovered while testing" },
6625 "outwardIssue": {
6626 "id": "10002",
6627 "key": "PROJ-2",
6628 "fields": { "summary": "Target" }
6629 }
6630 })));
6631 });
6632
6633 let delete_mock = server.mock(|when, then| {
6634 when.method(DELETE).path("/issueLink/46");
6635 then.status(204);
6636 });
6637
6638 let client = create_self_hosted_client(&server);
6639 client
6640 .unlink_issues("PROJ-1", "PROJ-2", "Discovered while testing")
6641 .await
6642 .unwrap();
6643 get_mock.assert();
6644 delete_mock.assert();
6645 }
6646
6647 #[tokio::test]
6648 async fn test_unlink_issues_returns_not_found_when_matching_link_missing() {
6649 let server = MockServer::start();
6650
6651 let get_mock = server.mock(|when, then| {
6652 when.method(GET)
6653 .path("/issue/PROJ-1")
6654 .query_param("fields", "issuelinks");
6655 then.status(200)
6656 .json_body(jira_issue_with_link(serde_json::json!({
6657 "id": "46",
6658 "type": { "name": "Blocks", "outward": "blocks", "inward": "is blocked by" },
6659 "outwardIssue": {
6660 "id": "10003",
6661 "key": "PROJ-3",
6662 "fields": { "summary": "Different target" }
6663 }
6664 })));
6665 });
6666
6667 let client = create_self_hosted_client(&server);
6668 let err = client
6669 .unlink_issues("PROJ-1", "PROJ-2", "blocks")
6670 .await
6671 .expect_err("missing link should return an error");
6672
6673 get_mock.assert();
6674 match err {
6675 Error::NotFound(message) => {
6676 assert!(message.contains("PROJ-1"));
6677 assert!(message.contains("PROJ-2"));
6678 assert!(message.contains("Blocks"));
6679 }
6680 other => panic!("expected NotFound error, got {other:?}"),
6681 }
6682 }
6683
6684 #[tokio::test]
6685 async fn test_unlink_issues_returns_invalid_data_when_link_id_missing() {
6686 let server = MockServer::start();
6687
6688 let get_mock = server.mock(|when, then| {
6689 when.method(GET)
6690 .path("/issue/PROJ-1")
6691 .query_param("fields", "issuelinks");
6692 then.status(200)
6693 .json_body(jira_issue_with_link(serde_json::json!({
6694 "type": { "name": "Blocks", "outward": "blocks", "inward": "is blocked by" },
6695 "outwardIssue": {
6696 "id": "10002",
6697 "key": "PROJ-2",
6698 "fields": { "summary": "Target" }
6699 }
6700 })));
6701 });
6702
6703 let client = create_self_hosted_client(&server);
6704 let err = client
6705 .unlink_issues("PROJ-1", "PROJ-2", "blocks")
6706 .await
6707 .expect_err("missing Jira link id should return an error");
6708
6709 get_mock.assert();
6710 match err {
6711 Error::InvalidData(message) => {
6712 assert!(message.contains("PROJ-1"));
6713 assert!(message.contains("PROJ-2"));
6714 assert!(message.contains("Blocks"));
6715 assert!(message.contains("missing id"));
6716 }
6717 other => panic!("expected InvalidData error, got {other:?}"),
6718 }
6719 }
6720
6721 #[tokio::test]
6727 async fn test_get_issue_relations_includes_epic_link_customfield() {
6728 let server = MockServer::start();
6729
6730 server.mock(|when, then| {
6731 when.method(GET).path("/field");
6732 then.status(200).json_body(serde_json::json!([
6733 {"id": "customfield_10014", "name": "Epic Link", "custom": true,
6734 "schema": {"type": "any"}}
6735 ]));
6736 });
6737
6738 server.mock(|when, then| {
6739 when.method(GET).path("/issue/PROJ-100");
6740 then.status(200).json_body(serde_json::json!({
6741 "id": "10100",
6742 "key": "PROJ-100",
6743 "fields": {
6744 "summary": "Story under epic",
6745 "issuetype": {"name": "Story"},
6746 "status": {"name": "Open"},
6747 "labels": [],
6748 "created": "2024-01-05T10:00:00.000+0000",
6749 "customfield_10014": "PROJ-1"
6750 }
6751 }));
6752 });
6753
6754 let client = create_self_hosted_client(&server);
6755 let relations = client.get_issue_relations("PROJ-100").await.unwrap();
6756 assert_eq!(relations.epic_key.as_deref(), Some("PROJ-1"));
6757 assert!(relations.parent.is_none());
6758 }
6759
6760 #[tokio::test]
6764 async fn test_get_issue_relations_cloud_team_managed_uses_parent() {
6765 let server = MockServer::start();
6766
6767 server.mock(|when, then| {
6768 when.method(GET).path("/issue/PROJ-200");
6769 then.status(200).json_body(serde_json::json!({
6770 "id": "10200",
6771 "key": "PROJ-200",
6772 "fields": {
6773 "summary": "Story under epic (team-managed)",
6774 "issuetype": {"name": "Story"},
6775 "status": {"name": "Open"},
6776 "labels": [],
6777 "created": "2024-01-05T10:00:00.000+0000",
6778 "parent": {
6779 "id": "9000",
6780 "key": "PROJ-1",
6781 "fields": {
6782 "summary": "Parent epic",
6783 "status": {"name": "Open"},
6784 "labels": [],
6785 "created": "2024-01-01T10:00:00.000+0000"
6786 }
6787 }
6788 }
6789 }));
6790 });
6791
6792 let client = create_self_hosted_client(&server);
6793 let relations = client.get_issue_relations("PROJ-200").await.unwrap();
6794 assert!(relations.parent.is_some());
6795 assert_eq!(relations.parent.as_ref().unwrap().key, "jira#PROJ-1");
6796 assert_eq!(relations.epic_key, None);
6800 }
6801
6802 #[tokio::test]
6808 async fn test_get_issue_epic_description_fallback() {
6809 let server = MockServer::start();
6810
6811 server.mock(|when, then| {
6812 when.method(GET).path("/field");
6813 then.status(200).json_body(serde_json::json!([
6814 {"id": "customfield_10017", "name": "Epic Description", "custom": true,
6815 "schema": {"type": "string"}}
6816 ]));
6817 });
6818
6819 server.mock(|when, then| {
6820 when.method(GET).path("/issue/EPIC-1");
6821 then.status(200).json_body(serde_json::json!({
6822 "id": "10001",
6823 "key": "EPIC-1",
6824 "fields": {
6825 "summary": "Q4 platform epic",
6826 "description": null,
6827 "issuetype": {"name": "Epic"},
6828 "status": {"name": "Open"},
6829 "labels": [],
6830 "created": "2024-01-01T10:00:00.000+0000",
6831 "customfield_10017": "Roll out the new pricing tier across all products."
6832 }
6833 }));
6834 });
6835
6836 let client = create_self_hosted_client(&server);
6837 let issue = client.get_issue("EPIC-1").await.unwrap();
6838 assert_eq!(
6839 issue.description.as_deref(),
6840 Some("Roll out the new pricing tier across all products.")
6841 );
6842 }
6843
6844 #[tokio::test]
6848 async fn test_get_issue_no_fallback_for_non_epic() {
6849 let server = MockServer::start();
6850
6851 server.mock(|when, then| {
6852 when.method(GET).path("/issue/PROJ-1");
6853 then.status(200).json_body(serde_json::json!({
6854 "id": "10001",
6855 "key": "PROJ-1",
6856 "fields": {
6857 "summary": "Regular task",
6858 "description": null,
6859 "issuetype": {"name": "Task"},
6860 "status": {"name": "Open"},
6861 "labels": [],
6862 "created": "2024-01-01T10:00:00.000+0000",
6863 "customfield_10017": "ignored"
6864 }
6865 }));
6866 });
6867
6868 let client = create_self_hosted_client(&server);
6869 let issue = client.get_issue("PROJ-1").await.unwrap();
6870 assert_eq!(issue.description, None);
6874 }
6875
6876 #[tokio::test]
6879 async fn test_get_issue_epic_keeps_existing_description() {
6880 let server = MockServer::start();
6881
6882 server.mock(|when, then| {
6883 when.method(GET).path("/issue/EPIC-2");
6884 then.status(200).json_body(serde_json::json!({
6885 "id": "10002",
6886 "key": "EPIC-2",
6887 "fields": {
6888 "summary": "Epic with system description",
6889 "description": "Top-level epic body.",
6890 "issuetype": {"name": "Epic"},
6891 "status": {"name": "Open"},
6892 "labels": [],
6893 "created": "2024-01-01T10:00:00.000+0000",
6894 "customfield_10017": "Should not be used."
6895 }
6896 }));
6897 });
6898
6899 let client = create_self_hosted_client(&server);
6900 let issue = client.get_issue("EPIC-2").await.unwrap();
6901 assert_eq!(issue.description.as_deref(), Some("Top-level epic body."));
6902 }
6903
6904 #[tokio::test]
6907 async fn test_list_custom_fields_filters_and_sorts() {
6908 let server = MockServer::start();
6909
6910 server.mock(|when, then| {
6911 when.method(GET).path("/field");
6912 then.status(200).json_body(serde_json::json!([
6913 {"id": "summary", "name": "Summary", "custom": false},
6914 {"id": "customfield_10020", "name": "Sprint", "custom": true,
6915 "schema": {"type": "array", "items": "json"}},
6916 {"id": "customfield_10014", "name": "Epic Link", "custom": true,
6917 "schema": {"type": "any"}},
6918 {"id": "customfield_10011", "name": "Epic Name", "custom": true,
6919 "schema": {"type": "string"}}
6920 ]));
6921 });
6922
6923 let client = create_self_hosted_client(&server);
6924 let result = client
6925 .list_custom_fields(devboy_core::ListCustomFieldsParams {
6926 search: Some("epic".to_string()),
6927 ..Default::default()
6928 })
6929 .await
6930 .unwrap();
6931
6932 assert_eq!(result.items.len(), 2);
6935 assert_eq!(result.items[0].name, "Epic Link");
6936 assert_eq!(result.items[1].name, "Epic Name");
6937 assert_eq!(result.items[0].field_type, "any");
6938 assert_eq!(result.items[1].field_type, "string");
6939
6940 let pagination = result.pagination.expect("pagination present");
6941 assert_eq!(pagination.total, Some(2));
6942 assert!(!pagination.has_more);
6943 }
6944
6945 #[tokio::test]
6948 async fn test_list_custom_fields_limit_truncates_with_has_more() {
6949 let server = MockServer::start();
6950
6951 server.mock(|when, then| {
6952 when.method(GET).path("/field");
6953 then.status(200).json_body(serde_json::json!([
6954 {"id": "customfield_10020", "name": "Sprint", "custom": true},
6955 {"id": "customfield_10014", "name": "Epic Link", "custom": true},
6956 {"id": "customfield_10011", "name": "Epic Name", "custom": true}
6957 ]));
6958 });
6959
6960 let client = create_self_hosted_client(&server);
6961 let result = client
6962 .list_custom_fields(devboy_core::ListCustomFieldsParams {
6963 limit: Some(2),
6964 ..Default::default()
6965 })
6966 .await
6967 .unwrap();
6968
6969 assert_eq!(result.items.len(), 2);
6970 let pagination = result.pagination.expect("pagination present");
6971 assert_eq!(pagination.total, Some(3));
6972 assert!(pagination.has_more);
6973 }
6974
6975 #[tokio::test]
6979 async fn test_resolve_field_id_by_name_caches_and_resolves() {
6980 let server = MockServer::start();
6981
6982 let field_mock = server.mock(|when, then| {
6984 when.method(GET).path("/field");
6985 then.status(200).json_body(serde_json::json!([
6986 {"id": "summary", "name": "Summary", "custom": false},
6987 {"id": "customfield_10014", "name": "Epic Link", "custom": true,
6988 "schema": {"type": "any", "custom": "com.pyxis.greenhopper.jira:gh-epic-link"}},
6989 {"id": "customfield_10020", "name": "Sprint", "custom": true},
6990 {"id": "customfield_10011", "name": "Epic Name", "custom": true}
6991 ]));
6992 });
6993
6994 let client = create_self_hosted_client(&server);
6995
6996 let epic_link = client.resolve_field_id_by_name("Epic Link").await.unwrap();
6997 assert_eq!(epic_link, Some("customfield_10014".to_string()));
6998
6999 let sprint = client.resolve_field_id_by_name("Sprint").await.unwrap();
7001 assert_eq!(sprint, Some("customfield_10020".to_string()));
7002
7003 field_mock.assert_calls(1);
7004 }
7005
7006 #[tokio::test]
7013 async fn test_resolve_field_id_by_name_errors_on_duplicate_names() {
7014 let server = MockServer::start();
7015
7016 server.mock(|when, then| {
7017 when.method(GET).path("/field");
7018 then.status(200).json_body(serde_json::json!([
7019 {"id": "customfield_10100", "name": "Severity", "custom": true},
7020 {"id": "customfield_10200", "name": "Severity", "custom": true}
7021 ]));
7022 });
7023
7024 let client = create_self_hosted_client(&server);
7025 let err = client
7026 .resolve_field_id_by_name("Severity")
7027 .await
7028 .unwrap_err();
7029 let msg = err.to_string();
7030 assert!(msg.contains("Severity"), "missing field name: {msg}");
7031 assert!(msg.contains("ambiguous"), "missing ambiguity hint: {msg}");
7032 assert!(msg.contains("customfield_10100"), "missing first id: {msg}");
7033 assert!(
7034 msg.contains("customfield_10200"),
7035 "missing second id: {msg}"
7036 );
7037 }
7038
7039 #[tokio::test]
7042 async fn test_resolve_field_id_by_name_returns_none_for_missing() {
7043 let server = MockServer::start();
7044
7045 server.mock(|when, then| {
7046 when.method(GET).path("/field");
7047 then.status(200).json_body(serde_json::json!([
7048 {"id": "summary", "name": "Summary", "custom": false}
7049 ]));
7050 });
7051
7052 let client = create_self_hosted_client(&server);
7053 let resolved = client.resolve_field_id_by_name("Epic Link").await.unwrap();
7054 assert_eq!(resolved, None);
7055 }
7056
7057 #[tokio::test]
7058 async fn test_update_issue() {
7059 let server = MockServer::start();
7060
7061 server.mock(|when, then| {
7062 when.method(PUT)
7063 .path("/issue/PROJ-1")
7064 .body_includes("\"summary\":\"Updated title\"");
7065 then.status(204);
7066 });
7067
7068 server.mock(|when, then| {
7069 when.method(GET).path("/issue/PROJ-1");
7070 then.status(200).json_body(serde_json::json!({
7071 "id": "10001",
7072 "key": "PROJ-1",
7073 "fields": {
7074 "summary": "Updated title",
7075 "status": {"name": "Open"},
7076 "labels": [],
7077 "created": "2024-01-01T10:00:00.000+0000"
7078 }
7079 }));
7080 });
7081
7082 let client = create_self_hosted_client(&server);
7083 let issue = client
7084 .update_issue(
7085 "PROJ-1",
7086 UpdateIssueInput {
7087 title: Some("Updated title".to_string()),
7088 ..Default::default()
7089 },
7090 )
7091 .await
7092 .unwrap();
7093
7094 assert_eq!(issue.title, "Updated title");
7095 }
7096
7097 #[tokio::test]
7098 async fn test_update_issue_with_status_transition() {
7099 let server = MockServer::start();
7100
7101 server.mock(|when, then| {
7103 when.method(GET).path("/issue/PROJ-1/transitions");
7104 then.status(200).json_body(serde_json::json!({
7105 "transitions": [
7106 {
7107 "id": "21",
7108 "name": "Start Progress",
7109 "to": {"name": "In Progress"}
7110 },
7111 {
7112 "id": "31",
7113 "name": "Done",
7114 "to": {"name": "Done"}
7115 }
7116 ]
7117 }));
7118 });
7119
7120 server.mock(|when, then| {
7122 when.method(POST)
7123 .path("/issue/PROJ-1/transitions")
7124 .body_includes("\"id\":\"31\"");
7125 then.status(204);
7126 });
7127
7128 server.mock(|when, then| {
7130 when.method(GET).path("/issue/PROJ-1");
7131 then.status(200).json_body(serde_json::json!({
7132 "id": "10001",
7133 "key": "PROJ-1",
7134 "fields": {
7135 "summary": "Test",
7136 "status": {"name": "Done"},
7137 "labels": []
7138 }
7139 }));
7140 });
7141
7142 let client = create_self_hosted_client(&server);
7143 let issue = client
7144 .update_issue(
7145 "PROJ-1",
7146 UpdateIssueInput {
7147 state: Some("Done".to_string()),
7148 ..Default::default()
7149 },
7150 )
7151 .await
7152 .unwrap();
7153
7154 assert_eq!(issue.state, "Done");
7155 }
7156
7157 fn mock_project_statuses(server: &MockServer, statuses: serde_json::Value) {
7159 server.mock(|when, then| {
7160 when.method(GET).path("/project/PROJ/statuses");
7161 then.status(200).json_body(statuses);
7162 });
7163 }
7164
7165 fn sample_project_statuses_json() -> serde_json::Value {
7167 serde_json::json!([{
7168 "name": "Task",
7169 "statuses": [
7170 {"name": "Offen", "id": "1", "statusCategory": {"key": "new"}},
7171 {"name": "In Bearbeitung", "id": "2", "statusCategory": {"key": "indeterminate"}},
7172 {"name": "Erledigt", "id": "3", "statusCategory": {"key": "done"}},
7173 {"name": "Abgebrochen", "id": "4", "statusCategory": {"key": "done"}}
7174 ]
7175 }])
7176 }
7177
7178 #[tokio::test]
7179 async fn test_update_issue_generic_closed_maps_to_done_category() {
7180 let server = MockServer::start();
7181
7182 server.mock(|when, then| {
7184 when.method(GET).path("/issue/PROJ-1/transitions");
7185 then.status(200).json_body(serde_json::json!({
7186 "transitions": [
7187 {
7188 "id": "21",
7189 "name": "Start Progress",
7190 "to": {
7191 "name": "In Bearbeitung",
7192 "statusCategory": {"key": "indeterminate"}
7193 }
7194 },
7195 {
7196 "id": "31",
7197 "name": "Erledigt",
7198 "to": {
7199 "name": "Erledigt",
7200 "statusCategory": {"key": "done"}
7201 }
7202 }
7203 ]
7204 }));
7205 });
7206
7207 mock_project_statuses(&server, sample_project_statuses_json());
7209
7210 server.mock(|when, then| {
7212 when.method(POST)
7213 .path("/issue/PROJ-1/transitions")
7214 .body_includes("\"id\":\"31\"");
7215 then.status(204);
7216 });
7217
7218 server.mock(|when, then| {
7220 when.method(GET).path("/issue/PROJ-1");
7221 then.status(200).json_body(serde_json::json!({
7222 "id": "10001",
7223 "key": "PROJ-1",
7224 "fields": {
7225 "summary": "Test",
7226 "status": {"name": "Erledigt"},
7227 "labels": []
7228 }
7229 }));
7230 });
7231
7232 let client = create_self_hosted_client(&server);
7233 let issue = client
7234 .update_issue(
7235 "PROJ-1",
7236 UpdateIssueInput {
7237 state: Some("closed".to_string()),
7238 ..Default::default()
7239 },
7240 )
7241 .await
7242 .unwrap();
7243
7244 assert_eq!(issue.state, "Erledigt");
7245 }
7246
7247 #[tokio::test]
7248 async fn test_update_issue_generic_open_maps_to_new_category() {
7249 let server = MockServer::start();
7250
7251 server.mock(|when, then| {
7252 when.method(GET).path("/issue/PROJ-1/transitions");
7253 then.status(200).json_body(serde_json::json!({
7254 "transitions": [
7255 {
7256 "id": "11",
7257 "name": "Offen",
7258 "to": {
7259 "name": "Offen",
7260 "statusCategory": {"key": "new"}
7261 }
7262 },
7263 {
7264 "id": "21",
7265 "name": "In Bearbeitung",
7266 "to": {
7267 "name": "In Bearbeitung",
7268 "statusCategory": {"key": "indeterminate"}
7269 }
7270 }
7271 ]
7272 }));
7273 });
7274
7275 mock_project_statuses(&server, sample_project_statuses_json());
7276
7277 server.mock(|when, then| {
7278 when.method(POST)
7279 .path("/issue/PROJ-1/transitions")
7280 .body_includes("\"id\":\"11\"");
7281 then.status(204);
7282 });
7283
7284 server.mock(|when, then| {
7285 when.method(GET).path("/issue/PROJ-1");
7286 then.status(200).json_body(serde_json::json!({
7287 "id": "10001",
7288 "key": "PROJ-1",
7289 "fields": {
7290 "summary": "Test",
7291 "status": {"name": "Offen"},
7292 "labels": []
7293 }
7294 }));
7295 });
7296
7297 let client = create_self_hosted_client(&server);
7298 let issue = client
7299 .update_issue(
7300 "PROJ-1",
7301 UpdateIssueInput {
7302 state: Some("open".to_string()),
7303 ..Default::default()
7304 },
7305 )
7306 .await
7307 .unwrap();
7308
7309 assert_eq!(issue.state, "Offen");
7310 }
7311
7312 #[tokio::test]
7313 async fn test_update_issue_canceled_resolves_via_project_statuses() {
7314 let server = MockServer::start();
7315
7316 server.mock(|when, then| {
7318 when.method(GET).path("/issue/PROJ-1/transitions");
7319 then.status(200).json_body(serde_json::json!({
7320 "transitions": [
7321 {
7322 "id": "21",
7323 "name": "Start Progress",
7324 "to": {
7325 "name": "In Bearbeitung",
7326 "statusCategory": {"key": "indeterminate"}
7327 }
7328 },
7329 {
7330 "id": "41",
7331 "name": "Cancel",
7332 "to": {
7333 "name": "Abgebrochen",
7334 "statusCategory": {"key": "done"}
7335 }
7336 }
7337 ]
7338 }));
7339 });
7340
7341 mock_project_statuses(&server, sample_project_statuses_json());
7343
7344 server.mock(|when, then| {
7346 when.method(POST)
7347 .path("/issue/PROJ-1/transitions")
7348 .body_includes("\"id\":\"41\"");
7349 then.status(204);
7350 });
7351
7352 server.mock(|when, then| {
7353 when.method(GET).path("/issue/PROJ-1");
7354 then.status(200).json_body(serde_json::json!({
7355 "id": "10001",
7356 "key": "PROJ-1",
7357 "fields": {
7358 "summary": "Test",
7359 "status": {"name": "Abgebrochen"},
7360 "labels": []
7361 }
7362 }));
7363 });
7364
7365 let client = create_self_hosted_client(&server);
7366 let issue = client
7367 .update_issue(
7368 "PROJ-1",
7369 UpdateIssueInput {
7370 state: Some("canceled".to_string()),
7371 ..Default::default()
7372 },
7373 )
7374 .await
7375 .unwrap();
7376
7377 assert_eq!(issue.state, "Abgebrochen");
7378 }
7379
7380 #[tokio::test]
7381 async fn test_update_issue_exact_project_status_name_match() {
7382 let server = MockServer::start();
7383
7384 server.mock(|when, then| {
7386 when.method(GET).path("/issue/PROJ-1/transitions");
7387 then.status(200).json_body(serde_json::json!({
7388 "transitions": [
7389 {
7390 "id": "41",
7391 "name": "Cancel",
7392 "to": {"name": "Abgebrochen", "statusCategory": {"key": "done"}}
7393 },
7394 {
7395 "id": "31",
7396 "name": "Done",
7397 "to": {"name": "Erledigt", "statusCategory": {"key": "done"}}
7398 }
7399 ]
7400 }));
7401 });
7402
7403 mock_project_statuses(&server, sample_project_statuses_json());
7404
7405 server.mock(|when, then| {
7407 when.method(POST)
7408 .path("/issue/PROJ-1/transitions")
7409 .body_includes("\"id\":\"41\"");
7410 then.status(204);
7411 });
7412
7413 server.mock(|when, then| {
7414 when.method(GET).path("/issue/PROJ-1");
7415 then.status(200).json_body(serde_json::json!({
7416 "id": "10001",
7417 "key": "PROJ-1",
7418 "fields": {
7419 "summary": "Test",
7420 "status": {"name": "Abgebrochen"},
7421 "labels": []
7422 }
7423 }));
7424 });
7425
7426 let client = create_self_hosted_client(&server);
7427 let issue = client
7428 .update_issue(
7429 "PROJ-1",
7430 UpdateIssueInput {
7431 state: Some("Abgebrochen".to_string()),
7432 ..Default::default()
7433 },
7434 )
7435 .await
7436 .unwrap();
7437
7438 assert_eq!(issue.state, "Abgebrochen");
7439 }
7440
7441 #[tokio::test]
7442 async fn test_update_issue_fallback_when_project_statuses_unavailable() {
7443 let server = MockServer::start();
7444
7445 server.mock(|when, then| {
7447 when.method(GET).path("/issue/PROJ-1/transitions");
7448 then.status(200).json_body(serde_json::json!({
7449 "transitions": [{
7450 "id": "31",
7451 "name": "Done",
7452 "to": {"name": "Done", "statusCategory": {"key": "done"}}
7453 }]
7454 }));
7455 });
7456
7457 server.mock(|when, then| {
7459 when.method(GET).path("/project/PROJ/statuses");
7460 then.status(403).body("Forbidden");
7461 });
7462
7463 server.mock(|when, then| {
7464 when.method(POST)
7465 .path("/issue/PROJ-1/transitions")
7466 .body_includes("\"id\":\"31\"");
7467 then.status(204);
7468 });
7469
7470 server.mock(|when, then| {
7471 when.method(GET).path("/issue/PROJ-1");
7472 then.status(200).json_body(serde_json::json!({
7473 "id": "10001",
7474 "key": "PROJ-1",
7475 "fields": {
7476 "summary": "Test",
7477 "status": {"name": "Done"},
7478 "labels": []
7479 }
7480 }));
7481 });
7482
7483 let client = create_self_hosted_client(&server);
7484 let issue = client
7486 .update_issue(
7487 "PROJ-1",
7488 UpdateIssueInput {
7489 state: Some("closed".to_string()),
7490 ..Default::default()
7491 },
7492 )
7493 .await
7494 .unwrap();
7495
7496 assert_eq!(issue.state, "Done");
7497 }
7498
7499 #[tokio::test]
7500 async fn test_get_comments() {
7501 let server = MockServer::start();
7502
7503 server.mock(|when, then| {
7504 when.method(GET).path("/issue/PROJ-1/comment");
7505 then.status(200).json_body(serde_json::json!({
7506 "comments": [{
7507 "id": "100",
7508 "body": "Great work!",
7509 "author": {
7510 "name": "reviewer",
7511 "displayName": "Reviewer"
7512 },
7513 "created": "2024-01-01T12:00:00.000+0000",
7514 "updated": "2024-01-01T12:00:00.000+0000"
7515 }]
7516 }));
7517 });
7518
7519 let client = create_self_hosted_client(&server);
7520 let comments = client.get_comments("PROJ-1").await.unwrap().items;
7521
7522 assert_eq!(comments.len(), 1);
7523 assert_eq!(comments[0].id, "100");
7524 assert_eq!(comments[0].body, "Great work!");
7525 assert_eq!(comments[0].author.as_ref().unwrap().username, "reviewer");
7526 }
7527
7528 #[tokio::test]
7529 async fn test_add_comment() {
7530 let server = MockServer::start();
7531
7532 server.mock(|when, then| {
7533 when.method(POST)
7534 .path("/issue/PROJ-1/comment")
7535 .body_includes("\"body\":\"My comment\"");
7536 then.status(201).json_body(serde_json::json!({
7537 "id": "101",
7538 "body": "My comment",
7539 "author": {
7540 "name": "user",
7541 "displayName": "User"
7542 },
7543 "created": "2024-01-01T13:00:00.000+0000"
7544 }));
7545 });
7546
7547 let client = create_self_hosted_client(&server);
7548 let comment = IssueProvider::add_comment(&client, "PROJ-1", "My comment")
7549 .await
7550 .unwrap();
7551
7552 assert_eq!(comment.id, "101");
7553 assert_eq!(comment.body, "My comment");
7554 }
7555
7556 #[tokio::test]
7561 async fn test_cloud_get_issues() {
7562 let server = MockServer::start();
7563
7564 server.mock(|when, then| {
7565 when.method(GET)
7566 .path("/search/jql")
7567 .query_param_exists("jql");
7568 then.status(200).json_body(serde_json::json!({
7569 "issues": [sample_cloud_issue_json()]
7570 }));
7571 });
7572
7573 let client = create_cloud_client(&server);
7574 let issues = client
7575 .get_issues(IssueFilter::default())
7576 .await
7577 .unwrap()
7578 .items;
7579
7580 assert_eq!(issues.len(), 1);
7581 assert_eq!(issues[0].key, "jira#PROJ-1");
7582 assert_eq!(
7583 issues[0].description,
7584 Some("Login fails on mobile".to_string())
7585 );
7586 }
7587
7588 #[tokio::test]
7589 async fn test_cloud_create_issue_adf() {
7590 let server = MockServer::start();
7591
7592 server.mock(|when, then| {
7594 when.method(POST)
7595 .path("/issue")
7596 .body_includes("\"type\":\"doc\"")
7597 .body_includes("\"version\":1");
7598 then.status(201).json_body(serde_json::json!({
7599 "id": "10003",
7600 "key": "PROJ-3"
7601 }));
7602 });
7603
7604 server.mock(|when, then| {
7605 when.method(GET).path("/issue/PROJ-3");
7606 then.status(200).json_body(serde_json::json!({
7607 "id": "10003",
7608 "key": "PROJ-3",
7609 "fields": {
7610 "summary": "Cloud task",
7611 "description": {
7612 "version": 1,
7613 "type": "doc",
7614 "content": [{
7615 "type": "paragraph",
7616 "content": [{"type": "text", "text": "Cloud description"}]
7617 }]
7618 },
7619 "status": {"name": "To Do"},
7620 "labels": []
7621 }
7622 }));
7623 });
7624
7625 let client = create_cloud_client(&server);
7626 let issue = client
7627 .create_issue(CreateIssueInput {
7628 title: "Cloud task".to_string(),
7629 description: Some("Cloud description".to_string()),
7630 ..Default::default()
7631 })
7632 .await
7633 .unwrap();
7634
7635 assert_eq!(issue.key, "jira#PROJ-3");
7636 assert_eq!(issue.description, Some("Cloud description".to_string()));
7637 }
7638
7639 #[tokio::test]
7640 async fn test_cloud_add_comment_adf() {
7641 let server = MockServer::start();
7642
7643 server.mock(|when, then| {
7644 when.method(POST)
7645 .path("/issue/PROJ-1/comment")
7646 .body_includes("\"type\":\"doc\"");
7647 then.status(201).json_body(serde_json::json!({
7648 "id": "201",
7649 "body": {
7650 "version": 1,
7651 "type": "doc",
7652 "content": [{
7653 "type": "paragraph",
7654 "content": [{"type": "text", "text": "ADF comment body"}]
7655 }]
7656 },
7657 "author": {
7658 "accountId": "abc123",
7659 "displayName": "Commenter"
7660 },
7661 "created": "2024-01-02T10:00:00.000+0000"
7662 }));
7663 });
7664
7665 let client = create_cloud_client(&server);
7666 let comment = IssueProvider::add_comment(&client, "PROJ-1", "ADF comment body")
7667 .await
7668 .unwrap();
7669
7670 assert_eq!(comment.id, "201");
7671 assert_eq!(comment.body, "ADF comment body");
7672 }
7673
7674 #[tokio::test]
7675 async fn test_cloud_get_issue_adf_description() {
7676 let server = MockServer::start();
7677
7678 server.mock(|when, then| {
7679 when.method(GET).path("/issue/PROJ-1");
7680 then.status(200).json_body(sample_cloud_issue_json());
7681 });
7682
7683 let client = create_cloud_client(&server);
7684 let issue = client.get_issue("PROJ-1").await.unwrap();
7685
7686 assert_eq!(issue.description, Some("Login fails on mobile".to_string()));
7687 }
7688
7689 #[tokio::test]
7694 async fn test_handle_401() {
7695 let server = MockServer::start();
7696
7697 server.mock(|when, then| {
7698 when.method(GET).path("/issue/PROJ-1");
7699 then.status(401).body("Unauthorized");
7700 });
7701
7702 let client = create_self_hosted_client(&server);
7703 let result = client.get_issue("PROJ-1").await;
7704
7705 assert!(result.is_err());
7706 assert!(matches!(result.unwrap_err(), Error::Unauthorized(_)));
7707 }
7708
7709 #[tokio::test]
7710 async fn test_handle_404() {
7711 let server = MockServer::start();
7712
7713 server.mock(|when, then| {
7714 when.method(GET).path("/issue/PROJ-999");
7715 then.status(404).body("Issue not found");
7716 });
7717
7718 let client = create_self_hosted_client(&server);
7719 let result = client.get_issue("PROJ-999").await;
7720
7721 assert!(result.is_err());
7722 assert!(matches!(result.unwrap_err(), Error::NotFound(_)));
7723 }
7724
7725 #[tokio::test]
7726 async fn test_handle_500() {
7727 let server = MockServer::start();
7728
7729 server.mock(|when, then| {
7730 when.method(GET).path("/search");
7731 then.status(500).body("Internal Server Error");
7732 });
7733
7734 let client = create_self_hosted_client(&server);
7735 let result = client.get_issues(IssueFilter::default()).await;
7736
7737 assert!(result.is_err());
7738 assert!(matches!(result.unwrap_err(), Error::ServerError { .. }));
7739 }
7740
7741 #[tokio::test]
7746 async fn test_mr_methods_unsupported() {
7747 let client = JiraClient::with_base_url(
7748 "http://localhost",
7749 "PROJ",
7750 "user@example.com",
7751 token("token"),
7752 false,
7753 );
7754
7755 let result = client.get_merge_requests(MrFilter::default()).await;
7756 assert!(matches!(
7757 result.unwrap_err(),
7758 Error::ProviderUnsupported { .. }
7759 ));
7760
7761 let result = client.get_merge_request("mr#1").await;
7762 assert!(matches!(
7763 result.unwrap_err(),
7764 Error::ProviderUnsupported { .. }
7765 ));
7766
7767 let result = client.get_discussions("mr#1").await;
7768 assert!(matches!(
7769 result.unwrap_err(),
7770 Error::ProviderUnsupported { .. }
7771 ));
7772
7773 let result = client.get_diffs("mr#1").await;
7774 assert!(matches!(
7775 result.unwrap_err(),
7776 Error::ProviderUnsupported { .. }
7777 ));
7778
7779 let result = MergeRequestProvider::add_comment(
7780 &client,
7781 "mr#1",
7782 CreateCommentInput {
7783 body: "test".to_string(),
7784 position: None,
7785 discussion_id: None,
7786 },
7787 )
7788 .await;
7789 assert!(matches!(
7790 result.unwrap_err(),
7791 Error::ProviderUnsupported { .. }
7792 ));
7793 }
7794
7795 #[tokio::test]
7800 async fn test_get_current_user() {
7801 let server = MockServer::start();
7802
7803 server.mock(|when, then| {
7804 when.method(GET).path("/myself");
7805 then.status(200).json_body(serde_json::json!({
7806 "name": "jdoe",
7807 "displayName": "John Doe",
7808 "emailAddress": "john@example.com"
7809 }));
7810 });
7811
7812 let client = create_self_hosted_client(&server);
7813 let user = client.get_current_user().await.unwrap();
7814
7815 assert_eq!(user.username, "jdoe");
7816 assert_eq!(user.name, Some("John Doe".to_string()));
7817 assert_eq!(user.email, Some("john@example.com".to_string()));
7818 }
7819
7820 #[tokio::test]
7821 async fn test_get_current_user_auth_failure() {
7822 let server = MockServer::start();
7823
7824 server.mock(|when, then| {
7825 when.method(GET).path("/myself");
7826 then.status(401).body("Unauthorized");
7827 });
7828
7829 let client = create_self_hosted_client(&server);
7830 let result = client.get_current_user().await;
7831
7832 assert!(result.is_err());
7833 assert!(matches!(result.unwrap_err(), Error::Unauthorized(_)));
7834 }
7835
7836 #[tokio::test]
7837 async fn test_transition_not_found_error_lists_available() {
7838 let server = MockServer::start();
7839
7840 server.mock(|when, then| {
7841 when.method(GET).path("/issue/PROJ-1/transitions");
7842 then.status(200).json_body(serde_json::json!({
7843 "transitions": [
7844 {
7845 "id": "21",
7846 "name": "Start Progress",
7847 "to": {
7848 "name": "In Bearbeitung",
7849 "statusCategory": {"key": "indeterminate"}
7850 }
7851 }
7852 ]
7853 }));
7854 });
7855
7856 mock_project_statuses(&server, sample_project_statuses_json());
7858
7859 let client = create_self_hosted_client(&server);
7860 let result = client
7861 .update_issue(
7862 "PROJ-1",
7863 UpdateIssueInput {
7864 state: Some("nonexistent".to_string()),
7865 ..Default::default()
7866 },
7867 )
7868 .await;
7869
7870 assert!(result.is_err());
7871 let err = result.unwrap_err().to_string();
7872 assert!(err.contains("No transition to status"), "got: {}", err);
7873 assert!(
7874 err.contains("In Bearbeitung"),
7875 "should list available: {}",
7876 err
7877 );
7878 }
7879
7880 #[tokio::test]
7881 async fn test_cloud_get_issues_pagination_next_page_token() {
7882 let server = MockServer::start();
7883
7884 server.mock(|when, then| {
7887 when.method(GET)
7888 .path("/search/jql")
7889 .query_param("nextPageToken", "page2token");
7890 then.status(200).json_body(serde_json::json!({
7891 "issues": [
7892 {
7893 "id": "10003",
7894 "key": "PROJ-3",
7895 "fields": {
7896 "summary": "Issue 3",
7897 "status": {"name": "Done"},
7898 "labels": [],
7899 "created": "2024-01-03T10:00:00.000+0000"
7900 }
7901 }
7902 ]
7903 }));
7904 });
7905
7906 server.mock(|when, then| {
7908 when.method(GET)
7909 .path("/search/jql")
7910 .query_param_exists("jql");
7911 then.status(200).json_body(serde_json::json!({
7912 "issues": [
7913 {
7914 "id": "10001",
7915 "key": "PROJ-1",
7916 "fields": {
7917 "summary": "Issue 1",
7918 "status": {"name": "Open"},
7919 "labels": [],
7920 "created": "2024-01-01T10:00:00.000+0000"
7921 }
7922 },
7923 {
7924 "id": "10002",
7925 "key": "PROJ-2",
7926 "fields": {
7927 "summary": "Issue 2",
7928 "status": {"name": "Open"},
7929 "labels": [],
7930 "created": "2024-01-02T10:00:00.000+0000"
7931 }
7932 }
7933 ],
7934 "nextPageToken": "page2token"
7935 }));
7936 });
7937
7938 let client = create_cloud_client(&server);
7939 let issues = client
7940 .get_issues(IssueFilter {
7941 limit: Some(3),
7942 ..Default::default()
7943 })
7944 .await
7945 .unwrap()
7946 .items;
7947
7948 assert_eq!(issues.len(), 3);
7949 assert_eq!(issues[0].key, "jira#PROJ-1");
7950 assert_eq!(issues[1].key, "jira#PROJ-2");
7951 assert_eq!(issues[2].key, "jira#PROJ-3");
7952 }
7953
7954 #[test]
7955 fn test_escape_jql() {
7956 assert_eq!(escape_jql("simple"), "simple");
7957 assert_eq!(escape_jql(r#"has "quotes""#), r#"has \"quotes\""#);
7958 assert_eq!(escape_jql(r"back\slash"), r"back\\slash");
7959 assert_eq!(
7960 escape_jql(r#"both "and" \ here"#),
7961 r#"both \"and\" \\ here"#
7962 );
7963 }
7964
7965 #[test]
7966 fn test_has_project_clause() {
7967 assert!(has_project_clause("project = \"PROJ\""));
7969 assert!(has_project_clause("project = PROJ AND status = Open"));
7970 assert!(has_project_clause("project IN (\"A\", \"B\")"));
7971 assert!(has_project_clause("project in(A, B)"));
7972 assert!(has_project_clause("PROJECT = KEY")); assert!(has_project_clause("status = Open AND project = X"));
7974 assert!(has_project_clause("project ~ KEY")); assert!(has_project_clause("project != \"PROJ\""));
7977 assert!(has_project_clause("project NOT IN (\"A\", \"B\")"));
7978 assert!(has_project_clause("project not in(A)"));
7979 assert!(!has_project_clause("fixVersion = \"1.0\""));
7981 assert!(!has_project_clause("status = Done"));
7982 assert!(!has_project_clause("summary ~ \"project plan\""));
7984 assert!(!has_project_clause("summary ~ \"project information\""));
7985 assert!(!has_project_clause("summary ~ \"project = foo\""));
7986 assert!(!has_project_clause("my_project = X"));
7988 }
7989
7990 #[test]
7995 fn test_merge_custom_fields_into_payload() {
7996 use crate::types::*;
7997 let payload = CreateIssuePayload {
7998 fields: CreateIssueFields {
7999 project: ProjectKey { key: "PROJ".into() },
8000 summary: "Test".into(),
8001 issuetype: IssueType {
8002 name: "Task".into(),
8003 },
8004 description: None,
8005 labels: None,
8006 priority: None,
8007 assignee: None,
8008 components: None,
8009 fix_versions: None,
8010 parent: None,
8011 },
8012 };
8013
8014 let cf = Some(serde_json::json!({"customfield_10001": 8, "customfield_10002": "x"}));
8015 let (merged, count) = merge_custom_fields_into_payload(payload, &cf).unwrap();
8016
8017 let fields = merged.get("fields").unwrap();
8018 assert_eq!(fields["customfield_10001"], 8);
8019 assert_eq!(fields["customfield_10002"], "x");
8020 assert_eq!(count, 2);
8021 assert_eq!(fields["summary"], "Test");
8022 assert_eq!(fields["project"]["key"], "PROJ");
8023 }
8024
8025 #[test]
8026 fn test_merge_custom_fields_none_is_noop() {
8027 use crate::types::*;
8028 let payload = CreateIssuePayload {
8029 fields: CreateIssueFields {
8030 project: ProjectKey { key: "PROJ".into() },
8031 summary: "Test".into(),
8032 issuetype: IssueType {
8033 name: "Task".into(),
8034 },
8035 description: None,
8036 labels: None,
8037 priority: None,
8038 assignee: None,
8039 components: None,
8040 fix_versions: None,
8041 parent: None,
8042 },
8043 };
8044
8045 let (merged, count) = merge_custom_fields_into_payload(payload, &None).unwrap();
8046 assert_eq!(count, 0);
8047 let fields = merged.get("fields").unwrap();
8048 assert_eq!(fields["summary"], "Test");
8049 assert!(fields.get("customfield_10001").is_none());
8050 }
8051
8052 #[test]
8053 fn test_merge_custom_fields_rejects_non_custom_keys() {
8054 use crate::types::*;
8055 let payload = CreateIssuePayload {
8056 fields: CreateIssueFields {
8057 project: ProjectKey { key: "PROJ".into() },
8058 summary: "Test".into(),
8059 issuetype: IssueType {
8060 name: "Task".into(),
8061 },
8062 description: None,
8063 labels: None,
8064 priority: None,
8065 assignee: None,
8066 components: None,
8067 fix_versions: None,
8068 parent: None,
8069 },
8070 };
8071
8072 let cf = Some(serde_json::json!({"summary": "HACKED", "customfield_10001": 5}));
8074 let (merged, count) = merge_custom_fields_into_payload(payload, &cf).unwrap();
8075
8076 let fields = merged.get("fields").unwrap();
8077 assert_eq!(fields["summary"], "Test"); assert_eq!(fields["customfield_10001"], 5); assert_eq!(count, 1); }
8081
8082 #[tokio::test]
8087 async fn test_get_issue_relations() {
8088 let server = MockServer::start();
8089
8090 server.mock(|when, then| {
8091 when.method(GET)
8092 .path("/issue/PROJ-1")
8093 .query_param_includes("fields", "parent");
8094 then.status(200).json_body(serde_json::json!({
8095 "id": "10001",
8096 "key": "PROJ-1",
8097 "fields": {
8098 "summary": "Main issue",
8099 "status": {"name": "Open"},
8100 "labels": [],
8101 "parent": {
8102 "id": "10000",
8103 "key": "PROJ-0",
8104 "fields": {
8105 "summary": "Parent issue",
8106 "status": {"name": "Open"},
8107 "labels": []
8108 }
8109 },
8110 "subtasks": [
8111 {
8112 "id": "10002",
8113 "key": "PROJ-2",
8114 "fields": {
8115 "summary": "Subtask 1",
8116 "status": {"name": "In Progress"},
8117 "labels": []
8118 }
8119 }
8120 ],
8121 "issuelinks": [
8122 {
8123 "type": {
8124 "name": "Blocks",
8125 "outward": "blocks",
8126 "inward": "is blocked by"
8127 },
8128 "outwardIssue": {
8129 "id": "10003",
8130 "key": "PROJ-3",
8131 "fields": {
8132 "summary": "Blocked issue",
8133 "status": {"name": "Open"},
8134 "labels": []
8135 }
8136 }
8137 }
8138 ]
8139 }
8140 }));
8141 });
8142
8143 let client = create_self_hosted_client(&server);
8144 let relations = client.get_issue_relations("jira#PROJ-1").await.unwrap();
8145
8146 assert!(relations.parent.is_some());
8147 assert_eq!(relations.parent.unwrap().key, "jira#PROJ-0");
8148 assert_eq!(relations.subtasks.len(), 1);
8149 assert_eq!(relations.subtasks[0].key, "jira#PROJ-2");
8150 assert_eq!(relations.blocks.len(), 1);
8151 assert_eq!(relations.blocks[0].issue.key, "jira#PROJ-3");
8152 }
8153
8154 #[tokio::test]
8159 async fn test_get_issue_attachments_maps_fields() {
8160 let server = MockServer::start();
8161
8162 server.mock(|when, then| {
8163 when.method(GET)
8164 .path("/issue/PROJ-1")
8165 .query_param("fields", "attachment");
8166 then.status(200).json_body(serde_json::json!({
8167 "id": "10001",
8168 "key": "PROJ-1",
8169 "fields": {
8170 "attachment": [
8171 {
8172 "id": "42",
8173 "filename": "crash.log",
8174 "content": "https://example/rest/api/2/attachment/content/42",
8175 "size": 2048,
8176 "mimeType": "text/plain",
8177 "created": "2024-01-01T00:00:00.000+0000",
8178 "author": {
8179 "name": "uploader",
8180 "displayName": "Upload User"
8181 }
8182 }
8183 ]
8184 }
8185 }));
8186 });
8187
8188 let client = create_self_hosted_client(&server);
8189 let assets = client.get_issue_attachments("jira#PROJ-1").await.unwrap();
8190 assert_eq!(assets.len(), 1);
8191 let a = &assets[0];
8192 assert_eq!(a.id, "42");
8193 assert_eq!(a.filename, "crash.log");
8194 assert_eq!(a.mime_type.as_deref(), Some("text/plain"));
8195 assert_eq!(a.size, Some(2048));
8196 assert_eq!(a.author.as_deref(), Some("Upload User"));
8197 }
8198
8199 #[tokio::test]
8200 async fn test_download_attachment_returns_bytes() {
8201 let server = MockServer::start();
8202
8203 let content_url = server.url("/secure/attachment/42/trace.log");
8205 server.mock(|when, then| {
8206 when.method(GET).path("/attachment/42");
8207 then.status(200).json_body(serde_json::json!({
8208 "self": "http://localhost/rest/api/2/attachment/42",
8209 "id": "42",
8210 "filename": "trace.log",
8211 "content": content_url,
8212 }));
8213 });
8214 server.mock(|when, then| {
8215 when.method(GET).path("/secure/attachment/42/trace.log");
8216 then.status(200).body("stack trace here");
8217 });
8218
8219 let client = create_self_hosted_client(&server);
8220 let bytes = client
8221 .download_attachment("jira#PROJ-1", "42")
8222 .await
8223 .unwrap();
8224 assert_eq!(bytes, b"stack trace here");
8225 }
8226
8227 #[tokio::test]
8228 async fn test_delete_attachment_ok() {
8229 let server = MockServer::start();
8230
8231 let mock = server.mock(|when, then| {
8232 when.method(DELETE).path("/attachment/42");
8233 then.status(204);
8234 });
8235
8236 let client = create_self_hosted_client(&server);
8237 client.delete_attachment("jira#PROJ-1", "42").await.unwrap();
8238 mock.assert();
8239 }
8240
8241 #[tokio::test]
8242 async fn test_upload_attachment_returns_content_url() {
8243 let server = MockServer::start();
8244
8245 server.mock(|when, then| {
8246 when.method(POST)
8247 .path("/issue/PROJ-1/attachments")
8248 .header("X-Atlassian-Token", "no-check");
8249 then.status(200).json_body(serde_json::json!([
8250 {
8251 "id": "99",
8252 "filename": "report.txt",
8253 "content": "https://example/rest/api/2/attachment/content/99",
8254 "size": 10
8255 }
8256 ]));
8257 });
8258
8259 let client = create_self_hosted_client(&server);
8260 let url = client
8261 .upload_attachment("jira#PROJ-1", "report.txt", b"0123456789")
8262 .await
8263 .unwrap();
8264 assert_eq!(url, "https://example/rest/api/2/attachment/content/99");
8265 }
8266
8267 #[tokio::test]
8268 async fn test_jira_asset_capabilities() {
8269 let server = MockServer::start();
8270 let client = create_self_hosted_client(&server);
8271 let caps = client.asset_capabilities();
8272 assert!(caps.issue.upload);
8273 assert!(caps.issue.download);
8274 assert!(caps.issue.delete);
8275 assert!(caps.issue.list);
8276 }
8277 }
8278
8279 #[test]
8284 fn test_map_relations_empty() {
8285 let issue = JiraIssue {
8286 id: "10001".to_string(),
8287 key: "PROJ-1".to_string(),
8288 fields: JiraIssueFields {
8289 summary: Some("Test".to_string()),
8290 description: None,
8291 status: None,
8292 priority: None,
8293 assignee: None,
8294 reporter: None,
8295 labels: vec![],
8296 created: None,
8297 updated: None,
8298 parent: None,
8299 subtasks: vec![],
8300 issuelinks: vec![],
8301 attachment: vec![],
8302 issuetype: None,
8303 extras: std::collections::HashMap::new(),
8304 },
8305 };
8306
8307 let relations = map_relations(&issue, JiraFlavor::Cloud, "https://test.atlassian.net");
8308
8309 assert!(relations.parent.is_none());
8310 assert!(relations.subtasks.is_empty());
8311 assert!(relations.blocks.is_empty());
8312 assert!(relations.blocked_by.is_empty());
8313 assert!(relations.related_to.is_empty());
8314 assert!(relations.duplicates.is_empty());
8315 }
8316
8317 #[test]
8318 fn test_map_relations_with_parent() {
8319 let parent = Box::new(JiraIssue {
8320 id: "10000".to_string(),
8321 key: "PROJ-0".to_string(),
8322 fields: JiraIssueFields {
8323 summary: Some("Parent Issue".to_string()),
8324 description: None,
8325 status: Some(JiraStatus {
8326 name: "Open".to_string(),
8327 status_category: None,
8328 }),
8329 priority: None,
8330 assignee: None,
8331 reporter: None,
8332 labels: vec![],
8333 created: None,
8334 updated: None,
8335 parent: None,
8336 subtasks: vec![],
8337 issuelinks: vec![],
8338 attachment: vec![],
8339 issuetype: None,
8340 extras: std::collections::HashMap::new(),
8341 },
8342 });
8343
8344 let issue = JiraIssue {
8345 id: "10001".to_string(),
8346 key: "PROJ-1".to_string(),
8347 fields: JiraIssueFields {
8348 summary: Some("Child Issue".to_string()),
8349 description: None,
8350 status: None,
8351 priority: None,
8352 assignee: None,
8353 reporter: None,
8354 labels: vec![],
8355 created: None,
8356 updated: None,
8357 parent: Some(parent),
8358 subtasks: vec![],
8359 issuelinks: vec![],
8360 attachment: vec![],
8361 issuetype: None,
8362 extras: std::collections::HashMap::new(),
8363 },
8364 };
8365
8366 let relations = map_relations(&issue, JiraFlavor::SelfHosted, "https://jira.example.com");
8367
8368 assert!(relations.parent.is_some());
8369 let parent_issue = relations.parent.unwrap();
8370 assert_eq!(parent_issue.key, "jira#PROJ-0");
8371 assert_eq!(parent_issue.title, "Parent Issue");
8372 }
8373
8374 #[test]
8375 fn test_map_relations_with_subtasks() {
8376 let issue = JiraIssue {
8377 id: "10001".to_string(),
8378 key: "PROJ-1".to_string(),
8379 fields: JiraIssueFields {
8380 summary: Some("Epic".to_string()),
8381 description: None,
8382 status: None,
8383 priority: None,
8384 assignee: None,
8385 reporter: None,
8386 labels: vec![],
8387 created: None,
8388 updated: None,
8389 parent: None,
8390 subtasks: vec![
8391 JiraIssue {
8392 id: "10002".to_string(),
8393 key: "PROJ-2".to_string(),
8394 fields: JiraIssueFields {
8395 summary: Some("Subtask 1".to_string()),
8396 description: None,
8397 status: Some(JiraStatus {
8398 name: "In Progress".to_string(),
8399 status_category: None,
8400 }),
8401 priority: None,
8402 assignee: None,
8403 reporter: None,
8404 labels: vec![],
8405 created: None,
8406 updated: None,
8407 parent: None,
8408 subtasks: vec![],
8409 issuelinks: vec![],
8410 attachment: vec![],
8411 issuetype: None,
8412 extras: std::collections::HashMap::new(),
8413 },
8414 },
8415 JiraIssue {
8416 id: "10003".to_string(),
8417 key: "PROJ-3".to_string(),
8418 fields: JiraIssueFields {
8419 summary: Some("Subtask 2".to_string()),
8420 description: None,
8421 status: None,
8422 priority: None,
8423 assignee: None,
8424 reporter: None,
8425 labels: vec![],
8426 created: None,
8427 updated: None,
8428 parent: None,
8429 subtasks: vec![],
8430 issuelinks: vec![],
8431 attachment: vec![],
8432 issuetype: None,
8433 extras: std::collections::HashMap::new(),
8434 },
8435 },
8436 ],
8437 issuelinks: vec![],
8438 attachment: vec![],
8439 issuetype: None,
8440 extras: std::collections::HashMap::new(),
8441 },
8442 };
8443
8444 let relations = map_relations(&issue, JiraFlavor::Cloud, "https://test.atlassian.net");
8445
8446 assert_eq!(relations.subtasks.len(), 2);
8447 assert_eq!(relations.subtasks[0].key, "jira#PROJ-2");
8448 assert_eq!(relations.subtasks[0].title, "Subtask 1");
8449 assert_eq!(relations.subtasks[1].key, "jira#PROJ-3");
8450 assert_eq!(relations.subtasks[1].title, "Subtask 2");
8451 }
8452
8453 #[test]
8454 fn test_map_relations_with_issuelinks_blocks() {
8455 let issue = JiraIssue {
8456 id: "10001".to_string(),
8457 key: "PROJ-1".to_string(),
8458 fields: JiraIssueFields {
8459 summary: Some("Test".to_string()),
8460 description: None,
8461 status: None,
8462 priority: None,
8463 assignee: None,
8464 reporter: None,
8465 labels: vec![],
8466 created: None,
8467 updated: None,
8468 parent: None,
8469 subtasks: vec![],
8470 issuelinks: vec![
8471 JiraIssueLink {
8473 id: Some("1".to_string()),
8474 link_type: JiraIssueLinkType {
8475 name: "Blocks".to_string(),
8476 outward: Some("blocks".to_string()),
8477 inward: Some("is blocked by".to_string()),
8478 },
8479 outward_issue: Some(Box::new(JiraIssue {
8480 id: "10002".to_string(),
8481 key: "PROJ-2".to_string(),
8482 fields: JiraIssueFields {
8483 summary: Some("Blocked".to_string()),
8484 description: None,
8485 status: None,
8486 priority: None,
8487 assignee: None,
8488 reporter: None,
8489 labels: vec![],
8490 created: None,
8491 updated: None,
8492 parent: None,
8493 subtasks: vec![],
8494 issuelinks: vec![],
8495 attachment: vec![],
8496 issuetype: None,
8497 extras: std::collections::HashMap::new(),
8498 },
8499 })),
8500 inward_issue: None,
8501 },
8502 JiraIssueLink {
8504 id: Some("2".to_string()),
8505 link_type: JiraIssueLinkType {
8506 name: "Blocks".to_string(),
8507 outward: Some("blocks".to_string()),
8508 inward: Some("is blocked by".to_string()),
8509 },
8510 outward_issue: None,
8511 inward_issue: Some(Box::new(JiraIssue {
8512 id: "10003".to_string(),
8513 key: "PROJ-3".to_string(),
8514 fields: JiraIssueFields {
8515 summary: Some("Blocker".to_string()),
8516 description: None,
8517 status: None,
8518 priority: None,
8519 assignee: None,
8520 reporter: None,
8521 labels: vec![],
8522 created: None,
8523 updated: None,
8524 parent: None,
8525 subtasks: vec![],
8526 issuelinks: vec![],
8527 attachment: vec![],
8528 issuetype: None,
8529 extras: std::collections::HashMap::new(),
8530 },
8531 })),
8532 },
8533 ],
8534 attachment: vec![],
8535 issuetype: None,
8536 extras: std::collections::HashMap::new(),
8537 },
8538 };
8539
8540 let relations = map_relations(&issue, JiraFlavor::Cloud, "https://test.atlassian.net");
8541
8542 assert_eq!(relations.blocks.len(), 1);
8543 assert_eq!(relations.blocks[0].issue.key, "jira#PROJ-2");
8544 assert_eq!(relations.blocks[0].link_type, "Blocks");
8545 assert_eq!(relations.blocked_by.len(), 1);
8546 assert_eq!(relations.blocked_by[0].issue.key, "jira#PROJ-3");
8547 }
8548
8549 #[test]
8550 fn test_map_relations_with_issuelinks_duplicates() {
8551 let issue = JiraIssue {
8552 id: "10001".to_string(),
8553 key: "PROJ-1".to_string(),
8554 fields: JiraIssueFields {
8555 summary: Some("Test".to_string()),
8556 description: None,
8557 status: None,
8558 priority: None,
8559 assignee: None,
8560 reporter: None,
8561 labels: vec![],
8562 created: None,
8563 updated: None,
8564 parent: None,
8565 subtasks: vec![],
8566 issuelinks: vec![
8567 JiraIssueLink {
8569 id: Some("1".to_string()),
8570 link_type: JiraIssueLinkType {
8571 name: "Duplicate".to_string(),
8572 outward: Some("duplicates".to_string()),
8573 inward: Some("is duplicated by".to_string()),
8574 },
8575 outward_issue: Some(Box::new(JiraIssue {
8576 id: "10002".to_string(),
8577 key: "PROJ-2".to_string(),
8578 fields: JiraIssueFields {
8579 summary: Some("Dup outward".to_string()),
8580 description: None,
8581 status: None,
8582 priority: None,
8583 assignee: None,
8584 reporter: None,
8585 labels: vec![],
8586 created: None,
8587 updated: None,
8588 parent: None,
8589 subtasks: vec![],
8590 issuelinks: vec![],
8591 attachment: vec![],
8592 issuetype: None,
8593 extras: std::collections::HashMap::new(),
8594 },
8595 })),
8596 inward_issue: None,
8597 },
8598 JiraIssueLink {
8600 id: Some("2".to_string()),
8601 link_type: JiraIssueLinkType {
8602 name: "Duplicate".to_string(),
8603 outward: Some("duplicates".to_string()),
8604 inward: Some("is duplicated by".to_string()),
8605 },
8606 outward_issue: None,
8607 inward_issue: Some(Box::new(JiraIssue {
8608 id: "10003".to_string(),
8609 key: "PROJ-3".to_string(),
8610 fields: JiraIssueFields {
8611 summary: Some("Dup inward".to_string()),
8612 description: None,
8613 status: None,
8614 priority: None,
8615 assignee: None,
8616 reporter: None,
8617 labels: vec![],
8618 created: None,
8619 updated: None,
8620 parent: None,
8621 subtasks: vec![],
8622 issuelinks: vec![],
8623 attachment: vec![],
8624 issuetype: None,
8625 extras: std::collections::HashMap::new(),
8626 },
8627 })),
8628 },
8629 ],
8630 attachment: vec![],
8631 issuetype: None,
8632 extras: std::collections::HashMap::new(),
8633 },
8634 };
8635
8636 let relations = map_relations(&issue, JiraFlavor::Cloud, "https://test.atlassian.net");
8637
8638 assert_eq!(relations.duplicates.len(), 2);
8640 assert_eq!(relations.duplicates[0].issue.key, "jira#PROJ-2");
8641 assert_eq!(relations.duplicates[1].issue.key, "jira#PROJ-3");
8642 }
8643
8644 #[test]
8645 fn test_map_relations_with_issuelinks_relates() {
8646 let issue = JiraIssue {
8647 id: "10001".to_string(),
8648 key: "PROJ-1".to_string(),
8649 fields: JiraIssueFields {
8650 summary: Some("Test".to_string()),
8651 description: None,
8652 status: None,
8653 priority: None,
8654 assignee: None,
8655 reporter: None,
8656 labels: vec![],
8657 created: None,
8658 updated: None,
8659 parent: None,
8660 subtasks: vec![],
8661 issuelinks: vec![JiraIssueLink {
8662 id: Some("1".to_string()),
8663 link_type: JiraIssueLinkType {
8664 name: "Relates".to_string(),
8665 outward: Some("relates to".to_string()),
8666 inward: Some("relates to".to_string()),
8667 },
8668 outward_issue: Some(Box::new(JiraIssue {
8669 id: "10002".to_string(),
8670 key: "PROJ-2".to_string(),
8671 fields: JiraIssueFields {
8672 summary: Some("Related".to_string()),
8673 description: None,
8674 status: None,
8675 priority: None,
8676 assignee: None,
8677 reporter: None,
8678 labels: vec![],
8679 created: None,
8680 updated: None,
8681 parent: None,
8682 subtasks: vec![],
8683 issuelinks: vec![],
8684 attachment: vec![],
8685 issuetype: None,
8686 extras: std::collections::HashMap::new(),
8687 },
8688 })),
8689 inward_issue: None,
8690 }],
8691 attachment: vec![],
8692 issuetype: None,
8693 extras: std::collections::HashMap::new(),
8694 },
8695 };
8696
8697 let relations = map_relations(&issue, JiraFlavor::Cloud, "https://test.atlassian.net");
8698
8699 assert_eq!(relations.related_to.len(), 1);
8700 assert_eq!(relations.related_to[0].issue.key, "jira#PROJ-2");
8701 assert_eq!(relations.related_to[0].link_type, "Relates");
8702 }
8703
8704 #[test]
8705 fn test_map_relations_mixed() {
8706 let issue = JiraIssue {
8707 id: "10001".to_string(),
8708 key: "PROJ-1".to_string(),
8709 fields: JiraIssueFields {
8710 summary: Some("Main".to_string()),
8711 description: None,
8712 status: None,
8713 priority: None,
8714 assignee: None,
8715 reporter: None,
8716 labels: vec![],
8717 created: None,
8718 updated: None,
8719 parent: Some(Box::new(JiraIssue {
8720 id: "10000".to_string(),
8721 key: "PROJ-0".to_string(),
8722 fields: JiraIssueFields {
8723 summary: Some("Parent".to_string()),
8724 description: None,
8725 status: None,
8726 priority: None,
8727 assignee: None,
8728 reporter: None,
8729 labels: vec![],
8730 created: None,
8731 updated: None,
8732 parent: None,
8733 subtasks: vec![],
8734 issuelinks: vec![],
8735 attachment: vec![],
8736 issuetype: None,
8737 extras: std::collections::HashMap::new(),
8738 },
8739 })),
8740 subtasks: vec![JiraIssue {
8741 id: "10002".to_string(),
8742 key: "PROJ-2".to_string(),
8743 fields: JiraIssueFields {
8744 summary: Some("Sub".to_string()),
8745 description: None,
8746 status: None,
8747 priority: None,
8748 assignee: None,
8749 reporter: None,
8750 labels: vec![],
8751 created: None,
8752 updated: None,
8753 parent: None,
8754 subtasks: vec![],
8755 issuelinks: vec![],
8756 attachment: vec![],
8757 issuetype: None,
8758 extras: std::collections::HashMap::new(),
8759 },
8760 }],
8761 issuelinks: vec![JiraIssueLink {
8762 id: Some("1".to_string()),
8763 link_type: JiraIssueLinkType {
8764 name: "Blocks".to_string(),
8765 outward: Some("blocks".to_string()),
8766 inward: Some("is blocked by".to_string()),
8767 },
8768 outward_issue: Some(Box::new(JiraIssue {
8769 id: "10003".to_string(),
8770 key: "PROJ-3".to_string(),
8771 fields: JiraIssueFields {
8772 summary: Some("Blocked".to_string()),
8773 description: None,
8774 status: None,
8775 priority: None,
8776 assignee: None,
8777 reporter: None,
8778 labels: vec![],
8779 created: None,
8780 updated: None,
8781 parent: None,
8782 subtasks: vec![],
8783 issuelinks: vec![],
8784 attachment: vec![],
8785 issuetype: None,
8786 extras: std::collections::HashMap::new(),
8787 },
8788 })),
8789 inward_issue: None,
8790 }],
8791 attachment: vec![],
8792 issuetype: None,
8793 extras: std::collections::HashMap::new(),
8794 },
8795 };
8796
8797 let relations = map_relations(&issue, JiraFlavor::Cloud, "https://test.atlassian.net");
8798
8799 assert!(relations.parent.is_some());
8800 assert_eq!(relations.parent.unwrap().key, "jira#PROJ-0");
8801 assert_eq!(relations.subtasks.len(), 1);
8802 assert_eq!(relations.subtasks[0].key, "jira#PROJ-2");
8803 assert_eq!(relations.blocks.len(), 1);
8804 assert_eq!(relations.blocks[0].issue.key, "jira#PROJ-3");
8805 assert!(relations.blocked_by.is_empty());
8806 assert!(relations.related_to.is_empty());
8807 assert!(relations.duplicates.is_empty());
8808 }
8809
8810 #[test]
8815 fn test_build_forest_tree_empty() {
8816 let tree = build_forest_tree(&[], &[]).unwrap();
8817 assert!(tree.is_empty());
8818 }
8819
8820 #[test]
8821 fn test_build_forest_tree_flat() {
8822 let rows = vec![
8823 JiraForestRow {
8824 id: 1,
8825 item_id: Some("PROJ-1".into()),
8826 item_type: Some("issue".into()),
8827 },
8828 JiraForestRow {
8829 id: 2,
8830 item_id: Some("PROJ-2".into()),
8831 item_type: Some("issue".into()),
8832 },
8833 ];
8834 let depths = vec![0, 0];
8835 let tree = build_forest_tree(&rows, &depths).unwrap();
8836 assert_eq!(tree.len(), 2);
8837 assert_eq!(tree[0].row_id, 1);
8838 assert_eq!(tree[1].row_id, 2);
8839 assert!(tree[0].children.is_empty());
8840 assert!(tree[1].children.is_empty());
8841 }
8842
8843 #[test]
8844 fn test_build_forest_tree_rejects_mismatched_lengths() {
8845 let rows = vec![JiraForestRow {
8846 id: 1,
8847 item_id: Some("PROJ-1".into()),
8848 item_type: None,
8849 }];
8850 let depths = vec![0, 1];
8851 let err = build_forest_tree(&rows, &depths).expect_err("mismatch must be rejected");
8852 assert!(
8853 matches!(err, Error::InvalidData(ref msg) if msg.contains("1 rows but 2 depths")),
8854 "unexpected error: {err:?}"
8855 );
8856 }
8857
8858 #[test]
8859 fn test_build_forest_tree_nested() {
8860 let rows = vec![
8865 JiraForestRow {
8866 id: 1,
8867 item_id: Some("PROJ-1".into()),
8868 item_type: None,
8869 },
8870 JiraForestRow {
8871 id: 2,
8872 item_id: Some("PROJ-2".into()),
8873 item_type: None,
8874 },
8875 JiraForestRow {
8876 id: 3,
8877 item_id: Some("PROJ-3".into()),
8878 item_type: None,
8879 },
8880 JiraForestRow {
8881 id: 4,
8882 item_id: Some("PROJ-4".into()),
8883 item_type: None,
8884 },
8885 ];
8886 let depths = vec![0, 1, 2, 1];
8887 let tree = build_forest_tree(&rows, &depths).unwrap();
8888
8889 assert_eq!(tree.len(), 1);
8890 assert_eq!(tree[0].row_id, 1);
8891 assert_eq!(tree[0].children.len(), 2);
8892 assert_eq!(tree[0].children[0].row_id, 2);
8893 assert_eq!(tree[0].children[0].children.len(), 1);
8894 assert_eq!(tree[0].children[0].children[0].row_id, 3);
8895 assert_eq!(tree[0].children[1].row_id, 4);
8896 assert!(tree[0].children[1].children.is_empty());
8897 }
8898
8899 #[test]
8900 fn test_build_forest_tree_multiple_roots() {
8901 let rows = vec![
8902 JiraForestRow {
8903 id: 1,
8904 item_id: Some("PROJ-1".into()),
8905 item_type: None,
8906 },
8907 JiraForestRow {
8908 id: 2,
8909 item_id: Some("PROJ-2".into()),
8910 item_type: None,
8911 },
8912 JiraForestRow {
8913 id: 3,
8914 item_id: Some("PROJ-3".into()),
8915 item_type: None,
8916 },
8917 JiraForestRow {
8918 id: 4,
8919 item_id: Some("PROJ-4".into()),
8920 item_type: None,
8921 },
8922 ];
8923 let depths = vec![0, 1, 0, 1];
8924 let tree = build_forest_tree(&rows, &depths).unwrap();
8925
8926 assert_eq!(tree.len(), 2);
8927 assert_eq!(tree[0].children.len(), 1);
8928 assert_eq!(tree[1].children.len(), 1);
8929 }
8930
8931 mod structure_integration {
8936 use super::*;
8937 use devboy_core::StructureRowItem;
8938 use httpmock::prelude::*;
8939
8940 fn token(s: &str) -> SecretString {
8941 SecretString::from(s.to_string())
8942 }
8943
8944 fn create_client(server: &MockServer) -> JiraClient {
8945 JiraClient::with_base_url(
8950 server.base_url(),
8951 "PROJ",
8952 "user@example.com",
8953 token("token"),
8954 false,
8955 )
8956 }
8957
8958 #[tokio::test]
8959 async fn test_get_structures() {
8960 let server = MockServer::start();
8961
8962 server.mock(|when, then| {
8963 when.method(GET).path("/rest/structure/2.0/structure");
8964 then.status(200).json_body(serde_json::json!({
8965 "structures": [
8966 {"id": 1, "name": "Q1 Planning", "description": "Quarter 1"},
8967 {"id": 2, "name": "Sprint Board"}
8968 ]
8969 }));
8970 });
8971
8972 let client = create_client(&server);
8973 let result = client.get_structures().await.unwrap();
8974 assert_eq!(result.items.len(), 2);
8975 assert_eq!(result.items[0].name, "Q1 Planning");
8976 assert_eq!(result.items[1].id, 2);
8977 }
8978
8979 #[tokio::test]
8980 async fn test_get_structure_forest() {
8981 let server = MockServer::start();
8982
8983 server.mock(|when, then| {
8984 when.method(POST).path("/rest/structure/2.0/forest/1/spec");
8985 then.status(200).json_body(serde_json::json!({
8986 "version": 42,
8987 "rows": [
8988 {"id": 100, "itemId": "PROJ-1", "itemType": "issue"},
8989 {"id": 101, "itemId": "PROJ-2", "itemType": "issue"},
8990 {"id": 102, "itemId": "PROJ-3", "itemType": "issue"}
8991 ],
8992 "depths": [0, 1, 1],
8993 "totalCount": 3
8994 }));
8995 });
8996
8997 let client = create_client(&server);
8998 let forest = client
8999 .get_structure_forest(
9000 1,
9001 GetForestOptions {
9002 offset: None,
9003 limit: Some(200),
9004 },
9005 )
9006 .await
9007 .unwrap();
9008
9009 assert_eq!(forest.version, 42);
9010 assert_eq!(forest.structure_id, 1);
9011 assert_eq!(forest.total_count, Some(3));
9012 assert_eq!(forest.tree.len(), 1); assert_eq!(forest.tree[0].item_id, Some("PROJ-1".into()));
9014 assert_eq!(forest.tree[0].children.len(), 2);
9015 }
9016
9017 #[tokio::test]
9018 async fn test_create_structure() {
9019 let server = MockServer::start();
9020
9021 server.mock(|when, then| {
9022 when.method(POST).path("/rest/structure/2.0/structure");
9023 then.status(200).json_body(serde_json::json!({
9024 "id": 99,
9025 "name": "New Structure",
9026 "description": "Test"
9027 }));
9028 });
9029
9030 let client = create_client(&server);
9031 let result = client
9032 .create_structure(CreateStructureInput {
9033 name: "New Structure".into(),
9034 description: Some("Test".into()),
9035 })
9036 .await
9037 .unwrap();
9038
9039 assert_eq!(result.id, 99);
9040 assert_eq!(result.name, "New Structure");
9041 }
9042
9043 #[tokio::test]
9044 async fn test_remove_structure_row() {
9045 let server = MockServer::start();
9046
9047 server.mock(|when, then| {
9048 when.method(DELETE)
9049 .path("/rest/structure/2.0/forest/1/item/100");
9050 then.status(204);
9051 });
9052
9053 let client = create_client(&server);
9054 client.remove_structure_row(1, 100).await.unwrap();
9055 }
9056
9057 #[tokio::test]
9058 async fn test_get_structure_views() {
9059 let server = MockServer::start();
9060
9061 server.mock(|when, then| {
9062 when.method(GET)
9063 .path("/rest/structure/2.0/view")
9064 .query_param("structureId", "1");
9065 then.status(200).json_body(serde_json::json!({
9066 "views": [
9067 {"id": 10, "name": "Default View", "structureId": 1, "columns": []},
9068 {"id": 11, "name": "Sprint View", "structureId": 1, "columns": [
9069 {"field": "summary"},
9070 {"field": "status"},
9071 {"formula": "SUM(\"Story Points\")"}
9072 ]}
9073 ]
9074 }));
9075 });
9076
9077 let client = create_client(&server);
9078 let views = client.get_structure_views(1, None).await.unwrap();
9079 assert_eq!(views.len(), 2);
9080 assert_eq!(views[1].columns.len(), 3);
9081 }
9082
9083 #[tokio::test]
9084 async fn test_get_structure_views_by_id_accepts_matching_structure() {
9085 let server = MockServer::start();
9086 server.mock(|when, then| {
9087 when.method(GET).path("/rest/structure/2.0/view/10");
9088 then.status(200).json_body(serde_json::json!({
9089 "id": 10,
9090 "name": "Default View",
9091 "structureId": 1,
9092 "columns": []
9093 }));
9094 });
9095
9096 let client = create_client(&server);
9097 let views = client.get_structure_views(1, Some(10)).await.unwrap();
9098 assert_eq!(views.len(), 1);
9099 assert_eq!(views[0].id, 10);
9100 }
9101
9102 #[tokio::test]
9103 async fn test_get_structure_views_by_id_rejects_cross_structure_view() {
9104 let server = MockServer::start();
9108 server.mock(|when, then| {
9109 when.method(GET).path("/rest/structure/2.0/view/99");
9110 then.status(200).json_body(serde_json::json!({
9111 "id": 99,
9112 "name": "Sibling view",
9113 "structureId": 7,
9114 "columns": []
9115 }));
9116 });
9117
9118 let client = create_client(&server);
9119 let err = client
9120 .get_structure_views(1, Some(99))
9121 .await
9122 .expect_err("mismatched structure must error");
9123 match err {
9124 Error::InvalidData(msg) => {
9125 assert!(msg.contains("belongs to structure 7"), "got: {msg}");
9126 assert!(msg.contains("but 1 was requested"), "got: {msg}");
9127 }
9128 other => panic!("expected InvalidData, got {other:?}"),
9129 }
9130 }
9131
9132 #[tokio::test]
9137 async fn test_structure_api_404_html_is_sanitised_end_to_end() {
9138 let server = MockServer::start();
9142 let jira_404_html =
9143 "<!DOCTYPE html><html><head><title>Oops, you've found a dead link.</title>"
9144 .to_string()
9145 + &"<script>var a=1;</script>".repeat(100)
9146 + "</head><body>404</body></html>";
9147 server.mock(|when, then| {
9148 when.method(GET).path("/rest/structure/2.0/structure");
9149 then.status(404)
9150 .header("content-type", "text/html;charset=UTF-8")
9151 .body(jira_404_html.clone());
9152 });
9153
9154 let client = create_client(&server);
9155 let err = client
9156 .get_structures()
9157 .await
9158 .expect_err("404 must error out");
9159 let msg = err.to_string();
9160 assert!(
9161 !msg.contains("<!DOCTYPE") && !msg.contains("<script>"),
9162 "HTML leaked into error message: {}",
9163 &msg[..msg.len().min(400)]
9164 );
9165 assert!(
9166 msg.contains("endpoint not found"),
9167 "expected soft wording: {msg}"
9168 );
9169 }
9170
9171 #[tokio::test]
9172 async fn test_structure_api_xml_404_is_sanitised_end_to_end() {
9173 let server = MockServer::start();
9176 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>"#;
9177 server.mock(|when, then| {
9178 when.method(GET).path("/rest/structure/2.0/structure");
9179 then.status(404)
9180 .header("content-type", "application/xml")
9181 .body(xml);
9182 });
9183
9184 let client = create_client(&server);
9185 let err = client
9186 .get_structures()
9187 .await
9188 .expect_err("XML 404 must error out");
9189 let msg = err.to_string();
9190 assert!(!msg.contains("<?xml"), "XML leaked: {msg}");
9191 assert!(msg.contains("endpoint not found"));
9192 }
9193
9194 #[tokio::test]
9195 async fn test_structure_api_json_error_forwarded_verbatim() {
9196 let server = MockServer::start();
9200 server.mock(|when, then| {
9201 when.method(PUT).path("/rest/structure/2.0/forest/1/item");
9202 then.status(409).json_body(serde_json::json!({
9203 "errorMessages": ["Forest version conflict"],
9204 "errors": {}
9205 }));
9206 });
9207
9208 let client = create_client(&server);
9209 let err = client
9210 .add_structure_rows(
9211 1,
9212 AddStructureRowsInput {
9213 items: vec![StructureRowItem {
9214 item_id: "PROJ-1".into(),
9215 item_type: None,
9216 }],
9217 under: None,
9218 after: None,
9219 forest_version: Some(100),
9220 },
9221 )
9222 .await
9223 .expect_err("409 must error out");
9224 let msg = err.to_string();
9225 assert!(
9226 msg.contains("Forest version conflict"),
9227 "JSON dropped: {msg}"
9228 );
9229 }
9230
9231 #[tokio::test]
9232 async fn test_structure_api_200_with_html_body_does_not_leak() {
9233 let server = MockServer::start();
9237 let html = "<!DOCTYPE html><html><body>".to_string()
9238 + &"password=secret".repeat(50)
9239 + "</body></html>";
9240 server.mock(|when, then| {
9241 when.method(GET).path("/rest/structure/2.0/structure");
9242 then.status(200)
9243 .header("content-type", "text/html;charset=UTF-8")
9244 .body(html.clone());
9245 });
9246
9247 let client = create_client(&server);
9248 let err = client
9249 .get_structures()
9250 .await
9251 .expect_err("HTML body must fail to parse");
9252 let msg = err.to_string();
9253 assert!(
9254 !msg.contains("password=secret") && !msg.contains("<!DOCTYPE"),
9255 "HTML body leaked into parse-error message: {}",
9256 &msg[..msg.len().min(400)]
9257 );
9258 assert!(msg.contains("redacted"), "missing redaction marker: {msg}");
9259 }
9260
9261 #[tokio::test]
9268 async fn test_list_structures_for_metadata_maps_response() {
9269 let server = MockServer::start();
9270 server.mock(|when, then| {
9271 when.method(GET).path("/rest/structure/2.0/structure");
9272 then.status(200).json_body(serde_json::json!({
9273 "structures": [
9274 {"id": 1, "name": "Q1 Planning", "description": "Quarter 1 plan"},
9275 {"id": 2, "name": "Sprint Board"}
9276 ]
9277 }));
9278 });
9279
9280 let client = create_client(&server);
9281 let refs = client.list_structures_for_metadata().await.unwrap();
9282
9283 assert_eq!(refs.len(), 2);
9284 assert_eq!(refs[0].id, 1);
9285 assert_eq!(refs[0].name, "Q1 Planning");
9286 assert_eq!(refs[0].description.as_deref(), Some("Quarter 1 plan"));
9287 assert_eq!(refs[1].id, 2);
9288 assert_eq!(refs[1].description, None);
9289 }
9290
9291 #[tokio::test]
9292 async fn test_list_structures_for_metadata_returns_empty_on_plugin_missing() {
9293 let server = MockServer::start();
9298 server.mock(|when, then| {
9299 when.method(GET).path("/rest/structure/2.0/structure");
9300 then.status(404)
9301 .header("content-type", "text/html;charset=UTF-8")
9302 .body("<!DOCTYPE html><html><title>Oops</title></html>");
9303 });
9304
9305 let client = create_client(&server);
9306 let refs = client.list_structures_for_metadata().await.unwrap();
9307 assert!(refs.is_empty());
9308 }
9309
9310 #[tokio::test]
9311 async fn test_list_structures_for_metadata_returns_empty_on_200_empty_list() {
9312 let server = MockServer::start();
9313 server.mock(|when, then| {
9314 when.method(GET).path("/rest/structure/2.0/structure");
9315 then.status(200)
9316 .json_body(serde_json::json!({ "structures": [] }));
9317 });
9318
9319 let client = create_client(&server);
9320 let refs = client.list_structures_for_metadata().await.unwrap();
9321 assert!(refs.is_empty());
9322 }
9323
9324 #[tokio::test]
9325 async fn test_list_structures_for_metadata_propagates_401() {
9326 let server = MockServer::start();
9330 server.mock(|when, then| {
9331 when.method(GET).path("/rest/structure/2.0/structure");
9332 then.status(401).body("Unauthorized");
9333 });
9334
9335 let client = create_client(&server);
9336 let err = client
9337 .list_structures_for_metadata()
9338 .await
9339 .expect_err("401 must not be swallowed");
9340 assert!(matches!(err, Error::Unauthorized(_)), "got {err:?}");
9341 }
9342
9343 #[tokio::test]
9344 async fn test_list_structures_for_metadata_propagates_403() {
9345 let server = MockServer::start();
9346 server.mock(|when, then| {
9347 when.method(GET).path("/rest/structure/2.0/structure");
9348 then.status(403).body("Forbidden");
9349 });
9350
9351 let client = create_client(&server);
9352 let err = client
9353 .list_structures_for_metadata()
9354 .await
9355 .expect_err("403 must not be swallowed");
9356 assert!(matches!(err, Error::Forbidden(_)), "got {err:?}");
9357 }
9358
9359 #[tokio::test]
9364 async fn test_structure_generator_lifecycle() {
9365 let server = MockServer::start();
9366
9367 server.mock(|when, then| {
9369 when.method(GET)
9370 .path("/rest/structure/2.0/structure/1/generator");
9371 then.status(200).json_body(serde_json::json!({
9372 "generators": [
9373 { "id": "g1", "type": "jql", "spec": {"query": "project = PROJ"} }
9374 ]
9375 }));
9376 });
9377 server.mock(|when, then| {
9379 when.method(POST)
9380 .path("/rest/structure/2.0/structure/1/generator")
9381 .body_includes("\"type\":\"agile-board\"");
9382 then.status(200).json_body(serde_json::json!({
9383 "id": "g2",
9384 "type": "agile-board",
9385 "spec": {"boardId": 42}
9386 }));
9387 });
9388 server.mock(|when, then| {
9390 when.method(POST)
9391 .path("/rest/structure/2.0/structure/1/generator/g2/sync");
9392 then.status(200).json_body(serde_json::json!({}));
9393 });
9394
9395 let client = create_client(&server);
9396
9397 let list = client.get_structure_generators(1).await.unwrap();
9398 assert_eq!(list.items.len(), 1);
9399 assert_eq!(list.items[0].generator_type, "jql");
9400
9401 let added = client
9402 .add_structure_generator(devboy_core::AddStructureGeneratorInput {
9403 structure_id: 1,
9404 generator_type: "agile-board".into(),
9405 spec: serde_json::json!({"boardId": 42}),
9406 })
9407 .await
9408 .unwrap();
9409 assert_eq!(added.id, "g2");
9410
9411 client
9412 .sync_structure_generator(devboy_core::SyncStructureGeneratorInput {
9413 structure_id: 1,
9414 generator_id: "g2".into(),
9415 })
9416 .await
9417 .unwrap();
9418 }
9419
9420 #[tokio::test]
9425 async fn test_delete_structure() {
9426 let server = MockServer::start();
9427 server.mock(|when, then| {
9428 when.method(DELETE).path("/rest/structure/2.0/structure/7");
9429 then.status(204);
9430 });
9431
9432 let client = create_client(&server);
9433 client.delete_structure(7).await.unwrap();
9434 }
9435
9436 #[tokio::test]
9437 async fn test_structure_automation() {
9438 let server = MockServer::start();
9439
9440 server.mock(|when, then| {
9441 when.method(PUT)
9442 .path("/rest/structure/2.0/structure/5/automation")
9443 .body_includes("\"enabled\":true");
9444 then.status(200).json_body(serde_json::json!({}));
9445 });
9446 server.mock(|when, then| {
9447 when.method(POST)
9448 .path("/rest/structure/2.0/structure/5/automation/run");
9449 then.status(200).json_body(serde_json::json!({}));
9450 });
9451
9452 let client = create_client(&server);
9453 client
9455 .update_structure_automation(devboy_core::UpdateStructureAutomationInput {
9456 structure_id: 5,
9457 automation_id: None,
9458 config: serde_json::json!({"enabled": true}),
9459 })
9460 .await
9461 .unwrap();
9462 client.trigger_structure_automation(5).await.unwrap();
9463 }
9464
9465 #[tokio::test]
9468 async fn test_structure_automation_rule_scoped() {
9469 let server = MockServer::start();
9470 server.mock(|when, then| {
9471 when.method(PUT)
9472 .path("/rest/structure/2.0/structure/5/automation/rule-7")
9473 .body_includes("\"action\":\"move\"");
9474 then.status(200).json_body(serde_json::json!({}));
9475 });
9476
9477 let client = create_client(&server);
9478 client
9479 .update_structure_automation(devboy_core::UpdateStructureAutomationInput {
9480 structure_id: 5,
9481 automation_id: Some("rule-7".into()),
9482 config: serde_json::json!({"action": "move"}),
9483 })
9484 .await
9485 .unwrap();
9486 }
9487 }
9488
9489 mod agile_integration {
9493 use super::*;
9494 use httpmock::prelude::*;
9495
9496 fn token(s: &str) -> SecretString {
9497 SecretString::from(s.to_string())
9498 }
9499
9500 fn create_client(server: &MockServer) -> JiraClient {
9501 JiraClient::with_base_url(
9502 server.base_url(),
9503 "PROJ",
9504 "user@example.com",
9505 token("token"),
9506 false,
9507 )
9508 }
9509
9510 #[tokio::test]
9511 async fn test_get_board_sprints_active() {
9512 let server = MockServer::start();
9513 server.mock(|when, then| {
9514 when.method(GET)
9515 .path("/rest/agile/1.0/board/10/sprint")
9516 .query_param("state", "active");
9517 then.status(200).json_body(serde_json::json!({
9518 "isLast": true,
9519 "values": [
9520 {
9521 "id": 1,
9522 "name": "Sprint 1",
9523 "state": "active",
9524 "originBoardId": 10,
9525 "startDate": "2026-04-01T00:00:00.000Z"
9526 }
9527 ]
9528 }));
9529 });
9530
9531 let client = create_client(&server);
9532 let sprints = client
9533 .get_board_sprints(10, devboy_core::SprintState::Active)
9534 .await
9535 .unwrap();
9536 assert_eq!(sprints.items.len(), 1);
9537 assert_eq!(sprints.items[0].state, "active");
9538 assert_eq!(sprints.items[0].origin_board_id, Some(10));
9539 }
9540
9541 #[tokio::test]
9544 async fn test_get_board_sprints_walks_pagination() {
9545 let server = MockServer::start();
9546 server.mock(|when, then| {
9547 when.method(GET)
9548 .path("/rest/agile/1.0/board/10/sprint")
9549 .query_param("startAt", "0");
9550 then.status(200).json_body(serde_json::json!({
9551 "isLast": false,
9552 "values": [
9553 {"id": 1, "name": "S1", "state": "closed"},
9554 {"id": 2, "name": "S2", "state": "closed"}
9555 ]
9556 }));
9557 });
9558 server.mock(|when, then| {
9559 when.method(GET)
9560 .path("/rest/agile/1.0/board/10/sprint")
9561 .query_param("startAt", "2");
9562 then.status(200).json_body(serde_json::json!({
9563 "isLast": true,
9564 "values": [
9565 {"id": 3, "name": "S3", "state": "active"}
9566 ]
9567 }));
9568 });
9569
9570 let client = create_client(&server);
9571 let sprints = client
9572 .get_board_sprints(10, devboy_core::SprintState::All)
9573 .await
9574 .unwrap();
9575 assert_eq!(sprints.items.len(), 3);
9576 assert_eq!(sprints.items[2].name, "S3");
9577 }
9578
9579 #[tokio::test]
9580 async fn test_get_board_sprints_all_omits_state() {
9581 let server = MockServer::start();
9582 server.mock(|when, then| {
9583 when.method(GET)
9584 .path("/rest/agile/1.0/board/10/sprint")
9585 .is_true(|req| req.query_params().iter().all(|(k, _)| k != "state"));
9586 then.status(200)
9587 .json_body(serde_json::json!({"values": []}));
9588 });
9589
9590 let client = create_client(&server);
9591 let sprints = client
9592 .get_board_sprints(10, devboy_core::SprintState::All)
9593 .await
9594 .unwrap();
9595 assert_eq!(sprints.items.len(), 0);
9596 }
9597
9598 #[tokio::test]
9599 async fn test_assign_to_sprint_strips_jira_prefix() {
9600 let server = MockServer::start();
9601 server.mock(|when, then| {
9602 when.method(POST)
9603 .path("/rest/agile/1.0/sprint/42/issue")
9604 .body_includes("\"issues\":[\"PROJ-1\",\"PROJ-2\"]");
9605 then.status(204);
9606 });
9607
9608 let client = create_client(&server);
9609 client
9610 .assign_to_sprint(devboy_core::AssignToSprintInput {
9611 sprint_id: 42,
9612 issue_keys: vec!["jira#PROJ-1".to_string(), "PROJ-2".to_string()],
9613 })
9614 .await
9615 .unwrap();
9616 }
9617 }
9618
9619 mod versions_integration {
9623 use super::*;
9624 use devboy_core::{ListProjectVersionsParams, UpsertProjectVersionInput};
9625 use httpmock::prelude::*;
9626
9627 fn token(s: &str) -> SecretString {
9628 SecretString::from(s.to_string())
9629 }
9630
9631 fn create_client(server: &MockServer) -> JiraClient {
9632 JiraClient::with_base_url(
9633 server.base_url(),
9634 "PROJ",
9635 "user@example.com",
9636 token("pat-token"),
9637 false,
9638 )
9639 }
9640
9641 fn create_cloud_client(server: &MockServer) -> JiraClient {
9642 JiraClient::with_base_url(
9643 server.base_url(),
9644 "PROJ",
9645 "user@example.com",
9646 token("api-token"),
9647 true,
9648 )
9649 }
9650
9651 fn version_dto(
9652 id: &str,
9653 name: &str,
9654 release_date: Option<&str>,
9655 released: bool,
9656 archived: bool,
9657 ) -> serde_json::Value {
9658 let mut v = serde_json::json!({
9659 "id": id,
9660 "name": name,
9661 "project": "PROJ",
9662 "released": released,
9663 "archived": archived,
9664 });
9665 if let Some(d) = release_date {
9666 v["releaseDate"] = serde_json::json!(d);
9667 }
9668 v
9669 }
9670
9671 #[tokio::test]
9672 async fn list_project_versions_returns_rich_payload() {
9673 let server = MockServer::start();
9674 server.mock(|when, then| {
9675 when.method(GET).path("/project/PROJ/versions");
9676 then.status(200).json_body(serde_json::json!([
9677 {
9678 "id": "10001",
9679 "name": "1.0.0",
9680 "project": "PROJ",
9681 "description": "Initial release",
9682 "startDate": "2025-01-01",
9683 "releaseDate": "2025-02-01",
9684 "released": true,
9685 "archived": false,
9686 "overdue": false,
9687 },
9688 version_dto("10002", "2.0.0", Some("2026-04-01"), false, false),
9689 version_dto("10003", "0.9.0", Some("2024-06-01"), true, true),
9690 ]));
9691 });
9692
9693 let client = create_client(&server);
9694 let result = client
9695 .list_project_versions(ListProjectVersionsParams {
9696 project: "PROJ".into(),
9697 released: None,
9698 archived: None,
9699 limit: None,
9700 include_issue_count: false,
9701 })
9702 .await
9703 .unwrap();
9704
9705 assert_eq!(result.items.len(), 3);
9706 assert_eq!(result.items[0].name, "2.0.0");
9708 assert_eq!(result.items[1].name, "1.0.0");
9709 assert_eq!(result.items[2].name, "0.9.0");
9710 assert_eq!(
9711 result.items[1].description.as_deref(),
9712 Some("Initial release")
9713 );
9714 assert_eq!(result.items[1].source, "jira");
9715 }
9716
9717 #[tokio::test]
9718 async fn list_project_versions_filters_archived_and_released() {
9719 let server = MockServer::start();
9720 server.mock(|when, then| {
9721 when.method(GET).path("/project/PROJ/versions");
9722 then.status(200).json_body(serde_json::json!([
9723 version_dto("1", "current", Some("2026-04-01"), false, false),
9724 version_dto("2", "shipped", Some("2025-12-01"), true, false),
9725 version_dto("3", "old", Some("2024-01-01"), true, true),
9726 ]));
9727 });
9728
9729 let client = create_client(&server);
9730
9731 let unreleased_only = client
9732 .list_project_versions(ListProjectVersionsParams {
9733 project: "PROJ".into(),
9734 released: Some(false),
9735 archived: Some(false),
9736 limit: None,
9737 include_issue_count: false,
9738 })
9739 .await
9740 .unwrap();
9741 assert_eq!(unreleased_only.items.len(), 1);
9742 assert_eq!(unreleased_only.items[0].name, "current");
9743
9744 }
9747
9748 #[tokio::test]
9749 async fn list_project_versions_applies_limit_and_keeps_most_recent() {
9750 let server = MockServer::start();
9751 server.mock(|when, then| {
9752 when.method(GET).path("/project/PROJ/versions");
9753 then.status(200).json_body(serde_json::json!([
9754 version_dto("1", "v1", Some("2024-01-01"), true, false),
9755 version_dto("2", "v2", Some("2025-01-01"), true, false),
9756 version_dto("3", "v3", Some("2026-01-01"), true, false),
9757 version_dto("4", "v4", Some("2026-02-01"), false, false),
9758 ]));
9759 });
9760
9761 let client = create_client(&server);
9762 let result = client
9763 .list_project_versions(ListProjectVersionsParams {
9764 project: "PROJ".into(),
9765 released: None,
9766 archived: None,
9767 limit: Some(2),
9768 include_issue_count: false,
9769 })
9770 .await
9771 .unwrap();
9772 assert_eq!(result.items.len(), 2);
9773 assert_eq!(result.items[0].name, "v4");
9774 assert_eq!(result.items[1].name, "v3");
9775 }
9776
9777 #[tokio::test]
9778 async fn list_project_versions_passes_expand_query_on_cloud() {
9779 let server = MockServer::start();
9784 let mock = server.mock(|when, then| {
9785 when.method(GET)
9786 .path("/project/PROJ/versions")
9787 .query_param("expand", "issuesstatus");
9788 then.status(200).json_body(serde_json::json!([
9789 {
9790 "id": "1",
9791 "name": "v1",
9792 "released": false,
9793 "archived": false,
9794 "issuesStatusForFixVersion": {
9795 "unmapped": 0,
9796 "toDo": 5,
9797 "inProgress": 3,
9798 "done": 2
9799 }
9800 }
9801 ]));
9802 });
9803
9804 let client = create_cloud_client(&server);
9805 let result = client
9806 .list_project_versions(ListProjectVersionsParams {
9807 project: "PROJ".into(),
9808 released: None,
9809 archived: None,
9810 limit: None,
9811 include_issue_count: true,
9812 })
9813 .await
9814 .unwrap();
9815 mock.assert();
9816 assert_eq!(result.items.len(), 1);
9817 assert_eq!(result.items[0].issue_count, Some(10));
9818 }
9819
9820 #[tokio::test]
9821 async fn list_project_versions_omits_expand_on_self_hosted() {
9822 let server = MockServer::start();
9828 let bare_mock = server.mock(|when, then| {
9829 when.method(GET).path("/project/PROJ/versions");
9830 then.status(200).json_body(serde_json::json!([{
9831 "id": "1",
9832 "name": "v1",
9833 "released": false,
9834 "archived": false,
9835 "issuesUnresolvedCount": 4,
9836 }]));
9837 });
9838 let expanded_mock = server.mock(|when, then| {
9839 when.method(GET)
9840 .path("/project/PROJ/versions")
9841 .query_param("expand", "issuesstatus");
9842 then.status(500); });
9844
9845 let client = create_client(&server); let result = client
9847 .list_project_versions(ListProjectVersionsParams {
9848 project: "PROJ".into(),
9849 released: None,
9850 archived: None,
9851 limit: None,
9852 include_issue_count: true,
9853 })
9854 .await
9855 .unwrap();
9856 bare_mock.assert();
9857 expanded_mock.assert_calls(0);
9858 assert_eq!(result.items[0].issue_count, None);
9862 assert_eq!(result.items[0].unresolved_issue_count, Some(4));
9863 }
9864
9865 #[tokio::test]
9866 async fn list_project_versions_orders_unreleased_first_then_recent() {
9867 let server = MockServer::start();
9871 server.mock(|when, then| {
9872 when.method(GET).path("/project/PROJ/versions");
9873 then.status(200).json_body(serde_json::json!([
9874 version_dto("1", "9.10.0", Some("2026-04-01"), true, false),
9875 version_dto("2", "10.0.0", Some("2026-04-02"), false, false),
9876 version_dto("3", "next", None, false, false),
9877 version_dto("4", "1.0.0", Some("2024-01-01"), true, true),
9878 ]));
9879 });
9880
9881 let client = create_client(&server);
9882 let result = client
9883 .list_project_versions(ListProjectVersionsParams {
9884 project: "PROJ".into(),
9885 released: None,
9886 archived: None,
9887 limit: None,
9888 include_issue_count: false,
9889 })
9890 .await
9891 .unwrap();
9892 let names: Vec<_> = result.items.iter().map(|v| v.name.as_str()).collect();
9895 assert_eq!(names, vec!["next", "10.0.0", "9.10.0", "1.0.0"]);
9896 }
9897
9898 #[tokio::test]
9899 async fn list_project_versions_pagination_reflects_truncation() {
9900 let server = MockServer::start();
9903 server.mock(|when, then| {
9904 when.method(GET).path("/project/PROJ/versions");
9905 then.status(200).json_body(serde_json::json!([
9906 version_dto("1", "v1", Some("2024-01-01"), true, false),
9907 version_dto("2", "v2", Some("2025-01-01"), true, false),
9908 version_dto("3", "v3", Some("2026-01-01"), true, false),
9909 ]));
9910 });
9911
9912 let client = create_client(&server);
9913 let result = client
9914 .list_project_versions(ListProjectVersionsParams {
9915 project: "PROJ".into(),
9916 released: None,
9917 archived: None,
9918 limit: Some(2),
9919 include_issue_count: false,
9920 })
9921 .await
9922 .unwrap();
9923 let p = result.pagination.expect("pagination must be set");
9924 assert_eq!(p.total, Some(3));
9925 assert_eq!(p.limit, 2);
9926 assert!(p.has_more);
9927
9928 let server2 = MockServer::start();
9930 server2.mock(|when, then| {
9931 when.method(GET).path("/project/PROJ/versions");
9932 then.status(200).json_body(serde_json::json!([version_dto(
9933 "1",
9934 "v1",
9935 Some("2024-01-01"),
9936 true,
9937 false
9938 ),]));
9939 });
9940 let client2 = create_client(&server2);
9941 let result2 = client2
9942 .list_project_versions(ListProjectVersionsParams {
9943 project: "PROJ".into(),
9944 released: None,
9945 archived: None,
9946 limit: Some(20),
9947 include_issue_count: false,
9948 })
9949 .await
9950 .unwrap();
9951 let p2 = result2.pagination.unwrap();
9952 assert_eq!(p2.total, Some(1));
9953 assert!(!p2.has_more);
9954 }
9955
9956 #[test]
9957 fn compare_version_names_handles_semver_and_alpha() {
9958 use std::cmp::Ordering;
9959 assert_eq!(compare_version_names("10.0.0", "9.10.0"), Ordering::Greater);
9960 assert_eq!(compare_version_names("1.0.0", "1.0.0"), Ordering::Equal);
9961 assert_eq!(compare_version_names("1.0.10", "1.0.2"), Ordering::Greater);
9962 assert_eq!(compare_version_names("1.0.0-rc1", "1.0.0"), Ordering::Less);
9964 let _ = compare_version_names("Sprint 42 cleanup", "Sprint 9 cleanup");
9967 }
9968
9969 #[tokio::test]
9970 async fn upsert_project_version_creates_when_missing() {
9971 let server = MockServer::start();
9972 server.mock(|when, then| {
9974 when.method(GET).path("/project/PROJ/versions");
9975 then.status(200).json_body(serde_json::json!([version_dto(
9976 "99",
9977 "1.0.0",
9978 Some("2025-01-01"),
9979 true,
9980 false
9981 ),]));
9982 });
9983 server.mock(|when, then| {
9985 when.method(POST)
9986 .path("/version")
9987 .body_includes("\"name\":\"3.18.0\"")
9988 .body_includes("\"project\":\"PROJ\"")
9989 .body_includes("\"description\":\"Release notes draft\"");
9990 then.status(201).json_body(serde_json::json!({
9991 "id": "10500",
9992 "name": "3.18.0",
9993 "project": "PROJ",
9994 "description": "Release notes draft",
9995 "released": false,
9996 "archived": false,
9997 }));
9998 });
9999
10000 let client = create_client(&server);
10001 let v = client
10002 .upsert_project_version(UpsertProjectVersionInput {
10003 project: "PROJ".into(),
10004 name: "3.18.0".into(),
10005 description: Some("Release notes draft".into()),
10006 start_date: None,
10007 release_date: None,
10008 released: None,
10009 archived: None,
10010 })
10011 .await
10012 .unwrap();
10013 assert_eq!(v.id, "10500");
10014 assert_eq!(v.name, "3.18.0");
10015 assert_eq!(v.description.as_deref(), Some("Release notes draft"));
10016 }
10017
10018 #[tokio::test]
10019 async fn upsert_project_version_updates_when_present() {
10020 let server = MockServer::start();
10021 server.mock(|when, then| {
10023 when.method(GET).path("/project/PROJ/versions");
10024 then.status(200).json_body(serde_json::json!([version_dto(
10025 "777", "3.18.0", None, false, false
10026 ),]));
10027 });
10028 server.mock(|when, then| {
10030 when.method(PUT)
10031 .path("/version/777")
10032 .body_includes("\"description\":\"final notes\"")
10033 .body_includes("\"released\":true")
10034 .body_includes("\"releaseDate\":\"2026-05-01\"");
10035 then.status(200).json_body(serde_json::json!({
10036 "id": "777",
10037 "name": "3.18.0",
10038 "project": "PROJ",
10039 "description": "final notes",
10040 "releaseDate": "2026-05-01",
10041 "released": true,
10042 "archived": false,
10043 }));
10044 });
10045
10046 let client = create_client(&server);
10047 let v = client
10048 .upsert_project_version(UpsertProjectVersionInput {
10049 project: "PROJ".into(),
10050 name: "3.18.0".into(),
10051 description: Some("final notes".into()),
10052 start_date: None,
10053 release_date: Some("2026-05-01".into()),
10054 released: Some(true),
10055 archived: None,
10056 })
10057 .await
10058 .unwrap();
10059 assert_eq!(v.id, "777");
10060 assert!(v.released);
10061 assert_eq!(v.release_date.as_deref(), Some("2026-05-01"));
10062 }
10063
10064 #[tokio::test]
10065 async fn upsert_project_version_partial_update_sends_only_description() {
10066 let server = MockServer::start();
10067 server.mock(|when, then| {
10068 when.method(GET).path("/project/PROJ/versions");
10069 then.status(200).json_body(serde_json::json!([version_dto(
10070 "42",
10071 "2.0.0",
10072 Some("2026-01-01"),
10073 false,
10074 false
10075 ),]));
10076 });
10077 let put_mock = server.mock(|when, then| {
10080 when.method(PUT)
10081 .path("/version/42")
10082 .body_includes("\"description\":\"draft\"")
10083 .body_excludes("\"name\":")
10084 .body_excludes("\"released\":")
10085 .body_excludes("\"archived\":")
10086 .body_excludes("\"releaseDate\":");
10087 then.status(200).json_body(serde_json::json!({
10088 "id": "42",
10089 "name": "2.0.0",
10090 "project": "PROJ",
10091 "description": "draft",
10092 "releaseDate": "2026-01-01",
10093 "released": false,
10094 "archived": false,
10095 }));
10096 });
10097
10098 let client = create_client(&server);
10099 client
10100 .upsert_project_version(UpsertProjectVersionInput {
10101 project: "PROJ".into(),
10102 name: "2.0.0".into(),
10103 description: Some("draft".into()),
10104 start_date: None,
10105 release_date: None,
10106 released: None,
10107 archived: None,
10108 })
10109 .await
10110 .unwrap();
10111 put_mock.assert();
10112 }
10113
10114 #[tokio::test]
10115 async fn upsert_project_version_rejects_empty_name() {
10116 let server = MockServer::start();
10117 let client = create_client(&server);
10118 let err = client
10119 .upsert_project_version(UpsertProjectVersionInput {
10120 project: "PROJ".into(),
10121 name: " ".into(),
10122 ..Default::default()
10123 })
10124 .await
10125 .unwrap_err();
10126 assert!(matches!(err, devboy_core::Error::InvalidData(_)));
10127 }
10128
10129 #[tokio::test]
10130 async fn upsert_project_version_rejects_overlong_name() {
10131 let server = MockServer::start();
10135 let client = create_client(&server);
10136 let err = client
10137 .upsert_project_version(UpsertProjectVersionInput {
10138 project: "PROJ".into(),
10139 name: "x".repeat(256),
10140 ..Default::default()
10141 })
10142 .await
10143 .unwrap_err();
10144 assert!(matches!(err, devboy_core::Error::InvalidData(_)));
10145 }
10146
10147 #[test]
10148 fn duplicate_version_error_classifier_matches_jira_phrasing() {
10149 let dup1 = devboy_core::Error::Api {
10154 status: 400,
10155 message: "A version with this name already exists in this project.".into(),
10156 };
10157 let dup2 = devboy_core::Error::Api {
10158 status: 400,
10159 message: "Name is already used by another version in this project.".into(),
10160 };
10161 let unrelated = devboy_core::Error::Api {
10162 status: 400,
10163 message: "releaseDate is in the wrong format.".into(),
10164 };
10165 assert!(is_duplicate_version_error(&dup1));
10166 assert!(is_duplicate_version_error(&dup2));
10167 assert!(!is_duplicate_version_error(&unrelated));
10168 }
10169
10170 #[tokio::test]
10171 async fn upsert_project_version_propagates_non_duplicate_400() {
10172 let server = MockServer::start();
10175 server.mock(|when, then| {
10176 when.method(GET).path("/project/PROJ/versions");
10177 then.status(200).json_body(serde_json::json!([]));
10178 });
10179 server.mock(|when, then| {
10180 when.method(POST).path("/version");
10181 then.status(400).json_body(serde_json::json!({
10182 "errorMessages": ["releaseDate is in the wrong format."]
10183 }));
10184 });
10185 let client = create_client(&server);
10186 let err = client
10187 .upsert_project_version(UpsertProjectVersionInput {
10188 project: "PROJ".into(),
10189 name: "3.18.0".into(),
10190 release_date: Some("not-a-date".into()),
10191 ..Default::default()
10192 })
10193 .await
10194 .unwrap_err();
10195 assert!(matches!(err, devboy_core::Error::Api { .. }));
10197 }
10198
10199 #[tokio::test]
10200 async fn upsert_project_version_works_on_cloud_flavor() {
10201 let server = MockServer::start();
10205 server.mock(|when, then| {
10206 when.method(GET).path("/project/CLOUDPROJ/versions");
10207 then.status(200).json_body(serde_json::json!([]));
10208 });
10209 let post_mock = server.mock(|when, then| {
10210 when.method(POST)
10211 .path("/version")
10212 .body_includes("\"name\":\"4.0.0\"")
10213 .body_includes("\"project\":\"CLOUDPROJ\"");
10214 then.status(201).json_body(serde_json::json!({
10215 "id": "30001",
10216 "name": "4.0.0",
10217 "project": "CLOUDPROJ",
10218 "description": "Cloud release",
10219 "released": false,
10220 "archived": false,
10221 }));
10225 });
10226
10227 let client = create_cloud_client(&server);
10228 let v = client
10229 .upsert_project_version(UpsertProjectVersionInput {
10230 project: "CLOUDPROJ".into(),
10231 name: "4.0.0".into(),
10232 description: Some("Cloud release".into()),
10233 ..Default::default()
10234 })
10235 .await
10236 .unwrap();
10237 post_mock.assert();
10238 assert_eq!(v.id, "30001");
10239 assert_eq!(v.project, "CLOUDPROJ");
10240 assert_eq!(v.description.as_deref(), Some("Cloud release"));
10241 }
10242 }
10243}