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