Skip to main content

devboy_jira/
client.rs

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