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 display: None, },
1665 )
1666 })
1667 .collect();
1668 Issue {
1669 custom_fields,
1670 key: format!("jira#{}", issue.key),
1671 title: issue.fields.summary.clone().unwrap_or_default(),
1672 description: read_description(&issue.fields.description, flavor),
1673 state: map_state(issue.fields.status.as_ref()),
1674 status: None, status_category: None,
1676 source: "jira".to_string(),
1677 priority: map_priority(issue.fields.priority.as_ref()),
1678 labels: issue.fields.labels.clone(),
1679 author: map_user(issue.fields.reporter.as_ref()),
1680 assignees: issue
1681 .fields
1682 .assignee
1683 .as_ref()
1684 .map(|a| vec![map_user(Some(a)).unwrap()])
1685 .unwrap_or_default(),
1686 url: Some(format!("{}/browse/{}", instance_url, issue.key)),
1687 created_at: issue.fields.created.clone(),
1688 updated_at: issue.fields.updated.clone(),
1689 attachments_count: if issue.fields.attachment.is_empty() {
1690 None
1691 } else {
1692 Some(issue.fields.attachment.len() as u32)
1693 },
1694 parent: None,
1695 subtasks: vec![],
1696 }
1697}
1698
1699fn map_relations(issue: &JiraIssue, flavor: JiraFlavor, instance_url: &str) -> IssueRelations {
1700 let mut relations = IssueRelations::default();
1701
1702 if let Some(parent) = &issue.fields.parent {
1704 relations.parent = Some(map_issue(parent, flavor, instance_url));
1705 }
1706
1707 relations.subtasks = issue
1709 .fields
1710 .subtasks
1711 .iter()
1712 .map(|s| map_issue(s, flavor, instance_url))
1713 .collect();
1714
1715 for link in &issue.fields.issuelinks {
1717 let link_name = &link.link_type.name;
1718
1719 let outward_lower = link.link_type.outward.as_deref().map(str::to_lowercase);
1720 let inward_lower = link.link_type.inward.as_deref().map(str::to_lowercase);
1721
1722 if let Some(outward) = &link.outward_issue {
1723 let mapped = map_issue(outward, flavor, instance_url);
1724 let issue_link = IssueLink {
1725 issue: mapped,
1726 link_type: link_name.clone(),
1727 };
1728
1729 match outward_lower.as_deref() {
1730 Some(s) if s.contains("block") => relations.blocks.push(issue_link),
1731 Some(s) if s.contains("duplicate") => relations.duplicates.push(issue_link),
1732 _ => relations.related_to.push(issue_link),
1733 }
1734 }
1735
1736 if let Some(inward) = &link.inward_issue {
1737 let mapped = map_issue(inward, flavor, instance_url);
1738 let issue_link = IssueLink {
1739 issue: mapped,
1740 link_type: link_name.clone(),
1741 };
1742
1743 match inward_lower.as_deref() {
1744 Some(s) if s.contains("block") => relations.blocked_by.push(issue_link),
1745 Some(s) if s.contains("duplicate") => relations.duplicates.push(issue_link),
1746 _ => relations.related_to.push(issue_link),
1747 }
1748 }
1749 }
1750
1751 relations
1752}
1753
1754fn map_comment(jira_comment: &JiraComment, flavor: JiraFlavor) -> Comment {
1755 Comment {
1756 id: jira_comment.id.clone(),
1757 body: read_comment_body(&jira_comment.body, flavor),
1758 author: map_user(jira_comment.author.as_ref()),
1759 created_at: jira_comment.created.clone(),
1760 updated_at: jira_comment.updated.clone(),
1761 position: None,
1762 }
1763}
1764
1765fn map_jira_attachment(raw: &JiraAttachment) -> AssetMeta {
1767 let filename = raw
1772 .filename
1773 .clone()
1774 .unwrap_or_else(|| format!("attachment-{}", raw.id));
1775 let author = raw
1776 .author
1777 .as_ref()
1778 .and_then(|u| map_user(Some(u)))
1779 .map(|u| u.name.unwrap_or(u.username));
1780
1781 AssetMeta {
1782 id: raw.id.clone(),
1783 filename,
1784 mime_type: raw.mime_type.clone(),
1785 size: raw.size,
1786 url: raw.content.clone(),
1787 created_at: raw.created.clone(),
1788 author,
1789 cached: false,
1790 local_path: None,
1791 checksum_sha256: None,
1792 analysis: None,
1793 }
1794}
1795
1796fn priority_to_jira(priority: &str) -> String {
1798 match priority {
1799 "urgent" => "Highest".to_string(),
1800 "high" => "High".to_string(),
1801 "normal" => "Medium".to_string(),
1802 "low" => "Low".to_string(),
1803 other => other.to_string(),
1804 }
1805}
1806
1807fn escape_jql(value: &str) -> String {
1815 value.replace('\\', "\\\\").replace('"', "\\\"")
1816}
1817
1818fn merge_custom_fields_into_payload<T: serde::Serialize>(
1823 payload: T,
1824 custom_fields: &Option<serde_json::Value>,
1825) -> Result<(serde_json::Value, usize)> {
1826 let mut value = serde_json::to_value(payload)
1827 .map_err(|e| Error::InvalidData(format!("failed to serialize issue payload: {e}")))?;
1828 let mut merged_count = 0;
1829 if let Some(serde_json::Value::Object(cf)) = custom_fields
1830 && let Some(fields) = value.get_mut("fields").and_then(|f| f.as_object_mut())
1831 {
1832 for (k, v) in cf {
1833 if k.starts_with("customfield_") {
1834 fields.insert(k.clone(), v.clone());
1835 merged_count += 1;
1836 } else {
1837 tracing::warn!(field = %k, "Skipping non-custom field in customFields (expected customfield_* prefix)");
1838 }
1839 }
1840 }
1841 Ok((value, merged_count))
1842}
1843
1844fn has_project_clause(jql: &str) -> bool {
1848 let lower = jql.to_lowercase();
1849 let bytes = lower.as_bytes();
1850 let keyword = b"project";
1851 let mut in_quote = false;
1852 let mut i = 0;
1853
1854 while i < bytes.len() {
1855 if bytes[i] == b'\\' && in_quote && i + 1 < bytes.len() {
1857 i += 2; continue;
1859 }
1860 if bytes[i] == b'"' {
1861 in_quote = !in_quote;
1862 i += 1;
1863 continue;
1864 }
1865 if in_quote {
1866 i += 1;
1867 continue;
1868 }
1869
1870 if i + keyword.len() <= bytes.len() && &bytes[i..i + keyword.len()] == keyword {
1872 if i > 0 && (bytes[i - 1].is_ascii_alphanumeric() || bytes[i - 1] == b'_') {
1874 i += 1;
1875 continue;
1876 }
1877 let after = &lower[i + keyword.len()..];
1879 let trimmed = after.trim_start();
1880 if trimmed.starts_with("!=")
1881 || trimmed.starts_with("not in ")
1882 || trimmed.starts_with("not in(")
1883 || trimmed.starts_with('=')
1884 || trimmed.starts_with('~')
1885 || trimmed.starts_with("in ")
1886 || trimmed.starts_with("in(")
1887 {
1888 return true;
1889 }
1890 }
1891 i += 1;
1892 }
1893 false
1894}
1895
1896fn generic_status_to_category(status: &str) -> Option<&'static str> {
1899 match status.to_lowercase().as_str() {
1900 "closed" | "done" | "resolved" | "canceled" | "cancelled" => Some("done"),
1901 "open" | "new" | "todo" | "to do" | "reopen" | "reopened" => Some("new"),
1902 "in_progress" | "in progress" | "in-progress" => Some("indeterminate"),
1903 _ => None,
1904 }
1905}
1906
1907fn has_unquoted_keyword(jql: &str, keyword: &str) -> bool {
1909 let lower = jql.to_lowercase();
1910 let kw = keyword.to_lowercase();
1911 let kw_bytes = kw.as_bytes();
1912 let bytes = lower.as_bytes();
1913 let mut in_quote = false;
1914 let mut i = 0;
1915
1916 while i < bytes.len() {
1917 if bytes[i] == b'\\' && in_quote && i + 1 < bytes.len() {
1918 i += 2;
1919 continue;
1920 }
1921 if bytes[i] == b'"' {
1922 in_quote = !in_quote;
1923 i += 1;
1924 continue;
1925 }
1926 if !in_quote
1927 && i + kw_bytes.len() <= bytes.len()
1928 && bytes[i..i + kw_bytes.len()] == *kw_bytes
1929 {
1930 return true;
1931 }
1932 i += 1;
1933 }
1934 false
1935}
1936
1937fn instance_url_from_base(base_url: &str) -> String {
1939 base_url
1940 .trim_end_matches("/rest/api/3")
1941 .trim_end_matches("/rest/api/2")
1942 .to_string()
1943}
1944
1945fn build_forest_tree(
1955 rows: &[crate::types::JiraForestRow],
1956 depths: &[u32],
1957) -> Result<Vec<StructureNode>> {
1958 if rows.len() != depths.len() {
1959 return Err(Error::InvalidData(format!(
1960 "Structure forest response has {} rows but {} depths",
1961 rows.len(),
1962 depths.len()
1963 )));
1964 }
1965 let mut roots: Vec<StructureNode> = Vec::new();
1966 let mut stack: Vec<StructureNode> = Vec::new();
1967
1968 for (row, depth) in rows.iter().zip(depths.iter()) {
1969 let depth = *depth as usize;
1970 let node = StructureNode {
1971 row_id: row.id,
1972 item_id: row.item_id.clone(),
1973 item_type: row.item_type.clone(),
1974 children: Vec::new(),
1975 };
1976
1977 while stack.len() > depth {
1979 let child = stack.pop().expect("stack.len() > depth > 0");
1980 if let Some(parent) = stack.last_mut() {
1981 parent.children.push(child);
1982 } else {
1983 roots.push(child);
1984 }
1985 }
1986
1987 stack.push(node);
1988 }
1989
1990 while let Some(child) = stack.pop() {
1992 if let Some(parent) = stack.last_mut() {
1993 parent.children.push(child);
1994 } else {
1995 roots.push(child);
1996 }
1997 }
1998
1999 Ok(roots)
2000}
2001
2002fn map_structure_view(view: crate::types::JiraStructureView) -> StructureView {
2004 StructureView {
2005 id: view.id,
2006 name: view.name,
2007 structure_id: view.structure_id,
2008 columns: view
2009 .columns
2010 .into_iter()
2011 .map(|c| StructureViewColumn {
2012 id: c.id,
2013 field: c.field,
2014 formula: c.formula,
2015 width: c.width,
2016 })
2017 .collect(),
2018 group_by: view.group_by,
2019 sort_by: view.sort_by,
2020 filter: view.filter,
2021 }
2022}
2023
2024#[async_trait]
2029impl IssueProvider for JiraClient {
2030 async fn get_issues(&self, filter: IssueFilter) -> Result<ProviderResult<Issue>> {
2031 let limit = filter.limit.unwrap_or(20);
2032 if limit == 0 {
2033 return Ok(vec![].into());
2034 }
2035 let offset = filter.offset.unwrap_or(0);
2036
2037 let effective_project = filter
2040 .project_key
2041 .as_deref()
2042 .filter(|k| !k.trim().is_empty())
2043 .unwrap_or(&self.project_key);
2044
2045 let escaped_project = escape_jql(effective_project);
2047 let jql = if let Some(native) = &filter.native_query
2048 && !native.trim().is_empty()
2049 {
2050 if has_project_clause(native) {
2053 native.clone()
2054 } else if native.trim_start().to_lowercase().starts_with("order by") {
2055 format!("project = \"{}\" {}", escaped_project, native)
2056 } else {
2057 format!("project = \"{}\" AND {}", escaped_project, native)
2058 }
2059 } else {
2060 let mut jql_parts: Vec<String> = vec![format!("project = \"{}\"", escaped_project)];
2061
2062 if let Some(state) = &filter.state {
2064 match state.as_str() {
2065 "open" | "opened" => {
2066 jql_parts.push("statusCategory != Done".to_string());
2067 }
2068 "closed" | "done" => {
2069 jql_parts.push("statusCategory = Done".to_string());
2070 }
2071 "all" => {} other => {
2073 jql_parts.push(format!("status = \"{}\"", escape_jql(other)));
2075 }
2076 }
2077 }
2078
2079 if let Some(search) = &filter.search {
2080 jql_parts.push(format!("summary ~ \"{}\"", escape_jql(search)));
2081 }
2082
2083 if let Some(labels) = &filter.labels {
2084 for label in labels {
2085 jql_parts.push(format!("labels = \"{}\"", escape_jql(label)));
2086 }
2087 }
2088
2089 if let Some(assignee) = &filter.assignee {
2090 jql_parts.push(format!("assignee = \"{}\"", escape_jql(assignee)));
2091 }
2092
2093 jql_parts.join(" AND ")
2094 };
2095
2096 let order_by = match filter.sort_by.as_deref() {
2098 Some("created_at" | "created") => "created",
2099 Some("priority") => "priority",
2100 _ => "updated",
2101 };
2102 let order = match filter.sort_order.as_deref() {
2103 Some("asc") => "ASC",
2104 _ => "DESC",
2105 };
2106 let has_order_by = has_unquoted_keyword(&jql, "order by");
2107 let jql_with_order = if has_order_by {
2108 jql
2109 } else {
2110 format!("{} ORDER BY {} {}", jql, order_by, order)
2111 };
2112
2113 let instance_url = &self.instance_url;
2114
2115 match self.flavor {
2116 JiraFlavor::Cloud => {
2117 let url = format!("{}/search/jql", self.base_url);
2119
2120 let mut all_issues: Vec<Issue> = Vec::new();
2121 let mut next_page_token: Option<String> = None;
2122 let total_needed = offset.saturating_add(limit);
2123 let mut fetched_count = 0u32;
2124
2125 let fields = "summary,description,status,priority,assignee,reporter,labels,created,updated,parent,subtasks,issuetype,*navigable".to_string();
2129
2130 loop {
2131 let mut params: Vec<(&str, String)> = vec![
2132 ("jql", jql_with_order.clone()),
2133 ("maxResults", std::cmp::min(limit, 50).to_string()),
2134 ("fields", fields.clone()),
2135 ];
2136
2137 if let Some(token) = &next_page_token {
2138 params.push(("nextPageToken", token.clone()));
2139 }
2140
2141 let param_refs: Vec<(&str, &str)> =
2142 params.iter().map(|(k, v)| (*k, v.as_str())).collect();
2143
2144 debug!(url = url, params = ?param_refs, "Jira Cloud search");
2145
2146 let response = self
2147 .request(reqwest::Method::GET, &url)
2148 .query(¶m_refs)
2149 .send()
2150 .await
2151 .map_err(|e| Error::Http(e.to_string()))?;
2152
2153 let search_resp: JiraCloudSearchResponse =
2154 self.handle_response(response).await?;
2155
2156 let page_len = search_resp.issues.len() as u32;
2157 for issue in &search_resp.issues {
2158 if fetched_count >= offset && all_issues.len() < limit as usize {
2159 let mut mapped = map_issue(issue, self.flavor, instance_url);
2160 if mapped.description.as_deref().is_none_or(str::is_empty)
2161 && let Some(epic_desc) =
2162 self.read_epic_description_fallback(issue).await?
2163 {
2164 mapped.description = Some(epic_desc);
2165 }
2166 all_issues.push(mapped);
2167 }
2168 fetched_count += 1;
2169 }
2170
2171 if all_issues.len() >= limit as usize {
2172 break;
2173 }
2174
2175 match search_resp.next_page_token {
2176 Some(token) if page_len > 0 && fetched_count < total_needed => {
2177 next_page_token = Some(token);
2178 }
2179 _ => break,
2180 }
2181 }
2182
2183 let mut result = ProviderResult::new(all_issues);
2184 result.pagination = Some(devboy_core::Pagination {
2185 offset,
2186 limit,
2187 total: None, has_more: next_page_token.is_some(),
2189 next_cursor: next_page_token,
2190 });
2191 result.sort_info = Some(devboy_core::SortInfo {
2192 sort_by: Some(order_by.into()),
2193 sort_order: match order {
2194 "ASC" => devboy_core::SortOrder::Asc,
2195 _ => devboy_core::SortOrder::Desc,
2196 },
2197 available_sorts: vec!["created".into(), "updated".into(), "priority".into()],
2198 });
2199 Ok(result)
2200 }
2201 JiraFlavor::SelfHosted => {
2202 let url = format!("{}/search", self.base_url);
2204
2205 let params: Vec<(&str, String)> = vec![
2206 ("jql", jql_with_order),
2207 ("startAt", offset.to_string()),
2208 ("maxResults", limit.to_string()),
2209 ("fields", "summary,description,status,priority,assignee,reporter,labels,created,updated,parent,subtasks,issuetype,*navigable".to_string()),
2210 ];
2211
2212 let param_refs: Vec<(&str, &str)> =
2213 params.iter().map(|(k, v)| (*k, v.as_str())).collect();
2214
2215 debug!(url = url, params = ?param_refs, "Jira Self-Hosted search");
2216
2217 let response = self
2218 .request(reqwest::Method::GET, &url)
2219 .query(¶m_refs)
2220 .send()
2221 .await
2222 .map_err(|e| Error::Http(e.to_string()))?;
2223
2224 let search_resp: JiraSearchResponse = self.handle_response(response).await?;
2225
2226 let total = search_resp.total;
2227 let has_more = match (total, search_resp.start_at, search_resp.max_results) {
2228 (Some(t), Some(s), Some(m)) => s + m < t,
2229 _ => false,
2230 };
2231
2232 let mut issues: Vec<Issue> = Vec::with_capacity(search_resp.issues.len());
2233 for raw in &search_resp.issues {
2234 let mut mapped = map_issue(raw, self.flavor, instance_url);
2235 if mapped.description.as_deref().is_none_or(str::is_empty)
2236 && let Some(epic_desc) = self.read_epic_description_fallback(raw).await?
2237 {
2238 mapped.description = Some(epic_desc);
2239 }
2240 issues.push(mapped);
2241 }
2242
2243 let mut result = ProviderResult::new(issues);
2244 result.pagination = Some(devboy_core::Pagination {
2245 offset,
2246 limit,
2247 total,
2248 has_more,
2249 next_cursor: None,
2250 });
2251 result.sort_info = Some(devboy_core::SortInfo {
2252 sort_by: Some(order_by.into()),
2253 sort_order: match order {
2254 "ASC" => devboy_core::SortOrder::Asc,
2255 _ => devboy_core::SortOrder::Desc,
2256 },
2257 available_sorts: vec!["created".into(), "updated".into(), "priority".into()],
2258 });
2259 Ok(result)
2260 }
2261 }
2262 }
2263
2264 async fn get_issue(&self, key: &str) -> Result<Issue> {
2265 let jira_key = parse_jira_key(key);
2266 let url = format!("{}/issue/{}", self.base_url, jira_key);
2267 let issue: JiraIssue = self.get(&url).await?;
2268 let mut mapped = map_issue(&issue, self.flavor, &self.instance_url);
2269 if mapped.description.as_deref().is_none_or(str::is_empty)
2270 && let Some(epic_desc) = self.read_epic_description_fallback(&issue).await?
2271 {
2272 mapped.description = Some(epic_desc);
2273 }
2274 Ok(mapped)
2275 }
2276
2277 async fn create_issue(&self, input: CreateIssueInput) -> Result<Issue> {
2278 let description = input.description.map(|d| {
2279 if self.flavor == JiraFlavor::Cloud {
2280 text_to_adf(&d)
2281 } else {
2282 serde_json::Value::String(d)
2283 }
2284 });
2285
2286 let labels = if input.labels.is_empty() {
2287 None
2288 } else {
2289 Some(input.labels)
2290 };
2291 let has_labels = labels.is_some();
2292
2293 let priority = input.priority.as_deref().map(|p| PriorityName {
2294 name: priority_to_jira(p),
2295 });
2296
2297 let assignee = input.assignees.first().map(|a| {
2298 if self.flavor == JiraFlavor::Cloud {
2299 serde_json::json!({ "accountId": a })
2300 } else {
2301 serde_json::json!({ "name": a })
2302 }
2303 });
2304
2305 let effective_project = input.project_id.unwrap_or_else(|| self.project_key.clone());
2306 let effective_issue_type = input.issue_type.unwrap_or_else(|| "Task".to_string());
2307
2308 let components = if input.components.is_empty() {
2310 None
2311 } else {
2312 Some(
2313 input
2314 .components
2315 .into_iter()
2316 .map(|name| crate::types::ComponentRef { name })
2317 .collect(),
2318 )
2319 };
2320
2321 let fix_versions = if input.fix_versions.is_empty() {
2322 None
2323 } else {
2324 Some(
2325 input
2326 .fix_versions
2327 .into_iter()
2328 .map(|name| crate::types::VersionRef { name })
2329 .collect(),
2330 )
2331 };
2332
2333 let payload = CreateIssuePayload {
2334 fields: CreateIssueFields {
2335 project: ProjectKey {
2336 key: effective_project,
2337 },
2338 summary: input.title,
2339 issuetype: IssueType {
2340 name: effective_issue_type,
2341 },
2342 description,
2343 labels,
2344 priority,
2345 assignee,
2346 components,
2347 fix_versions,
2348 parent: input.parent.map(|key| crate::types::IssueKeyRef { key }),
2349 },
2350 };
2351
2352 let (mut payload, _) = merge_custom_fields_into_payload(payload, &input.custom_fields)?;
2353
2354 self.inject_well_known_customfields(&mut payload, &input.epic_key, &input.epic_name)
2355 .await?;
2356
2357 let sprint_id = input.sprint_id;
2364 let url = format!("{}/issue", self.base_url);
2365 let create_result: std::result::Result<CreateIssueResponse, Error> =
2366 self.post(&url, &payload).await;
2367
2368 let create_resp = match create_result {
2369 Ok(resp) => resp,
2370 Err(e)
2371 if has_labels
2372 && e.to_string().contains("labels")
2373 && e.to_string().contains("not on the appropriate screen") =>
2374 {
2375 tracing::warn!("Create issue failed with labels, retrying without: {e}");
2379 let saved_labels = payload
2380 .get_mut("fields")
2381 .and_then(|f| f.as_object_mut())
2382 .and_then(|f| f.remove("labels"));
2383 let resp: CreateIssueResponse = self.post(&url, &payload).await?;
2384
2385 if let Some(lbl_value) = saved_labels
2387 && let Ok(lbl) = serde_json::from_value::<Vec<String>>(lbl_value)
2388 {
2389 let update = UpdateIssueInput {
2390 labels: Some(lbl),
2391 ..Default::default()
2392 };
2393 if let Err(e) = self.update_issue(&resp.key, update).await {
2394 tracing::warn!("Failed to set labels after create: {e}");
2395 }
2396 }
2397 resp
2398 }
2399 Err(e) => return Err(e),
2400 };
2401
2402 if let Some(sid) = sprint_id
2404 && sid > 0
2405 {
2406 self.assign_to_sprint(devboy_core::AssignToSprintInput {
2407 sprint_id: sid as u64,
2408 issue_keys: vec![create_resp.key.clone()],
2409 })
2410 .await?;
2411 }
2412
2413 self.get_issue(&create_resp.key).await
2415 }
2416
2417 async fn update_issue(&self, key: &str, input: UpdateIssueInput) -> Result<Issue> {
2418 let jira_key = parse_jira_key(key);
2419
2420 let description = input.description.map(|d| {
2421 if self.flavor == JiraFlavor::Cloud {
2422 text_to_adf(&d)
2423 } else {
2424 serde_json::Value::String(d)
2425 }
2426 });
2427
2428 let priority = input.priority.as_deref().map(|p| PriorityName {
2429 name: priority_to_jira(p),
2430 });
2431
2432 let assignee = input.assignees.as_ref().and_then(|a| {
2433 a.first().map(|username| {
2434 if self.flavor == JiraFlavor::Cloud {
2435 serde_json::json!({ "accountId": username })
2436 } else {
2437 serde_json::json!({ "name": username })
2438 }
2439 })
2440 });
2441
2442 let labels = input.labels;
2443
2444 let components = input.components.map(|ids| {
2446 ids.into_iter()
2447 .map(|name| crate::types::ComponentRef { name })
2448 .collect()
2449 });
2450 let has_components = components.is_some();
2451
2452 let fix_versions = input.fix_versions.map(|names| {
2454 names
2455 .into_iter()
2456 .map(|name| crate::types::VersionRef { name })
2457 .collect()
2458 });
2459 let has_fix_versions = fix_versions.is_some();
2460
2461 let fields = UpdateIssueFields {
2462 summary: input.title,
2463 description,
2464 labels,
2465 priority,
2466 assignee,
2467 components,
2468 fix_versions,
2469 };
2470
2471 let has_custom_fields = input.custom_fields.as_ref().is_some_and(|v| {
2472 v.as_object()
2473 .is_some_and(|obj| obj.keys().any(|k| k.starts_with("customfield_")))
2474 });
2475
2476 let sprint_id = input.sprint_id;
2480 let has_epic_fields = input.epic_key.is_some() || input.epic_name.is_some();
2481
2482 let has_field_updates = fields.summary.is_some()
2484 || fields.description.is_some()
2485 || fields.labels.is_some()
2486 || fields.priority.is_some()
2487 || fields.assignee.is_some()
2488 || has_components
2489 || has_fix_versions
2490 || has_custom_fields
2491 || has_epic_fields;
2492
2493 if has_field_updates {
2494 let url = format!("{}/issue/{}", self.base_url, jira_key);
2495 let payload = UpdateIssuePayload { fields };
2496 let (mut payload, _) = merge_custom_fields_into_payload(payload, &input.custom_fields)?;
2497 self.inject_well_known_customfields(&mut payload, &input.epic_key, &input.epic_name)
2498 .await?;
2499 self.put(&url, &payload).await?;
2500 }
2501
2502 if let Some(sid) = sprint_id
2503 && sid > 0
2504 {
2505 self.assign_to_sprint(devboy_core::AssignToSprintInput {
2506 sprint_id: sid as u64,
2507 issue_keys: vec![jira_key.to_string()],
2508 })
2509 .await?;
2510 }
2511
2512 if let Some(state) = &input.state {
2514 self.transition_issue(jira_key, state).await?;
2515 }
2516
2517 self.get_issue(jira_key).await
2519 }
2520
2521 async fn get_comments(&self, issue_key: &str) -> Result<ProviderResult<Comment>> {
2522 let jira_key = parse_jira_key(issue_key);
2523 let url = format!("{}/issue/{}/comment", self.base_url, jira_key);
2524 let response: JiraCommentsResponse = self.get(&url).await?;
2525 Ok(response
2526 .comments
2527 .iter()
2528 .map(|c| map_comment(c, self.flavor))
2529 .collect::<Vec<_>>()
2530 .into())
2531 }
2532
2533 async fn add_comment(&self, issue_key: &str, body: &str) -> Result<Comment> {
2534 let jira_key = parse_jira_key(issue_key);
2535 let comment_body = if self.flavor == JiraFlavor::Cloud {
2536 text_to_adf(body)
2537 } else {
2538 serde_json::Value::String(body.to_string())
2539 };
2540
2541 let payload = AddCommentPayload { body: comment_body };
2542
2543 let url = format!("{}/issue/{}/comment", self.base_url, jira_key);
2544 let jira_comment: JiraComment = self.post(&url, &payload).await?;
2545 Ok(map_comment(&jira_comment, self.flavor))
2546 }
2547
2548 async fn get_statuses(&self) -> Result<ProviderResult<IssueStatus>> {
2549 let project_statuses = self.get_project_statuses().await?;
2550
2551 let statuses: Vec<IssueStatus> = project_statuses
2552 .iter()
2553 .enumerate()
2554 .map(|(idx, s)| {
2555 let category = s
2556 .status_category
2557 .as_ref()
2558 .map(|sc| match sc.key.as_str() {
2559 "new" => "open".to_string(),
2560 "indeterminate" => "in_progress".to_string(),
2561 "done" => "done".to_string(),
2562 other => other.to_string(),
2563 })
2564 .unwrap_or_else(|| "custom".to_string());
2565
2566 IssueStatus {
2567 id: s.id.clone().unwrap_or_else(|| s.name.clone()),
2568 name: s.name.clone(),
2569 category,
2570 color: None,
2571 order: Some(idx as u32),
2572 }
2573 })
2574 .collect();
2575
2576 Ok(statuses.into())
2577 }
2578
2579 async fn get_users(&self, options: GetUsersOptions) -> Result<ProviderResult<User>> {
2580 let start_at = options.start_at.unwrap_or(0);
2581 let max_results = options.max_results.unwrap_or(50);
2582
2583 let url = if let Some(ref project_key) = options.project_key {
2585 format!(
2586 "{}/user/assignable/search?project={}&startAt={}&maxResults={}",
2587 self.base_url, project_key, start_at, max_results
2588 )
2589 } else {
2590 let query = options.search.as_deref().unwrap_or("");
2591 match self.flavor {
2592 JiraFlavor::Cloud => format!(
2593 "{}/user/search?query={}&startAt={}&maxResults={}",
2594 self.base_url, query, start_at, max_results
2595 ),
2596 JiraFlavor::SelfHosted => format!(
2597 "{}/user/search?username={}&startAt={}&maxResults={}",
2598 self.base_url,
2599 if query.is_empty() { "." } else { query },
2600 start_at,
2601 max_results
2602 ),
2603 }
2604 };
2605
2606 let jira_users: Vec<JiraUser> = self.get(&url).await?;
2607
2608 let users: Vec<User> = jira_users
2609 .iter()
2610 .map(|u| map_user(Some(u)).unwrap_or_default())
2611 .collect();
2612
2613 Ok(users.into())
2614 }
2615
2616 async fn link_issues(&self, source_key: &str, target_key: &str, link_type: &str) -> Result<()> {
2617 let source_jira_key = parse_jira_key(source_key).to_string();
2618 let target_jira_key = parse_jira_key(target_key).to_string();
2619
2620 let link_type_name = match link_type {
2629 "blocks" => "Blocks",
2630 "blocked_by" => "Blocks",
2631 "relates_to" => "Relates",
2632 "duplicates" | "duplicated_by" => "Duplicate",
2633 "clones" | "cloned_by" => "Cloners",
2634 "causes" | "caused_by" => "Causes",
2635 "implements" | "implemented_by" => "Implements",
2636 "created_by" | "creates" => "Created By",
2637 other => other,
2638 };
2639
2640 let reversed = matches!(
2647 link_type,
2648 "blocked_by"
2649 | "duplicated_by"
2650 | "cloned_by"
2651 | "caused_by"
2652 | "implemented_by"
2653 | "created_by"
2654 );
2655 let (outward_key, inward_key) = if reversed {
2656 (target_jira_key, source_jira_key)
2657 } else {
2658 (source_jira_key, target_jira_key)
2659 };
2660
2661 let payload = CreateIssueLinkPayload {
2662 link_type: IssueLinkTypeName {
2663 name: link_type_name.to_string(),
2664 },
2665 outward_issue: IssueKeyRef { key: outward_key },
2666 inward_issue: IssueKeyRef { key: inward_key },
2667 };
2668
2669 let url = format!("{}/issueLink", self.base_url);
2670 self.post_no_content(&url, &payload).await?;
2671
2672 Ok(())
2673 }
2674
2675 async fn get_issue_relations(&self, issue_key: &str) -> Result<IssueRelations> {
2676 let jira_key = parse_jira_key(issue_key);
2677 let url = format!(
2680 "{}/issue/{}?fields=parent,subtasks,issuelinks,summary,status,priority,issuetype,*navigable",
2681 self.base_url, jira_key
2682 );
2683 let issue: JiraIssue = self.get(&url).await?;
2684 let mut relations = map_relations(&issue, self.flavor, &self.instance_url);
2685 if relations.parent.is_none()
2689 && relations.epic_key.is_none()
2690 && let Some(epic_key) = self.read_epic_link_key(&issue).await?
2691 {
2692 relations.epic_key = Some(epic_key);
2693 }
2694 Ok(relations)
2695 }
2696
2697 async fn upload_attachment(
2698 &self,
2699 issue_key: &str,
2700 filename: &str,
2701 data: &[u8],
2702 ) -> Result<String> {
2703 let jira_key = parse_jira_key(issue_key);
2704 let url = format!("{}/issue/{}/attachments", self.base_url, jira_key);
2705
2706 let part = reqwest::multipart::Part::bytes(data.to_vec())
2707 .file_name(filename.to_string())
2708 .mime_str("application/octet-stream")
2709 .map_err(|e| Error::Http(format!("failed to build multipart: {e}")))?;
2710 let form = reqwest::multipart::Form::new().part("file", part);
2711
2712 let response = self
2716 .request_raw(reqwest::Method::POST, &url)
2717 .header("X-Atlassian-Token", "no-check")
2720 .multipart(form)
2721 .send()
2722 .await
2723 .map_err(|e| Error::Http(e.to_string()))?;
2724
2725 let status = response.status();
2726 if !status.is_success() {
2727 let message = response.text().await.unwrap_or_default();
2728 return Err(Error::from_status(status.as_u16(), message));
2729 }
2730
2731 let attachments: Vec<JiraAttachment> = response
2733 .json()
2734 .await
2735 .map_err(|e| Error::InvalidData(format!("failed to parse attachment response: {e}")))?;
2736 let url = attachments
2737 .into_iter()
2738 .next()
2739 .and_then(|a| a.content)
2740 .filter(|u| !u.is_empty())
2741 .ok_or_else(|| {
2742 Error::InvalidData(
2743 "Jira upload returned no attachment with a content URL".to_string(),
2744 )
2745 })?;
2746 Ok(url)
2747 }
2748
2749 async fn get_issue_attachments(&self, issue_key: &str) -> Result<Vec<AssetMeta>> {
2750 let jira_key = parse_jira_key(issue_key);
2751 let url = format!("{}/issue/{}?fields=attachment", self.base_url, jira_key);
2752 let issue: JiraIssue = self.get(&url).await?;
2753 Ok(issue
2754 .fields
2755 .attachment
2756 .iter()
2757 .map(map_jira_attachment)
2758 .collect())
2759 }
2760
2761 async fn download_attachment(&self, _issue_key: &str, asset_id: &str) -> Result<Vec<u8>> {
2762 let url = match self.flavor {
2766 JiraFlavor::Cloud => {
2767 format!("{}/attachment/content/{}", self.base_url, asset_id)
2768 }
2769 JiraFlavor::SelfHosted => {
2770 let meta_url = format!("{}/attachment/{}", self.base_url, asset_id);
2771 let meta: serde_json::Value = self.get(&meta_url).await?;
2772 meta.get("content")
2773 .and_then(|v| v.as_str())
2774 .ok_or_else(|| {
2775 Error::InvalidData(format!(
2776 "attachment {asset_id} metadata has no content URL"
2777 ))
2778 })?
2779 .to_string()
2780 }
2781 };
2782 let response = self
2783 .request(reqwest::Method::GET, &url)
2784 .send()
2785 .await
2786 .map_err(|e| Error::Http(e.to_string()))?;
2787
2788 let status = response.status();
2789 if !status.is_success() {
2790 let message = response.text().await.unwrap_or_default();
2791 return Err(Error::from_status(status.as_u16(), message));
2792 }
2793
2794 let bytes = response
2795 .bytes()
2796 .await
2797 .map_err(|e| Error::Http(format!("failed to read attachment bytes: {e}")))?;
2798 Ok(bytes.to_vec())
2799 }
2800
2801 async fn delete_attachment(&self, _issue_key: &str, asset_id: &str) -> Result<()> {
2802 let url = format!("{}/attachment/{}", self.base_url, asset_id);
2804 let response = self
2805 .request(reqwest::Method::DELETE, &url)
2806 .send()
2807 .await
2808 .map_err(|e| Error::Http(e.to_string()))?;
2809
2810 let status = response.status();
2811 if !status.is_success() {
2812 let message = response.text().await.unwrap_or_default();
2813 return Err(Error::from_status(status.as_u16(), message));
2814 }
2815 Ok(())
2816 }
2817
2818 fn asset_capabilities(&self) -> AssetCapabilities {
2819 AssetCapabilities {
2821 issue: ContextCapabilities {
2822 upload: true,
2823 download: true,
2824 delete: true,
2825 list: true,
2826 max_file_size: None,
2827 allowed_types: Vec::new(),
2828 },
2829 ..Default::default()
2830 }
2831 }
2832
2833 async fn get_structures(&self) -> Result<ProviderResult<Structure>> {
2836 let resp: JiraStructureListResponse = self.structure_get("/structure").await?;
2837 let items: Vec<Structure> = resp
2838 .structures
2839 .into_iter()
2840 .map(|s| Structure {
2841 id: s.id,
2842 name: s.name,
2843 description: s.description,
2844 })
2845 .collect();
2846 Ok(items.into())
2847 }
2848
2849 async fn get_structure_forest(
2850 &self,
2851 structure_id: u64,
2852 options: GetForestOptions,
2853 ) -> Result<StructureForest> {
2854 let mut spec = serde_json::Map::new();
2855 if let Some(offset) = options.offset {
2856 spec.insert("offset".into(), serde_json::json!(offset));
2857 }
2858 if let Some(limit) = options.limit {
2859 spec.insert("limit".into(), serde_json::json!(limit));
2860 }
2861
2862 let resp: JiraForestResponse = self
2863 .structure_post(
2864 &format!("/forest/{}/spec", structure_id),
2865 &serde_json::Value::Object(spec),
2866 )
2867 .await?;
2868
2869 let tree = build_forest_tree(&resp.rows, &resp.depths)?;
2870
2871 Ok(StructureForest {
2872 version: resp.version,
2873 structure_id,
2874 tree,
2875 total_count: resp.total_count,
2876 })
2877 }
2878
2879 async fn add_structure_rows(
2880 &self,
2881 structure_id: u64,
2882 input: AddStructureRowsInput,
2883 ) -> Result<ForestModifyResult> {
2884 let mut payload = serde_json::json!({
2885 "rows": input.items.iter().map(|i| {
2886 let mut row = serde_json::json!({"itemId": i.item_id});
2887 if let Some(ref t) = i.item_type {
2888 row["itemType"] = serde_json::json!(t);
2889 }
2890 row
2891 }).collect::<Vec<_>>()
2892 });
2893 if let Some(under) = input.under {
2894 payload["under"] = serde_json::json!(under);
2895 }
2896 if let Some(after) = input.after {
2897 payload["after"] = serde_json::json!(after);
2898 }
2899 if let Some(version) = input.forest_version {
2900 payload["forestVersion"] = serde_json::json!(version);
2901 }
2902
2903 let resp: JiraForestModifyResponse = self
2904 .structure_put(&format!("/forest/{}/item", structure_id), &payload)
2905 .await
2906 .map_err(|e| {
2907 if matches!(&e, Error::Api { status, .. } if *status == 409) {
2908 Error::Api {
2909 status: 409,
2910 message: "Forest version conflict. The structure was modified concurrently. Retry with the latest version.".to_string(),
2911 }
2912 } else {
2913 e
2914 }
2915 })?;
2916
2917 Ok(ForestModifyResult {
2918 version: resp.version,
2919 affected_count: input.items.len(),
2920 })
2921 }
2922
2923 async fn move_structure_rows(
2924 &self,
2925 structure_id: u64,
2926 input: MoveStructureRowsInput,
2927 ) -> Result<ForestModifyResult> {
2928 let mut payload = serde_json::json!({
2929 "rowIds": input.row_ids
2930 });
2931 if let Some(under) = input.under {
2932 payload["under"] = serde_json::json!(under);
2933 }
2934 if let Some(after) = input.after {
2935 payload["after"] = serde_json::json!(after);
2936 }
2937 if let Some(version) = input.forest_version {
2938 payload["forestVersion"] = serde_json::json!(version);
2939 }
2940
2941 let resp: JiraForestModifyResponse = self
2942 .structure_post(&format!("/forest/{}/move", structure_id), &payload)
2943 .await
2944 .map_err(|e| {
2945 if matches!(&e, Error::Api { status, .. } if *status == 409) {
2946 Error::Api {
2947 status: 409,
2948 message: "Forest version conflict. Retry with the latest version."
2949 .to_string(),
2950 }
2951 } else {
2952 e
2953 }
2954 })?;
2955
2956 Ok(ForestModifyResult {
2957 version: resp.version,
2958 affected_count: input.row_ids.len(),
2959 })
2960 }
2961
2962 async fn remove_structure_row(&self, structure_id: u64, row_id: u64) -> Result<()> {
2963 self.structure_delete_request(&format!("/forest/{}/item/{}", structure_id, row_id))
2964 .await
2965 }
2966
2967 async fn get_structure_values(
2968 &self,
2969 input: GetStructureValuesInput,
2970 ) -> Result<StructureValues> {
2971 let columns: Vec<serde_json::Value> = input
2972 .columns
2973 .iter()
2974 .map(|c| {
2975 let mut col = serde_json::Map::new();
2976 if let Some(ref id) = c.id {
2977 col.insert("id".into(), serde_json::json!(id));
2978 }
2979 if let Some(ref field) = c.field {
2980 col.insert("field".into(), serde_json::json!(field));
2981 }
2982 if let Some(ref formula) = c.formula {
2983 col.insert("formula".into(), serde_json::json!(formula));
2984 }
2985 serde_json::Value::Object(col)
2986 })
2987 .collect();
2988
2989 let payload = serde_json::json!({
2990 "structureId": input.structure_id,
2991 "rows": input.rows,
2992 "columns": columns,
2993 });
2994
2995 let resp: JiraStructureValuesResponse = self.structure_post("/value", &payload).await?;
2996
2997 let mut row_map: std::collections::BTreeMap<u64, Vec<StructureColumnValue>> =
3002 std::collections::BTreeMap::new();
3003 for entry in resp.values {
3004 let column = entry.column_id.ok_or_else(|| {
3005 Error::InvalidData(format!(
3006 "Structure value for row {} is missing `columnId`",
3007 entry.row_id
3008 ))
3009 })?;
3010 row_map
3011 .entry(entry.row_id)
3012 .or_default()
3013 .push(StructureColumnValue {
3014 column,
3015 value: entry.value,
3016 });
3017 }
3018
3019 let values = row_map
3020 .into_iter()
3021 .map(|(row_id, columns)| StructureRowValues { row_id, columns })
3022 .collect();
3023
3024 Ok(StructureValues {
3025 structure_id: input.structure_id,
3026 values,
3027 })
3028 }
3029
3030 async fn get_structure_views(
3031 &self,
3032 structure_id: u64,
3033 view_id: Option<u64>,
3034 ) -> Result<Vec<StructureView>> {
3035 if let Some(id) = view_id {
3036 let view: JiraStructureView = self.structure_get(&format!("/view/{}", id)).await?;
3037 if view.structure_id != structure_id {
3043 return Err(Error::InvalidData(format!(
3044 "view {id} belongs to structure {} but {structure_id} was requested",
3045 view.structure_id
3046 )));
3047 }
3048 Ok(vec![map_structure_view(view)])
3049 } else {
3050 let resp: JiraStructureViewListResponse = self
3051 .structure_get(&format!("/view?structureId={}", structure_id))
3052 .await?;
3053 Ok(resp.views.into_iter().map(map_structure_view).collect())
3054 }
3055 }
3056
3057 async fn save_structure_view(&self, input: SaveStructureViewInput) -> Result<StructureView> {
3058 let columns: Option<Vec<serde_json::Value>> = input.columns.as_ref().map(|cols| {
3059 cols.iter()
3060 .map(|c| {
3061 let mut col = serde_json::Map::new();
3062 if let Some(ref field) = c.field {
3063 col.insert("field".into(), serde_json::json!(field));
3064 }
3065 if let Some(ref formula) = c.formula {
3066 col.insert("formula".into(), serde_json::json!(formula));
3067 }
3068 if let Some(width) = c.width {
3069 col.insert("width".into(), serde_json::json!(width));
3070 }
3071 serde_json::Value::Object(col)
3072 })
3073 .collect()
3074 });
3075
3076 let mut payload = serde_json::json!({
3077 "structureId": input.structure_id,
3078 "name": input.name,
3079 });
3080 if let Some(cols) = columns {
3081 payload["columns"] = serde_json::json!(cols);
3082 }
3083 if let Some(ref g) = input.group_by {
3084 payload["groupBy"] = serde_json::json!(g);
3085 }
3086 if let Some(ref s) = input.sort_by {
3087 payload["sortBy"] = serde_json::json!(s);
3088 }
3089 if let Some(ref f) = input.filter {
3090 payload["filter"] = serde_json::json!(f);
3091 }
3092
3093 let view: JiraStructureView = if let Some(id) = input.id {
3094 self.structure_put(&format!("/view/{}", id), &payload)
3095 .await?
3096 } else {
3097 self.structure_post("/view", &payload).await?
3098 };
3099
3100 Ok(map_structure_view(view))
3101 }
3102
3103 async fn create_structure(&self, input: CreateStructureInput) -> Result<Structure> {
3104 let mut payload = serde_json::json!({"name": input.name});
3105 if let Some(ref desc) = input.description {
3106 payload["description"] = serde_json::json!(desc);
3107 }
3108 let s: JiraStructure = self.structure_post("/structure", &payload).await?;
3109 Ok(Structure {
3110 id: s.id,
3111 name: s.name,
3112 description: s.description,
3113 })
3114 }
3115
3116 async fn get_structure_generators(
3119 &self,
3120 structure_id: u64,
3121 ) -> Result<ProviderResult<devboy_core::StructureGenerator>> {
3122 #[derive(serde::Deserialize)]
3123 struct Resp {
3124 #[serde(default)]
3125 generators: Vec<RawGenerator>,
3126 }
3127 #[derive(serde::Deserialize)]
3128 struct RawGenerator {
3129 id: String,
3130 #[serde(rename = "type")]
3131 generator_type: String,
3132 #[serde(default)]
3133 spec: serde_json::Value,
3134 }
3135 let resp: Resp = self
3136 .structure_get(&format!("/structure/{}/generator", structure_id))
3137 .await?;
3138 let items: Vec<devboy_core::StructureGenerator> = resp
3139 .generators
3140 .into_iter()
3141 .map(|g| devboy_core::StructureGenerator {
3142 id: g.id,
3143 generator_type: g.generator_type,
3144 spec: g.spec,
3145 })
3146 .collect();
3147 Ok(items.into())
3148 }
3149
3150 async fn add_structure_generator(
3151 &self,
3152 input: devboy_core::AddStructureGeneratorInput,
3153 ) -> Result<devboy_core::StructureGenerator> {
3154 #[derive(serde::Deserialize)]
3157 struct Resp {
3158 id: String,
3159 #[serde(rename = "type")]
3160 generator_type: String,
3161 #[serde(default)]
3162 spec: serde_json::Value,
3163 }
3164 let body = serde_json::json!({
3165 "type": input.generator_type,
3166 "spec": input.spec,
3167 });
3168 let resp: Resp = self
3169 .structure_post(
3170 &format!("/structure/{}/generator", input.structure_id),
3171 &body,
3172 )
3173 .await?;
3174 Ok(devboy_core::StructureGenerator {
3175 id: resp.id,
3176 generator_type: resp.generator_type,
3177 spec: resp.spec,
3178 })
3179 }
3180
3181 async fn sync_structure_generator(
3182 &self,
3183 input: devboy_core::SyncStructureGeneratorInput,
3184 ) -> Result<()> {
3185 let body = serde_json::json!({});
3186 let _: serde_json::Value = self
3187 .structure_post(
3188 &format!(
3189 "/structure/{}/generator/{}/sync",
3190 input.structure_id, input.generator_id
3191 ),
3192 &body,
3193 )
3194 .await?;
3195 Ok(())
3196 }
3197
3198 async fn delete_structure(&self, structure_id: u64) -> Result<()> {
3201 self.structure_delete_request(&format!("/structure/{}", structure_id))
3202 .await
3203 }
3204
3205 async fn update_structure_automation(
3206 &self,
3207 input: devboy_core::UpdateStructureAutomationInput,
3208 ) -> Result<()> {
3209 let endpoint = match input.automation_id.as_deref() {
3212 Some(aid) => format!("/structure/{}/automation/{}", input.structure_id, aid),
3213 None => format!("/structure/{}/automation", input.structure_id),
3214 };
3215 let _: serde_json::Value = self.structure_put(&endpoint, &input.config).await?;
3216 Ok(())
3217 }
3218
3219 async fn trigger_structure_automation(&self, structure_id: u64) -> Result<()> {
3220 let body = serde_json::json!({});
3221 let _: serde_json::Value = self
3222 .structure_post(
3223 &format!("/structure/{}/automation/run", structure_id),
3224 &body,
3225 )
3226 .await?;
3227 Ok(())
3228 }
3229
3230 async fn get_board_sprints(
3233 &self,
3234 board_id: u64,
3235 state: devboy_core::SprintState,
3236 ) -> Result<ProviderResult<devboy_core::Sprint>> {
3237 #[derive(serde::Deserialize)]
3241 #[serde(rename_all = "camelCase")]
3242 struct Resp {
3243 #[serde(default)]
3244 is_last: bool,
3245 #[serde(default)]
3246 values: Vec<devboy_core::Sprint>,
3247 }
3248 const MAX_SPRINTS: usize = 5_000;
3251 const PAGE_SIZE: u32 = 50;
3252
3253 let state_param = state
3254 .as_query_value()
3255 .map(|s| format!("&state={}", s))
3256 .unwrap_or_default();
3257
3258 let mut sprints: Vec<devboy_core::Sprint> = Vec::new();
3259 let mut start_at: u32 = 0;
3260 loop {
3261 let endpoint = format!(
3262 "/board/{}/sprint?startAt={}&maxResults={}{}",
3263 board_id, start_at, PAGE_SIZE, state_param
3264 );
3265 let resp: Resp = self.agile_get(&endpoint).await?;
3266 let fetched = resp.values.len() as u32;
3267 sprints.extend(resp.values);
3268 if resp.is_last || fetched == 0 || sprints.len() >= MAX_SPRINTS {
3269 break;
3270 }
3271 start_at += fetched;
3272 }
3273 Ok(sprints.into())
3274 }
3275
3276 async fn assign_to_sprint(&self, input: devboy_core::AssignToSprintInput) -> Result<()> {
3277 let issues: Vec<String> = input
3280 .issue_keys
3281 .into_iter()
3282 .map(|k| parse_jira_key(&k).to_string())
3283 .collect();
3284 let body = serde_json::json!({ "issues": issues });
3285 self.agile_post_void(&format!("/sprint/{}/issue", input.sprint_id), &body)
3286 .await
3287 }
3288
3289 async fn list_project_versions(
3292 &self,
3293 params: ListProjectVersionsParams,
3294 ) -> Result<ProviderResult<ProjectVersion>> {
3295 let project_key = if params.project.is_empty() {
3296 self.project_key.clone()
3297 } else {
3298 params.project
3299 };
3300
3301 let mut url = format!("{}/project/{}/versions", self.base_url, project_key);
3312 if params.include_issue_count && self.flavor == JiraFlavor::Cloud {
3313 url.push_str("?expand=issuesstatus");
3314 }
3315
3316 let dtos: Vec<JiraVersionDto> = self.get(&url).await?;
3317
3318 let mut versions: Vec<ProjectVersion> = dtos
3319 .into_iter()
3320 .map(|dto| jira_version_to_project_version(dto, &project_key))
3321 .collect();
3322
3323 if let Some(want_released) = params.released {
3324 versions.retain(|v| v.released == want_released);
3325 }
3326 if let Some(want_archived) = params.archived {
3327 versions.retain(|v| v.archived == want_archived);
3328 }
3329
3330 versions.sort_by(|a, b| {
3340 use std::cmp::Ordering;
3341 let group = a.released.cmp(&b.released);
3342 if group != Ordering::Equal {
3343 return group;
3344 }
3345 let undated_first = !a.released;
3348 let date = match (&a.release_date, &b.release_date) {
3349 (Some(a_d), Some(b_d)) => b_d.cmp(a_d),
3350 (None, None) => Ordering::Equal,
3351 (None, Some(_)) if undated_first => Ordering::Less,
3352 (None, Some(_)) => Ordering::Greater,
3353 (Some(_), None) if undated_first => Ordering::Greater,
3354 (Some(_), None) => Ordering::Less,
3355 };
3356 date.then_with(|| compare_version_names(&b.name, &a.name))
3357 });
3358
3359 let total_after_filter = versions.len() as u32;
3360 let limit_applied = params.limit.unwrap_or(total_after_filter);
3361 if (limit_applied as usize) < versions.len() {
3362 versions.truncate(limit_applied as usize);
3363 }
3364
3365 let pagination = devboy_core::Pagination {
3370 offset: 0,
3371 limit: limit_applied,
3372 total: Some(total_after_filter),
3373 has_more: (versions.len() as u32) < total_after_filter,
3374 next_cursor: None,
3375 };
3376
3377 Ok(ProviderResult::new(versions).with_pagination(pagination))
3378 }
3379
3380 async fn upsert_project_version(
3381 &self,
3382 input: UpsertProjectVersionInput,
3383 ) -> Result<ProjectVersion> {
3384 let trimmed_name = input.name.trim().to_string();
3385 if trimmed_name.is_empty() {
3386 return Err(Error::InvalidData(
3387 "upsert_project_version: name must not be empty".into(),
3388 ));
3389 }
3390 if trimmed_name.chars().count() > 255 {
3393 return Err(Error::InvalidData(
3394 "upsert_project_version: name must be ≤ 255 characters".into(),
3395 ));
3396 }
3397 let project_key = if input.project.is_empty() {
3398 self.project_key.clone()
3399 } else {
3400 input.project.clone()
3401 };
3402
3403 let update_payload = UpdateVersionPayload {
3404 name: None,
3405 description: input.description.clone(),
3406 start_date: input.start_date.clone(),
3407 release_date: input.release_date.clone(),
3408 released: input.released,
3409 archived: input.archived,
3410 };
3411 let create_payload = CreateVersionPayload {
3412 name: trimmed_name.clone(),
3413 project: Some(project_key.clone()),
3414 project_id: None,
3415 description: input.description,
3416 start_date: input.start_date,
3417 release_date: input.release_date,
3418 released: input.released,
3419 archived: input.archived,
3420 };
3421
3422 let list_url = format!("{}/project/{}/versions", self.base_url, project_key);
3427 let dtos: Vec<JiraVersionDto> = self.get(&list_url).await?;
3428 let existing = dtos.into_iter().find(|d| d.name == trimmed_name);
3429
3430 let dto: JiraVersionDto = match existing {
3431 Some(existing) => {
3432 self.put_with_response(
3433 &format!("{}/version/{}", self.base_url, existing.id),
3434 &update_payload,
3435 )
3436 .await?
3437 }
3438 None => {
3439 match self
3445 .post::<JiraVersionDto, _>(
3446 &format!("{}/version", self.base_url),
3447 &create_payload,
3448 )
3449 .await
3450 {
3451 Ok(dto) => dto,
3452 Err(e) if is_duplicate_version_error(&e) => {
3453 let dtos: Vec<JiraVersionDto> = self.get(&list_url).await?;
3454 let recovered = dtos
3455 .into_iter()
3456 .find(|d| d.name == trimmed_name)
3457 .ok_or_else(|| {
3458 Error::InvalidData(format!(
3459 "upsert_project_version: create rejected as duplicate but version '{trimmed_name}' is not in the project list"
3460 ))
3461 })?;
3462 self.put_with_response(
3463 &format!("{}/version/{}", self.base_url, recovered.id),
3464 &update_payload,
3465 )
3466 .await?
3467 }
3468 Err(e) => return Err(e),
3469 }
3470 }
3471 };
3472
3473 Ok(jira_version_to_project_version(dto, &project_key))
3474 }
3475
3476 async fn list_custom_fields(
3477 &self,
3478 params: devboy_core::ListCustomFieldsParams,
3479 ) -> Result<ProviderResult<devboy_core::CustomFieldDescriptor>> {
3480 let _ = (¶ms.project, ¶ms.issue_type);
3485
3486 let fields = self.fetch_fields().await?;
3487 let limit = params.limit.unwrap_or(50).min(200);
3488 let needle = params.search.as_deref().map(str::to_lowercase);
3489
3490 let mut descriptors: Vec<devboy_core::CustomFieldDescriptor> = fields
3491 .into_iter()
3492 .filter(|f| f.custom)
3493 .filter(|f| match &needle {
3494 Some(n) => f.name.to_lowercase().contains(n),
3495 None => true,
3496 })
3497 .map(|f| {
3498 let field_type = f
3499 .schema
3500 .as_ref()
3501 .and_then(|s| s.field_type.clone())
3502 .unwrap_or_default();
3503 let native = f.schema.as_ref().and_then(|s| serde_json::to_value(s).ok());
3504 devboy_core::CustomFieldDescriptor {
3505 id: f.id,
3506 name: f.name,
3507 field_type,
3508 description: None,
3509 native,
3510 }
3511 })
3512 .collect();
3513
3514 descriptors.sort_by(|a, b| a.name.cmp(&b.name));
3515
3516 let total_after_filter = descriptors.len() as u32;
3517 if (limit as usize) < descriptors.len() {
3518 descriptors.truncate(limit as usize);
3519 }
3520
3521 let pagination = devboy_core::Pagination {
3522 offset: 0,
3523 limit,
3524 total: Some(total_after_filter),
3525 has_more: (descriptors.len() as u32) < total_after_filter,
3526 next_cursor: None,
3527 };
3528
3529 Ok(ProviderResult::new(descriptors).with_pagination(pagination))
3530 }
3531
3532 fn provider_name(&self) -> &'static str {
3533 "jira"
3534 }
3535}
3536
3537fn is_duplicate_version_error(e: &Error) -> bool {
3547 let lowered = e.to_string().to_lowercase();
3548 lowered.contains("already exists") || lowered.contains("already used")
3549}
3550
3551fn compare_version_names(a: &str, b: &str) -> std::cmp::Ordering {
3559 fn tokens(s: &str) -> Vec<(bool, &str)> {
3560 let mut out = Vec::new();
3561 let mut start = 0;
3562 let mut last_digit: Option<bool> = None;
3563 for (i, ch) in s.char_indices() {
3564 let is_digit = ch.is_ascii_digit();
3565 match last_digit {
3566 Some(prev) if prev != is_digit => {
3567 out.push((prev, &s[start..i]));
3568 start = i;
3569 }
3570 _ => {}
3571 }
3572 last_digit = Some(is_digit);
3573 }
3574 if let Some(prev) = last_digit {
3575 out.push((prev, &s[start..]));
3576 }
3577 out
3578 }
3579
3580 let a_toks = tokens(a);
3581 let b_toks = tokens(b);
3582 for (ax, bx) in a_toks.iter().zip(b_toks.iter()) {
3583 let cmp = match (ax, bx) {
3584 ((true, ad), (true, bd)) => {
3585 let an = ad.trim_start_matches('0');
3588 let bn = bd.trim_start_matches('0');
3589 an.len().cmp(&bn.len()).then_with(|| an.cmp(bn))
3590 }
3591 ((false, at), (false, bt)) => at.cmp(bt),
3592 ((true, _), (false, _)) => std::cmp::Ordering::Greater,
3595 ((false, _), (true, _)) => std::cmp::Ordering::Less,
3596 };
3597 if cmp != std::cmp::Ordering::Equal {
3598 return cmp;
3599 }
3600 }
3601 match a_toks.len().cmp(&b_toks.len()) {
3606 std::cmp::Ordering::Equal => std::cmp::Ordering::Equal,
3607 std::cmp::Ordering::Greater => {
3608 let next = a_toks[b_toks.len()].1;
3609 if next.starts_with('-') || next.starts_with('+') {
3610 std::cmp::Ordering::Less
3611 } else {
3612 std::cmp::Ordering::Greater
3613 }
3614 }
3615 std::cmp::Ordering::Less => {
3616 let next = b_toks[a_toks.len()].1;
3617 if next.starts_with('-') || next.starts_with('+') {
3618 std::cmp::Ordering::Greater
3619 } else {
3620 std::cmp::Ordering::Less
3621 }
3622 }
3623 }
3624}
3625
3626fn jira_version_to_project_version(dto: JiraVersionDto, project_fallback: &str) -> ProjectVersion {
3627 let issue_count = dto
3635 .issues_status_for_fix_version
3636 .as_ref()
3637 .map(|c| c.total());
3638 let unresolved_issue_count = dto.issues_unresolved_count;
3639
3640 ProjectVersion {
3641 id: dto.id,
3642 project: dto.project.unwrap_or_else(|| project_fallback.to_string()),
3643 name: dto.name,
3644 description: dto.description.filter(|d| !d.is_empty()),
3645 start_date: dto.start_date.filter(|d| !d.is_empty()),
3646 release_date: dto.release_date.filter(|d| !d.is_empty()),
3647 released: dto.released,
3648 archived: dto.archived,
3649 overdue: dto.overdue,
3650 issue_count,
3651 unresolved_issue_count,
3652 source: "jira".to_string(),
3653 }
3654}
3655
3656#[async_trait]
3657impl MergeRequestProvider for JiraClient {
3658 fn provider_name(&self) -> &'static str {
3659 "jira"
3660 }
3661}
3662
3663#[async_trait]
3664impl PipelineProvider for JiraClient {
3665 fn provider_name(&self) -> &'static str {
3666 "jira"
3667 }
3668}
3669
3670#[async_trait]
3671impl Provider for JiraClient {
3672 async fn get_current_user(&self) -> Result<User> {
3673 let url = format!("{}/myself", self.base_url);
3674 let jira_user: JiraUser = self.get(&url).await?;
3675 Ok(map_user(Some(&jira_user)).unwrap_or_default())
3676 }
3677}
3678
3679#[async_trait]
3682impl devboy_core::UserProvider for JiraClient {
3683 fn provider_name(&self) -> &'static str {
3684 "jira"
3685 }
3686
3687 async fn get_user_profile(&self, user_id: &str) -> Result<User> {
3688 let url = match self.flavor {
3689 JiraFlavor::Cloud => format!("{}/user?accountId={}", self.base_url, user_id),
3690 JiraFlavor::SelfHosted => format!("{}/user?username={}", self.base_url, user_id),
3691 };
3692 let jira_user: JiraUser = self.get(&url).await?;
3693 map_user(Some(&jira_user))
3694 .ok_or_else(|| Error::InvalidData("Jira /user returned no user".to_string()))
3695 }
3696
3697 async fn lookup_user_by_email(&self, email: &str) -> Result<Option<User>> {
3698 let url = match self.flavor {
3702 JiraFlavor::Cloud => format!("{}/user/search?query={}", self.base_url, email),
3703 JiraFlavor::SelfHosted => {
3704 format!("{}/user/search?username={}", self.base_url, email)
3705 }
3706 };
3707 let users: Vec<JiraUser> = self.get(&url).await?;
3708 Ok(users.into_iter().find_map(|u| map_user(Some(&u))))
3709 }
3710}
3711
3712#[cfg(test)]
3717mod tests {
3718 use super::*;
3719 use crate::types::*;
3720 use devboy_core::{CreateCommentInput, MrFilter};
3721
3722 fn token(s: &str) -> SecretString {
3723 SecretString::from(s.to_string())
3724 }
3725
3726 #[test]
3731 fn structure_install_hint_is_single_well_spaced_line() {
3732 assert!(
3735 !STRUCTURE_PLUGIN_HINT.contains(" "),
3736 "hint contains consecutive spaces: {STRUCTURE_PLUGIN_HINT:?}"
3737 );
3738 assert!(!STRUCTURE_PLUGIN_HINT.contains('\n'));
3739 assert!(STRUCTURE_PLUGIN_HINT.contains("marketplace.atlassian.com"));
3740 }
3741
3742 #[test]
3743 fn structure_404_with_html_returns_soft_endpoint_hint() {
3744 let html = "<!DOCTYPE html><html><body>Oops, you've found a dead link.</body></html>";
3745 let err = structure_error_from_status(404, "text/html;charset=UTF-8", html.into());
3746 let msg = err.to_string();
3747 assert!(!msg.contains("<!DOCTYPE"), "HTML leaked into error: {msg}");
3748 assert!(
3751 msg.contains("endpoint not found"),
3752 "expected soft 'endpoint not found' wording: {msg}"
3753 );
3754 assert!(
3755 msg.contains("may not be installed"),
3756 "expected soft install-hint wording: {msg}"
3757 );
3758 assert!(
3759 msg.contains("marketplace.atlassian.com"),
3760 "missing marketplace link: {msg}"
3761 );
3762 }
3763
3764 #[test]
3765 fn structure_500_with_html_strips_body() {
3766 let html = "<html><body>".to_string() + &"x".repeat(20_000) + "</body></html>";
3767 let err = structure_error_from_status(500, "text/html", html);
3768 let msg = err.to_string();
3769 assert!(
3770 !msg.contains("xxxx"),
3771 "raw HTML body leaked: {}",
3772 &msg[..msg.len().min(400)]
3773 );
3774 assert!(
3775 msg.contains("non-JSON"),
3776 "missing short status message: {msg}"
3777 );
3778 }
3779
3780 #[test]
3781 fn structure_json_error_is_forwarded_verbatim() {
3782 let body = r#"{"errorMessages":["Invalid forestVersion"],"errors":{}}"#;
3783 let err = structure_error_from_status(409, "application/json", body.into());
3784 let msg = err.to_string();
3785 assert!(
3786 msg.contains("Invalid forestVersion"),
3787 "JSON body dropped: {msg}"
3788 );
3789 }
3790
3791 #[test]
3792 fn structure_long_text_body_is_truncated() {
3793 let body = "plain text ".repeat(200); let err = structure_error_from_status(400, "text/plain", body);
3795 let msg = err.to_string();
3796 assert!(
3797 msg.contains("truncated"),
3798 "truncation marker missing: {msg}"
3799 );
3800 }
3801
3802 #[test]
3803 fn structure_html_detected_by_body_when_content_type_missing() {
3804 assert!(looks_like_html("", "<!DOCTYPE html><html>..."));
3805 assert!(looks_like_html("", "<html lang=\"en\">"));
3806 assert!(!looks_like_html("", " {\"ok\":true}"));
3807 assert!(!looks_like_html("application/json", "{\"ok\":true}"));
3808 }
3809
3810 #[test]
3811 fn structure_html_detected_by_content_type_only() {
3812 assert!(looks_like_html("text/html; charset=UTF-8", ""));
3813 assert!(looks_like_html("Text/HTML", ""));
3814 }
3815
3816 #[test]
3817 fn structure_xml_body_treated_as_non_json() {
3818 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>"#;
3820 assert!(looks_like_html("application/xml", xml));
3821 assert!(looks_like_html("", xml));
3822 let err = structure_error_from_status(404, "application/xml", xml.into());
3823 let msg = err.to_string();
3824 assert!(!msg.contains("<?xml"), "XML leaked into error: {msg}");
3825 assert!(
3826 msg.contains("endpoint not found"),
3827 "expected soft wording: {msg}"
3828 );
3829 }
3830
3831 #[test]
3832 fn structure_parse_preview_redacts_html_body() {
3833 let html = r#"<!DOCTYPE html><html><head><title>Login</title></head><body><form>…</form></body></html>"#;
3834 let preview = structure_parse_preview("text/html; charset=UTF-8", html);
3835 assert!(
3836 !preview.contains("<!DOCTYPE"),
3837 "HTML leaked into parse preview: {preview}"
3838 );
3839 assert!(
3840 !preview.contains("<html"),
3841 "HTML leaked into parse preview: {preview}"
3842 );
3843 assert!(
3844 preview.contains("redacted"),
3845 "expected redaction marker: {preview}"
3846 );
3847 assert!(
3848 preview.contains(&format!("{}", html.len())),
3849 "expected byte count in preview: {preview}"
3850 );
3851 }
3852
3853 #[test]
3854 fn structure_parse_preview_redacts_xml_body() {
3855 let xml = r#"<?xml version="1.0"?><status><code>200</code></status>"#;
3856 let preview = structure_parse_preview("application/xml", xml);
3857 assert!(!preview.contains("<?xml"), "XML leaked: {preview}");
3858 assert!(preview.contains("redacted"));
3859 }
3860
3861 #[test]
3862 fn structure_parse_preview_keeps_short_json_body_verbatim() {
3863 let body = r#"{"broken":"response"#; let preview = structure_parse_preview("application/json", body);
3865 assert_eq!(preview, body);
3866 }
3867
3868 #[test]
3869 fn structure_parse_preview_truncates_long_non_markup_body() {
3870 let body = "a".repeat(2000);
3871 let preview = structure_parse_preview("text/plain", &body);
3872 assert!(preview.contains("truncated"));
3873 assert!(preview.len() < body.len());
3874 }
3875
3876 #[test]
3881 fn test_flavor_detection_cloud() {
3882 assert_eq!(
3883 detect_flavor("https://company.atlassian.net"),
3884 JiraFlavor::Cloud
3885 );
3886 assert_eq!(
3887 detect_flavor("https://myorg.atlassian.net/"),
3888 JiraFlavor::Cloud
3889 );
3890 }
3891
3892 #[test]
3893 fn test_flavor_detection_self_hosted() {
3894 assert_eq!(
3895 detect_flavor("https://jira.company.com"),
3896 JiraFlavor::SelfHosted
3897 );
3898 assert_eq!(
3899 detect_flavor("https://jira.corp.internal"),
3900 JiraFlavor::SelfHosted
3901 );
3902 assert_eq!(
3903 detect_flavor("http://localhost:8080"),
3904 JiraFlavor::SelfHosted
3905 );
3906 }
3907
3908 #[test]
3913 fn test_api_url_cloud() {
3914 assert_eq!(
3915 build_api_base("https://company.atlassian.net", JiraFlavor::Cloud),
3916 "https://company.atlassian.net/rest/api/3"
3917 );
3918 }
3919
3920 #[test]
3921 fn test_api_url_self_hosted() {
3922 assert_eq!(
3923 build_api_base("https://jira.company.com", JiraFlavor::SelfHosted),
3924 "https://jira.company.com/rest/api/2"
3925 );
3926 }
3927
3928 #[test]
3929 fn test_api_url_strips_trailing_slash() {
3930 assert_eq!(
3931 build_api_base("https://company.atlassian.net/", JiraFlavor::Cloud),
3932 "https://company.atlassian.net/rest/api/3"
3933 );
3934 }
3935
3936 #[test]
3941 fn test_auth_header_cloud() {
3942 let client = JiraClient::with_base_url(
3943 "http://localhost",
3944 "PROJ",
3945 "user@example.com",
3946 token("api-token-123"),
3947 true,
3948 );
3949 let expected = base64_encode("user@example.com:api-token-123");
3951 let req = client.request(reqwest::Method::GET, "http://localhost/test");
3952 let built = req.build().unwrap();
3953 let auth = built
3954 .headers()
3955 .get("Authorization")
3956 .unwrap()
3957 .to_str()
3958 .unwrap();
3959 assert_eq!(auth, format!("Basic {}", expected));
3960 }
3961
3962 #[test]
3963 fn test_auth_header_self_hosted_bearer() {
3964 let client = JiraClient::with_base_url(
3965 "http://localhost",
3966 "PROJ",
3967 "user@example.com",
3968 token("personal-access-token"),
3969 false,
3970 );
3971 let req = client.request(reqwest::Method::GET, "http://localhost/test");
3972 let built = req.build().unwrap();
3973 let auth = built
3974 .headers()
3975 .get("Authorization")
3976 .unwrap()
3977 .to_str()
3978 .unwrap();
3979 assert_eq!(auth, "Bearer personal-access-token");
3980 }
3981
3982 #[test]
3983 fn test_auth_header_self_hosted_basic() {
3984 let client = JiraClient::with_base_url(
3985 "http://localhost",
3986 "PROJ",
3987 "user@example.com",
3988 token("user:password"),
3989 false,
3990 );
3991 let expected = base64_encode("user:password");
3992 let req = client.request(reqwest::Method::GET, "http://localhost/test");
3993 let built = req.build().unwrap();
3994 let auth = built
3995 .headers()
3996 .get("Authorization")
3997 .unwrap()
3998 .to_str()
3999 .unwrap();
4000 assert_eq!(auth, format!("Basic {}", expected));
4001 }
4002
4003 #[test]
4008 fn test_base64_encode() {
4009 assert_eq!(base64_encode("hello"), "aGVsbG8=");
4010 assert_eq!(base64_encode("user:pass"), "dXNlcjpwYXNz");
4011 assert_eq!(base64_encode(""), "");
4012 assert_eq!(base64_encode("a"), "YQ==");
4013 assert_eq!(base64_encode("ab"), "YWI=");
4014 assert_eq!(base64_encode("abc"), "YWJj");
4015 }
4016
4017 #[test]
4022 fn test_text_to_adf_simple() {
4023 let adf = text_to_adf("Hello world");
4024 assert_eq!(adf["type"], "doc");
4025 assert_eq!(adf["version"], 1);
4026 let content = adf["content"].as_array().unwrap();
4027 assert_eq!(content.len(), 1);
4028 assert_eq!(content[0]["type"], "paragraph");
4029 let inline = content[0]["content"].as_array().unwrap();
4030 assert_eq!(inline.len(), 1);
4031 assert_eq!(inline[0]["text"], "Hello world");
4032 }
4033
4034 #[test]
4035 fn test_text_to_adf_multi_paragraph() {
4036 let adf = text_to_adf("First paragraph\n\nSecond paragraph");
4037 let content = adf["content"].as_array().unwrap();
4038 assert_eq!(content.len(), 2);
4039 assert_eq!(content[0]["content"][0]["text"], "First paragraph");
4040 assert_eq!(content[1]["content"][0]["text"], "Second paragraph");
4041 }
4042
4043 #[test]
4044 fn test_text_to_adf_with_line_breaks() {
4045 let adf = text_to_adf("Line 1\nLine 2\nLine 3");
4046 let content = adf["content"].as_array().unwrap();
4047 assert_eq!(content.len(), 1);
4048 let inline = content[0]["content"].as_array().unwrap();
4049 assert_eq!(inline.len(), 5);
4051 assert_eq!(inline[0]["text"], "Line 1");
4052 assert_eq!(inline[1]["type"], "hardBreak");
4053 assert_eq!(inline[2]["text"], "Line 2");
4054 assert_eq!(inline[3]["type"], "hardBreak");
4055 assert_eq!(inline[4]["text"], "Line 3");
4056 }
4057
4058 #[test]
4059 fn test_text_to_adf_empty() {
4060 let adf = text_to_adf("");
4061 assert_eq!(adf["type"], "doc");
4062 let content = adf["content"].as_array().unwrap();
4063 assert_eq!(content.len(), 1);
4064 assert_eq!(content[0]["type"], "paragraph");
4065 assert!(content[0]["content"].as_array().unwrap().is_empty());
4066 }
4067
4068 #[test]
4069 fn test_adf_to_text_simple() {
4070 let adf = serde_json::json!({
4071 "version": 1,
4072 "type": "doc",
4073 "content": [{
4074 "type": "paragraph",
4075 "content": [{
4076 "type": "text",
4077 "text": "Hello world"
4078 }]
4079 }]
4080 });
4081 assert_eq!(adf_to_text(&adf), "Hello world");
4082 }
4083
4084 #[test]
4085 fn test_adf_to_text_multi() {
4086 let adf = serde_json::json!({
4087 "version": 1,
4088 "type": "doc",
4089 "content": [
4090 {
4091 "type": "paragraph",
4092 "content": [{
4093 "type": "text",
4094 "text": "First"
4095 }]
4096 },
4097 {
4098 "type": "paragraph",
4099 "content": [{
4100 "type": "text",
4101 "text": "Second"
4102 }]
4103 }
4104 ]
4105 });
4106 assert_eq!(adf_to_text(&adf), "First\n\nSecond");
4107 }
4108
4109 #[test]
4110 fn test_adf_to_text_with_hardbreak() {
4111 let adf = serde_json::json!({
4112 "version": 1,
4113 "type": "doc",
4114 "content": [{
4115 "type": "paragraph",
4116 "content": [
4117 {"type": "text", "text": "Line 1"},
4118 {"type": "hardBreak"},
4119 {"type": "text", "text": "Line 2"}
4120 ]
4121 }]
4122 });
4123 assert_eq!(adf_to_text(&adf), "Line 1\nLine 2");
4124 }
4125
4126 #[test]
4127 fn test_adf_to_text_empty() {
4128 let adf = serde_json::json!({
4129 "version": 1,
4130 "type": "doc",
4131 "content": []
4132 });
4133 assert_eq!(adf_to_text(&adf), "");
4134 }
4135
4136 #[test]
4137 fn test_adf_to_text_non_adf_string() {
4138 let value = serde_json::Value::String("plain text".to_string());
4139 assert_eq!(adf_to_text(&value), "plain text");
4140 }
4141
4142 #[test]
4143 fn test_adf_to_text_null() {
4144 assert_eq!(adf_to_text(&serde_json::Value::Null), "");
4145 }
4146
4147 fn sample_jira_user_cloud() -> JiraUser {
4152 JiraUser {
4153 account_id: Some("5b10a2844c20165700ede21g".to_string()),
4154 name: None,
4155 display_name: Some("John Doe".to_string()),
4156 email_address: Some("john@example.com".to_string()),
4157 }
4158 }
4159
4160 fn sample_jira_user_self_hosted() -> JiraUser {
4161 JiraUser {
4162 account_id: None,
4163 name: Some("jdoe".to_string()),
4164 display_name: Some("John Doe".to_string()),
4165 email_address: Some("john@example.com".to_string()),
4166 }
4167 }
4168
4169 #[test]
4170 fn test_map_user_cloud() {
4171 let user = map_user(Some(&sample_jira_user_cloud())).unwrap();
4172 assert_eq!(user.id, "5b10a2844c20165700ede21g");
4173 assert_eq!(user.username, "5b10a2844c20165700ede21g");
4174 assert_eq!(user.name, Some("John Doe".to_string()));
4175 assert_eq!(user.email, Some("john@example.com".to_string()));
4176 }
4177
4178 #[test]
4179 fn test_map_user_self_hosted() {
4180 let user = map_user(Some(&sample_jira_user_self_hosted())).unwrap();
4181 assert_eq!(user.id, "jdoe");
4182 assert_eq!(user.username, "jdoe");
4183 assert_eq!(user.name, Some("John Doe".to_string()));
4184 }
4185
4186 #[test]
4187 fn test_map_user_none() {
4188 assert!(map_user(None).is_none());
4189 }
4190
4191 #[test]
4192 fn test_map_priority() {
4193 let make_priority = |name: &str| JiraPriority {
4194 name: name.to_string(),
4195 };
4196
4197 assert_eq!(
4198 map_priority(Some(&make_priority("Highest"))),
4199 Some("urgent".to_string())
4200 );
4201 assert_eq!(
4202 map_priority(Some(&make_priority("High"))),
4203 Some("high".to_string())
4204 );
4205 assert_eq!(
4206 map_priority(Some(&make_priority("Medium"))),
4207 Some("normal".to_string())
4208 );
4209 assert_eq!(
4210 map_priority(Some(&make_priority("Low"))),
4211 Some("low".to_string())
4212 );
4213 assert_eq!(
4214 map_priority(Some(&make_priority("Lowest"))),
4215 Some("low".to_string())
4216 );
4217 assert_eq!(
4218 map_priority(Some(&make_priority("Blocker"))),
4219 Some("urgent".to_string())
4220 );
4221 assert_eq!(map_priority(None), None);
4222 }
4223
4224 #[test]
4225 fn test_map_issue() {
4226 let issue = JiraIssue {
4227 id: "10001".to_string(),
4228 key: "PROJ-123".to_string(),
4229 fields: JiraIssueFields {
4230 summary: Some("Fix login bug".to_string()),
4231 description: Some(serde_json::Value::String(
4232 "Login fails on mobile".to_string(),
4233 )),
4234 status: Some(JiraStatus {
4235 name: "In Progress".to_string(),
4236 status_category: None,
4237 }),
4238 priority: Some(JiraPriority {
4239 name: "High".to_string(),
4240 }),
4241 assignee: Some(sample_jira_user_self_hosted()),
4242 reporter: Some(JiraUser {
4243 account_id: None,
4244 name: Some("reporter".to_string()),
4245 display_name: Some("Reporter".to_string()),
4246 email_address: None,
4247 }),
4248 labels: vec!["bug".to_string(), "mobile".to_string()],
4249 created: Some("2024-01-01T10:00:00.000+0000".to_string()),
4250 updated: Some("2024-01-02T15:30:00.000+0000".to_string()),
4251 parent: None,
4252 subtasks: vec![],
4253 issuelinks: vec![],
4254 attachment: vec![],
4255 issuetype: None,
4256 extras: std::collections::HashMap::new(),
4257 },
4258 };
4259
4260 let mapped = map_issue(&issue, JiraFlavor::SelfHosted, "https://jira.example.com");
4261 assert_eq!(mapped.key, "jira#PROJ-123");
4262 assert_eq!(mapped.title, "Fix login bug");
4263 assert_eq!(
4264 mapped.description,
4265 Some("Login fails on mobile".to_string())
4266 );
4267 assert_eq!(mapped.state, "In Progress");
4268 assert_eq!(mapped.source, "jira");
4269 assert_eq!(mapped.priority, Some("high".to_string()));
4270 assert_eq!(mapped.labels, vec!["bug", "mobile"]);
4271 assert_eq!(mapped.assignees.len(), 1);
4272 assert_eq!(mapped.assignees[0].username, "jdoe");
4273 assert!(mapped.author.is_some());
4274 assert_eq!(mapped.author.unwrap().username, "reporter");
4275 assert_eq!(
4276 mapped.url,
4277 Some("https://jira.example.com/browse/PROJ-123".to_string())
4278 );
4279 assert_eq!(
4280 mapped.created_at,
4281 Some("2024-01-01T10:00:00.000+0000".to_string())
4282 );
4283 }
4284
4285 #[test]
4286 fn test_map_issue_cloud_adf_description() {
4287 let adf_desc = serde_json::json!({
4288 "version": 1,
4289 "type": "doc",
4290 "content": [{
4291 "type": "paragraph",
4292 "content": [{
4293 "type": "text",
4294 "text": "ADF description"
4295 }]
4296 }]
4297 });
4298
4299 let issue = JiraIssue {
4300 id: "10001".to_string(),
4301 key: "PROJ-1".to_string(),
4302 fields: JiraIssueFields {
4303 summary: Some("Test".to_string()),
4304 description: Some(adf_desc),
4305 status: None,
4306 priority: None,
4307 assignee: None,
4308 reporter: None,
4309 labels: vec![],
4310 created: None,
4311 updated: None,
4312 parent: None,
4313 subtasks: vec![],
4314 issuelinks: vec![],
4315 attachment: vec![],
4316 issuetype: None,
4317 extras: std::collections::HashMap::new(),
4318 },
4319 };
4320
4321 let mapped = map_issue(&issue, JiraFlavor::Cloud, "https://test.atlassian.net");
4322 assert_eq!(mapped.description, Some("ADF description".to_string()));
4323 }
4324
4325 #[test]
4326 fn test_map_issue_self_hosted_plain_description() {
4327 let issue = JiraIssue {
4328 id: "10001".to_string(),
4329 key: "PROJ-1".to_string(),
4330 fields: JiraIssueFields {
4331 summary: Some("Test".to_string()),
4332 description: Some(serde_json::Value::String("Plain text desc".to_string())),
4333 status: None,
4334 priority: None,
4335 assignee: None,
4336 reporter: None,
4337 labels: vec![],
4338 created: None,
4339 updated: None,
4340 parent: None,
4341 subtasks: vec![],
4342 issuelinks: vec![],
4343 attachment: vec![],
4344 issuetype: None,
4345 extras: std::collections::HashMap::new(),
4346 },
4347 };
4348
4349 let mapped = map_issue(&issue, JiraFlavor::SelfHosted, "https://jira.example.com");
4350 assert_eq!(mapped.description, Some("Plain text desc".to_string()));
4351 }
4352
4353 #[test]
4354 fn test_map_comment() {
4355 let comment = JiraComment {
4356 id: "100".to_string(),
4357 body: Some(serde_json::Value::String("Nice work!".to_string())),
4358 author: Some(sample_jira_user_self_hosted()),
4359 created: Some("2024-01-01T10:00:00.000+0000".to_string()),
4360 updated: Some("2024-01-01T11:00:00.000+0000".to_string()),
4361 };
4362
4363 let mapped = map_comment(&comment, JiraFlavor::SelfHosted);
4364 assert_eq!(mapped.id, "100");
4365 assert_eq!(mapped.body, "Nice work!");
4366 assert!(mapped.author.is_some());
4367 assert_eq!(mapped.author.unwrap().username, "jdoe");
4368 }
4369
4370 #[test]
4371 fn test_map_comment_cloud_adf() {
4372 let adf_body = serde_json::json!({
4373 "version": 1,
4374 "type": "doc",
4375 "content": [{
4376 "type": "paragraph",
4377 "content": [{
4378 "type": "text",
4379 "text": "ADF comment"
4380 }]
4381 }]
4382 });
4383
4384 let comment = JiraComment {
4385 id: "200".to_string(),
4386 body: Some(adf_body),
4387 author: None,
4388 created: None,
4389 updated: None,
4390 };
4391
4392 let mapped = map_comment(&comment, JiraFlavor::Cloud);
4393 assert_eq!(mapped.body, "ADF comment");
4394 }
4395
4396 #[test]
4401 fn test_provider_name() {
4402 let client = JiraClient::with_base_url(
4403 "http://localhost",
4404 "PROJ",
4405 "user@example.com",
4406 token("token"),
4407 false,
4408 );
4409 assert_eq!(IssueProvider::provider_name(&client), "jira");
4410 assert_eq!(MergeRequestProvider::provider_name(&client), "jira");
4411 }
4412
4413 #[test]
4418 fn test_generic_status_to_category() {
4419 assert_eq!(generic_status_to_category("closed"), Some("done"));
4421 assert_eq!(generic_status_to_category("done"), Some("done"));
4422 assert_eq!(generic_status_to_category("resolved"), Some("done"));
4423 assert_eq!(generic_status_to_category("canceled"), Some("done"));
4424 assert_eq!(generic_status_to_category("cancelled"), Some("done"));
4425 assert_eq!(generic_status_to_category("CLOSED"), Some("done"));
4426
4427 assert_eq!(generic_status_to_category("open"), Some("new"));
4429 assert_eq!(generic_status_to_category("new"), Some("new"));
4430 assert_eq!(generic_status_to_category("todo"), Some("new"));
4431 assert_eq!(generic_status_to_category("to do"), Some("new"));
4432 assert_eq!(generic_status_to_category("reopen"), Some("new"));
4433 assert_eq!(generic_status_to_category("reopened"), Some("new"));
4434
4435 assert_eq!(
4437 generic_status_to_category("in_progress"),
4438 Some("indeterminate")
4439 );
4440 assert_eq!(
4441 generic_status_to_category("in progress"),
4442 Some("indeterminate")
4443 );
4444 assert_eq!(
4445 generic_status_to_category("in-progress"),
4446 Some("indeterminate")
4447 );
4448
4449 assert_eq!(generic_status_to_category("custom status"), None);
4451 assert_eq!(generic_status_to_category("review"), None);
4452 }
4453
4454 #[test]
4455 fn test_priority_to_jira() {
4456 assert_eq!(priority_to_jira("urgent"), "Highest");
4457 assert_eq!(priority_to_jira("high"), "High");
4458 assert_eq!(priority_to_jira("normal"), "Medium");
4459 assert_eq!(priority_to_jira("low"), "Low");
4460 assert_eq!(priority_to_jira("custom"), "custom");
4461 }
4462
4463 #[test]
4468 fn test_instance_url_from_base() {
4469 assert_eq!(
4470 instance_url_from_base("https://company.atlassian.net/rest/api/3"),
4471 "https://company.atlassian.net"
4472 );
4473 assert_eq!(
4474 instance_url_from_base("https://jira.corp.com/rest/api/2"),
4475 "https://jira.corp.com"
4476 );
4477 assert_eq!(
4478 instance_url_from_base("http://localhost:8080"),
4479 "http://localhost:8080"
4480 );
4481 }
4482
4483 mod integration {
4488 use super::*;
4489 use httpmock::prelude::*;
4490
4491 fn token(s: &str) -> SecretString {
4492 SecretString::from(s.to_string())
4493 }
4494
4495 fn create_self_hosted_client(server: &MockServer) -> JiraClient {
4496 JiraClient::with_base_url(
4497 server.base_url(),
4498 "PROJ",
4499 "user@example.com",
4500 token("pat-token"),
4501 false,
4502 )
4503 }
4504
4505 fn create_cloud_client(server: &MockServer) -> JiraClient {
4506 JiraClient::with_base_url(
4507 server.base_url(),
4508 "PROJ",
4509 "user@example.com",
4510 token("api-token"),
4511 true,
4512 )
4513 }
4514
4515 fn sample_issue_json() -> serde_json::Value {
4516 serde_json::json!({
4517 "id": "10001",
4518 "key": "PROJ-1",
4519 "fields": {
4520 "summary": "Fix login bug",
4521 "description": "Login fails on mobile",
4522 "status": {"name": "Open"},
4523 "priority": {"name": "High"},
4524 "assignee": {
4525 "name": "jdoe",
4526 "displayName": "John Doe",
4527 "emailAddress": "john@example.com"
4528 },
4529 "reporter": {
4530 "name": "reporter",
4531 "displayName": "Reporter"
4532 },
4533 "labels": ["bug"],
4534 "created": "2024-01-01T10:00:00.000+0000",
4535 "updated": "2024-01-02T15:30:00.000+0000"
4536 }
4537 })
4538 }
4539
4540 fn sample_cloud_issue_json() -> serde_json::Value {
4541 serde_json::json!({
4542 "id": "10001",
4543 "key": "PROJ-1",
4544 "fields": {
4545 "summary": "Fix login bug",
4546 "description": {
4547 "version": 1,
4548 "type": "doc",
4549 "content": [{
4550 "type": "paragraph",
4551 "content": [{
4552 "type": "text",
4553 "text": "Login fails on mobile"
4554 }]
4555 }]
4556 },
4557 "status": {"name": "Open"},
4558 "priority": {"name": "High"},
4559 "assignee": {
4560 "accountId": "5b10a2844c20165700ede21g",
4561 "displayName": "John Doe",
4562 "emailAddress": "john@example.com"
4563 },
4564 "reporter": {
4565 "accountId": "5b10a284reporter",
4566 "displayName": "Reporter"
4567 },
4568 "labels": ["bug"],
4569 "created": "2024-01-01T10:00:00.000+0000",
4570 "updated": "2024-01-02T15:30:00.000+0000"
4571 }
4572 })
4573 }
4574
4575 #[tokio::test]
4580 async fn test_get_issues() {
4581 let server = MockServer::start();
4582
4583 server.mock(|when, then| {
4584 when.method(GET).path("/search").query_param_exists("jql");
4585 then.status(200).json_body(serde_json::json!({
4586 "issues": [sample_issue_json()],
4587 "startAt": 0,
4588 "maxResults": 20,
4589 "total": 1
4590 }));
4591 });
4592
4593 let client = create_self_hosted_client(&server);
4594 let issues = client
4595 .get_issues(IssueFilter::default())
4596 .await
4597 .unwrap()
4598 .items;
4599
4600 assert_eq!(issues.len(), 1);
4601 assert_eq!(issues[0].key, "jira#PROJ-1");
4602 assert_eq!(issues[0].title, "Fix login bug");
4603 assert_eq!(issues[0].source, "jira");
4604 assert_eq!(issues[0].priority, Some("high".to_string()));
4605 assert_eq!(
4606 issues[0].description,
4607 Some("Login fails on mobile".to_string())
4608 );
4609 }
4610
4611 #[tokio::test]
4612 async fn test_get_issues_with_filters() {
4613 let server = MockServer::start();
4614
4615 server.mock(|when, then| {
4616 when.method(GET)
4617 .path("/search")
4618 .query_param_includes("jql", "labels = \"bug\"")
4619 .query_param_includes("jql", "assignee = \"jdoe\"");
4620 then.status(200).json_body(serde_json::json!({
4621 "issues": [sample_issue_json()],
4622 "startAt": 0,
4623 "maxResults": 20,
4624 "total": 1
4625 }));
4626 });
4627
4628 let client = create_self_hosted_client(&server);
4629 let issues = client
4630 .get_issues(IssueFilter {
4631 labels: Some(vec!["bug".to_string()]),
4632 assignee: Some("jdoe".to_string()),
4633 ..Default::default()
4634 })
4635 .await
4636 .unwrap()
4637 .items;
4638
4639 assert_eq!(issues.len(), 1);
4640 }
4641
4642 #[tokio::test]
4643 async fn test_get_issues_pagination() {
4644 let server = MockServer::start();
4645
4646 server.mock(|when, then| {
4647 when.method(GET)
4648 .path("/search")
4649 .query_param("startAt", "5")
4650 .query_param("maxResults", "10");
4651 then.status(200).json_body(serde_json::json!({
4652 "issues": [sample_issue_json()],
4653 "startAt": 5,
4654 "maxResults": 10,
4655 "total": 20
4656 }));
4657 });
4658
4659 let client = create_self_hosted_client(&server);
4660 let issues = client
4661 .get_issues(IssueFilter {
4662 offset: Some(5),
4663 limit: Some(10),
4664 ..Default::default()
4665 })
4666 .await
4667 .unwrap()
4668 .items;
4669
4670 assert_eq!(issues.len(), 1);
4671 }
4672
4673 #[tokio::test]
4674 async fn test_get_issues_project_key_override() {
4675 let server = MockServer::start();
4676
4677 server.mock(|when, then| {
4678 when.method(GET)
4679 .path("/search")
4680 .query_param_includes("jql", "project = \"OTHER\"");
4681 then.status(200).json_body(serde_json::json!({
4682 "issues": [sample_issue_json()],
4683 "startAt": 0,
4684 "maxResults": 20,
4685 "total": 1
4686 }));
4687 });
4688
4689 let client = create_self_hosted_client(&server);
4690 let issues = client
4691 .get_issues(IssueFilter {
4692 project_key: Some("OTHER".to_string()),
4693 ..Default::default()
4694 })
4695 .await
4696 .unwrap()
4697 .items;
4698
4699 assert_eq!(issues.len(), 1);
4700 }
4701
4702 #[tokio::test]
4703 async fn test_get_issues_native_query_passthrough() {
4704 let server = MockServer::start();
4705
4706 server.mock(|when, then| {
4707 when.method(GET)
4708 .path("/search")
4709 .query_param_includes("jql", "project = \"CUSTOM\" AND fixVersion = \"1.0\"");
4710 then.status(200).json_body(serde_json::json!({
4711 "issues": [sample_issue_json()],
4712 "startAt": 0,
4713 "maxResults": 20,
4714 "total": 1
4715 }));
4716 });
4717
4718 let client = create_self_hosted_client(&server);
4719 let issues = client
4720 .get_issues(IssueFilter {
4721 native_query: Some("project = \"CUSTOM\" AND fixVersion = \"1.0\"".to_string()),
4722 ..Default::default()
4723 })
4724 .await
4725 .unwrap()
4726 .items;
4727
4728 assert_eq!(issues.len(), 1);
4729 }
4730
4731 #[tokio::test]
4732 async fn test_get_issues_native_query_auto_injects_project() {
4733 let server = MockServer::start();
4734
4735 server.mock(|when, then| {
4738 when.method(GET)
4739 .path("/search")
4740 .query_param_includes("jql", "project = \"PROJ\" AND fixVersion = \"2.0\"");
4741 then.status(200).json_body(serde_json::json!({
4742 "issues": [sample_issue_json()],
4743 "startAt": 0,
4744 "maxResults": 20,
4745 "total": 1
4746 }));
4747 });
4748
4749 let client = create_self_hosted_client(&server);
4750 let issues = client
4751 .get_issues(IssueFilter {
4752 native_query: Some("fixVersion = \"2.0\"".to_string()),
4753 ..Default::default()
4754 })
4755 .await
4756 .unwrap()
4757 .items;
4758
4759 assert_eq!(issues.len(), 1);
4760 }
4761
4762 #[tokio::test]
4763 async fn test_get_issues_native_query_with_project_in() {
4764 let server = MockServer::start();
4765
4766 server.mock(|when, then| {
4768 when.method(GET)
4769 .path("/search")
4770 .query_param_includes("jql", "project IN (\"A\", \"B\") AND status = \"Open\"");
4771 then.status(200).json_body(serde_json::json!({
4772 "issues": [sample_issue_json()],
4773 "startAt": 0,
4774 "maxResults": 20,
4775 "total": 1
4776 }));
4777 });
4778
4779 let client = create_self_hosted_client(&server);
4780 let issues = client
4781 .get_issues(IssueFilter {
4782 native_query: Some(
4783 "project IN (\"A\", \"B\") AND status = \"Open\"".to_string(),
4784 ),
4785 ..Default::default()
4786 })
4787 .await
4788 .unwrap()
4789 .items;
4790
4791 assert_eq!(issues.len(), 1);
4792 }
4793
4794 #[tokio::test]
4795 async fn test_get_issues_project_key_with_native_query() {
4796 let server = MockServer::start();
4797
4798 server.mock(|when, then| {
4801 when.method(GET)
4802 .path("/search")
4803 .query_param_includes("jql", "project = \"OVERRIDE\" AND sprint = 42");
4804 then.status(200).json_body(serde_json::json!({
4805 "issues": [sample_issue_json()],
4806 "startAt": 0,
4807 "maxResults": 20,
4808 "total": 1
4809 }));
4810 });
4811
4812 let client = create_self_hosted_client(&server); let issues = client
4814 .get_issues(IssueFilter {
4815 project_key: Some("OVERRIDE".to_string()),
4816 native_query: Some("sprint = 42".to_string()),
4817 ..Default::default()
4818 })
4819 .await
4820 .unwrap()
4821 .items;
4822
4823 assert_eq!(issues.len(), 1);
4824 }
4825
4826 #[tokio::test]
4827 async fn test_get_issues_empty_native_query_falls_back() {
4828 let server = MockServer::start();
4829
4830 server.mock(|when, then| {
4832 when.method(GET)
4833 .path("/search")
4834 .query_param_includes("jql", "project = \"PROJ\"");
4835 then.status(200).json_body(serde_json::json!({
4836 "issues": [sample_issue_json()],
4837 "startAt": 0,
4838 "maxResults": 20,
4839 "total": 1
4840 }));
4841 });
4842
4843 let client = create_self_hosted_client(&server);
4844 let issues = client
4845 .get_issues(IssueFilter {
4846 native_query: Some("".to_string()),
4847 ..Default::default()
4848 })
4849 .await
4850 .unwrap()
4851 .items;
4852
4853 assert_eq!(issues.len(), 1);
4854 }
4855
4856 #[tokio::test]
4857 async fn test_get_issues_native_query_order_by_only() {
4858 let server = MockServer::start();
4859
4860 server.mock(|when, then| {
4863 when.method(GET)
4864 .path("/search")
4865 .query_param_includes("jql", "project = \"PROJ\" ORDER BY created ASC");
4866 then.status(200).json_body(serde_json::json!({
4867 "issues": [sample_issue_json()],
4868 "startAt": 0,
4869 "maxResults": 20,
4870 "total": 1
4871 }));
4872 });
4873
4874 let client = create_self_hosted_client(&server);
4875 let issues = client
4876 .get_issues(IssueFilter {
4877 native_query: Some("ORDER BY created ASC".to_string()),
4878 ..Default::default()
4879 })
4880 .await
4881 .unwrap()
4882 .items;
4883
4884 assert_eq!(issues.len(), 1);
4885 }
4886
4887 #[tokio::test]
4888 async fn test_get_issue() {
4889 let server = MockServer::start();
4890
4891 server.mock(|when, then| {
4892 when.method(GET).path("/issue/PROJ-1");
4893 then.status(200).json_body(sample_issue_json());
4894 });
4895
4896 let client = create_self_hosted_client(&server);
4897 let issue = client.get_issue("jira#PROJ-1").await.unwrap();
4898
4899 assert_eq!(issue.key, "jira#PROJ-1");
4900 assert_eq!(issue.title, "Fix login bug");
4901 }
4902
4903 #[tokio::test]
4904 async fn test_create_issue() {
4905 let server = MockServer::start();
4906
4907 server.mock(|when, then| {
4908 when.method(POST)
4909 .path("/issue")
4910 .body_includes("\"summary\":\"New task\"");
4911 then.status(201).json_body(serde_json::json!({
4912 "id": "10002",
4913 "key": "PROJ-2"
4914 }));
4915 });
4916
4917 server.mock(|when, then| {
4918 when.method(GET).path("/issue/PROJ-2");
4919 then.status(200).json_body(serde_json::json!({
4920 "id": "10002",
4921 "key": "PROJ-2",
4922 "fields": {
4923 "summary": "New task",
4924 "status": {"name": "Open"},
4925 "labels": [],
4926 "created": "2024-01-03T10:00:00.000+0000"
4927 }
4928 }));
4929 });
4930
4931 let client = create_self_hosted_client(&server);
4932 let issue = client
4933 .create_issue(CreateIssueInput {
4934 title: "New task".to_string(),
4935 description: Some("Task description".to_string()),
4936 ..Default::default()
4937 })
4938 .await
4939 .unwrap();
4940
4941 assert_eq!(issue.key, "jira#PROJ-2");
4942 assert_eq!(issue.title, "New task");
4943 }
4944
4945 #[tokio::test]
4946 async fn test_create_issue_with_project_id_override() {
4947 let server = MockServer::start();
4948
4949 server.mock(|when, then| {
4951 when.method(POST)
4952 .path("/issue")
4953 .body_includes("\"key\":\"OTHER\"");
4954 then.status(201).json_body(serde_json::json!({
4955 "id": "10003",
4956 "key": "OTHER-1"
4957 }));
4958 });
4959
4960 server.mock(|when, then| {
4961 when.method(GET).path("/issue/OTHER-1");
4962 then.status(200).json_body(serde_json::json!({
4963 "id": "10003",
4964 "key": "OTHER-1",
4965 "fields": {
4966 "summary": "Task in other project",
4967 "status": {"name": "Open"},
4968 "labels": [],
4969 "created": "2024-01-03T10:00:00.000+0000"
4970 }
4971 }));
4972 });
4973
4974 let client = create_self_hosted_client(&server); let issue = client
4976 .create_issue(CreateIssueInput {
4977 title: "Task in other project".to_string(),
4978 project_id: Some("OTHER".to_string()),
4979 ..Default::default()
4980 })
4981 .await
4982 .unwrap();
4983
4984 assert_eq!(issue.key, "jira#OTHER-1");
4985 }
4986
4987 #[tokio::test]
4988 async fn test_create_issue_with_issue_type() {
4989 let server = MockServer::start();
4990
4991 server.mock(|when, then| {
4993 when.method(POST)
4994 .path("/issue")
4995 .body_includes("\"name\":\"Bug\"");
4996 then.status(201).json_body(serde_json::json!({
4997 "id": "10004",
4998 "key": "PROJ-3"
4999 }));
5000 });
5001
5002 server.mock(|when, then| {
5003 when.method(GET).path("/issue/PROJ-3");
5004 then.status(200).json_body(serde_json::json!({
5005 "id": "10004",
5006 "key": "PROJ-3",
5007 "fields": {
5008 "summary": "Bug report",
5009 "status": {"name": "Open"},
5010 "labels": [],
5011 "created": "2024-01-03T10:00:00.000+0000"
5012 }
5013 }));
5014 });
5015
5016 let client = create_self_hosted_client(&server);
5017 let issue = client
5018 .create_issue(CreateIssueInput {
5019 title: "Bug report".to_string(),
5020 issue_type: Some("Bug".to_string()),
5021 ..Default::default()
5022 })
5023 .await
5024 .unwrap();
5025
5026 assert_eq!(issue.key, "jira#PROJ-3");
5027 }
5028
5029 #[tokio::test]
5030 async fn test_create_issue_with_custom_fields() {
5031 let server = MockServer::start();
5032
5033 server.mock(|when, then| {
5035 when.method(POST)
5036 .path("/issue")
5037 .body_includes("\"customfield_10001\":8")
5038 .body_includes("\"customfield_10002\":\"goal-a\"");
5039 then.status(201).json_body(serde_json::json!({
5040 "id": "10005",
5041 "key": "PROJ-5"
5042 }));
5043 });
5044
5045 server.mock(|when, then| {
5046 when.method(GET).path("/issue/PROJ-5");
5047 then.status(200).json_body(serde_json::json!({
5048 "id": "10005",
5049 "key": "PROJ-5",
5050 "fields": {
5051 "summary": "With custom fields",
5052 "status": {"name": "Open"},
5053 "labels": [],
5054 "created": "2024-01-03T10:00:00.000+0000"
5055 }
5056 }));
5057 });
5058
5059 let client = create_self_hosted_client(&server);
5060 let issue = client
5061 .create_issue(CreateIssueInput {
5062 title: "With custom fields".to_string(),
5063 custom_fields: Some(serde_json::json!({
5064 "customfield_10001": 8,
5065 "customfield_10002": "goal-a"
5066 })),
5067 ..Default::default()
5068 })
5069 .await
5070 .unwrap();
5071
5072 assert_eq!(issue.key, "jira#PROJ-5");
5073 }
5074
5075 #[tokio::test]
5076 async fn test_update_issue_with_custom_fields() {
5077 let server = MockServer::start();
5078
5079 server.mock(|when, then| {
5081 when.method(PUT)
5082 .path("/issue/PROJ-1")
5083 .body_includes("\"customfield_10001\":5");
5084 then.status(204);
5085 });
5086
5087 server.mock(|when, then| {
5088 when.method(GET).path("/issue/PROJ-1");
5089 then.status(200).json_body(serde_json::json!({
5090 "id": "10001",
5091 "key": "PROJ-1",
5092 "fields": {
5093 "summary": "Fix login bug",
5094 "status": {"name": "Open"},
5095 "labels": [],
5096 "created": "2024-01-01T10:00:00.000+0000"
5097 }
5098 }));
5099 });
5100
5101 let client = create_self_hosted_client(&server);
5102 let issue = client
5103 .update_issue(
5104 "PROJ-1",
5105 UpdateIssueInput {
5106 custom_fields: Some(serde_json::json!({
5107 "customfield_10001": 5
5108 })),
5109 ..Default::default()
5110 },
5111 )
5112 .await
5113 .unwrap();
5114
5115 assert_eq!(issue.key, "jira#PROJ-1");
5116 }
5117
5118 #[tokio::test]
5120 async fn test_create_issue_with_components() {
5121 let server = MockServer::start();
5122
5123 server.mock(|when, then| {
5124 when.method(POST).path("/issue").body_includes(
5125 "\"components\":[{\"name\":\"Backend\"},{\"name\":\"Frontend\"}]",
5126 );
5127 then.status(201).json_body(serde_json::json!({
5128 "id": "10010",
5129 "key": "PROJ-10"
5130 }));
5131 });
5132
5133 server.mock(|when, then| {
5134 when.method(GET).path("/issue/PROJ-10");
5135 then.status(200).json_body(serde_json::json!({
5136 "id": "10010",
5137 "key": "PROJ-10",
5138 "fields": {
5139 "summary": "With components",
5140 "status": {"name": "Open"},
5141 "labels": [],
5142 "created": "2024-01-05T10:00:00.000+0000"
5143 }
5144 }));
5145 });
5146
5147 let client = create_self_hosted_client(&server);
5148 let issue = client
5149 .create_issue(CreateIssueInput {
5150 title: "With components".to_string(),
5151 components: vec!["Backend".to_string(), "Frontend".to_string()],
5152 ..Default::default()
5153 })
5154 .await
5155 .unwrap();
5156
5157 assert_eq!(issue.key, "jira#PROJ-10");
5158 }
5159
5160 #[tokio::test]
5163 async fn test_create_issue_without_components_omits_field() {
5164 let server = MockServer::start();
5165
5166 server.mock(|when, then| {
5167 when.method(POST).path("/issue").is_true(|req| {
5168 let body = String::from_utf8_lossy(req.body().as_ref());
5169 !body.contains("\"components\"")
5170 });
5171 then.status(201).json_body(serde_json::json!({
5172 "id": "10011",
5173 "key": "PROJ-11"
5174 }));
5175 });
5176
5177 server.mock(|when, then| {
5178 when.method(GET).path("/issue/PROJ-11");
5179 then.status(200).json_body(serde_json::json!({
5180 "id": "10011",
5181 "key": "PROJ-11",
5182 "fields": {
5183 "summary": "No components",
5184 "status": {"name": "Open"},
5185 "labels": [],
5186 "created": "2024-01-05T10:00:00.000+0000"
5187 }
5188 }));
5189 });
5190
5191 let client = create_self_hosted_client(&server);
5192 let issue = client
5193 .create_issue(CreateIssueInput {
5194 title: "No components".to_string(),
5195 components: vec![],
5196 ..Default::default()
5197 })
5198 .await
5199 .unwrap();
5200
5201 assert_eq!(issue.key, "jira#PROJ-11");
5202 }
5203
5204 #[tokio::test]
5207 async fn test_create_issue_with_fix_versions() {
5208 let server = MockServer::start();
5209
5210 server.mock(|when, then| {
5211 when.method(POST)
5212 .path("/issue")
5213 .body_includes("\"fixVersions\":[{\"name\":\"3.18.0\"},{\"name\":\"3.19.0\"}]");
5214 then.status(201).json_body(serde_json::json!({
5215 "id": "10012",
5216 "key": "PROJ-12"
5217 }));
5218 });
5219
5220 server.mock(|when, then| {
5221 when.method(GET).path("/issue/PROJ-12");
5222 then.status(200).json_body(serde_json::json!({
5223 "id": "10012",
5224 "key": "PROJ-12",
5225 "fields": {
5226 "summary": "With fix versions",
5227 "status": {"name": "Open"},
5228 "labels": [],
5229 "created": "2024-01-05T10:00:00.000+0000"
5230 }
5231 }));
5232 });
5233
5234 let client = create_self_hosted_client(&server);
5235 let issue = client
5236 .create_issue(CreateIssueInput {
5237 title: "With fix versions".to_string(),
5238 fix_versions: vec!["3.18.0".to_string(), "3.19.0".to_string()],
5239 ..Default::default()
5240 })
5241 .await
5242 .unwrap();
5243
5244 assert_eq!(issue.key, "jira#PROJ-12");
5245 }
5246
5247 #[tokio::test]
5251 async fn test_create_issue_without_fix_versions_omits_field() {
5252 let server = MockServer::start();
5253
5254 server.mock(|when, then| {
5255 when.method(POST).path("/issue").is_true(|req| {
5256 let body = String::from_utf8_lossy(req.body().as_ref());
5257 !body.contains("\"fixVersions\"")
5258 });
5259 then.status(201).json_body(serde_json::json!({
5260 "id": "10013",
5261 "key": "PROJ-13"
5262 }));
5263 });
5264
5265 server.mock(|when, then| {
5266 when.method(GET).path("/issue/PROJ-13");
5267 then.status(200).json_body(serde_json::json!({
5268 "id": "10013",
5269 "key": "PROJ-13",
5270 "fields": {
5271 "summary": "No fix versions",
5272 "status": {"name": "Open"},
5273 "labels": [],
5274 "created": "2024-01-05T10:00:00.000+0000"
5275 }
5276 }));
5277 });
5278
5279 let client = create_self_hosted_client(&server);
5280 let issue = client
5281 .create_issue(CreateIssueInput {
5282 title: "No fix versions".to_string(),
5283 fix_versions: vec![],
5284 ..Default::default()
5285 })
5286 .await
5287 .unwrap();
5288
5289 assert_eq!(issue.key, "jira#PROJ-13");
5290 }
5291
5292 #[tokio::test]
5296 async fn test_create_issue_subtask_includes_parent_in_payload() {
5297 let server = MockServer::start();
5298
5299 server.mock(|when, then| {
5300 when.method(POST).path("/issue").is_true(|req| {
5301 let body = String::from_utf8_lossy(req.body().as_ref());
5302 body.contains("\"parent\":{\"key\":\"PROJ-1\"}")
5303 && body.contains("\"name\":\"Sub-task\"")
5304 });
5305 then.status(201).json_body(serde_json::json!({
5306 "id": "10010",
5307 "key": "PROJ-10"
5308 }));
5309 });
5310
5311 server.mock(|when, then| {
5312 when.method(GET).path("/issue/PROJ-10");
5313 then.status(200).json_body(serde_json::json!({
5314 "id": "10010",
5315 "key": "PROJ-10",
5316 "fields": {
5317 "summary": "Sub task work",
5318 "status": {"name": "Open"},
5319 "labels": [],
5320 "created": "2024-01-06T10:00:00.000+0000"
5321 }
5322 }));
5323 });
5324
5325 let client = create_self_hosted_client(&server);
5326 let issue = client
5327 .create_issue(CreateIssueInput {
5328 title: "Sub task work".to_string(),
5329 issue_type: Some("Sub-task".to_string()),
5330 parent: Some("PROJ-1".to_string()),
5331 ..Default::default()
5332 })
5333 .await
5334 .unwrap();
5335
5336 assert_eq!(issue.key, "jira#PROJ-10");
5337 }
5338
5339 #[tokio::test]
5343 async fn test_create_issue_without_parent_omits_field() {
5344 let server = MockServer::start();
5345
5346 server.mock(|when, then| {
5347 when.method(POST).path("/issue").is_true(|req| {
5348 let body = String::from_utf8_lossy(req.body().as_ref());
5349 !body.contains("\"parent\"")
5350 });
5351 then.status(201).json_body(serde_json::json!({
5352 "id": "10011",
5353 "key": "PROJ-11"
5354 }));
5355 });
5356
5357 server.mock(|when, then| {
5358 when.method(GET).path("/issue/PROJ-11");
5359 then.status(200).json_body(serde_json::json!({
5360 "id": "10011",
5361 "key": "PROJ-11",
5362 "fields": {
5363 "summary": "Plain task",
5364 "status": {"name": "Open"},
5365 "labels": [],
5366 "created": "2024-01-06T10:00:00.000+0000"
5367 }
5368 }));
5369 });
5370
5371 let client = create_self_hosted_client(&server);
5372 let issue = client
5373 .create_issue(CreateIssueInput {
5374 title: "Plain task".to_string(),
5375 parent: None,
5376 ..Default::default()
5377 })
5378 .await
5379 .unwrap();
5380
5381 assert_eq!(issue.key, "jira#PROJ-11");
5382 }
5383
5384 #[tokio::test]
5387 async fn test_update_issue_replaces_components() {
5388 let server = MockServer::start();
5389
5390 server.mock(|when, then| {
5391 when.method(PUT)
5392 .path("/issue/PROJ-1")
5393 .body_includes("\"components\":[{\"name\":\"Backend\"}]");
5394 then.status(204);
5395 });
5396
5397 server.mock(|when, then| {
5398 when.method(GET).path("/issue/PROJ-1");
5399 then.status(200).json_body(serde_json::json!({
5400 "id": "10001",
5401 "key": "PROJ-1",
5402 "fields": {
5403 "summary": "Updated",
5404 "status": {"name": "Open"},
5405 "labels": [],
5406 "created": "2024-01-01T10:00:00.000+0000"
5407 }
5408 }));
5409 });
5410
5411 let client = create_self_hosted_client(&server);
5412 let issue = client
5413 .update_issue(
5414 "PROJ-1",
5415 UpdateIssueInput {
5416 components: Some(vec!["Backend".to_string()]),
5417 ..Default::default()
5418 },
5419 )
5420 .await
5421 .unwrap();
5422
5423 assert_eq!(issue.key, "jira#PROJ-1");
5424 }
5425
5426 #[tokio::test]
5429 async fn test_update_issue_replaces_fix_versions() {
5430 let server = MockServer::start();
5431
5432 server.mock(|when, then| {
5433 when.method(PUT)
5434 .path("/issue/PROJ-1")
5435 .body_includes("\"fixVersions\":[{\"name\":\"3.18.0\"}]");
5436 then.status(204);
5437 });
5438
5439 server.mock(|when, then| {
5440 when.method(GET).path("/issue/PROJ-1");
5441 then.status(200).json_body(serde_json::json!({
5442 "id": "10001",
5443 "key": "PROJ-1",
5444 "fields": {
5445 "summary": "Updated",
5446 "status": {"name": "Open"},
5447 "labels": [],
5448 "created": "2024-01-01T10:00:00.000+0000"
5449 }
5450 }));
5451 });
5452
5453 let client = create_self_hosted_client(&server);
5454 let issue = client
5455 .update_issue(
5456 "PROJ-1",
5457 UpdateIssueInput {
5458 fix_versions: Some(vec!["3.18.0".to_string()]),
5459 ..Default::default()
5460 },
5461 )
5462 .await
5463 .unwrap();
5464
5465 assert_eq!(issue.key, "jira#PROJ-1");
5466 }
5467
5468 #[tokio::test]
5473 async fn test_create_issue_with_epic_sprint_epicname() {
5474 let server = MockServer::start();
5475
5476 server.mock(|when, then| {
5477 when.method(GET).path("/field");
5478 then.status(200).json_body(serde_json::json!([
5479 {"id": "customfield_10014", "name": "Epic Link", "custom": true},
5480 {"id": "customfield_10011", "name": "Epic Name", "custom": true}
5481 ]));
5482 });
5483
5484 server.mock(|when, then| {
5488 when.method(POST).path("/issue").is_true(|req| {
5489 let body = String::from_utf8_lossy(req.body().as_ref());
5490 body.contains("\"customfield_10014\":\"PROJ-1\"")
5491 && !body.contains("customfield_10020")
5492 && body.contains("\"customfield_10011\":\"Q4 platform\"")
5493 });
5494 then.status(201).json_body(serde_json::json!({
5495 "id": "10100",
5496 "key": "PROJ-100"
5497 }));
5498 });
5499
5500 server.mock(|when, then| {
5503 when.method(POST)
5504 .path("/rest/agile/1.0/sprint/42/issue")
5505 .body_includes("\"issues\":[\"PROJ-100\"]");
5506 then.status(204);
5507 });
5508
5509 server.mock(|when, then| {
5510 when.method(GET).path("/issue/PROJ-100");
5511 then.status(200).json_body(serde_json::json!({
5512 "id": "10100",
5513 "key": "PROJ-100",
5514 "fields": {
5515 "summary": "Epic with agile fields",
5516 "status": {"name": "Open"},
5517 "labels": [],
5518 "created": "2024-01-05T10:00:00.000+0000"
5519 }
5520 }));
5521 });
5522
5523 let client = create_self_hosted_client(&server);
5524 let issue = client
5525 .create_issue(CreateIssueInput {
5526 title: "Epic with agile fields".to_string(),
5527 epic_key: Some("PROJ-1".to_string()),
5528 sprint_id: Some(42),
5529 epic_name: Some("Q4 platform".to_string()),
5530 ..Default::default()
5531 })
5532 .await
5533 .unwrap();
5534
5535 assert_eq!(issue.key, "jira#PROJ-100");
5536 }
5537
5538 #[tokio::test]
5542 async fn test_create_issue_epic_key_errors_when_field_missing() {
5543 let server = MockServer::start();
5544
5545 server.mock(|when, then| {
5546 when.method(GET).path("/field");
5547 then.status(200).json_body(serde_json::json!([
5549 {"id": "summary", "name": "Summary", "custom": false},
5550 {"id": "customfield_99999", "name": "Tenant", "custom": true}
5551 ]));
5552 });
5553
5554 let client = create_self_hosted_client(&server);
5555 let err = client
5556 .create_issue(CreateIssueInput {
5557 title: "No epic link".to_string(),
5558 epic_key: Some("PROJ-1".to_string()),
5559 ..Default::default()
5560 })
5561 .await
5562 .unwrap_err();
5563
5564 let msg = err.to_string();
5565 assert!(
5566 msg.contains("Epic Link"),
5567 "missing field name in error: {msg}"
5568 );
5569 assert!(
5570 msg.contains("get_custom_fields"),
5571 "missing discovery hint: {msg}"
5572 );
5573 }
5574
5575 #[tokio::test]
5579 async fn test_update_issue_replaces_epic_key() {
5580 let server = MockServer::start();
5581
5582 server.mock(|when, then| {
5583 when.method(GET).path("/field");
5584 then.status(200).json_body(serde_json::json!([
5585 {"id": "customfield_10014", "name": "Epic Link", "custom": true}
5586 ]));
5587 });
5588
5589 server.mock(|when, then| {
5590 when.method(PUT)
5591 .path("/issue/PROJ-1")
5592 .body_includes("\"customfield_10014\":\"PROJ-50\"");
5593 then.status(204);
5594 });
5595
5596 server.mock(|when, then| {
5597 when.method(GET).path("/issue/PROJ-1");
5598 then.status(200).json_body(serde_json::json!({
5599 "id": "10001",
5600 "key": "PROJ-1",
5601 "fields": {
5602 "summary": "Reparented",
5603 "status": {"name": "Open"},
5604 "labels": [],
5605 "created": "2024-01-01T10:00:00.000+0000"
5606 }
5607 }));
5608 });
5609
5610 let client = create_self_hosted_client(&server);
5611 let issue = client
5612 .update_issue(
5613 "PROJ-1",
5614 UpdateIssueInput {
5615 epic_key: Some("PROJ-50".to_string()),
5616 ..Default::default()
5617 },
5618 )
5619 .await
5620 .unwrap();
5621
5622 assert_eq!(issue.key, "jira#PROJ-1");
5623 }
5624
5625 #[tokio::test]
5635 async fn test_load_default_metadata_then_enrich_schema_e2e() {
5636 use crate::JiraSchemaEnricher;
5637 use devboy_core::{ToolEnricher, ToolSchema};
5638 use serde_json::json;
5639
5640 let server = MockServer::start();
5641
5642 server.mock(|when, then| {
5644 when.method(GET)
5645 .path("/project")
5646 .query_param("recent", "30");
5647 then.status(200).json_body(json!([
5648 {"key": "PROJ", "name": "Platform"}
5649 ]));
5650 });
5651
5652 server.mock(|when, then| {
5653 when.method(GET).path("/project/PROJ");
5654 then.status(200).json_body(json!({
5655 "key": "PROJ",
5656 "issueTypes": [
5657 {"id": "1", "name": "Task", "subtask": false},
5658 {"id": "10000", "name": "Epic", "subtask": false}
5659 ]
5660 }));
5661 });
5662
5663 server.mock(|when, then| {
5664 when.method(GET).path("/project/PROJ/components");
5665 then.status(200).json_body(json!([
5666 {"id": "100", "name": "Backend"}
5667 ]));
5668 });
5669
5670 server.mock(|when, then| {
5671 when.method(GET).path("/priority");
5672 then.status(200).json_body(json!([
5673 {"id": "1", "name": "High"},
5674 {"id": "2", "name": "Medium"}
5675 ]));
5676 });
5677
5678 server.mock(|when, then| {
5679 when.method(GET).path("/issueLinkType");
5680 then.status(200).json_body(json!({
5681 "issueLinkTypes": [
5682 {"id": "1", "name": "Blocks", "outward": "blocks", "inward": "is blocked by"}
5683 ]
5684 }));
5685 });
5686
5687 server.mock(|when, then| {
5691 when.method(GET).path("/field");
5692 then.status(200).json_body(json!([
5693 {"id": "summary", "name": "Summary", "custom": false},
5694 {"id": "customfield_10014", "name": "Epic Link", "custom": true,
5695 "schema": {"type": "any"}},
5696 {"id": "customfield_10001", "name": "Story Points", "custom": true,
5697 "schema": {"type": "number"}}
5698 ]));
5699 });
5700
5701 let client = create_self_hosted_client(&server);
5702 let metadata = client
5703 .load_default_metadata(crate::metadata::MetadataLoadStrategy::MyProjects)
5704 .await
5705 .expect("metadata loads");
5706 assert!(metadata.projects.contains_key("PROJ"));
5707
5708 let enricher = JiraSchemaEnricher::new(metadata);
5711 let mut schema = ToolSchema::from_json(&json!({
5712 "type": "object",
5713 "properties": {
5714 "customFields": { "type": "object" },
5715 "priority": { "type": "string" },
5716 "components": { "type": "array" }
5717 }
5718 }));
5719 enricher.enrich_schema("create_issue", &mut schema);
5720
5721 assert!(
5723 schema.properties.contains_key("epicKey"),
5724 "Epic Link customfield should promote to canonical `epicKey` alias"
5725 );
5726 assert!(!schema.properties.contains_key("cf_epic_link"));
5727 assert!(
5729 schema.properties.contains_key("cf_story_points"),
5730 "Story Points should surface as cf_story_points"
5731 );
5732 let priority = schema.properties.get("priority").unwrap();
5735 assert_eq!(
5736 priority.enum_values,
5737 Some(vec!["High".into(), "Medium".into()])
5738 );
5739 let components = schema.properties.get("components").unwrap();
5740 assert_eq!(components.enum_values, Some(vec!["Backend".into()]));
5741 }
5742
5743 #[tokio::test]
5747 async fn test_load_default_metadata_recent_activity_strategy() {
5748 let server = MockServer::start();
5749
5750 server.mock(|when, then| {
5751 when.method(GET)
5752 .path("/search")
5753 .query_param_includes("jql", "updated >= -7d")
5754 .query_param("fields", "project");
5755 then.status(200).json_body(serde_json::json!({
5756 "issues": [
5757 {"key": "ACTIVE-1", "fields": {"project": {"key": "ACTIVE"}}},
5758 {"key": "ACTIVE-2", "fields": {"project": {"key": "ACTIVE"}}},
5760 {"key": "QUIET-1", "fields": {"project": {"key": "QUIET"}}}
5761 ],
5762 "total": 3
5763 }));
5764 });
5765
5766 for key in &["ACTIVE", "QUIET"] {
5767 server.mock(|when, then| {
5768 when.method(GET).path(format!("/project/{key}"));
5769 then.status(200).json_body(serde_json::json!({
5770 "key": key,
5771 "issueTypes": [{"id": "1", "name": "Task", "subtask": false}]
5772 }));
5773 });
5774 server.mock(|when, then| {
5775 when.method(GET).path(format!("/project/{key}/components"));
5776 then.status(200).json_body(serde_json::json!([]));
5777 });
5778 }
5779 server.mock(|when, then| {
5780 when.method(GET).path("/priority");
5781 then.status(200).json_body(serde_json::json!([]));
5782 });
5783 server.mock(|when, then| {
5784 when.method(GET).path("/issueLinkType");
5785 then.status(200)
5786 .json_body(serde_json::json!({"issueLinkTypes": []}));
5787 });
5788 server.mock(|when, then| {
5789 when.method(GET).path("/field");
5790 then.status(200).json_body(serde_json::json!([]));
5791 });
5792
5793 let client = create_self_hosted_client(&server);
5794 let meta = client
5795 .load_default_metadata(crate::metadata::MetadataLoadStrategy::RecentActivity {
5796 days: 7,
5797 })
5798 .await
5799 .unwrap();
5800 assert_eq!(meta.projects.len(), 2);
5802 assert!(meta.projects.contains_key("ACTIVE"));
5803 assert!(meta.projects.contains_key("QUIET"));
5804 }
5805
5806 #[tokio::test]
5808 async fn test_load_default_metadata_my_projects_self_hosted() {
5809 let server = MockServer::start();
5810
5811 server.mock(|when, then| {
5812 when.method(GET)
5813 .path("/project")
5814 .query_param("recent", "30");
5815 then.status(200).json_body(serde_json::json!([
5816 {"key": "RECENT1", "name": "Recent 1"},
5817 {"key": "RECENT2", "name": "Recent 2"}
5818 ]));
5819 });
5820
5821 for key in &["RECENT1", "RECENT2"] {
5822 server.mock(|when, then| {
5823 when.method(GET).path(format!("/project/{key}"));
5824 then.status(200).json_body(serde_json::json!({
5825 "key": key,
5826 "issueTypes": [{"id": "1", "name": "Task", "subtask": false}]
5827 }));
5828 });
5829 server.mock(|when, then| {
5830 when.method(GET).path(format!("/project/{key}/components"));
5831 then.status(200).json_body(serde_json::json!([]));
5832 });
5833 }
5834 server.mock(|when, then| {
5835 when.method(GET).path("/priority");
5836 then.status(200).json_body(serde_json::json!([]));
5837 });
5838 server.mock(|when, then| {
5839 when.method(GET).path("/issueLinkType");
5840 then.status(200)
5841 .json_body(serde_json::json!({"issueLinkTypes": []}));
5842 });
5843 server.mock(|when, then| {
5844 when.method(GET).path("/field");
5845 then.status(200).json_body(serde_json::json!([]));
5846 });
5847
5848 let client = create_self_hosted_client(&server);
5849 let meta = client
5850 .load_default_metadata(crate::metadata::MetadataLoadStrategy::MyProjects)
5851 .await
5852 .unwrap();
5853 assert_eq!(meta.projects.len(), 2);
5854 assert!(meta.projects.contains_key("RECENT1"));
5855 assert!(meta.projects.contains_key("RECENT2"));
5856 }
5857
5858 #[tokio::test]
5861 async fn test_load_default_metadata_my_projects_cloud() {
5862 let server = MockServer::start();
5863
5864 server.mock(|when, then| {
5865 when.method(GET)
5866 .path("/project/search")
5867 .query_param("recent", "30");
5868 then.status(200).json_body(serde_json::json!({
5869 "values": [
5870 {"key": "CLOUD1", "name": "Cloud Project 1"}
5871 ],
5872 "isLast": true
5873 }));
5874 });
5875
5876 server.mock(|when, then| {
5877 when.method(GET).path("/project/CLOUD1");
5878 then.status(200).json_body(serde_json::json!({
5879 "key": "CLOUD1",
5880 "issueTypes": [{"id": "1", "name": "Task", "subtask": false}]
5881 }));
5882 });
5883 server.mock(|when, then| {
5884 when.method(GET).path("/project/CLOUD1/components");
5885 then.status(200).json_body(serde_json::json!([]));
5886 });
5887 server.mock(|when, then| {
5888 when.method(GET).path("/priority");
5889 then.status(200).json_body(serde_json::json!([]));
5890 });
5891 server.mock(|when, then| {
5892 when.method(GET).path("/issueLinkType");
5893 then.status(200)
5894 .json_body(serde_json::json!({"issueLinkTypes": []}));
5895 });
5896 server.mock(|when, then| {
5897 when.method(GET).path("/field");
5898 then.status(200).json_body(serde_json::json!([]));
5899 });
5900
5901 let client = create_cloud_client(&server);
5902 let meta = client
5903 .load_default_metadata(crate::metadata::MetadataLoadStrategy::MyProjects)
5904 .await
5905 .unwrap();
5906 assert_eq!(meta.projects.len(), 1);
5907 assert!(meta.projects.contains_key("CLOUD1"));
5908 assert_eq!(meta.flavor, crate::metadata::JiraFlavor::Cloud);
5909 }
5910
5911 #[tokio::test]
5915 async fn test_load_default_metadata_all_strategy_under_cap() {
5916 let server = MockServer::start();
5917
5918 server.mock(|when, then| {
5919 when.method(GET).path("/project");
5920 then.status(200).json_body(serde_json::json!([
5921 {"key": "PROJ", "name": "Platform"},
5922 {"key": "INFRA", "name": "Infrastructure"}
5923 ]));
5924 });
5925
5926 for key in &["PROJ", "INFRA"] {
5927 server.mock(|when, then| {
5928 when.method(GET).path(format!("/project/{key}"));
5929 then.status(200).json_body(serde_json::json!({
5930 "key": key,
5931 "issueTypes": [{"id": "1", "name": "Task", "subtask": false}]
5932 }));
5933 });
5934 server.mock(|when, then| {
5935 when.method(GET).path(format!("/project/{key}/components"));
5936 then.status(200).json_body(serde_json::json!([]));
5937 });
5938 }
5939
5940 server.mock(|when, then| {
5941 when.method(GET).path("/priority");
5942 then.status(200).json_body(serde_json::json!([]));
5943 });
5944 server.mock(|when, then| {
5945 when.method(GET).path("/issueLinkType");
5946 then.status(200)
5947 .json_body(serde_json::json!({"issueLinkTypes": []}));
5948 });
5949 server.mock(|when, then| {
5950 when.method(GET).path("/field");
5951 then.status(200).json_body(serde_json::json!([]));
5952 });
5953
5954 let client = create_self_hosted_client(&server);
5955 let meta = client
5956 .load_default_metadata(crate::metadata::MetadataLoadStrategy::All)
5957 .await
5958 .unwrap();
5959 assert_eq!(meta.projects.len(), 2);
5960 assert!(meta.projects.contains_key("PROJ"));
5961 assert!(meta.projects.contains_key("INFRA"));
5962 }
5963
5964 #[tokio::test]
5968 async fn test_load_default_metadata_all_strategy_errors_over_cap() {
5969 let server = MockServer::start();
5970
5971 let projects: Vec<serde_json::Value> = (1..=31)
5973 .map(
5974 |i| serde_json::json!({"key": format!("P{i}"), "name": format!("Project {i}")}),
5975 )
5976 .collect();
5977 server.mock(|when, then| {
5978 when.method(GET).path("/project");
5979 then.status(200).json_body(serde_json::json!(projects));
5980 });
5981
5982 let client = create_self_hosted_client(&server);
5983 let err = client
5984 .load_default_metadata(crate::metadata::MetadataLoadStrategy::All)
5985 .await
5986 .unwrap_err();
5987 let msg = err.to_string();
5988 assert!(msg.contains("31"), "missing count: {msg}");
5989 assert!(msg.contains("30"), "missing cap: {msg}");
5990 assert!(
5991 msg.contains("MyProjects"),
5992 "missing alternative hint: {msg}"
5993 );
5994 assert!(
5995 msg.contains("RecentActivity"),
5996 "missing alternative hint: {msg}"
5997 );
5998 assert!(
5999 msg.contains("Configured"),
6000 "missing alternative hint: {msg}"
6001 );
6002 }
6003
6004 #[tokio::test]
6011 async fn test_load_default_metadata_configured_strategy() {
6012 let server = MockServer::start();
6013
6014 for key in &["PROJ", "INFRA"] {
6015 server.mock(|when, then| {
6016 when.method(GET).path(format!("/project/{key}"));
6017 then.status(200).json_body(serde_json::json!({
6018 "key": key,
6019 "issueTypes": [
6020 {"id": "1", "name": "Task", "subtask": false}
6021 ]
6022 }));
6023 });
6024 server.mock(|when, then| {
6025 when.method(GET).path(format!("/project/{key}/components"));
6026 then.status(200).json_body(serde_json::json!([]));
6027 });
6028 }
6029
6030 server.mock(|when, then| {
6031 when.method(GET).path("/priority");
6032 then.status(200).json_body(serde_json::json!([
6033 {"id": "1", "name": "High"}
6034 ]));
6035 });
6036 server.mock(|when, then| {
6037 when.method(GET).path("/issueLinkType");
6038 then.status(200).json_body(serde_json::json!({
6039 "issueLinkTypes": [
6040 {"id": "1", "name": "Blocks", "outward": "blocks", "inward": "is blocked by"}
6041 ]
6042 }));
6043 });
6044 server.mock(|when, then| {
6045 when.method(GET).path("/field");
6046 then.status(200).json_body(serde_json::json!([
6047 {"id": "customfield_10001", "name": "Story Points", "custom": true,
6048 "schema": {"type": "number"}}
6049 ]));
6050 });
6051
6052 let client = create_self_hosted_client(&server);
6053 let meta = client
6054 .load_default_metadata(crate::metadata::MetadataLoadStrategy::Configured(vec![
6055 "PROJ".into(),
6056 "INFRA".into(),
6057 ]))
6058 .await
6059 .unwrap();
6060
6061 assert_eq!(meta.projects.len(), 2);
6062 assert!(meta.projects.contains_key("PROJ"));
6063 assert!(meta.projects.contains_key("INFRA"));
6064 assert_eq!(meta.flavor, crate::metadata::JiraFlavor::SelfHosted);
6065 for project in meta.projects.values() {
6067 assert_eq!(project.custom_fields.len(), 1);
6068 assert_eq!(project.custom_fields[0].id, "customfield_10001");
6069 }
6070 }
6071
6072 #[tokio::test]
6079 async fn test_build_project_metadata_assembles_from_five_endpoints() {
6080 let server = MockServer::start();
6081
6082 server.mock(|when, then| {
6083 when.method(GET).path("/project/PROJ");
6084 then.status(200).json_body(serde_json::json!({
6085 "key": "PROJ",
6086 "issueTypes": [
6087 {"id": "1", "name": "Task", "subtask": false},
6088 {"id": "5", "name": "Sub-task", "subtask": true}
6089 ]
6090 }));
6091 });
6092
6093 server.mock(|when, then| {
6094 when.method(GET).path("/project/PROJ/components");
6095 then.status(200).json_body(serde_json::json!([
6096 {"id": "10", "name": "API"},
6097 {"id": "11", "name": "Frontend"}
6098 ]));
6099 });
6100
6101 server.mock(|when, then| {
6102 when.method(GET).path("/priority");
6103 then.status(200).json_body(serde_json::json!([
6104 {"id": "1", "name": "Highest"},
6105 {"id": "2", "name": "Medium"}
6106 ]));
6107 });
6108
6109 server.mock(|when, then| {
6110 when.method(GET).path("/issueLinkType");
6111 then.status(200).json_body(serde_json::json!({
6112 "issueLinkTypes": [
6113 {"id": "1", "name": "Blocks", "outward": "blocks", "inward": "is blocked by"}
6114 ]
6115 }));
6116 });
6117
6118 server.mock(|when, then| {
6119 when.method(GET).path("/field");
6120 then.status(200).json_body(serde_json::json!([
6121 {"id": "summary", "name": "Summary", "custom": false},
6122 {"id": "customfield_10001", "name": "Story Points", "custom": true,
6123 "schema": {"type": "number"}}
6124 ]));
6125 });
6126
6127 let client = create_self_hosted_client(&server);
6128 let meta = client.build_project_metadata("PROJ").await.unwrap();
6129
6130 assert_eq!(meta.issue_types.len(), 2);
6131 assert!(
6132 meta.issue_types
6133 .iter()
6134 .any(|it| it.name == "Sub-task" && it.subtask)
6135 );
6136 assert_eq!(meta.components.len(), 2);
6137 assert_eq!(meta.priorities.len(), 2);
6138 assert_eq!(meta.link_types.len(), 1);
6139 assert_eq!(meta.link_types[0].outward.as_deref(), Some("blocks"));
6140 assert_eq!(meta.custom_fields.len(), 1);
6142 assert_eq!(meta.custom_fields[0].id, "customfield_10001");
6143 assert_eq!(
6144 meta.custom_fields[0].field_type,
6145 crate::metadata::JiraFieldType::Number
6146 );
6147 }
6148
6149 #[tokio::test]
6154 async fn test_get_issue_surfaces_customfield_values() {
6155 let server = MockServer::start();
6156
6157 server.mock(|when, then| {
6158 when.method(GET).path("/issue/PROJ-1");
6159 then.status(200).json_body(serde_json::json!({
6160 "id": "10001",
6161 "key": "PROJ-1",
6162 "fields": {
6163 "summary": "Issue with cf",
6164 "issuetype": {"name": "Task"},
6165 "status": {"name": "Open"},
6166 "labels": [],
6167 "created": "2024-01-01T10:00:00.000+0000",
6168 "customfield_10999": "tenant-a",
6169 "customfield_10888": 42,
6170 "customfield_10777": null
6171 }
6172 }));
6173 });
6174
6175 let client = create_self_hosted_client(&server);
6176 let issue = client.get_issue("PROJ-1").await.unwrap();
6177 let cf1 = issue
6178 .custom_fields
6179 .get("customfield_10999")
6180 .expect("cf 10999 present");
6181 assert!(
6182 cf1.name.is_none(),
6183 "Jira mapper leaves name resolution to get_custom_fields"
6184 );
6185 assert_eq!(cf1.value, serde_json::json!("tenant-a"));
6186 let cf2 = issue
6187 .custom_fields
6188 .get("customfield_10888")
6189 .expect("cf 10888 present");
6190 assert_eq!(cf2.value, serde_json::json!(42));
6191 assert!(!issue.custom_fields.contains_key("customfield_10777"));
6193 }
6194
6195 #[tokio::test]
6199 async fn test_link_issues_implements_canonical_name() {
6200 let server = MockServer::start();
6201
6202 server.mock(|when, then| {
6203 when.method(POST)
6204 .path("/issueLink")
6205 .body_includes("\"name\":\"Implements\"")
6206 .body_includes("\"outwardIssue\":{\"key\":\"PROJ-1\"}")
6207 .body_includes("\"inwardIssue\":{\"key\":\"PROJ-2\"}");
6208 then.status(201);
6209 });
6210
6211 let client = create_self_hosted_client(&server);
6212 client
6213 .link_issues("PROJ-1", "PROJ-2", "Implements")
6214 .await
6215 .unwrap();
6216 }
6217
6218 #[tokio::test]
6220 async fn test_link_issues_causes_alias_maps_to_canonical() {
6221 let server = MockServer::start();
6222
6223 server.mock(|when, then| {
6224 when.method(POST)
6225 .path("/issueLink")
6226 .body_includes("\"name\":\"Causes\"")
6227 .body_includes("\"outwardIssue\":{\"key\":\"PROJ-1\"}")
6228 .body_includes("\"inwardIssue\":{\"key\":\"PROJ-2\"}");
6229 then.status(201);
6230 });
6231
6232 let client = create_self_hosted_client(&server);
6233 client
6234 .link_issues("PROJ-1", "PROJ-2", "causes")
6235 .await
6236 .unwrap();
6237 }
6238
6239 #[tokio::test]
6244 async fn test_link_issues_created_by_flips_direction() {
6245 let server = MockServer::start();
6246
6247 server.mock(|when, then| {
6248 when.method(POST)
6249 .path("/issueLink")
6250 .body_includes("\"name\":\"Created By\"")
6251 .body_includes("\"outwardIssue\":{\"key\":\"PROJ-2\"}")
6252 .body_includes("\"inwardIssue\":{\"key\":\"PROJ-1\"}");
6253 then.status(201);
6254 });
6255
6256 let client = create_self_hosted_client(&server);
6257 client
6258 .link_issues("PROJ-1", "PROJ-2", "created_by")
6259 .await
6260 .unwrap();
6261 }
6262
6263 #[tokio::test]
6266 async fn test_link_issues_caused_by_flips_direction() {
6267 let server = MockServer::start();
6268
6269 server.mock(|when, then| {
6270 when.method(POST)
6271 .path("/issueLink")
6272 .body_includes("\"name\":\"Causes\"")
6273 .body_includes("\"outwardIssue\":{\"key\":\"PROJ-2\"}")
6276 .body_includes("\"inwardIssue\":{\"key\":\"PROJ-1\"}");
6277 then.status(201);
6278 });
6279
6280 let client = create_self_hosted_client(&server);
6281 client
6282 .link_issues("PROJ-1", "PROJ-2", "caused_by")
6283 .await
6284 .unwrap();
6285 }
6286
6287 #[tokio::test]
6293 async fn test_get_issue_relations_includes_epic_link_customfield() {
6294 let server = MockServer::start();
6295
6296 server.mock(|when, then| {
6297 when.method(GET).path("/field");
6298 then.status(200).json_body(serde_json::json!([
6299 {"id": "customfield_10014", "name": "Epic Link", "custom": true,
6300 "schema": {"type": "any"}}
6301 ]));
6302 });
6303
6304 server.mock(|when, then| {
6305 when.method(GET).path("/issue/PROJ-100");
6306 then.status(200).json_body(serde_json::json!({
6307 "id": "10100",
6308 "key": "PROJ-100",
6309 "fields": {
6310 "summary": "Story under epic",
6311 "issuetype": {"name": "Story"},
6312 "status": {"name": "Open"},
6313 "labels": [],
6314 "created": "2024-01-05T10:00:00.000+0000",
6315 "customfield_10014": "PROJ-1"
6316 }
6317 }));
6318 });
6319
6320 let client = create_self_hosted_client(&server);
6321 let relations = client.get_issue_relations("PROJ-100").await.unwrap();
6322 assert_eq!(relations.epic_key.as_deref(), Some("PROJ-1"));
6323 assert!(relations.parent.is_none());
6324 }
6325
6326 #[tokio::test]
6330 async fn test_get_issue_relations_cloud_team_managed_uses_parent() {
6331 let server = MockServer::start();
6332
6333 server.mock(|when, then| {
6334 when.method(GET).path("/issue/PROJ-200");
6335 then.status(200).json_body(serde_json::json!({
6336 "id": "10200",
6337 "key": "PROJ-200",
6338 "fields": {
6339 "summary": "Story under epic (team-managed)",
6340 "issuetype": {"name": "Story"},
6341 "status": {"name": "Open"},
6342 "labels": [],
6343 "created": "2024-01-05T10:00:00.000+0000",
6344 "parent": {
6345 "id": "9000",
6346 "key": "PROJ-1",
6347 "fields": {
6348 "summary": "Parent epic",
6349 "status": {"name": "Open"},
6350 "labels": [],
6351 "created": "2024-01-01T10:00:00.000+0000"
6352 }
6353 }
6354 }
6355 }));
6356 });
6357
6358 let client = create_self_hosted_client(&server);
6359 let relations = client.get_issue_relations("PROJ-200").await.unwrap();
6360 assert!(relations.parent.is_some());
6361 assert_eq!(relations.parent.as_ref().unwrap().key, "jira#PROJ-1");
6362 assert_eq!(relations.epic_key, None);
6366 }
6367
6368 #[tokio::test]
6374 async fn test_get_issue_epic_description_fallback() {
6375 let server = MockServer::start();
6376
6377 server.mock(|when, then| {
6378 when.method(GET).path("/field");
6379 then.status(200).json_body(serde_json::json!([
6380 {"id": "customfield_10017", "name": "Epic Description", "custom": true,
6381 "schema": {"type": "string"}}
6382 ]));
6383 });
6384
6385 server.mock(|when, then| {
6386 when.method(GET).path("/issue/EPIC-1");
6387 then.status(200).json_body(serde_json::json!({
6388 "id": "10001",
6389 "key": "EPIC-1",
6390 "fields": {
6391 "summary": "Q4 platform epic",
6392 "description": null,
6393 "issuetype": {"name": "Epic"},
6394 "status": {"name": "Open"},
6395 "labels": [],
6396 "created": "2024-01-01T10:00:00.000+0000",
6397 "customfield_10017": "Roll out the new pricing tier across all products."
6398 }
6399 }));
6400 });
6401
6402 let client = create_self_hosted_client(&server);
6403 let issue = client.get_issue("EPIC-1").await.unwrap();
6404 assert_eq!(
6405 issue.description.as_deref(),
6406 Some("Roll out the new pricing tier across all products.")
6407 );
6408 }
6409
6410 #[tokio::test]
6414 async fn test_get_issue_no_fallback_for_non_epic() {
6415 let server = MockServer::start();
6416
6417 server.mock(|when, then| {
6418 when.method(GET).path("/issue/PROJ-1");
6419 then.status(200).json_body(serde_json::json!({
6420 "id": "10001",
6421 "key": "PROJ-1",
6422 "fields": {
6423 "summary": "Regular task",
6424 "description": null,
6425 "issuetype": {"name": "Task"},
6426 "status": {"name": "Open"},
6427 "labels": [],
6428 "created": "2024-01-01T10:00:00.000+0000",
6429 "customfield_10017": "ignored"
6430 }
6431 }));
6432 });
6433
6434 let client = create_self_hosted_client(&server);
6435 let issue = client.get_issue("PROJ-1").await.unwrap();
6436 assert_eq!(issue.description, None);
6440 }
6441
6442 #[tokio::test]
6445 async fn test_get_issue_epic_keeps_existing_description() {
6446 let server = MockServer::start();
6447
6448 server.mock(|when, then| {
6449 when.method(GET).path("/issue/EPIC-2");
6450 then.status(200).json_body(serde_json::json!({
6451 "id": "10002",
6452 "key": "EPIC-2",
6453 "fields": {
6454 "summary": "Epic with system description",
6455 "description": "Top-level epic body.",
6456 "issuetype": {"name": "Epic"},
6457 "status": {"name": "Open"},
6458 "labels": [],
6459 "created": "2024-01-01T10:00:00.000+0000",
6460 "customfield_10017": "Should not be used."
6461 }
6462 }));
6463 });
6464
6465 let client = create_self_hosted_client(&server);
6466 let issue = client.get_issue("EPIC-2").await.unwrap();
6467 assert_eq!(issue.description.as_deref(), Some("Top-level epic body."));
6468 }
6469
6470 #[tokio::test]
6473 async fn test_list_custom_fields_filters_and_sorts() {
6474 let server = MockServer::start();
6475
6476 server.mock(|when, then| {
6477 when.method(GET).path("/field");
6478 then.status(200).json_body(serde_json::json!([
6479 {"id": "summary", "name": "Summary", "custom": false},
6480 {"id": "customfield_10020", "name": "Sprint", "custom": true,
6481 "schema": {"type": "array", "items": "json"}},
6482 {"id": "customfield_10014", "name": "Epic Link", "custom": true,
6483 "schema": {"type": "any"}},
6484 {"id": "customfield_10011", "name": "Epic Name", "custom": true,
6485 "schema": {"type": "string"}}
6486 ]));
6487 });
6488
6489 let client = create_self_hosted_client(&server);
6490 let result = client
6491 .list_custom_fields(devboy_core::ListCustomFieldsParams {
6492 search: Some("epic".to_string()),
6493 ..Default::default()
6494 })
6495 .await
6496 .unwrap();
6497
6498 assert_eq!(result.items.len(), 2);
6501 assert_eq!(result.items[0].name, "Epic Link");
6502 assert_eq!(result.items[1].name, "Epic Name");
6503 assert_eq!(result.items[0].field_type, "any");
6504 assert_eq!(result.items[1].field_type, "string");
6505
6506 let pagination = result.pagination.expect("pagination present");
6507 assert_eq!(pagination.total, Some(2));
6508 assert!(!pagination.has_more);
6509 }
6510
6511 #[tokio::test]
6514 async fn test_list_custom_fields_limit_truncates_with_has_more() {
6515 let server = MockServer::start();
6516
6517 server.mock(|when, then| {
6518 when.method(GET).path("/field");
6519 then.status(200).json_body(serde_json::json!([
6520 {"id": "customfield_10020", "name": "Sprint", "custom": true},
6521 {"id": "customfield_10014", "name": "Epic Link", "custom": true},
6522 {"id": "customfield_10011", "name": "Epic Name", "custom": true}
6523 ]));
6524 });
6525
6526 let client = create_self_hosted_client(&server);
6527 let result = client
6528 .list_custom_fields(devboy_core::ListCustomFieldsParams {
6529 limit: Some(2),
6530 ..Default::default()
6531 })
6532 .await
6533 .unwrap();
6534
6535 assert_eq!(result.items.len(), 2);
6536 let pagination = result.pagination.expect("pagination present");
6537 assert_eq!(pagination.total, Some(3));
6538 assert!(pagination.has_more);
6539 }
6540
6541 #[tokio::test]
6545 async fn test_resolve_field_id_by_name_caches_and_resolves() {
6546 let server = MockServer::start();
6547
6548 let field_mock = server.mock(|when, then| {
6550 when.method(GET).path("/field");
6551 then.status(200).json_body(serde_json::json!([
6552 {"id": "summary", "name": "Summary", "custom": false},
6553 {"id": "customfield_10014", "name": "Epic Link", "custom": true,
6554 "schema": {"type": "any", "custom": "com.pyxis.greenhopper.jira:gh-epic-link"}},
6555 {"id": "customfield_10020", "name": "Sprint", "custom": true},
6556 {"id": "customfield_10011", "name": "Epic Name", "custom": true}
6557 ]));
6558 });
6559
6560 let client = create_self_hosted_client(&server);
6561
6562 let epic_link = client.resolve_field_id_by_name("Epic Link").await.unwrap();
6563 assert_eq!(epic_link, Some("customfield_10014".to_string()));
6564
6565 let sprint = client.resolve_field_id_by_name("Sprint").await.unwrap();
6567 assert_eq!(sprint, Some("customfield_10020".to_string()));
6568
6569 field_mock.assert_calls(1);
6570 }
6571
6572 #[tokio::test]
6579 async fn test_resolve_field_id_by_name_errors_on_duplicate_names() {
6580 let server = MockServer::start();
6581
6582 server.mock(|when, then| {
6583 when.method(GET).path("/field");
6584 then.status(200).json_body(serde_json::json!([
6585 {"id": "customfield_10100", "name": "Severity", "custom": true},
6586 {"id": "customfield_10200", "name": "Severity", "custom": true}
6587 ]));
6588 });
6589
6590 let client = create_self_hosted_client(&server);
6591 let err = client
6592 .resolve_field_id_by_name("Severity")
6593 .await
6594 .unwrap_err();
6595 let msg = err.to_string();
6596 assert!(msg.contains("Severity"), "missing field name: {msg}");
6597 assert!(msg.contains("ambiguous"), "missing ambiguity hint: {msg}");
6598 assert!(msg.contains("customfield_10100"), "missing first id: {msg}");
6599 assert!(
6600 msg.contains("customfield_10200"),
6601 "missing second id: {msg}"
6602 );
6603 }
6604
6605 #[tokio::test]
6608 async fn test_resolve_field_id_by_name_returns_none_for_missing() {
6609 let server = MockServer::start();
6610
6611 server.mock(|when, then| {
6612 when.method(GET).path("/field");
6613 then.status(200).json_body(serde_json::json!([
6614 {"id": "summary", "name": "Summary", "custom": false}
6615 ]));
6616 });
6617
6618 let client = create_self_hosted_client(&server);
6619 let resolved = client.resolve_field_id_by_name("Epic Link").await.unwrap();
6620 assert_eq!(resolved, None);
6621 }
6622
6623 #[tokio::test]
6624 async fn test_update_issue() {
6625 let server = MockServer::start();
6626
6627 server.mock(|when, then| {
6628 when.method(PUT)
6629 .path("/issue/PROJ-1")
6630 .body_includes("\"summary\":\"Updated title\"");
6631 then.status(204);
6632 });
6633
6634 server.mock(|when, then| {
6635 when.method(GET).path("/issue/PROJ-1");
6636 then.status(200).json_body(serde_json::json!({
6637 "id": "10001",
6638 "key": "PROJ-1",
6639 "fields": {
6640 "summary": "Updated title",
6641 "status": {"name": "Open"},
6642 "labels": [],
6643 "created": "2024-01-01T10:00:00.000+0000"
6644 }
6645 }));
6646 });
6647
6648 let client = create_self_hosted_client(&server);
6649 let issue = client
6650 .update_issue(
6651 "PROJ-1",
6652 UpdateIssueInput {
6653 title: Some("Updated title".to_string()),
6654 ..Default::default()
6655 },
6656 )
6657 .await
6658 .unwrap();
6659
6660 assert_eq!(issue.title, "Updated title");
6661 }
6662
6663 #[tokio::test]
6664 async fn test_update_issue_with_status_transition() {
6665 let server = MockServer::start();
6666
6667 server.mock(|when, then| {
6669 when.method(GET).path("/issue/PROJ-1/transitions");
6670 then.status(200).json_body(serde_json::json!({
6671 "transitions": [
6672 {
6673 "id": "21",
6674 "name": "Start Progress",
6675 "to": {"name": "In Progress"}
6676 },
6677 {
6678 "id": "31",
6679 "name": "Done",
6680 "to": {"name": "Done"}
6681 }
6682 ]
6683 }));
6684 });
6685
6686 server.mock(|when, then| {
6688 when.method(POST)
6689 .path("/issue/PROJ-1/transitions")
6690 .body_includes("\"id\":\"31\"");
6691 then.status(204);
6692 });
6693
6694 server.mock(|when, then| {
6696 when.method(GET).path("/issue/PROJ-1");
6697 then.status(200).json_body(serde_json::json!({
6698 "id": "10001",
6699 "key": "PROJ-1",
6700 "fields": {
6701 "summary": "Test",
6702 "status": {"name": "Done"},
6703 "labels": []
6704 }
6705 }));
6706 });
6707
6708 let client = create_self_hosted_client(&server);
6709 let issue = client
6710 .update_issue(
6711 "PROJ-1",
6712 UpdateIssueInput {
6713 state: Some("Done".to_string()),
6714 ..Default::default()
6715 },
6716 )
6717 .await
6718 .unwrap();
6719
6720 assert_eq!(issue.state, "Done");
6721 }
6722
6723 fn mock_project_statuses(server: &MockServer, statuses: serde_json::Value) {
6725 server.mock(|when, then| {
6726 when.method(GET).path("/project/PROJ/statuses");
6727 then.status(200).json_body(statuses);
6728 });
6729 }
6730
6731 fn sample_project_statuses_json() -> serde_json::Value {
6733 serde_json::json!([{
6734 "name": "Task",
6735 "statuses": [
6736 {"name": "Offen", "id": "1", "statusCategory": {"key": "new"}},
6737 {"name": "In Bearbeitung", "id": "2", "statusCategory": {"key": "indeterminate"}},
6738 {"name": "Erledigt", "id": "3", "statusCategory": {"key": "done"}},
6739 {"name": "Abgebrochen", "id": "4", "statusCategory": {"key": "done"}}
6740 ]
6741 }])
6742 }
6743
6744 #[tokio::test]
6745 async fn test_update_issue_generic_closed_maps_to_done_category() {
6746 let server = MockServer::start();
6747
6748 server.mock(|when, then| {
6750 when.method(GET).path("/issue/PROJ-1/transitions");
6751 then.status(200).json_body(serde_json::json!({
6752 "transitions": [
6753 {
6754 "id": "21",
6755 "name": "Start Progress",
6756 "to": {
6757 "name": "In Bearbeitung",
6758 "statusCategory": {"key": "indeterminate"}
6759 }
6760 },
6761 {
6762 "id": "31",
6763 "name": "Erledigt",
6764 "to": {
6765 "name": "Erledigt",
6766 "statusCategory": {"key": "done"}
6767 }
6768 }
6769 ]
6770 }));
6771 });
6772
6773 mock_project_statuses(&server, sample_project_statuses_json());
6775
6776 server.mock(|when, then| {
6778 when.method(POST)
6779 .path("/issue/PROJ-1/transitions")
6780 .body_includes("\"id\":\"31\"");
6781 then.status(204);
6782 });
6783
6784 server.mock(|when, then| {
6786 when.method(GET).path("/issue/PROJ-1");
6787 then.status(200).json_body(serde_json::json!({
6788 "id": "10001",
6789 "key": "PROJ-1",
6790 "fields": {
6791 "summary": "Test",
6792 "status": {"name": "Erledigt"},
6793 "labels": []
6794 }
6795 }));
6796 });
6797
6798 let client = create_self_hosted_client(&server);
6799 let issue = client
6800 .update_issue(
6801 "PROJ-1",
6802 UpdateIssueInput {
6803 state: Some("closed".to_string()),
6804 ..Default::default()
6805 },
6806 )
6807 .await
6808 .unwrap();
6809
6810 assert_eq!(issue.state, "Erledigt");
6811 }
6812
6813 #[tokio::test]
6814 async fn test_update_issue_generic_open_maps_to_new_category() {
6815 let server = MockServer::start();
6816
6817 server.mock(|when, then| {
6818 when.method(GET).path("/issue/PROJ-1/transitions");
6819 then.status(200).json_body(serde_json::json!({
6820 "transitions": [
6821 {
6822 "id": "11",
6823 "name": "Offen",
6824 "to": {
6825 "name": "Offen",
6826 "statusCategory": {"key": "new"}
6827 }
6828 },
6829 {
6830 "id": "21",
6831 "name": "In Bearbeitung",
6832 "to": {
6833 "name": "In Bearbeitung",
6834 "statusCategory": {"key": "indeterminate"}
6835 }
6836 }
6837 ]
6838 }));
6839 });
6840
6841 mock_project_statuses(&server, sample_project_statuses_json());
6842
6843 server.mock(|when, then| {
6844 when.method(POST)
6845 .path("/issue/PROJ-1/transitions")
6846 .body_includes("\"id\":\"11\"");
6847 then.status(204);
6848 });
6849
6850 server.mock(|when, then| {
6851 when.method(GET).path("/issue/PROJ-1");
6852 then.status(200).json_body(serde_json::json!({
6853 "id": "10001",
6854 "key": "PROJ-1",
6855 "fields": {
6856 "summary": "Test",
6857 "status": {"name": "Offen"},
6858 "labels": []
6859 }
6860 }));
6861 });
6862
6863 let client = create_self_hosted_client(&server);
6864 let issue = client
6865 .update_issue(
6866 "PROJ-1",
6867 UpdateIssueInput {
6868 state: Some("open".to_string()),
6869 ..Default::default()
6870 },
6871 )
6872 .await
6873 .unwrap();
6874
6875 assert_eq!(issue.state, "Offen");
6876 }
6877
6878 #[tokio::test]
6879 async fn test_update_issue_canceled_resolves_via_project_statuses() {
6880 let server = MockServer::start();
6881
6882 server.mock(|when, then| {
6884 when.method(GET).path("/issue/PROJ-1/transitions");
6885 then.status(200).json_body(serde_json::json!({
6886 "transitions": [
6887 {
6888 "id": "21",
6889 "name": "Start Progress",
6890 "to": {
6891 "name": "In Bearbeitung",
6892 "statusCategory": {"key": "indeterminate"}
6893 }
6894 },
6895 {
6896 "id": "41",
6897 "name": "Cancel",
6898 "to": {
6899 "name": "Abgebrochen",
6900 "statusCategory": {"key": "done"}
6901 }
6902 }
6903 ]
6904 }));
6905 });
6906
6907 mock_project_statuses(&server, sample_project_statuses_json());
6909
6910 server.mock(|when, then| {
6912 when.method(POST)
6913 .path("/issue/PROJ-1/transitions")
6914 .body_includes("\"id\":\"41\"");
6915 then.status(204);
6916 });
6917
6918 server.mock(|when, then| {
6919 when.method(GET).path("/issue/PROJ-1");
6920 then.status(200).json_body(serde_json::json!({
6921 "id": "10001",
6922 "key": "PROJ-1",
6923 "fields": {
6924 "summary": "Test",
6925 "status": {"name": "Abgebrochen"},
6926 "labels": []
6927 }
6928 }));
6929 });
6930
6931 let client = create_self_hosted_client(&server);
6932 let issue = client
6933 .update_issue(
6934 "PROJ-1",
6935 UpdateIssueInput {
6936 state: Some("canceled".to_string()),
6937 ..Default::default()
6938 },
6939 )
6940 .await
6941 .unwrap();
6942
6943 assert_eq!(issue.state, "Abgebrochen");
6944 }
6945
6946 #[tokio::test]
6947 async fn test_update_issue_exact_project_status_name_match() {
6948 let server = MockServer::start();
6949
6950 server.mock(|when, then| {
6952 when.method(GET).path("/issue/PROJ-1/transitions");
6953 then.status(200).json_body(serde_json::json!({
6954 "transitions": [
6955 {
6956 "id": "41",
6957 "name": "Cancel",
6958 "to": {"name": "Abgebrochen", "statusCategory": {"key": "done"}}
6959 },
6960 {
6961 "id": "31",
6962 "name": "Done",
6963 "to": {"name": "Erledigt", "statusCategory": {"key": "done"}}
6964 }
6965 ]
6966 }));
6967 });
6968
6969 mock_project_statuses(&server, sample_project_statuses_json());
6970
6971 server.mock(|when, then| {
6973 when.method(POST)
6974 .path("/issue/PROJ-1/transitions")
6975 .body_includes("\"id\":\"41\"");
6976 then.status(204);
6977 });
6978
6979 server.mock(|when, then| {
6980 when.method(GET).path("/issue/PROJ-1");
6981 then.status(200).json_body(serde_json::json!({
6982 "id": "10001",
6983 "key": "PROJ-1",
6984 "fields": {
6985 "summary": "Test",
6986 "status": {"name": "Abgebrochen"},
6987 "labels": []
6988 }
6989 }));
6990 });
6991
6992 let client = create_self_hosted_client(&server);
6993 let issue = client
6994 .update_issue(
6995 "PROJ-1",
6996 UpdateIssueInput {
6997 state: Some("Abgebrochen".to_string()),
6998 ..Default::default()
6999 },
7000 )
7001 .await
7002 .unwrap();
7003
7004 assert_eq!(issue.state, "Abgebrochen");
7005 }
7006
7007 #[tokio::test]
7008 async fn test_update_issue_fallback_when_project_statuses_unavailable() {
7009 let server = MockServer::start();
7010
7011 server.mock(|when, then| {
7013 when.method(GET).path("/issue/PROJ-1/transitions");
7014 then.status(200).json_body(serde_json::json!({
7015 "transitions": [{
7016 "id": "31",
7017 "name": "Done",
7018 "to": {"name": "Done", "statusCategory": {"key": "done"}}
7019 }]
7020 }));
7021 });
7022
7023 server.mock(|when, then| {
7025 when.method(GET).path("/project/PROJ/statuses");
7026 then.status(403).body("Forbidden");
7027 });
7028
7029 server.mock(|when, then| {
7030 when.method(POST)
7031 .path("/issue/PROJ-1/transitions")
7032 .body_includes("\"id\":\"31\"");
7033 then.status(204);
7034 });
7035
7036 server.mock(|when, then| {
7037 when.method(GET).path("/issue/PROJ-1");
7038 then.status(200).json_body(serde_json::json!({
7039 "id": "10001",
7040 "key": "PROJ-1",
7041 "fields": {
7042 "summary": "Test",
7043 "status": {"name": "Done"},
7044 "labels": []
7045 }
7046 }));
7047 });
7048
7049 let client = create_self_hosted_client(&server);
7050 let issue = client
7052 .update_issue(
7053 "PROJ-1",
7054 UpdateIssueInput {
7055 state: Some("closed".to_string()),
7056 ..Default::default()
7057 },
7058 )
7059 .await
7060 .unwrap();
7061
7062 assert_eq!(issue.state, "Done");
7063 }
7064
7065 #[tokio::test]
7066 async fn test_get_comments() {
7067 let server = MockServer::start();
7068
7069 server.mock(|when, then| {
7070 when.method(GET).path("/issue/PROJ-1/comment");
7071 then.status(200).json_body(serde_json::json!({
7072 "comments": [{
7073 "id": "100",
7074 "body": "Great work!",
7075 "author": {
7076 "name": "reviewer",
7077 "displayName": "Reviewer"
7078 },
7079 "created": "2024-01-01T12:00:00.000+0000",
7080 "updated": "2024-01-01T12:00:00.000+0000"
7081 }]
7082 }));
7083 });
7084
7085 let client = create_self_hosted_client(&server);
7086 let comments = client.get_comments("PROJ-1").await.unwrap().items;
7087
7088 assert_eq!(comments.len(), 1);
7089 assert_eq!(comments[0].id, "100");
7090 assert_eq!(comments[0].body, "Great work!");
7091 assert_eq!(comments[0].author.as_ref().unwrap().username, "reviewer");
7092 }
7093
7094 #[tokio::test]
7095 async fn test_add_comment() {
7096 let server = MockServer::start();
7097
7098 server.mock(|when, then| {
7099 when.method(POST)
7100 .path("/issue/PROJ-1/comment")
7101 .body_includes("\"body\":\"My comment\"");
7102 then.status(201).json_body(serde_json::json!({
7103 "id": "101",
7104 "body": "My comment",
7105 "author": {
7106 "name": "user",
7107 "displayName": "User"
7108 },
7109 "created": "2024-01-01T13:00:00.000+0000"
7110 }));
7111 });
7112
7113 let client = create_self_hosted_client(&server);
7114 let comment = IssueProvider::add_comment(&client, "PROJ-1", "My comment")
7115 .await
7116 .unwrap();
7117
7118 assert_eq!(comment.id, "101");
7119 assert_eq!(comment.body, "My comment");
7120 }
7121
7122 #[tokio::test]
7127 async fn test_cloud_get_issues() {
7128 let server = MockServer::start();
7129
7130 server.mock(|when, then| {
7131 when.method(GET)
7132 .path("/search/jql")
7133 .query_param_exists("jql");
7134 then.status(200).json_body(serde_json::json!({
7135 "issues": [sample_cloud_issue_json()]
7136 }));
7137 });
7138
7139 let client = create_cloud_client(&server);
7140 let issues = client
7141 .get_issues(IssueFilter::default())
7142 .await
7143 .unwrap()
7144 .items;
7145
7146 assert_eq!(issues.len(), 1);
7147 assert_eq!(issues[0].key, "jira#PROJ-1");
7148 assert_eq!(
7149 issues[0].description,
7150 Some("Login fails on mobile".to_string())
7151 );
7152 }
7153
7154 #[tokio::test]
7155 async fn test_cloud_create_issue_adf() {
7156 let server = MockServer::start();
7157
7158 server.mock(|when, then| {
7160 when.method(POST)
7161 .path("/issue")
7162 .body_includes("\"type\":\"doc\"")
7163 .body_includes("\"version\":1");
7164 then.status(201).json_body(serde_json::json!({
7165 "id": "10003",
7166 "key": "PROJ-3"
7167 }));
7168 });
7169
7170 server.mock(|when, then| {
7171 when.method(GET).path("/issue/PROJ-3");
7172 then.status(200).json_body(serde_json::json!({
7173 "id": "10003",
7174 "key": "PROJ-3",
7175 "fields": {
7176 "summary": "Cloud task",
7177 "description": {
7178 "version": 1,
7179 "type": "doc",
7180 "content": [{
7181 "type": "paragraph",
7182 "content": [{"type": "text", "text": "Cloud description"}]
7183 }]
7184 },
7185 "status": {"name": "To Do"},
7186 "labels": []
7187 }
7188 }));
7189 });
7190
7191 let client = create_cloud_client(&server);
7192 let issue = client
7193 .create_issue(CreateIssueInput {
7194 title: "Cloud task".to_string(),
7195 description: Some("Cloud description".to_string()),
7196 ..Default::default()
7197 })
7198 .await
7199 .unwrap();
7200
7201 assert_eq!(issue.key, "jira#PROJ-3");
7202 assert_eq!(issue.description, Some("Cloud description".to_string()));
7203 }
7204
7205 #[tokio::test]
7206 async fn test_cloud_add_comment_adf() {
7207 let server = MockServer::start();
7208
7209 server.mock(|when, then| {
7210 when.method(POST)
7211 .path("/issue/PROJ-1/comment")
7212 .body_includes("\"type\":\"doc\"");
7213 then.status(201).json_body(serde_json::json!({
7214 "id": "201",
7215 "body": {
7216 "version": 1,
7217 "type": "doc",
7218 "content": [{
7219 "type": "paragraph",
7220 "content": [{"type": "text", "text": "ADF comment body"}]
7221 }]
7222 },
7223 "author": {
7224 "accountId": "abc123",
7225 "displayName": "Commenter"
7226 },
7227 "created": "2024-01-02T10:00:00.000+0000"
7228 }));
7229 });
7230
7231 let client = create_cloud_client(&server);
7232 let comment = IssueProvider::add_comment(&client, "PROJ-1", "ADF comment body")
7233 .await
7234 .unwrap();
7235
7236 assert_eq!(comment.id, "201");
7237 assert_eq!(comment.body, "ADF comment body");
7238 }
7239
7240 #[tokio::test]
7241 async fn test_cloud_get_issue_adf_description() {
7242 let server = MockServer::start();
7243
7244 server.mock(|when, then| {
7245 when.method(GET).path("/issue/PROJ-1");
7246 then.status(200).json_body(sample_cloud_issue_json());
7247 });
7248
7249 let client = create_cloud_client(&server);
7250 let issue = client.get_issue("PROJ-1").await.unwrap();
7251
7252 assert_eq!(issue.description, Some("Login fails on mobile".to_string()));
7253 }
7254
7255 #[tokio::test]
7260 async fn test_handle_401() {
7261 let server = MockServer::start();
7262
7263 server.mock(|when, then| {
7264 when.method(GET).path("/issue/PROJ-1");
7265 then.status(401).body("Unauthorized");
7266 });
7267
7268 let client = create_self_hosted_client(&server);
7269 let result = client.get_issue("PROJ-1").await;
7270
7271 assert!(result.is_err());
7272 assert!(matches!(result.unwrap_err(), Error::Unauthorized(_)));
7273 }
7274
7275 #[tokio::test]
7276 async fn test_handle_404() {
7277 let server = MockServer::start();
7278
7279 server.mock(|when, then| {
7280 when.method(GET).path("/issue/PROJ-999");
7281 then.status(404).body("Issue not found");
7282 });
7283
7284 let client = create_self_hosted_client(&server);
7285 let result = client.get_issue("PROJ-999").await;
7286
7287 assert!(result.is_err());
7288 assert!(matches!(result.unwrap_err(), Error::NotFound(_)));
7289 }
7290
7291 #[tokio::test]
7292 async fn test_handle_500() {
7293 let server = MockServer::start();
7294
7295 server.mock(|when, then| {
7296 when.method(GET).path("/search");
7297 then.status(500).body("Internal Server Error");
7298 });
7299
7300 let client = create_self_hosted_client(&server);
7301 let result = client.get_issues(IssueFilter::default()).await;
7302
7303 assert!(result.is_err());
7304 assert!(matches!(result.unwrap_err(), Error::ServerError { .. }));
7305 }
7306
7307 #[tokio::test]
7312 async fn test_mr_methods_unsupported() {
7313 let client = JiraClient::with_base_url(
7314 "http://localhost",
7315 "PROJ",
7316 "user@example.com",
7317 token("token"),
7318 false,
7319 );
7320
7321 let result = client.get_merge_requests(MrFilter::default()).await;
7322 assert!(matches!(
7323 result.unwrap_err(),
7324 Error::ProviderUnsupported { .. }
7325 ));
7326
7327 let result = client.get_merge_request("mr#1").await;
7328 assert!(matches!(
7329 result.unwrap_err(),
7330 Error::ProviderUnsupported { .. }
7331 ));
7332
7333 let result = client.get_discussions("mr#1").await;
7334 assert!(matches!(
7335 result.unwrap_err(),
7336 Error::ProviderUnsupported { .. }
7337 ));
7338
7339 let result = client.get_diffs("mr#1").await;
7340 assert!(matches!(
7341 result.unwrap_err(),
7342 Error::ProviderUnsupported { .. }
7343 ));
7344
7345 let result = MergeRequestProvider::add_comment(
7346 &client,
7347 "mr#1",
7348 CreateCommentInput {
7349 body: "test".to_string(),
7350 position: None,
7351 discussion_id: None,
7352 },
7353 )
7354 .await;
7355 assert!(matches!(
7356 result.unwrap_err(),
7357 Error::ProviderUnsupported { .. }
7358 ));
7359 }
7360
7361 #[tokio::test]
7366 async fn test_get_current_user() {
7367 let server = MockServer::start();
7368
7369 server.mock(|when, then| {
7370 when.method(GET).path("/myself");
7371 then.status(200).json_body(serde_json::json!({
7372 "name": "jdoe",
7373 "displayName": "John Doe",
7374 "emailAddress": "john@example.com"
7375 }));
7376 });
7377
7378 let client = create_self_hosted_client(&server);
7379 let user = client.get_current_user().await.unwrap();
7380
7381 assert_eq!(user.username, "jdoe");
7382 assert_eq!(user.name, Some("John Doe".to_string()));
7383 assert_eq!(user.email, Some("john@example.com".to_string()));
7384 }
7385
7386 #[tokio::test]
7387 async fn test_get_current_user_auth_failure() {
7388 let server = MockServer::start();
7389
7390 server.mock(|when, then| {
7391 when.method(GET).path("/myself");
7392 then.status(401).body("Unauthorized");
7393 });
7394
7395 let client = create_self_hosted_client(&server);
7396 let result = client.get_current_user().await;
7397
7398 assert!(result.is_err());
7399 assert!(matches!(result.unwrap_err(), Error::Unauthorized(_)));
7400 }
7401
7402 #[tokio::test]
7403 async fn test_transition_not_found_error_lists_available() {
7404 let server = MockServer::start();
7405
7406 server.mock(|when, then| {
7407 when.method(GET).path("/issue/PROJ-1/transitions");
7408 then.status(200).json_body(serde_json::json!({
7409 "transitions": [
7410 {
7411 "id": "21",
7412 "name": "Start Progress",
7413 "to": {
7414 "name": "In Bearbeitung",
7415 "statusCategory": {"key": "indeterminate"}
7416 }
7417 }
7418 ]
7419 }));
7420 });
7421
7422 mock_project_statuses(&server, sample_project_statuses_json());
7424
7425 let client = create_self_hosted_client(&server);
7426 let result = client
7427 .update_issue(
7428 "PROJ-1",
7429 UpdateIssueInput {
7430 state: Some("nonexistent".to_string()),
7431 ..Default::default()
7432 },
7433 )
7434 .await;
7435
7436 assert!(result.is_err());
7437 let err = result.unwrap_err().to_string();
7438 assert!(err.contains("No transition to status"), "got: {}", err);
7439 assert!(
7440 err.contains("In Bearbeitung"),
7441 "should list available: {}",
7442 err
7443 );
7444 }
7445
7446 #[tokio::test]
7447 async fn test_cloud_get_issues_pagination_next_page_token() {
7448 let server = MockServer::start();
7449
7450 server.mock(|when, then| {
7453 when.method(GET)
7454 .path("/search/jql")
7455 .query_param("nextPageToken", "page2token");
7456 then.status(200).json_body(serde_json::json!({
7457 "issues": [
7458 {
7459 "id": "10003",
7460 "key": "PROJ-3",
7461 "fields": {
7462 "summary": "Issue 3",
7463 "status": {"name": "Done"},
7464 "labels": [],
7465 "created": "2024-01-03T10:00:00.000+0000"
7466 }
7467 }
7468 ]
7469 }));
7470 });
7471
7472 server.mock(|when, then| {
7474 when.method(GET)
7475 .path("/search/jql")
7476 .query_param_exists("jql");
7477 then.status(200).json_body(serde_json::json!({
7478 "issues": [
7479 {
7480 "id": "10001",
7481 "key": "PROJ-1",
7482 "fields": {
7483 "summary": "Issue 1",
7484 "status": {"name": "Open"},
7485 "labels": [],
7486 "created": "2024-01-01T10:00:00.000+0000"
7487 }
7488 },
7489 {
7490 "id": "10002",
7491 "key": "PROJ-2",
7492 "fields": {
7493 "summary": "Issue 2",
7494 "status": {"name": "Open"},
7495 "labels": [],
7496 "created": "2024-01-02T10:00:00.000+0000"
7497 }
7498 }
7499 ],
7500 "nextPageToken": "page2token"
7501 }));
7502 });
7503
7504 let client = create_cloud_client(&server);
7505 let issues = client
7506 .get_issues(IssueFilter {
7507 limit: Some(3),
7508 ..Default::default()
7509 })
7510 .await
7511 .unwrap()
7512 .items;
7513
7514 assert_eq!(issues.len(), 3);
7515 assert_eq!(issues[0].key, "jira#PROJ-1");
7516 assert_eq!(issues[1].key, "jira#PROJ-2");
7517 assert_eq!(issues[2].key, "jira#PROJ-3");
7518 }
7519
7520 #[test]
7521 fn test_escape_jql() {
7522 assert_eq!(escape_jql("simple"), "simple");
7523 assert_eq!(escape_jql(r#"has "quotes""#), r#"has \"quotes\""#);
7524 assert_eq!(escape_jql(r"back\slash"), r"back\\slash");
7525 assert_eq!(
7526 escape_jql(r#"both "and" \ here"#),
7527 r#"both \"and\" \\ here"#
7528 );
7529 }
7530
7531 #[test]
7532 fn test_has_project_clause() {
7533 assert!(has_project_clause("project = \"PROJ\""));
7535 assert!(has_project_clause("project = PROJ AND status = Open"));
7536 assert!(has_project_clause("project IN (\"A\", \"B\")"));
7537 assert!(has_project_clause("project in(A, B)"));
7538 assert!(has_project_clause("PROJECT = KEY")); assert!(has_project_clause("status = Open AND project = X"));
7540 assert!(has_project_clause("project ~ KEY")); assert!(has_project_clause("project != \"PROJ\""));
7543 assert!(has_project_clause("project NOT IN (\"A\", \"B\")"));
7544 assert!(has_project_clause("project not in(A)"));
7545 assert!(!has_project_clause("fixVersion = \"1.0\""));
7547 assert!(!has_project_clause("status = Done"));
7548 assert!(!has_project_clause("summary ~ \"project plan\""));
7550 assert!(!has_project_clause("summary ~ \"project information\""));
7551 assert!(!has_project_clause("summary ~ \"project = foo\""));
7552 assert!(!has_project_clause("my_project = X"));
7554 }
7555
7556 #[test]
7561 fn test_merge_custom_fields_into_payload() {
7562 use crate::types::*;
7563 let payload = CreateIssuePayload {
7564 fields: CreateIssueFields {
7565 project: ProjectKey { key: "PROJ".into() },
7566 summary: "Test".into(),
7567 issuetype: IssueType {
7568 name: "Task".into(),
7569 },
7570 description: None,
7571 labels: None,
7572 priority: None,
7573 assignee: None,
7574 components: None,
7575 fix_versions: None,
7576 parent: None,
7577 },
7578 };
7579
7580 let cf = Some(serde_json::json!({"customfield_10001": 8, "customfield_10002": "x"}));
7581 let (merged, count) = merge_custom_fields_into_payload(payload, &cf).unwrap();
7582
7583 let fields = merged.get("fields").unwrap();
7584 assert_eq!(fields["customfield_10001"], 8);
7585 assert_eq!(fields["customfield_10002"], "x");
7586 assert_eq!(count, 2);
7587 assert_eq!(fields["summary"], "Test");
7588 assert_eq!(fields["project"]["key"], "PROJ");
7589 }
7590
7591 #[test]
7592 fn test_merge_custom_fields_none_is_noop() {
7593 use crate::types::*;
7594 let payload = CreateIssuePayload {
7595 fields: CreateIssueFields {
7596 project: ProjectKey { key: "PROJ".into() },
7597 summary: "Test".into(),
7598 issuetype: IssueType {
7599 name: "Task".into(),
7600 },
7601 description: None,
7602 labels: None,
7603 priority: None,
7604 assignee: None,
7605 components: None,
7606 fix_versions: None,
7607 parent: None,
7608 },
7609 };
7610
7611 let (merged, count) = merge_custom_fields_into_payload(payload, &None).unwrap();
7612 assert_eq!(count, 0);
7613 let fields = merged.get("fields").unwrap();
7614 assert_eq!(fields["summary"], "Test");
7615 assert!(fields.get("customfield_10001").is_none());
7616 }
7617
7618 #[test]
7619 fn test_merge_custom_fields_rejects_non_custom_keys() {
7620 use crate::types::*;
7621 let payload = CreateIssuePayload {
7622 fields: CreateIssueFields {
7623 project: ProjectKey { key: "PROJ".into() },
7624 summary: "Test".into(),
7625 issuetype: IssueType {
7626 name: "Task".into(),
7627 },
7628 description: None,
7629 labels: None,
7630 priority: None,
7631 assignee: None,
7632 components: None,
7633 fix_versions: None,
7634 parent: None,
7635 },
7636 };
7637
7638 let cf = Some(serde_json::json!({"summary": "HACKED", "customfield_10001": 5}));
7640 let (merged, count) = merge_custom_fields_into_payload(payload, &cf).unwrap();
7641
7642 let fields = merged.get("fields").unwrap();
7643 assert_eq!(fields["summary"], "Test"); assert_eq!(fields["customfield_10001"], 5); assert_eq!(count, 1); }
7647
7648 #[tokio::test]
7653 async fn test_get_issue_relations() {
7654 let server = MockServer::start();
7655
7656 server.mock(|when, then| {
7657 when.method(GET)
7658 .path("/issue/PROJ-1")
7659 .query_param_includes("fields", "parent");
7660 then.status(200).json_body(serde_json::json!({
7661 "id": "10001",
7662 "key": "PROJ-1",
7663 "fields": {
7664 "summary": "Main issue",
7665 "status": {"name": "Open"},
7666 "labels": [],
7667 "parent": {
7668 "id": "10000",
7669 "key": "PROJ-0",
7670 "fields": {
7671 "summary": "Parent issue",
7672 "status": {"name": "Open"},
7673 "labels": []
7674 }
7675 },
7676 "subtasks": [
7677 {
7678 "id": "10002",
7679 "key": "PROJ-2",
7680 "fields": {
7681 "summary": "Subtask 1",
7682 "status": {"name": "In Progress"},
7683 "labels": []
7684 }
7685 }
7686 ],
7687 "issuelinks": [
7688 {
7689 "type": {
7690 "name": "Blocks",
7691 "outward": "blocks",
7692 "inward": "is blocked by"
7693 },
7694 "outwardIssue": {
7695 "id": "10003",
7696 "key": "PROJ-3",
7697 "fields": {
7698 "summary": "Blocked issue",
7699 "status": {"name": "Open"},
7700 "labels": []
7701 }
7702 }
7703 }
7704 ]
7705 }
7706 }));
7707 });
7708
7709 let client = create_self_hosted_client(&server);
7710 let relations = client.get_issue_relations("jira#PROJ-1").await.unwrap();
7711
7712 assert!(relations.parent.is_some());
7713 assert_eq!(relations.parent.unwrap().key, "jira#PROJ-0");
7714 assert_eq!(relations.subtasks.len(), 1);
7715 assert_eq!(relations.subtasks[0].key, "jira#PROJ-2");
7716 assert_eq!(relations.blocks.len(), 1);
7717 assert_eq!(relations.blocks[0].issue.key, "jira#PROJ-3");
7718 }
7719
7720 #[tokio::test]
7725 async fn test_get_issue_attachments_maps_fields() {
7726 let server = MockServer::start();
7727
7728 server.mock(|when, then| {
7729 when.method(GET)
7730 .path("/issue/PROJ-1")
7731 .query_param("fields", "attachment");
7732 then.status(200).json_body(serde_json::json!({
7733 "id": "10001",
7734 "key": "PROJ-1",
7735 "fields": {
7736 "attachment": [
7737 {
7738 "id": "42",
7739 "filename": "crash.log",
7740 "content": "https://example/rest/api/2/attachment/content/42",
7741 "size": 2048,
7742 "mimeType": "text/plain",
7743 "created": "2024-01-01T00:00:00.000+0000",
7744 "author": {
7745 "name": "uploader",
7746 "displayName": "Upload User"
7747 }
7748 }
7749 ]
7750 }
7751 }));
7752 });
7753
7754 let client = create_self_hosted_client(&server);
7755 let assets = client.get_issue_attachments("jira#PROJ-1").await.unwrap();
7756 assert_eq!(assets.len(), 1);
7757 let a = &assets[0];
7758 assert_eq!(a.id, "42");
7759 assert_eq!(a.filename, "crash.log");
7760 assert_eq!(a.mime_type.as_deref(), Some("text/plain"));
7761 assert_eq!(a.size, Some(2048));
7762 assert_eq!(a.author.as_deref(), Some("Upload User"));
7763 }
7764
7765 #[tokio::test]
7766 async fn test_download_attachment_returns_bytes() {
7767 let server = MockServer::start();
7768
7769 let content_url = server.url("/secure/attachment/42/trace.log");
7771 server.mock(|when, then| {
7772 when.method(GET).path("/attachment/42");
7773 then.status(200).json_body(serde_json::json!({
7774 "self": "http://localhost/rest/api/2/attachment/42",
7775 "id": "42",
7776 "filename": "trace.log",
7777 "content": content_url,
7778 }));
7779 });
7780 server.mock(|when, then| {
7781 when.method(GET).path("/secure/attachment/42/trace.log");
7782 then.status(200).body("stack trace here");
7783 });
7784
7785 let client = create_self_hosted_client(&server);
7786 let bytes = client
7787 .download_attachment("jira#PROJ-1", "42")
7788 .await
7789 .unwrap();
7790 assert_eq!(bytes, b"stack trace here");
7791 }
7792
7793 #[tokio::test]
7794 async fn test_delete_attachment_ok() {
7795 let server = MockServer::start();
7796
7797 let mock = server.mock(|when, then| {
7798 when.method(DELETE).path("/attachment/42");
7799 then.status(204);
7800 });
7801
7802 let client = create_self_hosted_client(&server);
7803 client.delete_attachment("jira#PROJ-1", "42").await.unwrap();
7804 mock.assert();
7805 }
7806
7807 #[tokio::test]
7808 async fn test_upload_attachment_returns_content_url() {
7809 let server = MockServer::start();
7810
7811 server.mock(|when, then| {
7812 when.method(POST)
7813 .path("/issue/PROJ-1/attachments")
7814 .header("X-Atlassian-Token", "no-check");
7815 then.status(200).json_body(serde_json::json!([
7816 {
7817 "id": "99",
7818 "filename": "report.txt",
7819 "content": "https://example/rest/api/2/attachment/content/99",
7820 "size": 10
7821 }
7822 ]));
7823 });
7824
7825 let client = create_self_hosted_client(&server);
7826 let url = client
7827 .upload_attachment("jira#PROJ-1", "report.txt", b"0123456789")
7828 .await
7829 .unwrap();
7830 assert_eq!(url, "https://example/rest/api/2/attachment/content/99");
7831 }
7832
7833 #[tokio::test]
7834 async fn test_jira_asset_capabilities() {
7835 let server = MockServer::start();
7836 let client = create_self_hosted_client(&server);
7837 let caps = client.asset_capabilities();
7838 assert!(caps.issue.upload);
7839 assert!(caps.issue.download);
7840 assert!(caps.issue.delete);
7841 assert!(caps.issue.list);
7842 }
7843 }
7844
7845 #[test]
7850 fn test_map_relations_empty() {
7851 let issue = JiraIssue {
7852 id: "10001".to_string(),
7853 key: "PROJ-1".to_string(),
7854 fields: JiraIssueFields {
7855 summary: Some("Test".to_string()),
7856 description: None,
7857 status: None,
7858 priority: None,
7859 assignee: None,
7860 reporter: None,
7861 labels: vec![],
7862 created: None,
7863 updated: None,
7864 parent: None,
7865 subtasks: vec![],
7866 issuelinks: vec![],
7867 attachment: vec![],
7868 issuetype: None,
7869 extras: std::collections::HashMap::new(),
7870 },
7871 };
7872
7873 let relations = map_relations(&issue, JiraFlavor::Cloud, "https://test.atlassian.net");
7874
7875 assert!(relations.parent.is_none());
7876 assert!(relations.subtasks.is_empty());
7877 assert!(relations.blocks.is_empty());
7878 assert!(relations.blocked_by.is_empty());
7879 assert!(relations.related_to.is_empty());
7880 assert!(relations.duplicates.is_empty());
7881 }
7882
7883 #[test]
7884 fn test_map_relations_with_parent() {
7885 let parent = Box::new(JiraIssue {
7886 id: "10000".to_string(),
7887 key: "PROJ-0".to_string(),
7888 fields: JiraIssueFields {
7889 summary: Some("Parent Issue".to_string()),
7890 description: None,
7891 status: Some(JiraStatus {
7892 name: "Open".to_string(),
7893 status_category: None,
7894 }),
7895 priority: None,
7896 assignee: None,
7897 reporter: None,
7898 labels: vec![],
7899 created: None,
7900 updated: None,
7901 parent: None,
7902 subtasks: vec![],
7903 issuelinks: vec![],
7904 attachment: vec![],
7905 issuetype: None,
7906 extras: std::collections::HashMap::new(),
7907 },
7908 });
7909
7910 let issue = JiraIssue {
7911 id: "10001".to_string(),
7912 key: "PROJ-1".to_string(),
7913 fields: JiraIssueFields {
7914 summary: Some("Child Issue".to_string()),
7915 description: None,
7916 status: None,
7917 priority: None,
7918 assignee: None,
7919 reporter: None,
7920 labels: vec![],
7921 created: None,
7922 updated: None,
7923 parent: Some(parent),
7924 subtasks: vec![],
7925 issuelinks: vec![],
7926 attachment: vec![],
7927 issuetype: None,
7928 extras: std::collections::HashMap::new(),
7929 },
7930 };
7931
7932 let relations = map_relations(&issue, JiraFlavor::SelfHosted, "https://jira.example.com");
7933
7934 assert!(relations.parent.is_some());
7935 let parent_issue = relations.parent.unwrap();
7936 assert_eq!(parent_issue.key, "jira#PROJ-0");
7937 assert_eq!(parent_issue.title, "Parent Issue");
7938 }
7939
7940 #[test]
7941 fn test_map_relations_with_subtasks() {
7942 let issue = JiraIssue {
7943 id: "10001".to_string(),
7944 key: "PROJ-1".to_string(),
7945 fields: JiraIssueFields {
7946 summary: Some("Epic".to_string()),
7947 description: None,
7948 status: None,
7949 priority: None,
7950 assignee: None,
7951 reporter: None,
7952 labels: vec![],
7953 created: None,
7954 updated: None,
7955 parent: None,
7956 subtasks: vec![
7957 JiraIssue {
7958 id: "10002".to_string(),
7959 key: "PROJ-2".to_string(),
7960 fields: JiraIssueFields {
7961 summary: Some("Subtask 1".to_string()),
7962 description: None,
7963 status: Some(JiraStatus {
7964 name: "In Progress".to_string(),
7965 status_category: None,
7966 }),
7967 priority: None,
7968 assignee: None,
7969 reporter: None,
7970 labels: vec![],
7971 created: None,
7972 updated: None,
7973 parent: None,
7974 subtasks: vec![],
7975 issuelinks: vec![],
7976 attachment: vec![],
7977 issuetype: None,
7978 extras: std::collections::HashMap::new(),
7979 },
7980 },
7981 JiraIssue {
7982 id: "10003".to_string(),
7983 key: "PROJ-3".to_string(),
7984 fields: JiraIssueFields {
7985 summary: Some("Subtask 2".to_string()),
7986 description: None,
7987 status: None,
7988 priority: None,
7989 assignee: None,
7990 reporter: None,
7991 labels: vec![],
7992 created: None,
7993 updated: None,
7994 parent: None,
7995 subtasks: vec![],
7996 issuelinks: vec![],
7997 attachment: vec![],
7998 issuetype: None,
7999 extras: std::collections::HashMap::new(),
8000 },
8001 },
8002 ],
8003 issuelinks: vec![],
8004 attachment: vec![],
8005 issuetype: None,
8006 extras: std::collections::HashMap::new(),
8007 },
8008 };
8009
8010 let relations = map_relations(&issue, JiraFlavor::Cloud, "https://test.atlassian.net");
8011
8012 assert_eq!(relations.subtasks.len(), 2);
8013 assert_eq!(relations.subtasks[0].key, "jira#PROJ-2");
8014 assert_eq!(relations.subtasks[0].title, "Subtask 1");
8015 assert_eq!(relations.subtasks[1].key, "jira#PROJ-3");
8016 assert_eq!(relations.subtasks[1].title, "Subtask 2");
8017 }
8018
8019 #[test]
8020 fn test_map_relations_with_issuelinks_blocks() {
8021 let issue = JiraIssue {
8022 id: "10001".to_string(),
8023 key: "PROJ-1".to_string(),
8024 fields: JiraIssueFields {
8025 summary: Some("Test".to_string()),
8026 description: None,
8027 status: None,
8028 priority: None,
8029 assignee: None,
8030 reporter: None,
8031 labels: vec![],
8032 created: None,
8033 updated: None,
8034 parent: None,
8035 subtasks: vec![],
8036 issuelinks: vec![
8037 JiraIssueLink {
8039 id: Some("1".to_string()),
8040 link_type: JiraIssueLinkType {
8041 name: "Blocks".to_string(),
8042 outward: Some("blocks".to_string()),
8043 inward: Some("is blocked by".to_string()),
8044 },
8045 outward_issue: Some(Box::new(JiraIssue {
8046 id: "10002".to_string(),
8047 key: "PROJ-2".to_string(),
8048 fields: JiraIssueFields {
8049 summary: Some("Blocked".to_string()),
8050 description: None,
8051 status: None,
8052 priority: None,
8053 assignee: None,
8054 reporter: None,
8055 labels: vec![],
8056 created: None,
8057 updated: None,
8058 parent: None,
8059 subtasks: vec![],
8060 issuelinks: vec![],
8061 attachment: vec![],
8062 issuetype: None,
8063 extras: std::collections::HashMap::new(),
8064 },
8065 })),
8066 inward_issue: None,
8067 },
8068 JiraIssueLink {
8070 id: Some("2".to_string()),
8071 link_type: JiraIssueLinkType {
8072 name: "Blocks".to_string(),
8073 outward: Some("blocks".to_string()),
8074 inward: Some("is blocked by".to_string()),
8075 },
8076 outward_issue: None,
8077 inward_issue: Some(Box::new(JiraIssue {
8078 id: "10003".to_string(),
8079 key: "PROJ-3".to_string(),
8080 fields: JiraIssueFields {
8081 summary: Some("Blocker".to_string()),
8082 description: None,
8083 status: None,
8084 priority: None,
8085 assignee: None,
8086 reporter: None,
8087 labels: vec![],
8088 created: None,
8089 updated: None,
8090 parent: None,
8091 subtasks: vec![],
8092 issuelinks: vec![],
8093 attachment: vec![],
8094 issuetype: None,
8095 extras: std::collections::HashMap::new(),
8096 },
8097 })),
8098 },
8099 ],
8100 attachment: vec![],
8101 issuetype: None,
8102 extras: std::collections::HashMap::new(),
8103 },
8104 };
8105
8106 let relations = map_relations(&issue, JiraFlavor::Cloud, "https://test.atlassian.net");
8107
8108 assert_eq!(relations.blocks.len(), 1);
8109 assert_eq!(relations.blocks[0].issue.key, "jira#PROJ-2");
8110 assert_eq!(relations.blocks[0].link_type, "Blocks");
8111 assert_eq!(relations.blocked_by.len(), 1);
8112 assert_eq!(relations.blocked_by[0].issue.key, "jira#PROJ-3");
8113 }
8114
8115 #[test]
8116 fn test_map_relations_with_issuelinks_duplicates() {
8117 let issue = JiraIssue {
8118 id: "10001".to_string(),
8119 key: "PROJ-1".to_string(),
8120 fields: JiraIssueFields {
8121 summary: Some("Test".to_string()),
8122 description: None,
8123 status: None,
8124 priority: None,
8125 assignee: None,
8126 reporter: None,
8127 labels: vec![],
8128 created: None,
8129 updated: None,
8130 parent: None,
8131 subtasks: vec![],
8132 issuelinks: vec![
8133 JiraIssueLink {
8135 id: Some("1".to_string()),
8136 link_type: JiraIssueLinkType {
8137 name: "Duplicate".to_string(),
8138 outward: Some("duplicates".to_string()),
8139 inward: Some("is duplicated by".to_string()),
8140 },
8141 outward_issue: Some(Box::new(JiraIssue {
8142 id: "10002".to_string(),
8143 key: "PROJ-2".to_string(),
8144 fields: JiraIssueFields {
8145 summary: Some("Dup outward".to_string()),
8146 description: None,
8147 status: None,
8148 priority: None,
8149 assignee: None,
8150 reporter: None,
8151 labels: vec![],
8152 created: None,
8153 updated: None,
8154 parent: None,
8155 subtasks: vec![],
8156 issuelinks: vec![],
8157 attachment: vec![],
8158 issuetype: None,
8159 extras: std::collections::HashMap::new(),
8160 },
8161 })),
8162 inward_issue: None,
8163 },
8164 JiraIssueLink {
8166 id: Some("2".to_string()),
8167 link_type: JiraIssueLinkType {
8168 name: "Duplicate".to_string(),
8169 outward: Some("duplicates".to_string()),
8170 inward: Some("is duplicated by".to_string()),
8171 },
8172 outward_issue: None,
8173 inward_issue: Some(Box::new(JiraIssue {
8174 id: "10003".to_string(),
8175 key: "PROJ-3".to_string(),
8176 fields: JiraIssueFields {
8177 summary: Some("Dup inward".to_string()),
8178 description: None,
8179 status: None,
8180 priority: None,
8181 assignee: None,
8182 reporter: None,
8183 labels: vec![],
8184 created: None,
8185 updated: None,
8186 parent: None,
8187 subtasks: vec![],
8188 issuelinks: vec![],
8189 attachment: vec![],
8190 issuetype: None,
8191 extras: std::collections::HashMap::new(),
8192 },
8193 })),
8194 },
8195 ],
8196 attachment: vec![],
8197 issuetype: None,
8198 extras: std::collections::HashMap::new(),
8199 },
8200 };
8201
8202 let relations = map_relations(&issue, JiraFlavor::Cloud, "https://test.atlassian.net");
8203
8204 assert_eq!(relations.duplicates.len(), 2);
8206 assert_eq!(relations.duplicates[0].issue.key, "jira#PROJ-2");
8207 assert_eq!(relations.duplicates[1].issue.key, "jira#PROJ-3");
8208 }
8209
8210 #[test]
8211 fn test_map_relations_with_issuelinks_relates() {
8212 let issue = JiraIssue {
8213 id: "10001".to_string(),
8214 key: "PROJ-1".to_string(),
8215 fields: JiraIssueFields {
8216 summary: Some("Test".to_string()),
8217 description: None,
8218 status: None,
8219 priority: None,
8220 assignee: None,
8221 reporter: None,
8222 labels: vec![],
8223 created: None,
8224 updated: None,
8225 parent: None,
8226 subtasks: vec![],
8227 issuelinks: vec![JiraIssueLink {
8228 id: Some("1".to_string()),
8229 link_type: JiraIssueLinkType {
8230 name: "Relates".to_string(),
8231 outward: Some("relates to".to_string()),
8232 inward: Some("relates to".to_string()),
8233 },
8234 outward_issue: Some(Box::new(JiraIssue {
8235 id: "10002".to_string(),
8236 key: "PROJ-2".to_string(),
8237 fields: JiraIssueFields {
8238 summary: Some("Related".to_string()),
8239 description: None,
8240 status: None,
8241 priority: None,
8242 assignee: None,
8243 reporter: None,
8244 labels: vec![],
8245 created: None,
8246 updated: None,
8247 parent: None,
8248 subtasks: vec![],
8249 issuelinks: vec![],
8250 attachment: vec![],
8251 issuetype: None,
8252 extras: std::collections::HashMap::new(),
8253 },
8254 })),
8255 inward_issue: None,
8256 }],
8257 attachment: vec![],
8258 issuetype: None,
8259 extras: std::collections::HashMap::new(),
8260 },
8261 };
8262
8263 let relations = map_relations(&issue, JiraFlavor::Cloud, "https://test.atlassian.net");
8264
8265 assert_eq!(relations.related_to.len(), 1);
8266 assert_eq!(relations.related_to[0].issue.key, "jira#PROJ-2");
8267 assert_eq!(relations.related_to[0].link_type, "Relates");
8268 }
8269
8270 #[test]
8271 fn test_map_relations_mixed() {
8272 let issue = JiraIssue {
8273 id: "10001".to_string(),
8274 key: "PROJ-1".to_string(),
8275 fields: JiraIssueFields {
8276 summary: Some("Main".to_string()),
8277 description: None,
8278 status: None,
8279 priority: None,
8280 assignee: None,
8281 reporter: None,
8282 labels: vec![],
8283 created: None,
8284 updated: None,
8285 parent: Some(Box::new(JiraIssue {
8286 id: "10000".to_string(),
8287 key: "PROJ-0".to_string(),
8288 fields: JiraIssueFields {
8289 summary: Some("Parent".to_string()),
8290 description: None,
8291 status: None,
8292 priority: None,
8293 assignee: None,
8294 reporter: None,
8295 labels: vec![],
8296 created: None,
8297 updated: None,
8298 parent: None,
8299 subtasks: vec![],
8300 issuelinks: vec![],
8301 attachment: vec![],
8302 issuetype: None,
8303 extras: std::collections::HashMap::new(),
8304 },
8305 })),
8306 subtasks: vec![JiraIssue {
8307 id: "10002".to_string(),
8308 key: "PROJ-2".to_string(),
8309 fields: JiraIssueFields {
8310 summary: Some("Sub".to_string()),
8311 description: None,
8312 status: None,
8313 priority: None,
8314 assignee: None,
8315 reporter: None,
8316 labels: vec![],
8317 created: None,
8318 updated: None,
8319 parent: None,
8320 subtasks: vec![],
8321 issuelinks: vec![],
8322 attachment: vec![],
8323 issuetype: None,
8324 extras: std::collections::HashMap::new(),
8325 },
8326 }],
8327 issuelinks: vec![JiraIssueLink {
8328 id: Some("1".to_string()),
8329 link_type: JiraIssueLinkType {
8330 name: "Blocks".to_string(),
8331 outward: Some("blocks".to_string()),
8332 inward: Some("is blocked by".to_string()),
8333 },
8334 outward_issue: Some(Box::new(JiraIssue {
8335 id: "10003".to_string(),
8336 key: "PROJ-3".to_string(),
8337 fields: JiraIssueFields {
8338 summary: Some("Blocked".to_string()),
8339 description: None,
8340 status: None,
8341 priority: None,
8342 assignee: None,
8343 reporter: None,
8344 labels: vec![],
8345 created: None,
8346 updated: None,
8347 parent: None,
8348 subtasks: vec![],
8349 issuelinks: vec![],
8350 attachment: vec![],
8351 issuetype: None,
8352 extras: std::collections::HashMap::new(),
8353 },
8354 })),
8355 inward_issue: None,
8356 }],
8357 attachment: vec![],
8358 issuetype: None,
8359 extras: std::collections::HashMap::new(),
8360 },
8361 };
8362
8363 let relations = map_relations(&issue, JiraFlavor::Cloud, "https://test.atlassian.net");
8364
8365 assert!(relations.parent.is_some());
8366 assert_eq!(relations.parent.unwrap().key, "jira#PROJ-0");
8367 assert_eq!(relations.subtasks.len(), 1);
8368 assert_eq!(relations.subtasks[0].key, "jira#PROJ-2");
8369 assert_eq!(relations.blocks.len(), 1);
8370 assert_eq!(relations.blocks[0].issue.key, "jira#PROJ-3");
8371 assert!(relations.blocked_by.is_empty());
8372 assert!(relations.related_to.is_empty());
8373 assert!(relations.duplicates.is_empty());
8374 }
8375
8376 #[test]
8381 fn test_build_forest_tree_empty() {
8382 let tree = build_forest_tree(&[], &[]).unwrap();
8383 assert!(tree.is_empty());
8384 }
8385
8386 #[test]
8387 fn test_build_forest_tree_flat() {
8388 let rows = vec![
8389 JiraForestRow {
8390 id: 1,
8391 item_id: Some("PROJ-1".into()),
8392 item_type: Some("issue".into()),
8393 },
8394 JiraForestRow {
8395 id: 2,
8396 item_id: Some("PROJ-2".into()),
8397 item_type: Some("issue".into()),
8398 },
8399 ];
8400 let depths = vec![0, 0];
8401 let tree = build_forest_tree(&rows, &depths).unwrap();
8402 assert_eq!(tree.len(), 2);
8403 assert_eq!(tree[0].row_id, 1);
8404 assert_eq!(tree[1].row_id, 2);
8405 assert!(tree[0].children.is_empty());
8406 assert!(tree[1].children.is_empty());
8407 }
8408
8409 #[test]
8410 fn test_build_forest_tree_rejects_mismatched_lengths() {
8411 let rows = vec![JiraForestRow {
8412 id: 1,
8413 item_id: Some("PROJ-1".into()),
8414 item_type: None,
8415 }];
8416 let depths = vec![0, 1];
8417 let err = build_forest_tree(&rows, &depths).expect_err("mismatch must be rejected");
8418 assert!(
8419 matches!(err, Error::InvalidData(ref msg) if msg.contains("1 rows but 2 depths")),
8420 "unexpected error: {err:?}"
8421 );
8422 }
8423
8424 #[test]
8425 fn test_build_forest_tree_nested() {
8426 let rows = vec![
8431 JiraForestRow {
8432 id: 1,
8433 item_id: Some("PROJ-1".into()),
8434 item_type: None,
8435 },
8436 JiraForestRow {
8437 id: 2,
8438 item_id: Some("PROJ-2".into()),
8439 item_type: None,
8440 },
8441 JiraForestRow {
8442 id: 3,
8443 item_id: Some("PROJ-3".into()),
8444 item_type: None,
8445 },
8446 JiraForestRow {
8447 id: 4,
8448 item_id: Some("PROJ-4".into()),
8449 item_type: None,
8450 },
8451 ];
8452 let depths = vec![0, 1, 2, 1];
8453 let tree = build_forest_tree(&rows, &depths).unwrap();
8454
8455 assert_eq!(tree.len(), 1);
8456 assert_eq!(tree[0].row_id, 1);
8457 assert_eq!(tree[0].children.len(), 2);
8458 assert_eq!(tree[0].children[0].row_id, 2);
8459 assert_eq!(tree[0].children[0].children.len(), 1);
8460 assert_eq!(tree[0].children[0].children[0].row_id, 3);
8461 assert_eq!(tree[0].children[1].row_id, 4);
8462 assert!(tree[0].children[1].children.is_empty());
8463 }
8464
8465 #[test]
8466 fn test_build_forest_tree_multiple_roots() {
8467 let rows = vec![
8468 JiraForestRow {
8469 id: 1,
8470 item_id: Some("PROJ-1".into()),
8471 item_type: None,
8472 },
8473 JiraForestRow {
8474 id: 2,
8475 item_id: Some("PROJ-2".into()),
8476 item_type: None,
8477 },
8478 JiraForestRow {
8479 id: 3,
8480 item_id: Some("PROJ-3".into()),
8481 item_type: None,
8482 },
8483 JiraForestRow {
8484 id: 4,
8485 item_id: Some("PROJ-4".into()),
8486 item_type: None,
8487 },
8488 ];
8489 let depths = vec![0, 1, 0, 1];
8490 let tree = build_forest_tree(&rows, &depths).unwrap();
8491
8492 assert_eq!(tree.len(), 2);
8493 assert_eq!(tree[0].children.len(), 1);
8494 assert_eq!(tree[1].children.len(), 1);
8495 }
8496
8497 mod structure_integration {
8502 use super::*;
8503 use devboy_core::StructureRowItem;
8504 use httpmock::prelude::*;
8505
8506 fn token(s: &str) -> SecretString {
8507 SecretString::from(s.to_string())
8508 }
8509
8510 fn create_client(server: &MockServer) -> JiraClient {
8511 JiraClient::with_base_url(
8516 server.base_url(),
8517 "PROJ",
8518 "user@example.com",
8519 token("token"),
8520 false,
8521 )
8522 }
8523
8524 #[tokio::test]
8525 async fn test_get_structures() {
8526 let server = MockServer::start();
8527
8528 server.mock(|when, then| {
8529 when.method(GET).path("/rest/structure/2.0/structure");
8530 then.status(200).json_body(serde_json::json!({
8531 "structures": [
8532 {"id": 1, "name": "Q1 Planning", "description": "Quarter 1"},
8533 {"id": 2, "name": "Sprint Board"}
8534 ]
8535 }));
8536 });
8537
8538 let client = create_client(&server);
8539 let result = client.get_structures().await.unwrap();
8540 assert_eq!(result.items.len(), 2);
8541 assert_eq!(result.items[0].name, "Q1 Planning");
8542 assert_eq!(result.items[1].id, 2);
8543 }
8544
8545 #[tokio::test]
8546 async fn test_get_structure_forest() {
8547 let server = MockServer::start();
8548
8549 server.mock(|when, then| {
8550 when.method(POST).path("/rest/structure/2.0/forest/1/spec");
8551 then.status(200).json_body(serde_json::json!({
8552 "version": 42,
8553 "rows": [
8554 {"id": 100, "itemId": "PROJ-1", "itemType": "issue"},
8555 {"id": 101, "itemId": "PROJ-2", "itemType": "issue"},
8556 {"id": 102, "itemId": "PROJ-3", "itemType": "issue"}
8557 ],
8558 "depths": [0, 1, 1],
8559 "totalCount": 3
8560 }));
8561 });
8562
8563 let client = create_client(&server);
8564 let forest = client
8565 .get_structure_forest(
8566 1,
8567 GetForestOptions {
8568 offset: None,
8569 limit: Some(200),
8570 },
8571 )
8572 .await
8573 .unwrap();
8574
8575 assert_eq!(forest.version, 42);
8576 assert_eq!(forest.structure_id, 1);
8577 assert_eq!(forest.total_count, Some(3));
8578 assert_eq!(forest.tree.len(), 1); assert_eq!(forest.tree[0].item_id, Some("PROJ-1".into()));
8580 assert_eq!(forest.tree[0].children.len(), 2);
8581 }
8582
8583 #[tokio::test]
8584 async fn test_create_structure() {
8585 let server = MockServer::start();
8586
8587 server.mock(|when, then| {
8588 when.method(POST).path("/rest/structure/2.0/structure");
8589 then.status(200).json_body(serde_json::json!({
8590 "id": 99,
8591 "name": "New Structure",
8592 "description": "Test"
8593 }));
8594 });
8595
8596 let client = create_client(&server);
8597 let result = client
8598 .create_structure(CreateStructureInput {
8599 name: "New Structure".into(),
8600 description: Some("Test".into()),
8601 })
8602 .await
8603 .unwrap();
8604
8605 assert_eq!(result.id, 99);
8606 assert_eq!(result.name, "New Structure");
8607 }
8608
8609 #[tokio::test]
8610 async fn test_remove_structure_row() {
8611 let server = MockServer::start();
8612
8613 server.mock(|when, then| {
8614 when.method(DELETE)
8615 .path("/rest/structure/2.0/forest/1/item/100");
8616 then.status(204);
8617 });
8618
8619 let client = create_client(&server);
8620 client.remove_structure_row(1, 100).await.unwrap();
8621 }
8622
8623 #[tokio::test]
8624 async fn test_get_structure_views() {
8625 let server = MockServer::start();
8626
8627 server.mock(|when, then| {
8628 when.method(GET)
8629 .path("/rest/structure/2.0/view")
8630 .query_param("structureId", "1");
8631 then.status(200).json_body(serde_json::json!({
8632 "views": [
8633 {"id": 10, "name": "Default View", "structureId": 1, "columns": []},
8634 {"id": 11, "name": "Sprint View", "structureId": 1, "columns": [
8635 {"field": "summary"},
8636 {"field": "status"},
8637 {"formula": "SUM(\"Story Points\")"}
8638 ]}
8639 ]
8640 }));
8641 });
8642
8643 let client = create_client(&server);
8644 let views = client.get_structure_views(1, None).await.unwrap();
8645 assert_eq!(views.len(), 2);
8646 assert_eq!(views[1].columns.len(), 3);
8647 }
8648
8649 #[tokio::test]
8650 async fn test_get_structure_views_by_id_accepts_matching_structure() {
8651 let server = MockServer::start();
8652 server.mock(|when, then| {
8653 when.method(GET).path("/rest/structure/2.0/view/10");
8654 then.status(200).json_body(serde_json::json!({
8655 "id": 10,
8656 "name": "Default View",
8657 "structureId": 1,
8658 "columns": []
8659 }));
8660 });
8661
8662 let client = create_client(&server);
8663 let views = client.get_structure_views(1, Some(10)).await.unwrap();
8664 assert_eq!(views.len(), 1);
8665 assert_eq!(views[0].id, 10);
8666 }
8667
8668 #[tokio::test]
8669 async fn test_get_structure_views_by_id_rejects_cross_structure_view() {
8670 let server = MockServer::start();
8674 server.mock(|when, then| {
8675 when.method(GET).path("/rest/structure/2.0/view/99");
8676 then.status(200).json_body(serde_json::json!({
8677 "id": 99,
8678 "name": "Sibling view",
8679 "structureId": 7,
8680 "columns": []
8681 }));
8682 });
8683
8684 let client = create_client(&server);
8685 let err = client
8686 .get_structure_views(1, Some(99))
8687 .await
8688 .expect_err("mismatched structure must error");
8689 match err {
8690 Error::InvalidData(msg) => {
8691 assert!(msg.contains("belongs to structure 7"), "got: {msg}");
8692 assert!(msg.contains("but 1 was requested"), "got: {msg}");
8693 }
8694 other => panic!("expected InvalidData, got {other:?}"),
8695 }
8696 }
8697
8698 #[tokio::test]
8703 async fn test_structure_api_404_html_is_sanitised_end_to_end() {
8704 let server = MockServer::start();
8708 let jira_404_html =
8709 "<!DOCTYPE html><html><head><title>Oops, you've found a dead link.</title>"
8710 .to_string()
8711 + &"<script>var a=1;</script>".repeat(100)
8712 + "</head><body>404</body></html>";
8713 server.mock(|when, then| {
8714 when.method(GET).path("/rest/structure/2.0/structure");
8715 then.status(404)
8716 .header("content-type", "text/html;charset=UTF-8")
8717 .body(jira_404_html.clone());
8718 });
8719
8720 let client = create_client(&server);
8721 let err = client
8722 .get_structures()
8723 .await
8724 .expect_err("404 must error out");
8725 let msg = err.to_string();
8726 assert!(
8727 !msg.contains("<!DOCTYPE") && !msg.contains("<script>"),
8728 "HTML leaked into error message: {}",
8729 &msg[..msg.len().min(400)]
8730 );
8731 assert!(
8732 msg.contains("endpoint not found"),
8733 "expected soft wording: {msg}"
8734 );
8735 }
8736
8737 #[tokio::test]
8738 async fn test_structure_api_xml_404_is_sanitised_end_to_end() {
8739 let server = MockServer::start();
8742 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>"#;
8743 server.mock(|when, then| {
8744 when.method(GET).path("/rest/structure/2.0/structure");
8745 then.status(404)
8746 .header("content-type", "application/xml")
8747 .body(xml);
8748 });
8749
8750 let client = create_client(&server);
8751 let err = client
8752 .get_structures()
8753 .await
8754 .expect_err("XML 404 must error out");
8755 let msg = err.to_string();
8756 assert!(!msg.contains("<?xml"), "XML leaked: {msg}");
8757 assert!(msg.contains("endpoint not found"));
8758 }
8759
8760 #[tokio::test]
8761 async fn test_structure_api_json_error_forwarded_verbatim() {
8762 let server = MockServer::start();
8766 server.mock(|when, then| {
8767 when.method(PUT).path("/rest/structure/2.0/forest/1/item");
8768 then.status(409).json_body(serde_json::json!({
8769 "errorMessages": ["Forest version conflict"],
8770 "errors": {}
8771 }));
8772 });
8773
8774 let client = create_client(&server);
8775 let err = client
8776 .add_structure_rows(
8777 1,
8778 AddStructureRowsInput {
8779 items: vec![StructureRowItem {
8780 item_id: "PROJ-1".into(),
8781 item_type: None,
8782 }],
8783 under: None,
8784 after: None,
8785 forest_version: Some(100),
8786 },
8787 )
8788 .await
8789 .expect_err("409 must error out");
8790 let msg = err.to_string();
8791 assert!(
8792 msg.contains("Forest version conflict"),
8793 "JSON dropped: {msg}"
8794 );
8795 }
8796
8797 #[tokio::test]
8798 async fn test_structure_api_200_with_html_body_does_not_leak() {
8799 let server = MockServer::start();
8803 let html = "<!DOCTYPE html><html><body>".to_string()
8804 + &"password=secret".repeat(50)
8805 + "</body></html>";
8806 server.mock(|when, then| {
8807 when.method(GET).path("/rest/structure/2.0/structure");
8808 then.status(200)
8809 .header("content-type", "text/html;charset=UTF-8")
8810 .body(html.clone());
8811 });
8812
8813 let client = create_client(&server);
8814 let err = client
8815 .get_structures()
8816 .await
8817 .expect_err("HTML body must fail to parse");
8818 let msg = err.to_string();
8819 assert!(
8820 !msg.contains("password=secret") && !msg.contains("<!DOCTYPE"),
8821 "HTML body leaked into parse-error message: {}",
8822 &msg[..msg.len().min(400)]
8823 );
8824 assert!(msg.contains("redacted"), "missing redaction marker: {msg}");
8825 }
8826
8827 #[tokio::test]
8834 async fn test_list_structures_for_metadata_maps_response() {
8835 let server = MockServer::start();
8836 server.mock(|when, then| {
8837 when.method(GET).path("/rest/structure/2.0/structure");
8838 then.status(200).json_body(serde_json::json!({
8839 "structures": [
8840 {"id": 1, "name": "Q1 Planning", "description": "Quarter 1 plan"},
8841 {"id": 2, "name": "Sprint Board"}
8842 ]
8843 }));
8844 });
8845
8846 let client = create_client(&server);
8847 let refs = client.list_structures_for_metadata().await.unwrap();
8848
8849 assert_eq!(refs.len(), 2);
8850 assert_eq!(refs[0].id, 1);
8851 assert_eq!(refs[0].name, "Q1 Planning");
8852 assert_eq!(refs[0].description.as_deref(), Some("Quarter 1 plan"));
8853 assert_eq!(refs[1].id, 2);
8854 assert_eq!(refs[1].description, None);
8855 }
8856
8857 #[tokio::test]
8858 async fn test_list_structures_for_metadata_returns_empty_on_plugin_missing() {
8859 let server = MockServer::start();
8864 server.mock(|when, then| {
8865 when.method(GET).path("/rest/structure/2.0/structure");
8866 then.status(404)
8867 .header("content-type", "text/html;charset=UTF-8")
8868 .body("<!DOCTYPE html><html><title>Oops</title></html>");
8869 });
8870
8871 let client = create_client(&server);
8872 let refs = client.list_structures_for_metadata().await.unwrap();
8873 assert!(refs.is_empty());
8874 }
8875
8876 #[tokio::test]
8877 async fn test_list_structures_for_metadata_returns_empty_on_200_empty_list() {
8878 let server = MockServer::start();
8879 server.mock(|when, then| {
8880 when.method(GET).path("/rest/structure/2.0/structure");
8881 then.status(200)
8882 .json_body(serde_json::json!({ "structures": [] }));
8883 });
8884
8885 let client = create_client(&server);
8886 let refs = client.list_structures_for_metadata().await.unwrap();
8887 assert!(refs.is_empty());
8888 }
8889
8890 #[tokio::test]
8891 async fn test_list_structures_for_metadata_propagates_401() {
8892 let server = MockServer::start();
8896 server.mock(|when, then| {
8897 when.method(GET).path("/rest/structure/2.0/structure");
8898 then.status(401).body("Unauthorized");
8899 });
8900
8901 let client = create_client(&server);
8902 let err = client
8903 .list_structures_for_metadata()
8904 .await
8905 .expect_err("401 must not be swallowed");
8906 assert!(matches!(err, Error::Unauthorized(_)), "got {err:?}");
8907 }
8908
8909 #[tokio::test]
8910 async fn test_list_structures_for_metadata_propagates_403() {
8911 let server = MockServer::start();
8912 server.mock(|when, then| {
8913 when.method(GET).path("/rest/structure/2.0/structure");
8914 then.status(403).body("Forbidden");
8915 });
8916
8917 let client = create_client(&server);
8918 let err = client
8919 .list_structures_for_metadata()
8920 .await
8921 .expect_err("403 must not be swallowed");
8922 assert!(matches!(err, Error::Forbidden(_)), "got {err:?}");
8923 }
8924
8925 #[tokio::test]
8930 async fn test_structure_generator_lifecycle() {
8931 let server = MockServer::start();
8932
8933 server.mock(|when, then| {
8935 when.method(GET)
8936 .path("/rest/structure/2.0/structure/1/generator");
8937 then.status(200).json_body(serde_json::json!({
8938 "generators": [
8939 { "id": "g1", "type": "jql", "spec": {"query": "project = PROJ"} }
8940 ]
8941 }));
8942 });
8943 server.mock(|when, then| {
8945 when.method(POST)
8946 .path("/rest/structure/2.0/structure/1/generator")
8947 .body_includes("\"type\":\"agile-board\"");
8948 then.status(200).json_body(serde_json::json!({
8949 "id": "g2",
8950 "type": "agile-board",
8951 "spec": {"boardId": 42}
8952 }));
8953 });
8954 server.mock(|when, then| {
8956 when.method(POST)
8957 .path("/rest/structure/2.0/structure/1/generator/g2/sync");
8958 then.status(200).json_body(serde_json::json!({}));
8959 });
8960
8961 let client = create_client(&server);
8962
8963 let list = client.get_structure_generators(1).await.unwrap();
8964 assert_eq!(list.items.len(), 1);
8965 assert_eq!(list.items[0].generator_type, "jql");
8966
8967 let added = client
8968 .add_structure_generator(devboy_core::AddStructureGeneratorInput {
8969 structure_id: 1,
8970 generator_type: "agile-board".into(),
8971 spec: serde_json::json!({"boardId": 42}),
8972 })
8973 .await
8974 .unwrap();
8975 assert_eq!(added.id, "g2");
8976
8977 client
8978 .sync_structure_generator(devboy_core::SyncStructureGeneratorInput {
8979 structure_id: 1,
8980 generator_id: "g2".into(),
8981 })
8982 .await
8983 .unwrap();
8984 }
8985
8986 #[tokio::test]
8991 async fn test_delete_structure() {
8992 let server = MockServer::start();
8993 server.mock(|when, then| {
8994 when.method(DELETE).path("/rest/structure/2.0/structure/7");
8995 then.status(204);
8996 });
8997
8998 let client = create_client(&server);
8999 client.delete_structure(7).await.unwrap();
9000 }
9001
9002 #[tokio::test]
9003 async fn test_structure_automation() {
9004 let server = MockServer::start();
9005
9006 server.mock(|when, then| {
9007 when.method(PUT)
9008 .path("/rest/structure/2.0/structure/5/automation")
9009 .body_includes("\"enabled\":true");
9010 then.status(200).json_body(serde_json::json!({}));
9011 });
9012 server.mock(|when, then| {
9013 when.method(POST)
9014 .path("/rest/structure/2.0/structure/5/automation/run");
9015 then.status(200).json_body(serde_json::json!({}));
9016 });
9017
9018 let client = create_client(&server);
9019 client
9021 .update_structure_automation(devboy_core::UpdateStructureAutomationInput {
9022 structure_id: 5,
9023 automation_id: None,
9024 config: serde_json::json!({"enabled": true}),
9025 })
9026 .await
9027 .unwrap();
9028 client.trigger_structure_automation(5).await.unwrap();
9029 }
9030
9031 #[tokio::test]
9034 async fn test_structure_automation_rule_scoped() {
9035 let server = MockServer::start();
9036 server.mock(|when, then| {
9037 when.method(PUT)
9038 .path("/rest/structure/2.0/structure/5/automation/rule-7")
9039 .body_includes("\"action\":\"move\"");
9040 then.status(200).json_body(serde_json::json!({}));
9041 });
9042
9043 let client = create_client(&server);
9044 client
9045 .update_structure_automation(devboy_core::UpdateStructureAutomationInput {
9046 structure_id: 5,
9047 automation_id: Some("rule-7".into()),
9048 config: serde_json::json!({"action": "move"}),
9049 })
9050 .await
9051 .unwrap();
9052 }
9053 }
9054
9055 mod agile_integration {
9059 use super::*;
9060 use httpmock::prelude::*;
9061
9062 fn token(s: &str) -> SecretString {
9063 SecretString::from(s.to_string())
9064 }
9065
9066 fn create_client(server: &MockServer) -> JiraClient {
9067 JiraClient::with_base_url(
9068 server.base_url(),
9069 "PROJ",
9070 "user@example.com",
9071 token("token"),
9072 false,
9073 )
9074 }
9075
9076 #[tokio::test]
9077 async fn test_get_board_sprints_active() {
9078 let server = MockServer::start();
9079 server.mock(|when, then| {
9080 when.method(GET)
9081 .path("/rest/agile/1.0/board/10/sprint")
9082 .query_param("state", "active");
9083 then.status(200).json_body(serde_json::json!({
9084 "isLast": true,
9085 "values": [
9086 {
9087 "id": 1,
9088 "name": "Sprint 1",
9089 "state": "active",
9090 "originBoardId": 10,
9091 "startDate": "2026-04-01T00:00:00.000Z"
9092 }
9093 ]
9094 }));
9095 });
9096
9097 let client = create_client(&server);
9098 let sprints = client
9099 .get_board_sprints(10, devboy_core::SprintState::Active)
9100 .await
9101 .unwrap();
9102 assert_eq!(sprints.items.len(), 1);
9103 assert_eq!(sprints.items[0].state, "active");
9104 assert_eq!(sprints.items[0].origin_board_id, Some(10));
9105 }
9106
9107 #[tokio::test]
9110 async fn test_get_board_sprints_walks_pagination() {
9111 let server = MockServer::start();
9112 server.mock(|when, then| {
9113 when.method(GET)
9114 .path("/rest/agile/1.0/board/10/sprint")
9115 .query_param("startAt", "0");
9116 then.status(200).json_body(serde_json::json!({
9117 "isLast": false,
9118 "values": [
9119 {"id": 1, "name": "S1", "state": "closed"},
9120 {"id": 2, "name": "S2", "state": "closed"}
9121 ]
9122 }));
9123 });
9124 server.mock(|when, then| {
9125 when.method(GET)
9126 .path("/rest/agile/1.0/board/10/sprint")
9127 .query_param("startAt", "2");
9128 then.status(200).json_body(serde_json::json!({
9129 "isLast": true,
9130 "values": [
9131 {"id": 3, "name": "S3", "state": "active"}
9132 ]
9133 }));
9134 });
9135
9136 let client = create_client(&server);
9137 let sprints = client
9138 .get_board_sprints(10, devboy_core::SprintState::All)
9139 .await
9140 .unwrap();
9141 assert_eq!(sprints.items.len(), 3);
9142 assert_eq!(sprints.items[2].name, "S3");
9143 }
9144
9145 #[tokio::test]
9146 async fn test_get_board_sprints_all_omits_state() {
9147 let server = MockServer::start();
9148 server.mock(|when, then| {
9149 when.method(GET)
9150 .path("/rest/agile/1.0/board/10/sprint")
9151 .is_true(|req| req.query_params().iter().all(|(k, _)| k != "state"));
9152 then.status(200)
9153 .json_body(serde_json::json!({"values": []}));
9154 });
9155
9156 let client = create_client(&server);
9157 let sprints = client
9158 .get_board_sprints(10, devboy_core::SprintState::All)
9159 .await
9160 .unwrap();
9161 assert_eq!(sprints.items.len(), 0);
9162 }
9163
9164 #[tokio::test]
9165 async fn test_assign_to_sprint_strips_jira_prefix() {
9166 let server = MockServer::start();
9167 server.mock(|when, then| {
9168 when.method(POST)
9169 .path("/rest/agile/1.0/sprint/42/issue")
9170 .body_includes("\"issues\":[\"PROJ-1\",\"PROJ-2\"]");
9171 then.status(204);
9172 });
9173
9174 let client = create_client(&server);
9175 client
9176 .assign_to_sprint(devboy_core::AssignToSprintInput {
9177 sprint_id: 42,
9178 issue_keys: vec!["jira#PROJ-1".to_string(), "PROJ-2".to_string()],
9179 })
9180 .await
9181 .unwrap();
9182 }
9183 }
9184
9185 mod versions_integration {
9189 use super::*;
9190 use devboy_core::{ListProjectVersionsParams, UpsertProjectVersionInput};
9191 use httpmock::prelude::*;
9192
9193 fn token(s: &str) -> SecretString {
9194 SecretString::from(s.to_string())
9195 }
9196
9197 fn create_client(server: &MockServer) -> JiraClient {
9198 JiraClient::with_base_url(
9199 server.base_url(),
9200 "PROJ",
9201 "user@example.com",
9202 token("pat-token"),
9203 false,
9204 )
9205 }
9206
9207 fn create_cloud_client(server: &MockServer) -> JiraClient {
9208 JiraClient::with_base_url(
9209 server.base_url(),
9210 "PROJ",
9211 "user@example.com",
9212 token("api-token"),
9213 true,
9214 )
9215 }
9216
9217 fn version_dto(
9218 id: &str,
9219 name: &str,
9220 release_date: Option<&str>,
9221 released: bool,
9222 archived: bool,
9223 ) -> serde_json::Value {
9224 let mut v = serde_json::json!({
9225 "id": id,
9226 "name": name,
9227 "project": "PROJ",
9228 "released": released,
9229 "archived": archived,
9230 });
9231 if let Some(d) = release_date {
9232 v["releaseDate"] = serde_json::json!(d);
9233 }
9234 v
9235 }
9236
9237 #[tokio::test]
9238 async fn list_project_versions_returns_rich_payload() {
9239 let server = MockServer::start();
9240 server.mock(|when, then| {
9241 when.method(GET).path("/project/PROJ/versions");
9242 then.status(200).json_body(serde_json::json!([
9243 {
9244 "id": "10001",
9245 "name": "1.0.0",
9246 "project": "PROJ",
9247 "description": "Initial release",
9248 "startDate": "2025-01-01",
9249 "releaseDate": "2025-02-01",
9250 "released": true,
9251 "archived": false,
9252 "overdue": false,
9253 },
9254 version_dto("10002", "2.0.0", Some("2026-04-01"), false, false),
9255 version_dto("10003", "0.9.0", Some("2024-06-01"), true, true),
9256 ]));
9257 });
9258
9259 let client = create_client(&server);
9260 let result = client
9261 .list_project_versions(ListProjectVersionsParams {
9262 project: "PROJ".into(),
9263 released: None,
9264 archived: None,
9265 limit: None,
9266 include_issue_count: false,
9267 })
9268 .await
9269 .unwrap();
9270
9271 assert_eq!(result.items.len(), 3);
9272 assert_eq!(result.items[0].name, "2.0.0");
9274 assert_eq!(result.items[1].name, "1.0.0");
9275 assert_eq!(result.items[2].name, "0.9.0");
9276 assert_eq!(
9277 result.items[1].description.as_deref(),
9278 Some("Initial release")
9279 );
9280 assert_eq!(result.items[1].source, "jira");
9281 }
9282
9283 #[tokio::test]
9284 async fn list_project_versions_filters_archived_and_released() {
9285 let server = MockServer::start();
9286 server.mock(|when, then| {
9287 when.method(GET).path("/project/PROJ/versions");
9288 then.status(200).json_body(serde_json::json!([
9289 version_dto("1", "current", Some("2026-04-01"), false, false),
9290 version_dto("2", "shipped", Some("2025-12-01"), true, false),
9291 version_dto("3", "old", Some("2024-01-01"), true, true),
9292 ]));
9293 });
9294
9295 let client = create_client(&server);
9296
9297 let unreleased_only = client
9298 .list_project_versions(ListProjectVersionsParams {
9299 project: "PROJ".into(),
9300 released: Some(false),
9301 archived: Some(false),
9302 limit: None,
9303 include_issue_count: false,
9304 })
9305 .await
9306 .unwrap();
9307 assert_eq!(unreleased_only.items.len(), 1);
9308 assert_eq!(unreleased_only.items[0].name, "current");
9309
9310 }
9313
9314 #[tokio::test]
9315 async fn list_project_versions_applies_limit_and_keeps_most_recent() {
9316 let server = MockServer::start();
9317 server.mock(|when, then| {
9318 when.method(GET).path("/project/PROJ/versions");
9319 then.status(200).json_body(serde_json::json!([
9320 version_dto("1", "v1", Some("2024-01-01"), true, false),
9321 version_dto("2", "v2", Some("2025-01-01"), true, false),
9322 version_dto("3", "v3", Some("2026-01-01"), true, false),
9323 version_dto("4", "v4", Some("2026-02-01"), false, false),
9324 ]));
9325 });
9326
9327 let client = create_client(&server);
9328 let result = client
9329 .list_project_versions(ListProjectVersionsParams {
9330 project: "PROJ".into(),
9331 released: None,
9332 archived: None,
9333 limit: Some(2),
9334 include_issue_count: false,
9335 })
9336 .await
9337 .unwrap();
9338 assert_eq!(result.items.len(), 2);
9339 assert_eq!(result.items[0].name, "v4");
9340 assert_eq!(result.items[1].name, "v3");
9341 }
9342
9343 #[tokio::test]
9344 async fn list_project_versions_passes_expand_query_on_cloud() {
9345 let server = MockServer::start();
9350 let mock = server.mock(|when, then| {
9351 when.method(GET)
9352 .path("/project/PROJ/versions")
9353 .query_param("expand", "issuesstatus");
9354 then.status(200).json_body(serde_json::json!([
9355 {
9356 "id": "1",
9357 "name": "v1",
9358 "released": false,
9359 "archived": false,
9360 "issuesStatusForFixVersion": {
9361 "unmapped": 0,
9362 "toDo": 5,
9363 "inProgress": 3,
9364 "done": 2
9365 }
9366 }
9367 ]));
9368 });
9369
9370 let client = create_cloud_client(&server);
9371 let result = client
9372 .list_project_versions(ListProjectVersionsParams {
9373 project: "PROJ".into(),
9374 released: None,
9375 archived: None,
9376 limit: None,
9377 include_issue_count: true,
9378 })
9379 .await
9380 .unwrap();
9381 mock.assert();
9382 assert_eq!(result.items.len(), 1);
9383 assert_eq!(result.items[0].issue_count, Some(10));
9384 }
9385
9386 #[tokio::test]
9387 async fn list_project_versions_omits_expand_on_self_hosted() {
9388 let server = MockServer::start();
9394 let bare_mock = server.mock(|when, then| {
9395 when.method(GET).path("/project/PROJ/versions");
9396 then.status(200).json_body(serde_json::json!([{
9397 "id": "1",
9398 "name": "v1",
9399 "released": false,
9400 "archived": false,
9401 "issuesUnresolvedCount": 4,
9402 }]));
9403 });
9404 let expanded_mock = server.mock(|when, then| {
9405 when.method(GET)
9406 .path("/project/PROJ/versions")
9407 .query_param("expand", "issuesstatus");
9408 then.status(500); });
9410
9411 let client = create_client(&server); let result = client
9413 .list_project_versions(ListProjectVersionsParams {
9414 project: "PROJ".into(),
9415 released: None,
9416 archived: None,
9417 limit: None,
9418 include_issue_count: true,
9419 })
9420 .await
9421 .unwrap();
9422 bare_mock.assert();
9423 expanded_mock.assert_calls(0);
9424 assert_eq!(result.items[0].issue_count, None);
9428 assert_eq!(result.items[0].unresolved_issue_count, Some(4));
9429 }
9430
9431 #[tokio::test]
9432 async fn list_project_versions_orders_unreleased_first_then_recent() {
9433 let server = MockServer::start();
9437 server.mock(|when, then| {
9438 when.method(GET).path("/project/PROJ/versions");
9439 then.status(200).json_body(serde_json::json!([
9440 version_dto("1", "9.10.0", Some("2026-04-01"), true, false),
9441 version_dto("2", "10.0.0", Some("2026-04-02"), false, false),
9442 version_dto("3", "next", None, false, false),
9443 version_dto("4", "1.0.0", Some("2024-01-01"), true, true),
9444 ]));
9445 });
9446
9447 let client = create_client(&server);
9448 let result = client
9449 .list_project_versions(ListProjectVersionsParams {
9450 project: "PROJ".into(),
9451 released: None,
9452 archived: None,
9453 limit: None,
9454 include_issue_count: false,
9455 })
9456 .await
9457 .unwrap();
9458 let names: Vec<_> = result.items.iter().map(|v| v.name.as_str()).collect();
9461 assert_eq!(names, vec!["next", "10.0.0", "9.10.0", "1.0.0"]);
9462 }
9463
9464 #[tokio::test]
9465 async fn list_project_versions_pagination_reflects_truncation() {
9466 let server = MockServer::start();
9469 server.mock(|when, then| {
9470 when.method(GET).path("/project/PROJ/versions");
9471 then.status(200).json_body(serde_json::json!([
9472 version_dto("1", "v1", Some("2024-01-01"), true, false),
9473 version_dto("2", "v2", Some("2025-01-01"), true, false),
9474 version_dto("3", "v3", Some("2026-01-01"), true, false),
9475 ]));
9476 });
9477
9478 let client = create_client(&server);
9479 let result = client
9480 .list_project_versions(ListProjectVersionsParams {
9481 project: "PROJ".into(),
9482 released: None,
9483 archived: None,
9484 limit: Some(2),
9485 include_issue_count: false,
9486 })
9487 .await
9488 .unwrap();
9489 let p = result.pagination.expect("pagination must be set");
9490 assert_eq!(p.total, Some(3));
9491 assert_eq!(p.limit, 2);
9492 assert!(p.has_more);
9493
9494 let server2 = MockServer::start();
9496 server2.mock(|when, then| {
9497 when.method(GET).path("/project/PROJ/versions");
9498 then.status(200).json_body(serde_json::json!([version_dto(
9499 "1",
9500 "v1",
9501 Some("2024-01-01"),
9502 true,
9503 false
9504 ),]));
9505 });
9506 let client2 = create_client(&server2);
9507 let result2 = client2
9508 .list_project_versions(ListProjectVersionsParams {
9509 project: "PROJ".into(),
9510 released: None,
9511 archived: None,
9512 limit: Some(20),
9513 include_issue_count: false,
9514 })
9515 .await
9516 .unwrap();
9517 let p2 = result2.pagination.unwrap();
9518 assert_eq!(p2.total, Some(1));
9519 assert!(!p2.has_more);
9520 }
9521
9522 #[test]
9523 fn compare_version_names_handles_semver_and_alpha() {
9524 use std::cmp::Ordering;
9525 assert_eq!(compare_version_names("10.0.0", "9.10.0"), Ordering::Greater);
9526 assert_eq!(compare_version_names("1.0.0", "1.0.0"), Ordering::Equal);
9527 assert_eq!(compare_version_names("1.0.10", "1.0.2"), Ordering::Greater);
9528 assert_eq!(compare_version_names("1.0.0-rc1", "1.0.0"), Ordering::Less);
9530 let _ = compare_version_names("Sprint 42 cleanup", "Sprint 9 cleanup");
9533 }
9534
9535 #[tokio::test]
9536 async fn upsert_project_version_creates_when_missing() {
9537 let server = MockServer::start();
9538 server.mock(|when, then| {
9540 when.method(GET).path("/project/PROJ/versions");
9541 then.status(200).json_body(serde_json::json!([version_dto(
9542 "99",
9543 "1.0.0",
9544 Some("2025-01-01"),
9545 true,
9546 false
9547 ),]));
9548 });
9549 server.mock(|when, then| {
9551 when.method(POST)
9552 .path("/version")
9553 .body_includes("\"name\":\"3.18.0\"")
9554 .body_includes("\"project\":\"PROJ\"")
9555 .body_includes("\"description\":\"Release notes draft\"");
9556 then.status(201).json_body(serde_json::json!({
9557 "id": "10500",
9558 "name": "3.18.0",
9559 "project": "PROJ",
9560 "description": "Release notes draft",
9561 "released": false,
9562 "archived": false,
9563 }));
9564 });
9565
9566 let client = create_client(&server);
9567 let v = client
9568 .upsert_project_version(UpsertProjectVersionInput {
9569 project: "PROJ".into(),
9570 name: "3.18.0".into(),
9571 description: Some("Release notes draft".into()),
9572 start_date: None,
9573 release_date: None,
9574 released: None,
9575 archived: None,
9576 })
9577 .await
9578 .unwrap();
9579 assert_eq!(v.id, "10500");
9580 assert_eq!(v.name, "3.18.0");
9581 assert_eq!(v.description.as_deref(), Some("Release notes draft"));
9582 }
9583
9584 #[tokio::test]
9585 async fn upsert_project_version_updates_when_present() {
9586 let server = MockServer::start();
9587 server.mock(|when, then| {
9589 when.method(GET).path("/project/PROJ/versions");
9590 then.status(200).json_body(serde_json::json!([version_dto(
9591 "777", "3.18.0", None, false, false
9592 ),]));
9593 });
9594 server.mock(|when, then| {
9596 when.method(PUT)
9597 .path("/version/777")
9598 .body_includes("\"description\":\"final notes\"")
9599 .body_includes("\"released\":true")
9600 .body_includes("\"releaseDate\":\"2026-05-01\"");
9601 then.status(200).json_body(serde_json::json!({
9602 "id": "777",
9603 "name": "3.18.0",
9604 "project": "PROJ",
9605 "description": "final notes",
9606 "releaseDate": "2026-05-01",
9607 "released": true,
9608 "archived": false,
9609 }));
9610 });
9611
9612 let client = create_client(&server);
9613 let v = client
9614 .upsert_project_version(UpsertProjectVersionInput {
9615 project: "PROJ".into(),
9616 name: "3.18.0".into(),
9617 description: Some("final notes".into()),
9618 start_date: None,
9619 release_date: Some("2026-05-01".into()),
9620 released: Some(true),
9621 archived: None,
9622 })
9623 .await
9624 .unwrap();
9625 assert_eq!(v.id, "777");
9626 assert!(v.released);
9627 assert_eq!(v.release_date.as_deref(), Some("2026-05-01"));
9628 }
9629
9630 #[tokio::test]
9631 async fn upsert_project_version_partial_update_sends_only_description() {
9632 let server = MockServer::start();
9633 server.mock(|when, then| {
9634 when.method(GET).path("/project/PROJ/versions");
9635 then.status(200).json_body(serde_json::json!([version_dto(
9636 "42",
9637 "2.0.0",
9638 Some("2026-01-01"),
9639 false,
9640 false
9641 ),]));
9642 });
9643 let put_mock = server.mock(|when, then| {
9646 when.method(PUT)
9647 .path("/version/42")
9648 .body_includes("\"description\":\"draft\"")
9649 .body_excludes("\"name\":")
9650 .body_excludes("\"released\":")
9651 .body_excludes("\"archived\":")
9652 .body_excludes("\"releaseDate\":");
9653 then.status(200).json_body(serde_json::json!({
9654 "id": "42",
9655 "name": "2.0.0",
9656 "project": "PROJ",
9657 "description": "draft",
9658 "releaseDate": "2026-01-01",
9659 "released": false,
9660 "archived": false,
9661 }));
9662 });
9663
9664 let client = create_client(&server);
9665 client
9666 .upsert_project_version(UpsertProjectVersionInput {
9667 project: "PROJ".into(),
9668 name: "2.0.0".into(),
9669 description: Some("draft".into()),
9670 start_date: None,
9671 release_date: None,
9672 released: None,
9673 archived: None,
9674 })
9675 .await
9676 .unwrap();
9677 put_mock.assert();
9678 }
9679
9680 #[tokio::test]
9681 async fn upsert_project_version_rejects_empty_name() {
9682 let server = MockServer::start();
9683 let client = create_client(&server);
9684 let err = client
9685 .upsert_project_version(UpsertProjectVersionInput {
9686 project: "PROJ".into(),
9687 name: " ".into(),
9688 ..Default::default()
9689 })
9690 .await
9691 .unwrap_err();
9692 assert!(matches!(err, devboy_core::Error::InvalidData(_)));
9693 }
9694
9695 #[tokio::test]
9696 async fn upsert_project_version_rejects_overlong_name() {
9697 let server = MockServer::start();
9701 let client = create_client(&server);
9702 let err = client
9703 .upsert_project_version(UpsertProjectVersionInput {
9704 project: "PROJ".into(),
9705 name: "x".repeat(256),
9706 ..Default::default()
9707 })
9708 .await
9709 .unwrap_err();
9710 assert!(matches!(err, devboy_core::Error::InvalidData(_)));
9711 }
9712
9713 #[test]
9714 fn duplicate_version_error_classifier_matches_jira_phrasing() {
9715 let dup1 = devboy_core::Error::Api {
9720 status: 400,
9721 message: "A version with this name already exists in this project.".into(),
9722 };
9723 let dup2 = devboy_core::Error::Api {
9724 status: 400,
9725 message: "Name is already used by another version in this project.".into(),
9726 };
9727 let unrelated = devboy_core::Error::Api {
9728 status: 400,
9729 message: "releaseDate is in the wrong format.".into(),
9730 };
9731 assert!(is_duplicate_version_error(&dup1));
9732 assert!(is_duplicate_version_error(&dup2));
9733 assert!(!is_duplicate_version_error(&unrelated));
9734 }
9735
9736 #[tokio::test]
9737 async fn upsert_project_version_propagates_non_duplicate_400() {
9738 let server = MockServer::start();
9741 server.mock(|when, then| {
9742 when.method(GET).path("/project/PROJ/versions");
9743 then.status(200).json_body(serde_json::json!([]));
9744 });
9745 server.mock(|when, then| {
9746 when.method(POST).path("/version");
9747 then.status(400).json_body(serde_json::json!({
9748 "errorMessages": ["releaseDate is in the wrong format."]
9749 }));
9750 });
9751 let client = create_client(&server);
9752 let err = client
9753 .upsert_project_version(UpsertProjectVersionInput {
9754 project: "PROJ".into(),
9755 name: "3.18.0".into(),
9756 release_date: Some("not-a-date".into()),
9757 ..Default::default()
9758 })
9759 .await
9760 .unwrap_err();
9761 assert!(matches!(err, devboy_core::Error::Api { .. }));
9763 }
9764
9765 #[tokio::test]
9766 async fn upsert_project_version_works_on_cloud_flavor() {
9767 let server = MockServer::start();
9771 server.mock(|when, then| {
9772 when.method(GET).path("/project/CLOUDPROJ/versions");
9773 then.status(200).json_body(serde_json::json!([]));
9774 });
9775 let post_mock = server.mock(|when, then| {
9776 when.method(POST)
9777 .path("/version")
9778 .body_includes("\"name\":\"4.0.0\"")
9779 .body_includes("\"project\":\"CLOUDPROJ\"");
9780 then.status(201).json_body(serde_json::json!({
9781 "id": "30001",
9782 "name": "4.0.0",
9783 "project": "CLOUDPROJ",
9784 "description": "Cloud release",
9785 "released": false,
9786 "archived": false,
9787 }));
9791 });
9792
9793 let client = create_cloud_client(&server);
9794 let v = client
9795 .upsert_project_version(UpsertProjectVersionInput {
9796 project: "CLOUDPROJ".into(),
9797 name: "4.0.0".into(),
9798 description: Some("Cloud release".into()),
9799 ..Default::default()
9800 })
9801 .await
9802 .unwrap();
9803 post_mock.assert();
9804 assert_eq!(v.id, "30001");
9805 assert_eq!(v.project, "CLOUDPROJ");
9806 assert_eq!(v.description.as_deref(), Some("Cloud release"));
9807 }
9808 }
9809}