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