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