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,
36    JiraForestModifyResponse, JiraForestResponse, JiraIssue, JiraIssueTypeStatuses, JiraPriority,
37    JiraProjectStatus, JiraSearchResponse, JiraStatus, JiraStructure, JiraStructureListResponse,
38    JiraStructureValuesResponse, JiraStructureView, JiraStructureViewListResponse, JiraTransition,
39    JiraTransitionsResponse, JiraUser, JiraVersionDto, PriorityName, ProjectKey, TransitionId,
40    TransitionPayload, UpdateIssueFields, UpdateIssuePayload, UpdateVersionPayload,
41};
42
43/// Jira deployment flavor.
44#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
45#[serde(rename_all = "snake_case")]
46pub enum JiraFlavor {
47    /// Jira Cloud — API v3, ADF format, accountId-based users
48    Cloud,
49    /// Jira Self-Hosted / Data Center — API v2, plain text, username-based users
50    SelfHosted,
51}
52
53pub struct JiraClient {
54    base_url: String,
55    /// Original Jira instance URL for generating browse links.
56    /// When proxy is used, base_url points to proxy but instance_url points to real Jira.
57    instance_url: String,
58    project_key: String,
59    email: String,
60    token: SecretString,
61    flavor: JiraFlavor,
62    proxy_headers: Option<std::collections::HashMap<String, String>>,
63    client: reqwest::Client,
64}
65
66impl JiraClient {
67    /// Create a new Jira client. Flavor is auto-detected from the URL.
68    pub fn new(
69        url: impl Into<String>,
70        project_key: impl Into<String>,
71        email: impl Into<String>,
72        token: SecretString,
73    ) -> Self {
74        let url = url.into();
75        let flavor = detect_flavor(&url);
76        let instance = url.trim_end_matches('/').to_string();
77        let api_base = build_api_base(&url, flavor);
78        Self {
79            base_url: api_base,
80            instance_url: instance,
81            project_key: project_key.into(),
82            email: email.into(),
83            token,
84            flavor,
85            proxy_headers: None,
86            client: reqwest::Client::builder()
87                .user_agent("devboy-tools")
88                .build()
89                .expect("Failed to create HTTP client"),
90        }
91    }
92
93    /// Configure proxy mode with extra headers added to every request.
94    /// When proxy is active, the provider's own auth headers are suppressed —
95    /// the proxy handles authentication.
96    /// Note: `instance_url` is preserved for generating browse links.
97    pub fn with_proxy(mut self, headers: std::collections::HashMap<String, String>) -> Self {
98        self.proxy_headers = Some(headers);
99        self
100    }
101
102    /// Override the instance URL used for generating browse links.
103    /// Useful when proxy URL differs from real Jira instance URL.
104    pub fn with_instance_url(mut self, url: impl Into<String>) -> Self {
105        self.instance_url = url.into().trim_end_matches('/').to_string();
106        self
107    }
108
109    /// Override auto-detected flavor.
110    /// Use when the URL doesn't reflect the actual Jira deployment
111    /// (e.g. proxy URL instead of real Jira URL).
112    pub fn with_flavor(mut self, flavor: JiraFlavor) -> Self {
113        if self.flavor != flavor {
114            // Rebuild API base URL with new flavor
115            let instance_url = instance_url_from_base(&self.base_url);
116            self.base_url = build_api_base(&instance_url, flavor);
117            self.flavor = flavor;
118        }
119        self
120    }
121
122    /// Create a new Jira client with explicit base URL (for testing with httpmock).
123    /// The base URL is used as-is (no `/rest/api/N` suffix appended).
124    pub fn with_base_url(
125        base_url: impl Into<String>,
126        project_key: impl Into<String>,
127        email: impl Into<String>,
128        token: SecretString,
129        flavor: bool, // true = Cloud, false = SelfHosted
130    ) -> Self {
131        let url = base_url.into().trim_end_matches('/').to_string();
132        Self {
133            instance_url: url.clone(),
134            base_url: url,
135            project_key: project_key.into(),
136            email: email.into(),
137            token,
138            flavor: if flavor {
139                JiraFlavor::Cloud
140            } else {
141                JiraFlavor::SelfHosted
142            },
143            proxy_headers: None,
144            client: reqwest::Client::builder()
145                .user_agent("devboy-tools")
146                .build()
147                .expect("Failed to create HTTP client"),
148        }
149    }
150
151    /// Build request with auth headers and JSON content type.
152    ///
153    /// When proxy is configured, provider's own auth is suppressed and
154    /// proxy headers are added instead. The proxy handles authentication.
155    fn request(&self, method: reqwest::Method, url: &str) -> reqwest::RequestBuilder {
156        self.request_raw(method, url)
157            .header("Content-Type", "application/json")
158    }
159
160    /// Build request with auth headers but **no** Content-Type header.
161    ///
162    /// Use this for multipart uploads where reqwest must set its own
163    /// `Content-Type: multipart/form-data; boundary=...` header.
164    fn request_raw(&self, method: reqwest::Method, url: &str) -> reqwest::RequestBuilder {
165        let mut builder = self.client.request(method, url);
166
167        if let Some(headers) = &self.proxy_headers {
168            for (key, value) in headers {
169                builder = builder.header(key.as_str(), value.as_str());
170            }
171        } else {
172            builder = match self.flavor {
173                JiraFlavor::Cloud => {
174                    let token_value = self.token.expose_secret();
175                    let credentials = base64_encode(&format!("{}:{}", self.email, token_value));
176                    builder.header("Authorization", format!("Basic {}", credentials))
177                }
178                JiraFlavor::SelfHosted => {
179                    let token_value = self.token.expose_secret();
180                    if token_value.contains(':') {
181                        let credentials = base64_encode(token_value);
182                        builder.header("Authorization", format!("Basic {}", credentials))
183                    } else {
184                        builder.header("Authorization", format!("Bearer {}", token_value))
185                    }
186                }
187            };
188        }
189        builder
190    }
191
192    /// Make an authenticated GET request.
193    async fn get<T: serde::de::DeserializeOwned>(&self, url: &str) -> Result<T> {
194        debug!(url = url, "Jira GET request");
195
196        let response = self
197            .request(reqwest::Method::GET, url)
198            .send()
199            .await
200            .map_err(|e| Error::Http(e.to_string()))?;
201
202        self.handle_response(response).await
203    }
204
205    /// Make an authenticated POST request.
206    async fn post<T: serde::de::DeserializeOwned, B: serde::Serialize>(
207        &self,
208        url: &str,
209        body: &B,
210    ) -> Result<T> {
211        debug!(url = url, "Jira POST request");
212
213        let response = self
214            .request(reqwest::Method::POST, url)
215            .json(body)
216            .send()
217            .await
218            .map_err(|e| Error::Http(e.to_string()))?;
219
220        self.handle_response(response).await
221    }
222
223    /// Make an authenticated POST request that returns no body (201/204).
224    async fn post_no_content<B: serde::Serialize>(&self, url: &str, body: &B) -> Result<()> {
225        debug!(url = url, "Jira POST (no content) 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        let status = response.status();
235        if !status.is_success() {
236            let status_code = status.as_u16();
237            let message = response.text().await.unwrap_or_default();
238            warn!(
239                status = status_code,
240                message = message,
241                "Jira API error response"
242            );
243            return Err(Error::from_status(status_code, message));
244        }
245
246        Ok(())
247    }
248
249    /// Make an authenticated PUT request that parses a JSON response body.
250    ///
251    /// Jira's `PUT /version/{id}` (issue #238) — unlike `PUT /issue/{key}` —
252    /// returns the updated entity, so we need a typed variant alongside the
253    /// `put` helper that discards the body.
254    async fn put_with_response<T: serde::de::DeserializeOwned, B: serde::Serialize>(
255        &self,
256        url: &str,
257        body: &B,
258    ) -> Result<T> {
259        debug!(url = url, "Jira PUT request (typed response)");
260
261        let response = self
262            .request(reqwest::Method::PUT, url)
263            .json(body)
264            .send()
265            .await
266            .map_err(|e| Error::Http(e.to_string()))?;
267
268        self.handle_response(response).await
269    }
270
271    /// Make an authenticated PUT request (Jira PUT returns 204 No Content).
272    async fn put<B: serde::Serialize>(&self, url: &str, body: &B) -> Result<()> {
273        debug!(url = url, "Jira PUT request");
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        let status = response.status();
283        if !status.is_success() {
284            let status_code = status.as_u16();
285            let message = response.text().await.unwrap_or_default();
286            warn!(
287                status = status_code,
288                message = message,
289                "Jira API error response"
290            );
291            return Err(Error::from_status(status_code, message));
292        }
293
294        Ok(())
295    }
296
297    /// Handle response and map errors.
298    async fn handle_response<T: serde::de::DeserializeOwned>(
299        &self,
300        response: reqwest::Response,
301    ) -> Result<T> {
302        let status = response.status();
303
304        if !status.is_success() {
305            let status_code = status.as_u16();
306            let message = response.text().await.unwrap_or_default();
307            warn!(
308                status = status_code,
309                message = message,
310                "Jira API error response"
311            );
312            return Err(Error::from_status(status_code, message));
313        }
314
315        let body = response
316            .text()
317            .await
318            .map_err(|e| Error::InvalidData(format!("Failed to read response body: {}", e)))?;
319
320        serde_json::from_str::<T>(&body).map_err(|e| {
321            // Use safe_char_boundary to avoid panic on multi-byte UTF-8
322            let preview = if body.len() > 500 {
323                let end = safe_char_boundary(&body, 500);
324                format!("{}...(truncated, total {} bytes)", &body[..end], body.len())
325            } else {
326                body.clone()
327            };
328            warn!(
329                error = %e,
330                body_preview = preview,
331                "Failed to parse Jira response"
332            );
333            let preview = if body.len() > 300 {
334                let end = safe_char_boundary(&body, 300);
335                format!("{}...(truncated)", &body[..end])
336            } else {
337                body.clone()
338            };
339            Error::InvalidData(format!(
340                "Failed to parse response: {}. Response preview: {}",
341                e, preview
342            ))
343        })
344    }
345
346    /// Transition an issue to a new status by finding matching transition.
347    ///
348    /// Matching order:
349    /// 1. Exact match on transition `to.name` (case-insensitive)
350    /// 2. Exact match on transition `name` (case-insensitive)
351    /// 3. Resolve via project statuses: fetch `GET /project/{key}/statuses`,
352    ///    find status matching `target_status` by name or category alias,
353    ///    then match against available transitions.
354    async fn transition_issue(&self, key: &str, target_status: &str) -> Result<()> {
355        let url = format!("{}/issue/{}/transitions", self.base_url, key);
356        let transitions: JiraTransitionsResponse = self.get(&url).await?;
357
358        // 1. Exact match on to.name
359        let transition = transitions
360            .transitions
361            .iter()
362            .find(|t| t.to.name.eq_ignore_ascii_case(target_status))
363            .or_else(|| {
364                // 2. Exact match on transition name
365                transitions
366                    .transitions
367                    .iter()
368                    .find(|t| t.name.eq_ignore_ascii_case(target_status))
369            });
370
371        let transition = if let Some(t) = transition {
372            t
373        } else {
374            // 3. Resolve via project statuses + category mapping
375            self.find_transition_by_project_statuses(target_status, &transitions)
376                .await?
377                .ok_or_else(|| {
378                    let available: Vec<String> = transitions
379                        .transitions
380                        .iter()
381                        .map(|t| {
382                            let cat =
383                                t.to.status_category
384                                    .as_ref()
385                                    .map(|sc| sc.key.as_str())
386                                    .unwrap_or("?");
387                            format!("{} [{}]", t.to.name, cat)
388                        })
389                        .collect();
390                    Error::InvalidData(format!(
391                        "No transition to status '{}' found for issue {}. Available: {:?}",
392                        target_status, key, available
393                    ))
394                })?
395        };
396
397        let payload = TransitionPayload {
398            transition: TransitionId {
399                id: transition.id.clone(),
400            },
401        };
402
403        let post_url = format!("{}/issue/{}/transitions", self.base_url, key);
404        debug!(
405            issue = key,
406            transition_id = transition.id,
407            target = target_status,
408            "Transitioning issue"
409        );
410
411        let response = self
412            .request(reqwest::Method::POST, &post_url)
413            .json(&payload)
414            .send()
415            .await
416            .map_err(|e| Error::Http(e.to_string()))?;
417
418        let status = response.status();
419        if !status.is_success() {
420            let status_code = status.as_u16();
421            let message = response.text().await.unwrap_or_default();
422            return Err(Error::from_status(status_code, message));
423        }
424
425        Ok(())
426    }
427
428    /// Fetch project statuses and find a matching transition.
429    ///
430    /// Strategy:
431    /// 1. Map user input to a category key (e.g., "cancelled" → "done")
432    /// 2. Fetch all project statuses via `GET /project/{key}/statuses`
433    /// 3. Find project statuses matching by name or category
434    /// 4. Match those status names against available transitions
435    async fn find_transition_by_project_statuses<'a>(
436        &self,
437        target_status: &str,
438        transitions: &'a JiraTransitionsResponse,
439    ) -> Result<Option<&'a JiraTransition>> {
440        let project_statuses = self.get_project_statuses().await.unwrap_or_default();
441
442        if project_statuses.is_empty() {
443            // Fallback: match directly on transition category (no project statuses available)
444            let category_key = generic_status_to_category(target_status);
445            return Ok(category_key.and_then(|cat| {
446                transitions.transitions.iter().find(|t| {
447                    t.to.status_category
448                        .as_ref()
449                        .is_some_and(|sc| sc.key == cat)
450                })
451            }));
452        }
453
454        // 1. Try to find project status by exact name match
455        let matching_status = project_statuses
456            .iter()
457            .find(|s| s.name.eq_ignore_ascii_case(target_status));
458
459        if let Some(status) = matching_status {
460            // Found exact status name in project — find transition to it
461            if let Some(t) = transitions
462                .transitions
463                .iter()
464                .find(|t| t.to.name.eq_ignore_ascii_case(&status.name))
465            {
466                return Ok(Some(t));
467            }
468        }
469
470        // 2. Map generic alias to category, find project statuses in that category,
471        //    then match against available transitions
472        if let Some(category_key) = generic_status_to_category(target_status) {
473            // Find all project statuses in this category
474            let category_status_names: Vec<&str> = project_statuses
475                .iter()
476                .filter(|s| {
477                    s.status_category
478                        .as_ref()
479                        .is_some_and(|sc| sc.key == category_key)
480                })
481                .map(|s| s.name.as_str())
482                .collect();
483
484            debug!(
485                target = target_status,
486                category = category_key,
487                statuses = ?category_status_names,
488                "Resolved category to project statuses"
489            );
490
491            // Find transition to any of these statuses
492            for status_name in &category_status_names {
493                if let Some(t) = transitions
494                    .transitions
495                    .iter()
496                    .find(|t| t.to.name.eq_ignore_ascii_case(status_name))
497                {
498                    return Ok(Some(t));
499                }
500            }
501
502            // Last resort: match transition by category key directly
503            return Ok(transitions.transitions.iter().find(|t| {
504                t.to.status_category
505                    .as_ref()
506                    .is_some_and(|sc| sc.key == category_key)
507            }));
508        }
509
510        Ok(None)
511    }
512
513    /// Fetch all unique statuses for the project.
514    ///
515    /// Calls `GET /project/{key}/statuses` and flattens statuses
516    /// from all issue types, deduplicating by name.
517    async fn get_project_statuses(&self) -> Result<Vec<JiraProjectStatus>> {
518        let url = format!("{}/project/{}/statuses", self.base_url, self.project_key);
519        let issue_type_statuses: Vec<JiraIssueTypeStatuses> = self.get(&url).await?;
520
521        let mut seen = std::collections::HashSet::new();
522        let mut statuses = Vec::new();
523
524        for its in &issue_type_statuses {
525            for status in &its.statuses {
526                let name_lower = status.name.to_lowercase();
527                if seen.insert(name_lower) {
528                    statuses.push(status.clone());
529                }
530            }
531        }
532
533        debug!(
534            project = self.project_key,
535            count = statuses.len(),
536            "Fetched project statuses"
537        );
538
539        Ok(statuses)
540    }
541
542    // =========================================================================
543    // Jira Structure Plugin API (/rest/structure/2.0/)
544    // =========================================================================
545
546    /// Build a URL for the Structure REST API.
547    ///
548    /// Uses the same root as the regular REST API (`base_url` with the
549    /// `/rest/api/{2,3}` suffix stripped) so that Structure calls go
550    /// through the configured proxy — `instance_url` is intentionally
551    /// reserved for browse links and bypasses any proxy headers/auth
552    /// installed via [`JiraClient::with_proxy`].
553    fn structure_url(&self, endpoint: &str) -> String {
554        let root = instance_url_from_base(&self.base_url);
555        format!("{}/rest/structure/2.0{}", root, endpoint)
556    }
557
558    /// Make an authenticated GET request to the Structure API.
559    async fn structure_get<T: serde::de::DeserializeOwned>(&self, endpoint: &str) -> Result<T> {
560        let url = self.structure_url(endpoint);
561        debug!(url = %url, "Jira Structure GET");
562        let response = self
563            .request(reqwest::Method::GET, &url)
564            .send()
565            .await
566            .map_err(|e| Error::Http(e.to_string()))?;
567        handle_structure_response(response).await
568    }
569
570    /// Make an authenticated POST request to the Structure API.
571    async fn structure_post<T: serde::de::DeserializeOwned, B: serde::Serialize>(
572        &self,
573        endpoint: &str,
574        body: &B,
575    ) -> Result<T> {
576        let url = self.structure_url(endpoint);
577        debug!(url = %url, "Jira Structure POST");
578        let response = self
579            .request(reqwest::Method::POST, &url)
580            .json(body)
581            .send()
582            .await
583            .map_err(|e| Error::Http(e.to_string()))?;
584        handle_structure_response(response).await
585    }
586
587    /// Make an authenticated PUT request to the Structure API (returns body).
588    async fn structure_put<T: serde::de::DeserializeOwned, B: serde::Serialize>(
589        &self,
590        endpoint: &str,
591        body: &B,
592    ) -> Result<T> {
593        let url = self.structure_url(endpoint);
594        debug!(url = %url, "Jira Structure PUT");
595        let response = self
596            .request(reqwest::Method::PUT, &url)
597            .json(body)
598            .send()
599            .await
600            .map_err(|e| Error::Http(e.to_string()))?;
601        handle_structure_response(response).await
602    }
603
604    /// Build a URL for the Jira Agile REST API (`/rest/agile/1.0/*`).
605    /// Issue #198.
606    fn agile_url(&self, endpoint: &str) -> String {
607        let root = instance_url_from_base(&self.base_url);
608        format!("{}/rest/agile/1.0{}", root, endpoint)
609    }
610
611    /// Authenticated GET against the Agile REST API.
612    ///
613    /// Uses generic `get` — Agile endpoints return JSON and we don't want
614    /// the Structure-plugin-specific error hint (Copilot review on PR #205)
615    /// leaking into Agile failures.
616    async fn agile_get<T: serde::de::DeserializeOwned>(&self, endpoint: &str) -> Result<T> {
617        let url = self.agile_url(endpoint);
618        debug!(url = %url, "Jira Agile GET");
619        self.get(&url).await
620    }
621
622    /// Authenticated POST against the Agile REST API with no response
623    /// body (Agile's `/sprint/{id}/issue` returns 204). Uses
624    /// `post_no_content` for the same reason as `agile_get`.
625    async fn agile_post_void<B: serde::Serialize>(&self, endpoint: &str, body: &B) -> Result<()> {
626        let url = self.agile_url(endpoint);
627        debug!(url = %url, "Jira Agile POST");
628        self.post_no_content(&url, body).await
629    }
630
631    /// Make an authenticated DELETE request to the Structure API.
632    async fn structure_delete_request(&self, endpoint: &str) -> Result<()> {
633        let url = self.structure_url(endpoint);
634        debug!(url = %url, "Jira Structure DELETE");
635        let response = self
636            .request(reqwest::Method::DELETE, &url)
637            .send()
638            .await
639            .map_err(|e| Error::Http(e.to_string()))?;
640        let status = response.status();
641        if !status.is_success() {
642            let (content_type, body) = read_structure_error_body(response).await;
643            return Err(structure_error_from_status(
644                status.as_u16(),
645                &content_type,
646                body,
647            ));
648        }
649        Ok(())
650    }
651
652    /// Fetch a compact list of accessible Structures for metadata enrichment.
653    ///
654    /// Unlike [`Self::get_structures`], this is intended to be called from a
655    /// metadata-assembly pipeline and **swallows the "Structure plugin not
656    /// installed" error** (HTTP 404, which the Structure endpoint returns as
657    /// a generic Jira "dead link" HTML page) into an empty [`Vec`]. The
658    /// resulting `Ok(vec![])` is the "no structures are available here"
659    /// signal that downstream enrichers key on to decide whether to touch
660    /// the `structureId` parameter of Structure tools.
661    ///
662    /// Credential / permission failures (401 `Unauthorized`, 403
663    /// `Forbidden`) still propagate — those indicate the caller's
664    /// integration is misconfigured, not that the feature is absent, and
665    /// the metadata build should surface the error rather than silently
666    /// pretend no structures exist.
667    pub async fn list_structures_for_metadata(
668        &self,
669    ) -> Result<Vec<crate::metadata::JiraStructureRef>> {
670        match self
671            .structure_get::<crate::types::JiraStructureListResponse>("/structure")
672            .await
673        {
674            Ok(resp) => Ok(resp
675                .structures
676                .into_iter()
677                .map(|s| crate::metadata::JiraStructureRef {
678                    id: s.id,
679                    name: s.name,
680                    description: s.description,
681                })
682                .collect()),
683            // Structure plugin not installed or endpoint removed — treat as
684            // "no structures available" so metadata build keeps going.
685            Err(Error::NotFound(_)) => Ok(vec![]),
686            Err(other) => Err(other),
687        }
688    }
689}
690
691/// Install hint shown when the Structure plugin may not be detected on the
692/// Jira host. Phrased as a possibility rather than a definitive diagnosis —
693/// the same HTML/XML 404 pattern can also appear if the plugin is installed
694/// but the client hit a wrong/changed endpoint.
695const 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";
696
697/// Detect markup response bodies (HTML and XML) — Jira returns a full
698/// login/404 HTML page for missing endpoints and unauthenticated requests,
699/// and the Structure plugin itself returns an XML 404 envelope for unknown
700/// sub-paths. In both cases we refuse to dump the raw body into the MCP tool
701/// response.
702fn looks_like_html(content_type: &str, body: &str) -> bool {
703    let ct = content_type.to_ascii_lowercase();
704    if ct.contains("text/html") || ct.contains("application/xml") || ct.contains("text/xml") {
705        return true;
706    }
707    let head = body.trim_start();
708    head.starts_with("<!DOCTYPE")
709        || head.starts_with("<!doctype")
710        || head.starts_with("<html")
711        || head.starts_with("<HTML")
712        || head.starts_with("<?xml")
713}
714
715/// Read the response body + Content-Type for error diagnostics, tolerating
716/// reqwest read failures.
717async fn read_structure_error_body(response: reqwest::Response) -> (String, String) {
718    let content_type = response
719        .headers()
720        .get(reqwest::header::CONTENT_TYPE)
721        .and_then(|v| v.to_str().ok())
722        .unwrap_or("")
723        .to_string();
724    let body = response.text().await.unwrap_or_default();
725    (content_type, body)
726}
727
728/// Translate a failed Structure API HTTP response into a [`Result`] error.
729///
730/// Specifically:
731///
732/// - `404` with an HTML/XML body → soft hint that the endpoint was not found
733///   and the Structure plugin may not be installed (without asserting it).
734/// - Any other status with an HTML/XML body → short «status + hint» line
735///   (we strip the markup to avoid dumping a whole login/error page into the
736///   MCP tool response).
737/// - JSON / plain-text bodies are forwarded as-is, trimmed to 500 chars so
738///   the MCP tool output stays readable.
739fn structure_error_from_status(status: u16, content_type: &str, body: String) -> Error {
740    let html = looks_like_html(content_type, &body);
741
742    if status == 404 && html {
743        return Error::from_status(
744            status,
745            format!("Structure API endpoint not found (HTTP 404). {STRUCTURE_PLUGIN_HINT}"),
746        );
747    }
748
749    if html {
750        return Error::from_status(
751            status,
752            format!(
753                "Jira returned a non-JSON (HTML/XML) response for a Structure API call (HTTP {status}). {STRUCTURE_PLUGIN_HINT}"
754            ),
755        );
756    }
757
758    let trimmed = if body.len() > 500 {
759        let end = safe_char_boundary(&body, 500);
760        format!("{}...(truncated, total {} bytes)", &body[..end], body.len())
761    } else {
762        body
763    };
764    Error::from_status(status, trimmed)
765}
766
767/// Build a redacted preview of a response body for parse-error diagnostics.
768/// If the body is markup (HTML/XML), we never include any portion of it —
769/// login pages can contain ~2 KB of boilerplate that would leak into the MCP
770/// tool output. Otherwise, truncate to 300 chars on a UTF-8 boundary.
771fn structure_parse_preview(content_type: &str, body: &str) -> String {
772    if looks_like_html(content_type, body) {
773        format!(
774            "<{} bytes of HTML/XML redacted — non-JSON body indicates a non-Structure endpoint or missing plugin>",
775            body.len()
776        )
777    } else if body.len() > 300 {
778        let end = safe_char_boundary(body, 300);
779        format!("{}...(truncated, total {} bytes)", &body[..end], body.len())
780    } else {
781        body.to_string()
782    }
783}
784
785/// Unified response handler for Structure API helpers — mirrors
786/// [`JiraClient::handle_response`] but routes both error bodies and
787/// successful-but-unparseable bodies through [`looks_like_html`] so markup
788/// never leaks into MCP tool output.
789async fn handle_structure_response<T: serde::de::DeserializeOwned>(
790    response: reqwest::Response,
791) -> Result<T> {
792    let status = response.status();
793
794    if !status.is_success() {
795        let (content_type, body) = read_structure_error_body(response).await;
796        warn!(
797            status = status.as_u16(),
798            content_type = %content_type,
799            body_len = body.len(),
800            "Jira Structure API error response"
801        );
802        return Err(structure_error_from_status(
803            status.as_u16(),
804            &content_type,
805            body,
806        ));
807    }
808
809    // Capture Content-Type before consuming the response into text — needed
810    // for markup detection on the parse-error path when Jira returns e.g. an
811    // SSO redirect HTML page with a 2xx status.
812    let content_type = response
813        .headers()
814        .get(reqwest::header::CONTENT_TYPE)
815        .and_then(|v| v.to_str().ok())
816        .unwrap_or("")
817        .to_string();
818
819    let body = response.text().await.map_err(|e| {
820        Error::InvalidData(format!("Failed to read Structure response body: {}", e))
821    })?;
822
823    serde_json::from_str::<T>(&body).map_err(|e| {
824        let preview = structure_parse_preview(&content_type, &body);
825        warn!(
826            error = %e,
827            body_preview = preview,
828            content_type = %content_type,
829            "Failed to parse Jira Structure response"
830        );
831        Error::InvalidData(format!(
832            "Failed to parse Jira Structure response: {}. Body preview: {}",
833            e, preview
834        ))
835    })
836}
837
838// =============================================================================
839// Flavor detection and URL building
840// =============================================================================
841
842/// Detect Jira flavor from the instance URL.
843fn detect_flavor(url: &str) -> JiraFlavor {
844    if url.contains(".atlassian.net") {
845        JiraFlavor::Cloud
846    } else {
847        JiraFlavor::SelfHosted
848    }
849}
850
851/// Build the API base URL from the instance URL and flavor.
852fn build_api_base(url: &str, flavor: JiraFlavor) -> String {
853    let base = url.trim_end_matches('/');
854    match flavor {
855        JiraFlavor::Cloud => format!("{}/rest/api/3", base),
856        JiraFlavor::SelfHosted => format!("{}/rest/api/2", base),
857    }
858}
859
860/// Base64-encode a string (simple implementation without external crate).
861fn base64_encode(input: &str) -> String {
862    const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
863    let bytes = input.as_bytes();
864    let mut result = String::new();
865
866    for chunk in bytes.chunks(3) {
867        let b0 = chunk[0] as u32;
868        let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
869        let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
870
871        let triple = (b0 << 16) | (b1 << 8) | b2;
872
873        result.push(CHARSET[((triple >> 18) & 0x3F) as usize] as char);
874        result.push(CHARSET[((triple >> 12) & 0x3F) as usize] as char);
875
876        if chunk.len() > 1 {
877            result.push(CHARSET[((triple >> 6) & 0x3F) as usize] as char);
878        } else {
879            result.push('=');
880        }
881
882        if chunk.len() > 2 {
883            result.push(CHARSET[(triple & 0x3F) as usize] as char);
884        } else {
885            result.push('=');
886        }
887    }
888
889    result
890}
891
892// =============================================================================
893// ADF (Atlassian Document Format) converters
894// =============================================================================
895
896/// Convert plain text to ADF document (for Jira Cloud API v3).
897///
898/// Splits on `\n\n` for paragraphs, uses `hardBreak` for single `\n`.
899fn text_to_adf(text: &str) -> serde_json::Value {
900    if text.is_empty() {
901        return serde_json::json!({
902            "version": 1,
903            "type": "doc",
904            "content": [{
905                "type": "paragraph",
906                "content": []
907            }]
908        });
909    }
910
911    let paragraphs: Vec<&str> = text.split("\n\n").collect();
912    let content: Vec<serde_json::Value> = paragraphs
913        .iter()
914        .map(|para| {
915            let lines: Vec<&str> = para.split('\n').collect();
916            let mut inline_content: Vec<serde_json::Value> = Vec::new();
917
918            for (i, line) in lines.iter().enumerate() {
919                if i > 0 {
920                    inline_content.push(serde_json::json!({ "type": "hardBreak" }));
921                }
922                if !line.is_empty() {
923                    inline_content.push(serde_json::json!({
924                        "type": "text",
925                        "text": *line
926                    }));
927                }
928            }
929
930            serde_json::json!({
931                "type": "paragraph",
932                "content": inline_content
933            })
934        })
935        .collect();
936
937    serde_json::json!({
938        "version": 1,
939        "type": "doc",
940        "content": content
941    })
942}
943
944/// Extract plain text from an ADF document (for Jira Cloud API v3 responses).
945///
946/// Recursively walks the ADF tree extracting text nodes.
947/// Falls back to returning the value as a string if it's not an ADF document.
948fn adf_to_text(value: &serde_json::Value) -> String {
949    match value {
950        serde_json::Value::String(s) => s.clone(),
951        serde_json::Value::Object(obj) => {
952            let doc_type = obj.get("type").and_then(|t| t.as_str());
953
954            // If it's a text node, return the text
955            if doc_type == Some("text") {
956                return obj
957                    .get("text")
958                    .and_then(|t| t.as_str())
959                    .unwrap_or("")
960                    .to_string();
961            }
962
963            // If it's a hardBreak, return newline
964            if doc_type == Some("hardBreak") {
965                return "\n".to_string();
966            }
967
968            // Recurse into content array
969            if let Some(content) = obj.get("content").and_then(|c| c.as_array()) {
970                let texts: Vec<String> = content.iter().map(adf_to_text).collect();
971                let joined = texts.join("");
972
973                // Add paragraph separation for top-level paragraphs
974                if doc_type == Some("paragraph") {
975                    return joined;
976                }
977                if doc_type == Some("doc") {
978                    // Join paragraphs with double newline
979                    let para_texts: Vec<String> = content
980                        .iter()
981                        .map(adf_to_text)
982                        .filter(|s| !s.is_empty())
983                        .collect();
984                    return para_texts.join("\n\n");
985                }
986
987                return joined;
988            }
989
990            String::new()
991        }
992        serde_json::Value::Null => String::new(),
993        other => other.to_string(),
994    }
995}
996
997/// Read description from a Jira issue, handling both ADF and plain text.
998fn read_description(value: &Option<serde_json::Value>, flavor: JiraFlavor) -> Option<String> {
999    let value = value.as_ref()?;
1000    match value {
1001        serde_json::Value::Null => None,
1002        serde_json::Value::String(s) => {
1003            if s.is_empty() {
1004                None
1005            } else {
1006                Some(s.clone())
1007            }
1008        }
1009        _ => {
1010            if flavor == JiraFlavor::Cloud {
1011                let text = adf_to_text(value);
1012                if text.is_empty() { None } else { Some(text) }
1013            } else {
1014                // Self-hosted v2 shouldn't return ADF, but handle gracefully
1015                Some(value.to_string())
1016            }
1017        }
1018    }
1019}
1020
1021/// Read comment body from a Jira comment, handling both ADF and plain text.
1022fn read_comment_body(value: &Option<serde_json::Value>, flavor: JiraFlavor) -> String {
1023    match value {
1024        Some(serde_json::Value::String(s)) => s.clone(),
1025        Some(serde_json::Value::Null) | None => String::new(),
1026        Some(v) => {
1027            if flavor == JiraFlavor::Cloud {
1028                adf_to_text(v)
1029            } else {
1030                v.to_string()
1031            }
1032        }
1033    }
1034}
1035
1036// =============================================================================
1037// Mapping functions: Jira types -> Unified types
1038// =============================================================================
1039
1040fn map_user(jira_user: Option<&JiraUser>) -> Option<User> {
1041    jira_user.map(|u| {
1042        let id = u
1043            .account_id
1044            .clone()
1045            .or_else(|| u.name.clone())
1046            .unwrap_or_default();
1047        let username = u
1048            .name
1049            .clone()
1050            .or_else(|| u.account_id.clone())
1051            .unwrap_or_default();
1052        User {
1053            id,
1054            username,
1055            name: u.display_name.clone(),
1056            email: u.email_address.clone(),
1057            avatar_url: None,
1058        }
1059    })
1060}
1061
1062fn map_priority(jira_priority: Option<&JiraPriority>) -> Option<String> {
1063    jira_priority.map(|p| match p.name.to_lowercase().as_str() {
1064        "highest" | "critical" | "blocker" => "urgent".to_string(),
1065        "high" => "high".to_string(),
1066        "medium" => "normal".to_string(),
1067        "low" => "low".to_string(),
1068        "lowest" | "trivial" => "low".to_string(),
1069        other => other.to_string(),
1070    })
1071}
1072
1073fn map_state(status: Option<&JiraStatus>) -> String {
1074    status
1075        .map(|s| s.name.clone())
1076        .unwrap_or_else(|| "unknown".to_string())
1077}
1078
1079/// Parse issue key like "jira#WEB-1" to get the raw Jira key "WEB-1".
1080/// If the key doesn't have a "jira#" prefix, returns it as-is (for internal calls).
1081fn parse_jira_key(key: &str) -> &str {
1082    key.strip_prefix("jira#").unwrap_or(key)
1083}
1084
1085fn map_issue(issue: &JiraIssue, flavor: JiraFlavor, instance_url: &str) -> Issue {
1086    Issue {
1087        key: format!("jira#{}", issue.key),
1088        title: issue.fields.summary.clone().unwrap_or_default(),
1089        description: read_description(&issue.fields.description, flavor),
1090        state: map_state(issue.fields.status.as_ref()),
1091        source: "jira".to_string(),
1092        priority: map_priority(issue.fields.priority.as_ref()),
1093        labels: issue.fields.labels.clone(),
1094        author: map_user(issue.fields.reporter.as_ref()),
1095        assignees: issue
1096            .fields
1097            .assignee
1098            .as_ref()
1099            .map(|a| vec![map_user(Some(a)).unwrap()])
1100            .unwrap_or_default(),
1101        url: Some(format!("{}/browse/{}", instance_url, issue.key)),
1102        created_at: issue.fields.created.clone(),
1103        updated_at: issue.fields.updated.clone(),
1104        attachments_count: if issue.fields.attachment.is_empty() {
1105            None
1106        } else {
1107            Some(issue.fields.attachment.len() as u32)
1108        },
1109        parent: None,
1110        subtasks: vec![],
1111    }
1112}
1113
1114fn map_relations(issue: &JiraIssue, flavor: JiraFlavor, instance_url: &str) -> IssueRelations {
1115    let mut relations = IssueRelations::default();
1116
1117    // Parent
1118    if let Some(parent) = &issue.fields.parent {
1119        relations.parent = Some(map_issue(parent, flavor, instance_url));
1120    }
1121
1122    // Subtasks
1123    relations.subtasks = issue
1124        .fields
1125        .subtasks
1126        .iter()
1127        .map(|s| map_issue(s, flavor, instance_url))
1128        .collect();
1129
1130    // Issue links
1131    for link in &issue.fields.issuelinks {
1132        let link_name = &link.link_type.name;
1133
1134        let outward_lower = link.link_type.outward.as_deref().map(str::to_lowercase);
1135        let inward_lower = link.link_type.inward.as_deref().map(str::to_lowercase);
1136
1137        if let Some(outward) = &link.outward_issue {
1138            let mapped = map_issue(outward, flavor, instance_url);
1139            let issue_link = IssueLink {
1140                issue: mapped,
1141                link_type: link_name.clone(),
1142            };
1143
1144            match outward_lower.as_deref() {
1145                Some(s) if s.contains("block") => relations.blocks.push(issue_link),
1146                Some(s) if s.contains("duplicate") => relations.duplicates.push(issue_link),
1147                _ => relations.related_to.push(issue_link),
1148            }
1149        }
1150
1151        if let Some(inward) = &link.inward_issue {
1152            let mapped = map_issue(inward, flavor, instance_url);
1153            let issue_link = IssueLink {
1154                issue: mapped,
1155                link_type: link_name.clone(),
1156            };
1157
1158            match inward_lower.as_deref() {
1159                Some(s) if s.contains("block") => relations.blocked_by.push(issue_link),
1160                Some(s) if s.contains("duplicate") => relations.duplicates.push(issue_link),
1161                _ => relations.related_to.push(issue_link),
1162            }
1163        }
1164    }
1165
1166    relations
1167}
1168
1169fn map_comment(jira_comment: &JiraComment, flavor: JiraFlavor) -> Comment {
1170    Comment {
1171        id: jira_comment.id.clone(),
1172        body: read_comment_body(&jira_comment.body, flavor),
1173        author: map_user(jira_comment.author.as_ref()),
1174        created_at: jira_comment.created.clone(),
1175        updated_at: jira_comment.updated.clone(),
1176        position: None,
1177    }
1178}
1179
1180/// Map a Jira attachment payload to the provider-agnostic [`AssetMeta`].
1181fn map_jira_attachment(raw: &JiraAttachment) -> AssetMeta {
1182    // Prefer the explicit `filename` from Jira. Don't fall back to
1183    // `filename_from_url(content)` because Jira content URLs typically
1184    // end with `/attachment/content/{id}`, producing useless filenames
1185    // like "42". Fall back to `attachment-{id}` instead.
1186    let filename = raw
1187        .filename
1188        .clone()
1189        .unwrap_or_else(|| format!("attachment-{}", raw.id));
1190    let author = raw
1191        .author
1192        .as_ref()
1193        .and_then(|u| map_user(Some(u)))
1194        .map(|u| u.name.unwrap_or(u.username));
1195
1196    AssetMeta {
1197        id: raw.id.clone(),
1198        filename,
1199        mime_type: raw.mime_type.clone(),
1200        size: raw.size,
1201        url: raw.content.clone(),
1202        created_at: raw.created.clone(),
1203        author,
1204        cached: false,
1205        local_path: None,
1206        checksum_sha256: None,
1207        analysis: None,
1208    }
1209}
1210
1211/// Map a unified priority string to a Jira priority name.
1212fn priority_to_jira(priority: &str) -> String {
1213    match priority {
1214        "urgent" => "Highest".to_string(),
1215        "high" => "High".to_string(),
1216        "normal" => "Medium".to_string(),
1217        "low" => "Low".to_string(),
1218        other => other.to_string(),
1219    }
1220}
1221
1222/// Map generic/alias status names to Jira status category keys.
1223///
1224/// Jira has 4 status categories: `new`, `indeterminate`, `done`, `undefined`.
1225/// Escape special characters in a JQL string value.
1226///
1227/// JQL uses double quotes for string values. Backslashes and double quotes
1228/// inside the value must be escaped to prevent injection.
1229fn escape_jql(value: &str) -> String {
1230    value.replace('\\', "\\\\").replace('"', "\\\"")
1231}
1232
1233/// Merge custom fields (Object format) into a serializable payload.
1234/// Only keys with `customfield_` prefix are merged to prevent overwriting
1235/// core Jira fields like `project`, `summary`, `issuetype`.
1236/// Returns the number of custom fields actually merged.
1237fn merge_custom_fields_into_payload<T: serde::Serialize>(
1238    payload: T,
1239    custom_fields: &Option<serde_json::Value>,
1240) -> Result<(serde_json::Value, usize)> {
1241    let mut value = serde_json::to_value(payload)
1242        .map_err(|e| Error::InvalidData(format!("failed to serialize issue payload: {e}")))?;
1243    let mut merged_count = 0;
1244    if let Some(serde_json::Value::Object(cf)) = custom_fields
1245        && let Some(fields) = value.get_mut("fields").and_then(|f| f.as_object_mut())
1246    {
1247        for (k, v) in cf {
1248            if k.starts_with("customfield_") {
1249                fields.insert(k.clone(), v.clone());
1250                merged_count += 1;
1251            } else {
1252                tracing::warn!(field = %k, "Skipping non-custom field in customFields (expected customfield_* prefix)");
1253            }
1254        }
1255    }
1256    Ok((value, merged_count))
1257}
1258
1259/// Check whether a JQL string already contains a project filter clause.
1260/// Matches `project` as a JQL field name (word boundary) followed by an operator.
1261/// Skips occurrences inside quoted strings to avoid false positives.
1262fn has_project_clause(jql: &str) -> bool {
1263    let lower = jql.to_lowercase();
1264    let bytes = lower.as_bytes();
1265    let keyword = b"project";
1266    let mut in_quote = false;
1267    let mut i = 0;
1268
1269    while i < bytes.len() {
1270        // Track quoted strings — skip content inside quotes
1271        if bytes[i] == b'\\' && in_quote && i + 1 < bytes.len() {
1272            i += 2; // skip escaped character
1273            continue;
1274        }
1275        if bytes[i] == b'"' {
1276            in_quote = !in_quote;
1277            i += 1;
1278            continue;
1279        }
1280        if in_quote {
1281            i += 1;
1282            continue;
1283        }
1284
1285        // Check for "project" keyword at position i
1286        if i + keyword.len() <= bytes.len() && &bytes[i..i + keyword.len()] == keyword {
1287            // Word boundary before: not preceded by alphanumeric or underscore
1288            if i > 0 && (bytes[i - 1].is_ascii_alphanumeric() || bytes[i - 1] == b'_') {
1289                i += 1;
1290                continue;
1291            }
1292            // Check what follows — skip whitespace, then expect a JQL operator
1293            let after = &lower[i + keyword.len()..];
1294            let trimmed = after.trim_start();
1295            if trimmed.starts_with("!=")
1296                || trimmed.starts_with("not in ")
1297                || trimmed.starts_with("not in(")
1298                || trimmed.starts_with('=')
1299                || trimmed.starts_with('~')
1300                || trimmed.starts_with("in ")
1301                || trimmed.starts_with("in(")
1302            {
1303                return true;
1304            }
1305        }
1306        i += 1;
1307    }
1308    false
1309}
1310
1311/// This maps user-friendly aliases to the correct category key, used as fallback
1312/// when the exact status name is not found in available transitions.
1313fn generic_status_to_category(status: &str) -> Option<&'static str> {
1314    match status.to_lowercase().as_str() {
1315        "closed" | "done" | "resolved" | "canceled" | "cancelled" => Some("done"),
1316        "open" | "new" | "todo" | "to do" | "reopen" | "reopened" => Some("new"),
1317        "in_progress" | "in progress" | "in-progress" => Some("indeterminate"),
1318        _ => None,
1319    }
1320}
1321
1322/// Check if a keyword appears outside quoted strings in JQL.
1323fn has_unquoted_keyword(jql: &str, keyword: &str) -> bool {
1324    let lower = jql.to_lowercase();
1325    let kw = keyword.to_lowercase();
1326    let kw_bytes = kw.as_bytes();
1327    let bytes = lower.as_bytes();
1328    let mut in_quote = false;
1329    let mut i = 0;
1330
1331    while i < bytes.len() {
1332        if bytes[i] == b'\\' && in_quote && i + 1 < bytes.len() {
1333            i += 2;
1334            continue;
1335        }
1336        if bytes[i] == b'"' {
1337            in_quote = !in_quote;
1338            i += 1;
1339            continue;
1340        }
1341        if !in_quote
1342            && i + kw_bytes.len() <= bytes.len()
1343            && bytes[i..i + kw_bytes.len()] == *kw_bytes
1344        {
1345            return true;
1346        }
1347        i += 1;
1348    }
1349    false
1350}
1351
1352/// Get the Jira instance URL from the API base URL.
1353fn instance_url_from_base(base_url: &str) -> String {
1354    base_url
1355        .trim_end_matches("/rest/api/3")
1356        .trim_end_matches("/rest/api/2")
1357        .to_string()
1358}
1359
1360// =============================================================================
1361// Structure helpers
1362// =============================================================================
1363
1364/// Transform compact `rows[] + depths[]` from Structure API into a
1365/// nested tree. Returns `InvalidData` if the two vectors are not the
1366/// same length — the Structure API contract guarantees alignment, and
1367/// a mismatch here would otherwise silently nest rows at depth 0 and
1368/// produce a subtly wrong tree.
1369fn build_forest_tree(
1370    rows: &[crate::types::JiraForestRow],
1371    depths: &[u32],
1372) -> Result<Vec<StructureNode>> {
1373    if rows.len() != depths.len() {
1374        return Err(Error::InvalidData(format!(
1375            "Structure forest response has {} rows but {} depths",
1376            rows.len(),
1377            depths.len()
1378        )));
1379    }
1380    let mut roots: Vec<StructureNode> = Vec::new();
1381    let mut stack: Vec<StructureNode> = Vec::new();
1382
1383    for (row, depth) in rows.iter().zip(depths.iter()) {
1384        let depth = *depth as usize;
1385        let node = StructureNode {
1386            row_id: row.id,
1387            item_id: row.item_id.clone(),
1388            item_type: row.item_type.clone(),
1389            children: Vec::new(),
1390        };
1391
1392        // Pop stack to find parent at depth - 1
1393        while stack.len() > depth {
1394            let child = stack.pop().expect("stack.len() > depth > 0");
1395            if let Some(parent) = stack.last_mut() {
1396                parent.children.push(child);
1397            } else {
1398                roots.push(child);
1399            }
1400        }
1401
1402        stack.push(node);
1403    }
1404
1405    // Flush remaining stack
1406    while let Some(child) = stack.pop() {
1407        if let Some(parent) = stack.last_mut() {
1408            parent.children.push(child);
1409        } else {
1410            roots.push(child);
1411        }
1412    }
1413
1414    Ok(roots)
1415}
1416
1417/// Map Jira Structure view to unified type.
1418fn map_structure_view(view: crate::types::JiraStructureView) -> StructureView {
1419    StructureView {
1420        id: view.id,
1421        name: view.name,
1422        structure_id: view.structure_id,
1423        columns: view
1424            .columns
1425            .into_iter()
1426            .map(|c| StructureViewColumn {
1427                id: c.id,
1428                field: c.field,
1429                formula: c.formula,
1430                width: c.width,
1431            })
1432            .collect(),
1433        group_by: view.group_by,
1434        sort_by: view.sort_by,
1435        filter: view.filter,
1436    }
1437}
1438
1439// =============================================================================
1440// Trait implementations
1441// =============================================================================
1442
1443#[async_trait]
1444impl IssueProvider for JiraClient {
1445    async fn get_issues(&self, filter: IssueFilter) -> Result<ProviderResult<Issue>> {
1446        let limit = filter.limit.unwrap_or(20);
1447        if limit == 0 {
1448            return Ok(vec![].into());
1449        }
1450        let offset = filter.offset.unwrap_or(0);
1451
1452        // Resolve effective project key: filter override → self.project_key
1453        // Treat blank project_key as unset
1454        let effective_project = filter
1455            .project_key
1456            .as_deref()
1457            .filter(|k| !k.trim().is_empty())
1458            .unwrap_or(&self.project_key);
1459
1460        // Build JQL query — native_query takes precedence over filter-based construction
1461        let escaped_project = escape_jql(effective_project);
1462        let jql = if let Some(native) = &filter.native_query
1463            && !native.trim().is_empty()
1464        {
1465            // If native query doesn't mention a project clause, prepend one
1466            // (Jira Cloud requires a project filter)
1467            if has_project_clause(native) {
1468                native.clone()
1469            } else if native.trim_start().to_lowercase().starts_with("order by") {
1470                format!("project = \"{}\" {}", escaped_project, native)
1471            } else {
1472                format!("project = \"{}\" AND {}", escaped_project, native)
1473            }
1474        } else {
1475            let mut jql_parts: Vec<String> = vec![format!("project = \"{}\"", escaped_project)];
1476
1477            // State filter
1478            if let Some(state) = &filter.state {
1479                match state.as_str() {
1480                    "open" | "opened" => {
1481                        jql_parts.push("statusCategory != Done".to_string());
1482                    }
1483                    "closed" | "done" => {
1484                        jql_parts.push("statusCategory = Done".to_string());
1485                    }
1486                    "all" => {} // No filter
1487                    other => {
1488                        // Exact status name
1489                        jql_parts.push(format!("status = \"{}\"", escape_jql(other)));
1490                    }
1491                }
1492            }
1493
1494            if let Some(search) = &filter.search {
1495                jql_parts.push(format!("summary ~ \"{}\"", escape_jql(search)));
1496            }
1497
1498            if let Some(labels) = &filter.labels {
1499                for label in labels {
1500                    jql_parts.push(format!("labels = \"{}\"", escape_jql(label)));
1501                }
1502            }
1503
1504            if let Some(assignee) = &filter.assignee {
1505                jql_parts.push(format!("assignee = \"{}\"", escape_jql(assignee)));
1506            }
1507
1508            jql_parts.join(" AND ")
1509        };
1510
1511        // Add ORDER BY — skip if native_query already contains one
1512        let order_by = match filter.sort_by.as_deref() {
1513            Some("created_at" | "created") => "created",
1514            Some("priority") => "priority",
1515            _ => "updated",
1516        };
1517        let order = match filter.sort_order.as_deref() {
1518            Some("asc") => "ASC",
1519            _ => "DESC",
1520        };
1521        let has_order_by = has_unquoted_keyword(&jql, "order by");
1522        let jql_with_order = if has_order_by {
1523            jql
1524        } else {
1525            format!("{} ORDER BY {} {}", jql, order_by, order)
1526        };
1527
1528        let instance_url = &self.instance_url;
1529
1530        match self.flavor {
1531            JiraFlavor::Cloud => {
1532                // Cloud: GET /search/jql?jql=...&maxResults=...&nextPageToken=...
1533                let url = format!("{}/search/jql", self.base_url);
1534
1535                let mut all_issues: Vec<Issue> = Vec::new();
1536                let mut next_page_token: Option<String> = None;
1537                let total_needed = offset.saturating_add(limit);
1538                let mut fetched_count = 0u32;
1539
1540                // Explicitly request required fields — without this, Jira Cloud
1541                // may return minimal responses (only `id`) for certain JQL queries
1542                // (e.g., label filters), causing deserialization failures.
1543                let fields = "summary,status,priority,assignee,reporter,labels,created,updated,parent,subtasks".to_string();
1544
1545                loop {
1546                    let mut params: Vec<(&str, String)> = vec![
1547                        ("jql", jql_with_order.clone()),
1548                        ("maxResults", std::cmp::min(limit, 50).to_string()),
1549                        ("fields", fields.clone()),
1550                    ];
1551
1552                    if let Some(token) = &next_page_token {
1553                        params.push(("nextPageToken", token.clone()));
1554                    }
1555
1556                    let param_refs: Vec<(&str, &str)> =
1557                        params.iter().map(|(k, v)| (*k, v.as_str())).collect();
1558
1559                    debug!(url = url, params = ?param_refs, "Jira Cloud search");
1560
1561                    let response = self
1562                        .request(reqwest::Method::GET, &url)
1563                        .query(&param_refs)
1564                        .send()
1565                        .await
1566                        .map_err(|e| Error::Http(e.to_string()))?;
1567
1568                    let search_resp: JiraCloudSearchResponse =
1569                        self.handle_response(response).await?;
1570
1571                    let page_len = search_resp.issues.len() as u32;
1572                    for issue in &search_resp.issues {
1573                        if fetched_count >= offset && all_issues.len() < limit as usize {
1574                            all_issues.push(map_issue(issue, self.flavor, instance_url));
1575                        }
1576                        fetched_count += 1;
1577                    }
1578
1579                    if all_issues.len() >= limit as usize {
1580                        break;
1581                    }
1582
1583                    match search_resp.next_page_token {
1584                        Some(token) if page_len > 0 && fetched_count < total_needed => {
1585                            next_page_token = Some(token);
1586                        }
1587                        _ => break,
1588                    }
1589                }
1590
1591                let mut result = ProviderResult::new(all_issues);
1592                result.pagination = Some(devboy_core::Pagination {
1593                    offset,
1594                    limit,
1595                    total: None, // Jira Cloud cursor-based, no total
1596                    has_more: next_page_token.is_some(),
1597                    next_cursor: next_page_token,
1598                });
1599                result.sort_info = Some(devboy_core::SortInfo {
1600                    sort_by: Some(order_by.into()),
1601                    sort_order: match order {
1602                        "ASC" => devboy_core::SortOrder::Asc,
1603                        _ => devboy_core::SortOrder::Desc,
1604                    },
1605                    available_sorts: vec!["created".into(), "updated".into(), "priority".into()],
1606                });
1607                Ok(result)
1608            }
1609            JiraFlavor::SelfHosted => {
1610                // Self-Hosted: GET /search?jql=...&startAt=...&maxResults=...
1611                let url = format!("{}/search", self.base_url);
1612
1613                let params: Vec<(&str, String)> = vec![
1614                    ("jql", jql_with_order),
1615                    ("startAt", offset.to_string()),
1616                    ("maxResults", limit.to_string()),
1617                    ("fields", "summary,status,priority,assignee,reporter,labels,created,updated,parent,subtasks".to_string()),
1618                ];
1619
1620                let param_refs: Vec<(&str, &str)> =
1621                    params.iter().map(|(k, v)| (*k, v.as_str())).collect();
1622
1623                debug!(url = url, params = ?param_refs, "Jira Self-Hosted search");
1624
1625                let response = self
1626                    .request(reqwest::Method::GET, &url)
1627                    .query(&param_refs)
1628                    .send()
1629                    .await
1630                    .map_err(|e| Error::Http(e.to_string()))?;
1631
1632                let search_resp: JiraSearchResponse = self.handle_response(response).await?;
1633
1634                let total = search_resp.total;
1635                let has_more = match (total, search_resp.start_at, search_resp.max_results) {
1636                    (Some(t), Some(s), Some(m)) => s + m < t,
1637                    _ => false,
1638                };
1639
1640                let issues: Vec<Issue> = search_resp
1641                    .issues
1642                    .iter()
1643                    .map(|i| map_issue(i, self.flavor, instance_url))
1644                    .collect();
1645
1646                let mut result = ProviderResult::new(issues);
1647                result.pagination = Some(devboy_core::Pagination {
1648                    offset,
1649                    limit,
1650                    total,
1651                    has_more,
1652                    next_cursor: None,
1653                });
1654                result.sort_info = Some(devboy_core::SortInfo {
1655                    sort_by: Some(order_by.into()),
1656                    sort_order: match order {
1657                        "ASC" => devboy_core::SortOrder::Asc,
1658                        _ => devboy_core::SortOrder::Desc,
1659                    },
1660                    available_sorts: vec!["created".into(), "updated".into(), "priority".into()],
1661                });
1662                Ok(result)
1663            }
1664        }
1665    }
1666
1667    async fn get_issue(&self, key: &str) -> Result<Issue> {
1668        let jira_key = parse_jira_key(key);
1669        let url = format!("{}/issue/{}", self.base_url, jira_key);
1670        let issue: JiraIssue = self.get(&url).await?;
1671        Ok(map_issue(&issue, self.flavor, &self.instance_url))
1672    }
1673
1674    async fn create_issue(&self, input: CreateIssueInput) -> Result<Issue> {
1675        let description = input.description.map(|d| {
1676            if self.flavor == JiraFlavor::Cloud {
1677                text_to_adf(&d)
1678            } else {
1679                serde_json::Value::String(d)
1680            }
1681        });
1682
1683        let labels = if input.labels.is_empty() {
1684            None
1685        } else {
1686            Some(input.labels)
1687        };
1688        let has_labels = labels.is_some();
1689
1690        let priority = input.priority.as_deref().map(|p| PriorityName {
1691            name: priority_to_jira(p),
1692        });
1693
1694        let assignee = input.assignees.first().map(|a| {
1695            if self.flavor == JiraFlavor::Cloud {
1696                serde_json::json!({ "accountId": a })
1697            } else {
1698                serde_json::json!({ "name": a })
1699            }
1700        });
1701
1702        let effective_project = input.project_id.unwrap_or_else(|| self.project_key.clone());
1703        let effective_issue_type = input.issue_type.unwrap_or_else(|| "Task".to_string());
1704
1705        // Issue #197: pass through component IDs to the payload.
1706        let components = if input.components.is_empty() {
1707            None
1708        } else {
1709            Some(
1710                input
1711                    .components
1712                    .into_iter()
1713                    .map(|name| crate::types::ComponentRef { name })
1714                    .collect(),
1715            )
1716        };
1717
1718        let payload = CreateIssuePayload {
1719            fields: CreateIssueFields {
1720                project: ProjectKey {
1721                    key: effective_project,
1722                },
1723                summary: input.title,
1724                issuetype: IssueType {
1725                    name: effective_issue_type,
1726                },
1727                description,
1728                labels,
1729                priority,
1730                assignee,
1731                components,
1732                parent: input.parent.map(|key| crate::types::IssueKeyRef { key }),
1733            },
1734        };
1735
1736        let (mut payload, _) = merge_custom_fields_into_payload(payload, &input.custom_fields)?;
1737
1738        let url = format!("{}/issue", self.base_url);
1739        let create_result: std::result::Result<CreateIssueResponse, Error> =
1740            self.post(&url, &payload).await;
1741
1742        let create_resp = match create_result {
1743            Ok(resp) => resp,
1744            Err(e)
1745                if has_labels
1746                    && e.to_string().contains("labels")
1747                    && e.to_string().contains("not on the appropriate screen") =>
1748            {
1749                // Labels field is not on the Jira create screen
1750                // (common on Self-Hosted). Retry without labels and set them via
1751                // update afterwards.
1752                tracing::warn!("Create issue failed with labels, retrying without: {e}");
1753                let saved_labels = payload
1754                    .get_mut("fields")
1755                    .and_then(|f| f.as_object_mut())
1756                    .and_then(|f| f.remove("labels"));
1757                let resp: CreateIssueResponse = self.post(&url, &payload).await?;
1758
1759                // Best-effort: try to set labels via PUT update
1760                if let Some(lbl_value) = saved_labels
1761                    && let Ok(lbl) = serde_json::from_value::<Vec<String>>(lbl_value)
1762                {
1763                    let update = UpdateIssueInput {
1764                        labels: Some(lbl),
1765                        ..Default::default()
1766                    };
1767                    if let Err(e) = self.update_issue(&resp.key, update).await {
1768                        tracing::warn!("Failed to set labels after create: {e}");
1769                    }
1770                }
1771                resp
1772            }
1773            Err(e) => return Err(e),
1774        };
1775
1776        // Fetch the full issue to return
1777        self.get_issue(&create_resp.key).await
1778    }
1779
1780    async fn update_issue(&self, key: &str, input: UpdateIssueInput) -> Result<Issue> {
1781        let jira_key = parse_jira_key(key);
1782
1783        let description = input.description.map(|d| {
1784            if self.flavor == JiraFlavor::Cloud {
1785                text_to_adf(&d)
1786            } else {
1787                serde_json::Value::String(d)
1788            }
1789        });
1790
1791        let priority = input.priority.as_deref().map(|p| PriorityName {
1792            name: priority_to_jira(p),
1793        });
1794
1795        let assignee = input.assignees.as_ref().and_then(|a| {
1796            a.first().map(|username| {
1797                if self.flavor == JiraFlavor::Cloud {
1798                    serde_json::json!({ "accountId": username })
1799                } else {
1800                    serde_json::json!({ "name": username })
1801                }
1802            })
1803        });
1804
1805        let labels = input.labels;
1806
1807        // Issue #197: components. `None` → untouched, `Some([])` → clear.
1808        let components = input.components.map(|ids| {
1809            ids.into_iter()
1810                .map(|name| crate::types::ComponentRef { name })
1811                .collect()
1812        });
1813        let has_components = components.is_some();
1814
1815        let fields = UpdateIssueFields {
1816            summary: input.title,
1817            description,
1818            labels,
1819            priority,
1820            assignee,
1821            components,
1822        };
1823
1824        let has_custom_fields = input.custom_fields.as_ref().is_some_and(|v| {
1825            v.as_object()
1826                .is_some_and(|obj| obj.keys().any(|k| k.starts_with("customfield_")))
1827        });
1828
1829        // Only call PUT if there are field updates
1830        let has_field_updates = fields.summary.is_some()
1831            || fields.description.is_some()
1832            || fields.labels.is_some()
1833            || fields.priority.is_some()
1834            || fields.assignee.is_some()
1835            || has_components
1836            || has_custom_fields;
1837
1838        if has_field_updates {
1839            let url = format!("{}/issue/{}", self.base_url, jira_key);
1840            let payload = UpdateIssuePayload { fields };
1841            let (payload, _) = merge_custom_fields_into_payload(payload, &input.custom_fields)?;
1842            self.put(&url, &payload).await?;
1843        }
1844
1845        // Handle status change via transitions
1846        if let Some(state) = &input.state {
1847            self.transition_issue(jira_key, state).await?;
1848        }
1849
1850        // Fetch updated issue
1851        self.get_issue(jira_key).await
1852    }
1853
1854    async fn get_comments(&self, issue_key: &str) -> Result<ProviderResult<Comment>> {
1855        let jira_key = parse_jira_key(issue_key);
1856        let url = format!("{}/issue/{}/comment", self.base_url, jira_key);
1857        let response: JiraCommentsResponse = self.get(&url).await?;
1858        Ok(response
1859            .comments
1860            .iter()
1861            .map(|c| map_comment(c, self.flavor))
1862            .collect::<Vec<_>>()
1863            .into())
1864    }
1865
1866    async fn add_comment(&self, issue_key: &str, body: &str) -> Result<Comment> {
1867        let jira_key = parse_jira_key(issue_key);
1868        let comment_body = if self.flavor == JiraFlavor::Cloud {
1869            text_to_adf(body)
1870        } else {
1871            serde_json::Value::String(body.to_string())
1872        };
1873
1874        let payload = AddCommentPayload { body: comment_body };
1875
1876        let url = format!("{}/issue/{}/comment", self.base_url, jira_key);
1877        let jira_comment: JiraComment = self.post(&url, &payload).await?;
1878        Ok(map_comment(&jira_comment, self.flavor))
1879    }
1880
1881    async fn get_statuses(&self) -> Result<ProviderResult<IssueStatus>> {
1882        let project_statuses = self.get_project_statuses().await?;
1883
1884        let statuses: Vec<IssueStatus> = project_statuses
1885            .iter()
1886            .enumerate()
1887            .map(|(idx, s)| {
1888                let category = s
1889                    .status_category
1890                    .as_ref()
1891                    .map(|sc| match sc.key.as_str() {
1892                        "new" => "open".to_string(),
1893                        "indeterminate" => "in_progress".to_string(),
1894                        "done" => "done".to_string(),
1895                        other => other.to_string(),
1896                    })
1897                    .unwrap_or_else(|| "custom".to_string());
1898
1899                IssueStatus {
1900                    id: s.id.clone().unwrap_or_else(|| s.name.clone()),
1901                    name: s.name.clone(),
1902                    category,
1903                    color: None,
1904                    order: Some(idx as u32),
1905                }
1906            })
1907            .collect();
1908
1909        Ok(statuses.into())
1910    }
1911
1912    async fn get_users(&self, options: GetUsersOptions) -> Result<ProviderResult<User>> {
1913        let start_at = options.start_at.unwrap_or(0);
1914        let max_results = options.max_results.unwrap_or(50);
1915
1916        // Use assignable search if project_key is provided, otherwise generic user search
1917        let url = if let Some(ref project_key) = options.project_key {
1918            format!(
1919                "{}/user/assignable/search?project={}&startAt={}&maxResults={}",
1920                self.base_url, project_key, start_at, max_results
1921            )
1922        } else {
1923            let query = options.search.as_deref().unwrap_or("");
1924            match self.flavor {
1925                JiraFlavor::Cloud => format!(
1926                    "{}/user/search?query={}&startAt={}&maxResults={}",
1927                    self.base_url, query, start_at, max_results
1928                ),
1929                JiraFlavor::SelfHosted => format!(
1930                    "{}/user/search?username={}&startAt={}&maxResults={}",
1931                    self.base_url,
1932                    if query.is_empty() { "." } else { query },
1933                    start_at,
1934                    max_results
1935                ),
1936            }
1937        };
1938
1939        let jira_users: Vec<JiraUser> = self.get(&url).await?;
1940
1941        let users: Vec<User> = jira_users
1942            .iter()
1943            .map(|u| map_user(Some(u)).unwrap_or_default())
1944            .collect();
1945
1946        Ok(users.into())
1947    }
1948
1949    async fn link_issues(&self, source_key: &str, target_key: &str, link_type: &str) -> Result<()> {
1950        let source_jira_key = parse_jira_key(source_key).to_string();
1951        let target_jira_key = parse_jira_key(target_key).to_string();
1952
1953        let link_type_name = match link_type {
1954            "blocks" => "Blocks",
1955            "blocked_by" => "Blocks", // reversed direction
1956            "relates_to" => "Relates",
1957            "duplicates" => "Duplicate",
1958            "clones" => "Cloners",
1959            other => other,
1960        };
1961
1962        // For "blocked_by", swap source and target so the direction is correct
1963        let (outward_key, inward_key) = if link_type == "blocked_by" {
1964            (target_jira_key, source_jira_key)
1965        } else {
1966            (source_jira_key, target_jira_key)
1967        };
1968
1969        let payload = CreateIssueLinkPayload {
1970            link_type: IssueLinkTypeName {
1971                name: link_type_name.to_string(),
1972            },
1973            outward_issue: IssueKeyRef { key: outward_key },
1974            inward_issue: IssueKeyRef { key: inward_key },
1975        };
1976
1977        let url = format!("{}/issueLink", self.base_url);
1978        self.post_no_content(&url, &payload).await?;
1979
1980        Ok(())
1981    }
1982
1983    async fn get_issue_relations(&self, issue_key: &str) -> Result<IssueRelations> {
1984        let jira_key = parse_jira_key(issue_key);
1985        let url = format!(
1986            "{}/issue/{}?fields=parent,subtasks,issuelinks,summary,status,priority",
1987            self.base_url, jira_key
1988        );
1989        let issue: JiraIssue = self.get(&url).await?;
1990        Ok(map_relations(&issue, self.flavor, &self.instance_url))
1991    }
1992
1993    async fn upload_attachment(
1994        &self,
1995        issue_key: &str,
1996        filename: &str,
1997        data: &[u8],
1998    ) -> Result<String> {
1999        let jira_key = parse_jira_key(issue_key);
2000        let url = format!("{}/issue/{}/attachments", self.base_url, jira_key);
2001
2002        let part = reqwest::multipart::Part::bytes(data.to_vec())
2003            .file_name(filename.to_string())
2004            .mime_str("application/octet-stream")
2005            .map_err(|e| Error::Http(format!("failed to build multipart: {e}")))?;
2006        let form = reqwest::multipart::Form::new().part("file", part);
2007
2008        // Use request_raw (no Content-Type) so reqwest can set its own
2009        // multipart/form-data boundary header. self.request() sets
2010        // Content-Type: application/json which conflicts with multipart.
2011        let response = self
2012            .request_raw(reqwest::Method::POST, &url)
2013            // Jira requires the X-Atlassian-Token header to bypass its XSRF check
2014            // on file uploads: https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issue-attachments/
2015            .header("X-Atlassian-Token", "no-check")
2016            .multipart(form)
2017            .send()
2018            .await
2019            .map_err(|e| Error::Http(e.to_string()))?;
2020
2021        let status = response.status();
2022        if !status.is_success() {
2023            let message = response.text().await.unwrap_or_default();
2024            return Err(Error::from_status(status.as_u16(), message));
2025        }
2026
2027        // Jira returns an array of attachment descriptors; we take the first.
2028        let attachments: Vec<JiraAttachment> = response
2029            .json()
2030            .await
2031            .map_err(|e| Error::InvalidData(format!("failed to parse attachment response: {e}")))?;
2032        let url = attachments
2033            .into_iter()
2034            .next()
2035            .and_then(|a| a.content)
2036            .filter(|u| !u.is_empty())
2037            .ok_or_else(|| {
2038                Error::InvalidData(
2039                    "Jira upload returned no attachment with a content URL".to_string(),
2040                )
2041            })?;
2042        Ok(url)
2043    }
2044
2045    async fn get_issue_attachments(&self, issue_key: &str) -> Result<Vec<AssetMeta>> {
2046        let jira_key = parse_jira_key(issue_key);
2047        let url = format!("{}/issue/{}?fields=attachment", self.base_url, jira_key);
2048        let issue: JiraIssue = self.get(&url).await?;
2049        Ok(issue
2050            .fields
2051            .attachment
2052            .iter()
2053            .map(map_jira_attachment)
2054            .collect())
2055    }
2056
2057    async fn download_attachment(&self, _issue_key: &str, asset_id: &str) -> Result<Vec<u8>> {
2058        // Cloud: GET /rest/api/3/attachment/content/{id}
2059        // Self-Hosted: the Cloud endpoint doesn't exist; fetch attachment
2060        // metadata first and download from its `content` URL.
2061        let url = match self.flavor {
2062            JiraFlavor::Cloud => {
2063                format!("{}/attachment/content/{}", self.base_url, asset_id)
2064            }
2065            JiraFlavor::SelfHosted => {
2066                let meta_url = format!("{}/attachment/{}", self.base_url, asset_id);
2067                let meta: serde_json::Value = self.get(&meta_url).await?;
2068                meta.get("content")
2069                    .and_then(|v| v.as_str())
2070                    .ok_or_else(|| {
2071                        Error::InvalidData(format!(
2072                            "attachment {asset_id} metadata has no content URL"
2073                        ))
2074                    })?
2075                    .to_string()
2076            }
2077        };
2078        let response = self
2079            .request(reqwest::Method::GET, &url)
2080            .send()
2081            .await
2082            .map_err(|e| Error::Http(e.to_string()))?;
2083
2084        let status = response.status();
2085        if !status.is_success() {
2086            let message = response.text().await.unwrap_or_default();
2087            return Err(Error::from_status(status.as_u16(), message));
2088        }
2089
2090        let bytes = response
2091            .bytes()
2092            .await
2093            .map_err(|e| Error::Http(format!("failed to read attachment bytes: {e}")))?;
2094        Ok(bytes.to_vec())
2095    }
2096
2097    async fn delete_attachment(&self, _issue_key: &str, asset_id: &str) -> Result<()> {
2098        // DELETE /rest/api/{v}/attachment/{id} — 204 on success.
2099        let url = format!("{}/attachment/{}", self.base_url, asset_id);
2100        let response = self
2101            .request(reqwest::Method::DELETE, &url)
2102            .send()
2103            .await
2104            .map_err(|e| Error::Http(e.to_string()))?;
2105
2106        let status = response.status();
2107        if !status.is_success() {
2108            let message = response.text().await.unwrap_or_default();
2109            return Err(Error::from_status(status.as_u16(), message));
2110        }
2111        Ok(())
2112    }
2113
2114    fn asset_capabilities(&self) -> AssetCapabilities {
2115        // Jira exposes a full CRUD REST API for attachments on issues.
2116        AssetCapabilities {
2117            issue: ContextCapabilities {
2118                upload: true,
2119                download: true,
2120                delete: true,
2121                list: true,
2122                max_file_size: None,
2123                allowed_types: Vec::new(),
2124            },
2125            ..Default::default()
2126        }
2127    }
2128
2129    // --- Jira Structure plugin ---
2130
2131    async fn get_structures(&self) -> Result<ProviderResult<Structure>> {
2132        let resp: JiraStructureListResponse = self.structure_get("/structure").await?;
2133        let items: Vec<Structure> = resp
2134            .structures
2135            .into_iter()
2136            .map(|s| Structure {
2137                id: s.id,
2138                name: s.name,
2139                description: s.description,
2140            })
2141            .collect();
2142        Ok(items.into())
2143    }
2144
2145    async fn get_structure_forest(
2146        &self,
2147        structure_id: u64,
2148        options: GetForestOptions,
2149    ) -> Result<StructureForest> {
2150        let mut spec = serde_json::Map::new();
2151        if let Some(offset) = options.offset {
2152            spec.insert("offset".into(), serde_json::json!(offset));
2153        }
2154        if let Some(limit) = options.limit {
2155            spec.insert("limit".into(), serde_json::json!(limit));
2156        }
2157
2158        let resp: JiraForestResponse = self
2159            .structure_post(
2160                &format!("/forest/{}/spec", structure_id),
2161                &serde_json::Value::Object(spec),
2162            )
2163            .await?;
2164
2165        let tree = build_forest_tree(&resp.rows, &resp.depths)?;
2166
2167        Ok(StructureForest {
2168            version: resp.version,
2169            structure_id,
2170            tree,
2171            total_count: resp.total_count,
2172        })
2173    }
2174
2175    async fn add_structure_rows(
2176        &self,
2177        structure_id: u64,
2178        input: AddStructureRowsInput,
2179    ) -> Result<ForestModifyResult> {
2180        let mut payload = serde_json::json!({
2181            "rows": input.items.iter().map(|i| {
2182                let mut row = serde_json::json!({"itemId": i.item_id});
2183                if let Some(ref t) = i.item_type {
2184                    row["itemType"] = serde_json::json!(t);
2185                }
2186                row
2187            }).collect::<Vec<_>>()
2188        });
2189        if let Some(under) = input.under {
2190            payload["under"] = serde_json::json!(under);
2191        }
2192        if let Some(after) = input.after {
2193            payload["after"] = serde_json::json!(after);
2194        }
2195        if let Some(version) = input.forest_version {
2196            payload["forestVersion"] = serde_json::json!(version);
2197        }
2198
2199        let resp: JiraForestModifyResponse = self
2200            .structure_put(&format!("/forest/{}/item", structure_id), &payload)
2201            .await
2202            .map_err(|e| {
2203                if matches!(&e, Error::Api { status, .. } if *status == 409) {
2204                    Error::Api {
2205                        status: 409,
2206                        message: "Forest version conflict. The structure was modified concurrently. Retry with the latest version.".to_string(),
2207                    }
2208                } else {
2209                    e
2210                }
2211            })?;
2212
2213        Ok(ForestModifyResult {
2214            version: resp.version,
2215            affected_count: input.items.len(),
2216        })
2217    }
2218
2219    async fn move_structure_rows(
2220        &self,
2221        structure_id: u64,
2222        input: MoveStructureRowsInput,
2223    ) -> Result<ForestModifyResult> {
2224        let mut payload = serde_json::json!({
2225            "rowIds": input.row_ids
2226        });
2227        if let Some(under) = input.under {
2228            payload["under"] = serde_json::json!(under);
2229        }
2230        if let Some(after) = input.after {
2231            payload["after"] = serde_json::json!(after);
2232        }
2233        if let Some(version) = input.forest_version {
2234            payload["forestVersion"] = serde_json::json!(version);
2235        }
2236
2237        let resp: JiraForestModifyResponse = self
2238            .structure_post(&format!("/forest/{}/move", structure_id), &payload)
2239            .await
2240            .map_err(|e| {
2241                if matches!(&e, Error::Api { status, .. } if *status == 409) {
2242                    Error::Api {
2243                        status: 409,
2244                        message: "Forest version conflict. Retry with the latest version."
2245                            .to_string(),
2246                    }
2247                } else {
2248                    e
2249                }
2250            })?;
2251
2252        Ok(ForestModifyResult {
2253            version: resp.version,
2254            affected_count: input.row_ids.len(),
2255        })
2256    }
2257
2258    async fn remove_structure_row(&self, structure_id: u64, row_id: u64) -> Result<()> {
2259        self.structure_delete_request(&format!("/forest/{}/item/{}", structure_id, row_id))
2260            .await
2261    }
2262
2263    async fn get_structure_values(
2264        &self,
2265        input: GetStructureValuesInput,
2266    ) -> Result<StructureValues> {
2267        let columns: Vec<serde_json::Value> = input
2268            .columns
2269            .iter()
2270            .map(|c| {
2271                let mut col = serde_json::Map::new();
2272                if let Some(ref id) = c.id {
2273                    col.insert("id".into(), serde_json::json!(id));
2274                }
2275                if let Some(ref field) = c.field {
2276                    col.insert("field".into(), serde_json::json!(field));
2277                }
2278                if let Some(ref formula) = c.formula {
2279                    col.insert("formula".into(), serde_json::json!(formula));
2280                }
2281                serde_json::Value::Object(col)
2282            })
2283            .collect();
2284
2285        let payload = serde_json::json!({
2286            "structureId": input.structure_id,
2287            "rows": input.rows,
2288            "columns": columns,
2289        });
2290
2291        let resp: JiraStructureValuesResponse = self.structure_post("/value", &payload).await?;
2292
2293        // Group values by row_id. A missing `columnId` is treated as
2294        // an error rather than defaulted to `""` — silently bucketing
2295        // unknown columns under the empty-string key would merge
2296        // values from different columns and destroy user data.
2297        let mut row_map: std::collections::BTreeMap<u64, Vec<StructureColumnValue>> =
2298            std::collections::BTreeMap::new();
2299        for entry in resp.values {
2300            let column = entry.column_id.ok_or_else(|| {
2301                Error::InvalidData(format!(
2302                    "Structure value for row {} is missing `columnId`",
2303                    entry.row_id
2304                ))
2305            })?;
2306            row_map
2307                .entry(entry.row_id)
2308                .or_default()
2309                .push(StructureColumnValue {
2310                    column,
2311                    value: entry.value,
2312                });
2313        }
2314
2315        let values = row_map
2316            .into_iter()
2317            .map(|(row_id, columns)| StructureRowValues { row_id, columns })
2318            .collect();
2319
2320        Ok(StructureValues {
2321            structure_id: input.structure_id,
2322            values,
2323        })
2324    }
2325
2326    async fn get_structure_views(
2327        &self,
2328        structure_id: u64,
2329        view_id: Option<u64>,
2330    ) -> Result<Vec<StructureView>> {
2331        if let Some(id) = view_id {
2332            let view: JiraStructureView = self.structure_get(&format!("/view/{}", id)).await?;
2333            // Validate that the returned view actually belongs to the
2334            // requested structure — the Structure API's `/view/{id}`
2335            // endpoint ignores the structure id in the request, so a
2336            // caller who mixes up ids would otherwise silently see a
2337            // view from a different structure.
2338            if view.structure_id != structure_id {
2339                return Err(Error::InvalidData(format!(
2340                    "view {id} belongs to structure {} but {structure_id} was requested",
2341                    view.structure_id
2342                )));
2343            }
2344            Ok(vec![map_structure_view(view)])
2345        } else {
2346            let resp: JiraStructureViewListResponse = self
2347                .structure_get(&format!("/view?structureId={}", structure_id))
2348                .await?;
2349            Ok(resp.views.into_iter().map(map_structure_view).collect())
2350        }
2351    }
2352
2353    async fn save_structure_view(&self, input: SaveStructureViewInput) -> Result<StructureView> {
2354        let columns: Option<Vec<serde_json::Value>> = input.columns.as_ref().map(|cols| {
2355            cols.iter()
2356                .map(|c| {
2357                    let mut col = serde_json::Map::new();
2358                    if let Some(ref field) = c.field {
2359                        col.insert("field".into(), serde_json::json!(field));
2360                    }
2361                    if let Some(ref formula) = c.formula {
2362                        col.insert("formula".into(), serde_json::json!(formula));
2363                    }
2364                    if let Some(width) = c.width {
2365                        col.insert("width".into(), serde_json::json!(width));
2366                    }
2367                    serde_json::Value::Object(col)
2368                })
2369                .collect()
2370        });
2371
2372        let mut payload = serde_json::json!({
2373            "structureId": input.structure_id,
2374            "name": input.name,
2375        });
2376        if let Some(cols) = columns {
2377            payload["columns"] = serde_json::json!(cols);
2378        }
2379        if let Some(ref g) = input.group_by {
2380            payload["groupBy"] = serde_json::json!(g);
2381        }
2382        if let Some(ref s) = input.sort_by {
2383            payload["sortBy"] = serde_json::json!(s);
2384        }
2385        if let Some(ref f) = input.filter {
2386            payload["filter"] = serde_json::json!(f);
2387        }
2388
2389        let view: JiraStructureView = if let Some(id) = input.id {
2390            self.structure_put(&format!("/view/{}", id), &payload)
2391                .await?
2392        } else {
2393            self.structure_post("/view", &payload).await?
2394        };
2395
2396        Ok(map_structure_view(view))
2397    }
2398
2399    async fn create_structure(&self, input: CreateStructureInput) -> Result<Structure> {
2400        let mut payload = serde_json::json!({"name": input.name});
2401        if let Some(ref desc) = input.description {
2402            payload["description"] = serde_json::json!(desc);
2403        }
2404        let s: JiraStructure = self.structure_post("/structure", &payload).await?;
2405        Ok(Structure {
2406            id: s.id,
2407            name: s.name,
2408            description: s.description,
2409        })
2410    }
2411
2412    // --- Structure generators (issue #179) -----------------------------
2413
2414    async fn get_structure_generators(
2415        &self,
2416        structure_id: u64,
2417    ) -> Result<ProviderResult<devboy_core::StructureGenerator>> {
2418        #[derive(serde::Deserialize)]
2419        struct Resp {
2420            #[serde(default)]
2421            generators: Vec<RawGenerator>,
2422        }
2423        #[derive(serde::Deserialize)]
2424        struct RawGenerator {
2425            id: String,
2426            #[serde(rename = "type")]
2427            generator_type: String,
2428            #[serde(default)]
2429            spec: serde_json::Value,
2430        }
2431        let resp: Resp = self
2432            .structure_get(&format!("/structure/{}/generator", structure_id))
2433            .await?;
2434        let items: Vec<devboy_core::StructureGenerator> = resp
2435            .generators
2436            .into_iter()
2437            .map(|g| devboy_core::StructureGenerator {
2438                id: g.id,
2439                generator_type: g.generator_type,
2440                spec: g.spec,
2441            })
2442            .collect();
2443        Ok(items.into())
2444    }
2445
2446    async fn add_structure_generator(
2447        &self,
2448        input: devboy_core::AddStructureGeneratorInput,
2449    ) -> Result<devboy_core::StructureGenerator> {
2450        // Typed response so missing `id`/`type` surface as a deserialise
2451        // error instead of silent empty strings (Copilot review on PR #205).
2452        #[derive(serde::Deserialize)]
2453        struct Resp {
2454            id: String,
2455            #[serde(rename = "type")]
2456            generator_type: String,
2457            #[serde(default)]
2458            spec: serde_json::Value,
2459        }
2460        let body = serde_json::json!({
2461            "type": input.generator_type,
2462            "spec": input.spec,
2463        });
2464        let resp: Resp = self
2465            .structure_post(
2466                &format!("/structure/{}/generator", input.structure_id),
2467                &body,
2468            )
2469            .await?;
2470        Ok(devboy_core::StructureGenerator {
2471            id: resp.id,
2472            generator_type: resp.generator_type,
2473            spec: resp.spec,
2474        })
2475    }
2476
2477    async fn sync_structure_generator(
2478        &self,
2479        input: devboy_core::SyncStructureGeneratorInput,
2480    ) -> Result<()> {
2481        let body = serde_json::json!({});
2482        let _: serde_json::Value = self
2483            .structure_post(
2484                &format!(
2485                    "/structure/{}/generator/{}/sync",
2486                    input.structure_id, input.generator_id
2487                ),
2488                &body,
2489            )
2490            .await?;
2491        Ok(())
2492    }
2493
2494    // --- Structure delete + automation (issue #180) --------------------
2495
2496    async fn delete_structure(&self, structure_id: u64) -> Result<()> {
2497        self.structure_delete_request(&format!("/structure/{}", structure_id))
2498            .await
2499    }
2500
2501    async fn update_structure_automation(
2502        &self,
2503        input: devboy_core::UpdateStructureAutomationInput,
2504    ) -> Result<()> {
2505        // `automation_id = Some(id)` → rule-scoped PUT; `None` → replace
2506        // the whole automation collection (Copilot review on PR #205).
2507        let endpoint = match input.automation_id.as_deref() {
2508            Some(aid) => format!("/structure/{}/automation/{}", input.structure_id, aid),
2509            None => format!("/structure/{}/automation", input.structure_id),
2510        };
2511        let _: serde_json::Value = self.structure_put(&endpoint, &input.config).await?;
2512        Ok(())
2513    }
2514
2515    async fn trigger_structure_automation(&self, structure_id: u64) -> Result<()> {
2516        let body = serde_json::json!({});
2517        let _: serde_json::Value = self
2518            .structure_post(
2519                &format!("/structure/{}/automation/run", structure_id),
2520                &body,
2521            )
2522            .await?;
2523        Ok(())
2524    }
2525
2526    // --- Agile / Sprint (issue #198) -----------------------------------
2527
2528    async fn get_board_sprints(
2529        &self,
2530        board_id: u64,
2531        state: devboy_core::SprintState,
2532    ) -> Result<ProviderResult<devboy_core::Sprint>> {
2533        // Walk Jira Agile pagination (`startAt` + `isLast`) so callers on
2534        // boards with many closed/future sprints get the full list
2535        // (Codex review on PR #205).
2536        #[derive(serde::Deserialize)]
2537        #[serde(rename_all = "camelCase")]
2538        struct Resp {
2539            #[serde(default)]
2540            is_last: bool,
2541            #[serde(default)]
2542            values: Vec<devboy_core::Sprint>,
2543        }
2544        // Cap at 5k sprints — plenty for any realistic board, prevents
2545        // an infinite loop if `isLast` is ever misreported.
2546        const MAX_SPRINTS: usize = 5_000;
2547        const PAGE_SIZE: u32 = 50;
2548
2549        let state_param = state
2550            .as_query_value()
2551            .map(|s| format!("&state={}", s))
2552            .unwrap_or_default();
2553
2554        let mut sprints: Vec<devboy_core::Sprint> = Vec::new();
2555        let mut start_at: u32 = 0;
2556        loop {
2557            let endpoint = format!(
2558                "/board/{}/sprint?startAt={}&maxResults={}{}",
2559                board_id, start_at, PAGE_SIZE, state_param
2560            );
2561            let resp: Resp = self.agile_get(&endpoint).await?;
2562            let fetched = resp.values.len() as u32;
2563            sprints.extend(resp.values);
2564            if resp.is_last || fetched == 0 || sprints.len() >= MAX_SPRINTS {
2565                break;
2566            }
2567            start_at += fetched;
2568        }
2569        Ok(sprints.into())
2570    }
2571
2572    async fn assign_to_sprint(&self, input: devboy_core::AssignToSprintInput) -> Result<()> {
2573        // Jira accepts issue keys in the form `["PROJ-1", ...]`. Our
2574        // provider-normalised keys may carry a `jira#` prefix — strip it.
2575        let issues: Vec<String> = input
2576            .issue_keys
2577            .into_iter()
2578            .map(|k| parse_jira_key(&k).to_string())
2579            .collect();
2580        let body = serde_json::json!({ "issues": issues });
2581        self.agile_post_void(&format!("/sprint/{}/issue", input.sprint_id), &body)
2582            .await
2583    }
2584
2585    // --- Project versions / fixVersion (issue #238) --------------------
2586
2587    async fn list_project_versions(
2588        &self,
2589        params: ListProjectVersionsParams,
2590    ) -> Result<ProviderResult<ProjectVersion>> {
2591        let project_key = if params.project.is_empty() {
2592            self.project_key.clone()
2593        } else {
2594            params.project
2595        };
2596
2597        // Both Cloud v3 and Server/DC v2 expose
2598        // `GET /project/{key}/versions` as an unpaginated list of all
2599        // versions. The paginated `/version/page` endpoint exists on
2600        // Cloud only and isn't worth the flavor split — projects with
2601        // O(10²) versions still fit in one round-trip; we trim the
2602        // response in-memory below to honour Paper 1's 8k-token cap.
2603        //
2604        // `?expand=issuesstatus` is a Cloud-only payload extension —
2605        // Server/DC ignores it but we still skip the param there so we
2606        // don't bake hidden flavor-quirk dependencies into the URL.
2607        let mut url = format!("{}/project/{}/versions", self.base_url, project_key);
2608        if params.include_issue_count && self.flavor == JiraFlavor::Cloud {
2609            url.push_str("?expand=issuesstatus");
2610        }
2611
2612        let dtos: Vec<JiraVersionDto> = self.get(&url).await?;
2613
2614        let mut versions: Vec<ProjectVersion> = dtos
2615            .into_iter()
2616            .map(|dto| jira_version_to_project_version(dto, &project_key))
2617            .collect();
2618
2619        if let Some(want_released) = params.released {
2620            versions.retain(|v| v.released == want_released);
2621        }
2622        if let Some(want_archived) = params.archived {
2623            versions.retain(|v| v.archived == want_archived);
2624        }
2625
2626        // Order (Paper 1 — keep the *current* release at the top, not the
2627        // most recently shipped one):
2628        //   1. unreleased before released — work-in-flight beats history;
2629        //   2. release_date placement depends on the group:
2630        //      - unreleased: undated *first* (undated == "planned, no
2631        //        date yet" → still in flight), then dated desc;
2632        //      - released: dated desc, undated *last* ("released without
2633        //        a date" usually means unspecified history);
2634        //   3. semver-numeric tiebreak on name so "10.0.0" beats "9.10.0".
2635        versions.sort_by(|a, b| {
2636            use std::cmp::Ordering;
2637            let group = a.released.cmp(&b.released);
2638            if group != Ordering::Equal {
2639                return group;
2640            }
2641            // Both `a` and `b` are in the same released/unreleased group,
2642            // so checking one is enough.
2643            let undated_first = !a.released;
2644            let date = match (&a.release_date, &b.release_date) {
2645                (Some(a_d), Some(b_d)) => b_d.cmp(a_d),
2646                (None, None) => Ordering::Equal,
2647                (None, Some(_)) if undated_first => Ordering::Less,
2648                (None, Some(_)) => Ordering::Greater,
2649                (Some(_), None) if undated_first => Ordering::Greater,
2650                (Some(_), None) => Ordering::Less,
2651            };
2652            date.then_with(|| compare_version_names(&b.name, &a.name))
2653        });
2654
2655        let total_after_filter = versions.len() as u32;
2656        let limit_applied = params.limit.unwrap_or(total_after_filter);
2657        if (limit_applied as usize) < versions.len() {
2658            versions.truncate(limit_applied as usize);
2659        }
2660
2661        // Pagination carries total + has_more so the formatter can render
2662        // a "[+N more …]" hint when truncation hid items (Paper 1 §Chunk
2663        // Index). We start at offset 0 — the list endpoint is unpaginated
2664        // server-side, all chunking is client-side trimming.
2665        let pagination = devboy_core::Pagination {
2666            offset: 0,
2667            limit: limit_applied,
2668            total: Some(total_after_filter),
2669            has_more: (versions.len() as u32) < total_after_filter,
2670            next_cursor: None,
2671        };
2672
2673        Ok(ProviderResult::new(versions).with_pagination(pagination))
2674    }
2675
2676    async fn upsert_project_version(
2677        &self,
2678        input: UpsertProjectVersionInput,
2679    ) -> Result<ProjectVersion> {
2680        let trimmed_name = input.name.trim().to_string();
2681        if trimmed_name.is_empty() {
2682            return Err(Error::InvalidData(
2683                "upsert_project_version: name must not be empty".into(),
2684            ));
2685        }
2686        // Jira limits version names to 255 characters; rejecting client-side
2687        // gives a clearer error than a late 400 from the server.
2688        if trimmed_name.chars().count() > 255 {
2689            return Err(Error::InvalidData(
2690                "upsert_project_version: name must be ≤ 255 characters".into(),
2691            ));
2692        }
2693        let project_key = if input.project.is_empty() {
2694            self.project_key.clone()
2695        } else {
2696            input.project.clone()
2697        };
2698
2699        let update_payload = UpdateVersionPayload {
2700            name: None,
2701            description: input.description.clone(),
2702            start_date: input.start_date.clone(),
2703            release_date: input.release_date.clone(),
2704            released: input.released,
2705            archived: input.archived,
2706        };
2707        let create_payload = CreateVersionPayload {
2708            name: trimmed_name.clone(),
2709            project: Some(project_key.clone()),
2710            project_id: None,
2711            description: input.description,
2712            start_date: input.start_date,
2713            release_date: input.release_date,
2714            released: input.released,
2715            archived: input.archived,
2716        };
2717
2718        // Resolve `(project, name)` → existing id. We list all versions
2719        // in the project rather than filtering server-side — Jira has no
2720        // exact-name lookup, and a single project rarely has more than a
2721        // few hundred versions.
2722        let list_url = format!("{}/project/{}/versions", self.base_url, project_key);
2723        let dtos: Vec<JiraVersionDto> = self.get(&list_url).await?;
2724        let existing = dtos.into_iter().find(|d| d.name == trimmed_name);
2725
2726        let dto: JiraVersionDto = match existing {
2727            Some(existing) => {
2728                self.put_with_response(
2729                    &format!("{}/version/{}", self.base_url, existing.id),
2730                    &update_payload,
2731                )
2732                .await?
2733            }
2734            None => {
2735                // Create path. Two callers can race here: both miss the
2736                // version on the initial list and both POST. Jira rejects
2737                // the loser with a 400 + "already exists" message; we
2738                // re-list, find the winner's id, and apply the update so
2739                // the loser still observes a consistent post-condition.
2740                match self
2741                    .post::<JiraVersionDto, _>(
2742                        &format!("{}/version", self.base_url),
2743                        &create_payload,
2744                    )
2745                    .await
2746                {
2747                    Ok(dto) => dto,
2748                    Err(e) if is_duplicate_version_error(&e) => {
2749                        let dtos: Vec<JiraVersionDto> = self.get(&list_url).await?;
2750                        let recovered = dtos
2751                            .into_iter()
2752                            .find(|d| d.name == trimmed_name)
2753                            .ok_or_else(|| {
2754                                Error::InvalidData(format!(
2755                                    "upsert_project_version: create rejected as duplicate but version '{trimmed_name}' is not in the project list"
2756                                ))
2757                            })?;
2758                        self.put_with_response(
2759                            &format!("{}/version/{}", self.base_url, recovered.id),
2760                            &update_payload,
2761                        )
2762                        .await?
2763                    }
2764                    Err(e) => return Err(e),
2765                }
2766            }
2767        };
2768
2769        Ok(jira_version_to_project_version(dto, &project_key))
2770    }
2771
2772    fn provider_name(&self) -> &'static str {
2773        "jira"
2774    }
2775}
2776
2777/// Map the raw Jira version DTO to the provider-agnostic [`ProjectVersion`].
2778///
2779/// `project_fallback` covers DTOs returned by `POST /version` on some
2780/// Server/DC builds where the `project` key is omitted — we fall back to
2781/// the key the caller addressed.
2782/// True when the error returned by `POST /version` indicates Jira
2783/// rejected the create because a version with that name already exists
2784/// in the project. Both Cloud v3 and Server/DC v2 surface this as a 400
2785/// with the phrase "already exists" in the response body.
2786fn is_duplicate_version_error(e: &Error) -> bool {
2787    let lowered = e.to_string().to_lowercase();
2788    lowered.contains("already exists") || lowered.contains("already used")
2789}
2790
2791/// Compare two Jira version *names* with semver-aware ordering.
2792///
2793/// Splits each name into runs of digits and runs of non-digits and
2794/// compares them piecewise — digit runs numerically (so `10` > `9`),
2795/// non-digit runs lexicographically (so `1.0.0-rc1` < `1.0.0`). Falls
2796/// back to plain string compare when either side has no digits, which
2797/// keeps non-semver release names (e.g. `"Sprint 42 cleanup"`) stable.
2798fn compare_version_names(a: &str, b: &str) -> std::cmp::Ordering {
2799    fn tokens(s: &str) -> Vec<(bool, &str)> {
2800        let mut out = Vec::new();
2801        let mut start = 0;
2802        let mut last_digit: Option<bool> = None;
2803        for (i, ch) in s.char_indices() {
2804            let is_digit = ch.is_ascii_digit();
2805            match last_digit {
2806                Some(prev) if prev != is_digit => {
2807                    out.push((prev, &s[start..i]));
2808                    start = i;
2809                }
2810                _ => {}
2811            }
2812            last_digit = Some(is_digit);
2813        }
2814        if let Some(prev) = last_digit {
2815            out.push((prev, &s[start..]));
2816        }
2817        out
2818    }
2819
2820    let a_toks = tokens(a);
2821    let b_toks = tokens(b);
2822    for (ax, bx) in a_toks.iter().zip(b_toks.iter()) {
2823        let cmp = match (ax, bx) {
2824            ((true, ad), (true, bd)) => {
2825                // Numeric token compare — strip leading zeros, then by
2826                // length, then lexicographically as a tiebreak.
2827                let an = ad.trim_start_matches('0');
2828                let bn = bd.trim_start_matches('0');
2829                an.len().cmp(&bn.len()).then_with(|| an.cmp(bn))
2830            }
2831            ((false, at), (false, bt)) => at.cmp(bt),
2832            // Numeric runs sort *after* alpha runs at the same position
2833            // — this matches semver's rule that `1.0.0-rc1 < 1.0.0`.
2834            ((true, _), (false, _)) => std::cmp::Ordering::Greater,
2835            ((false, _), (true, _)) => std::cmp::Ordering::Less,
2836        };
2837        if cmp != std::cmp::Ordering::Equal {
2838            return cmp;
2839        }
2840    }
2841    // Equal token-by-token up to the shorter side. SemVer treats a
2842    // pre-release suffix as *lower* than the bare version, so when one
2843    // side has more tokens *and* the next token starts with `-` (or
2844    // `+` build metadata), the longer side is considered smaller.
2845    match a_toks.len().cmp(&b_toks.len()) {
2846        std::cmp::Ordering::Equal => std::cmp::Ordering::Equal,
2847        std::cmp::Ordering::Greater => {
2848            let next = a_toks[b_toks.len()].1;
2849            if next.starts_with('-') || next.starts_with('+') {
2850                std::cmp::Ordering::Less
2851            } else {
2852                std::cmp::Ordering::Greater
2853            }
2854        }
2855        std::cmp::Ordering::Less => {
2856            let next = b_toks[a_toks.len()].1;
2857            if next.starts_with('-') || next.starts_with('+') {
2858                std::cmp::Ordering::Greater
2859            } else {
2860                std::cmp::Ordering::Less
2861            }
2862        }
2863    }
2864}
2865
2866fn jira_version_to_project_version(dto: JiraVersionDto, project_fallback: &str) -> ProjectVersion {
2867    // Cloud `?expand=issuesstatus` returns a per-category breakdown we
2868    // sum into a true `issue_count` total. Server/DC inlines
2869    // `issuesUnresolvedCount` on the base payload but *not* a total, so
2870    // we route that into `unresolved_issue_count` and leave
2871    // `issue_count` unset there — conflating the two would let callers
2872    // compare a Cloud total against a Server unresolved count and not
2873    // notice the categorical mismatch.
2874    let issue_count = dto
2875        .issues_status_for_fix_version
2876        .as_ref()
2877        .map(|c| c.total());
2878    let unresolved_issue_count = dto.issues_unresolved_count;
2879
2880    ProjectVersion {
2881        id: dto.id,
2882        project: dto.project.unwrap_or_else(|| project_fallback.to_string()),
2883        name: dto.name,
2884        description: dto.description.filter(|d| !d.is_empty()),
2885        start_date: dto.start_date.filter(|d| !d.is_empty()),
2886        release_date: dto.release_date.filter(|d| !d.is_empty()),
2887        released: dto.released,
2888        archived: dto.archived,
2889        overdue: dto.overdue,
2890        issue_count,
2891        unresolved_issue_count,
2892        source: "jira".to_string(),
2893    }
2894}
2895
2896#[async_trait]
2897impl MergeRequestProvider for JiraClient {
2898    fn provider_name(&self) -> &'static str {
2899        "jira"
2900    }
2901}
2902
2903#[async_trait]
2904impl PipelineProvider for JiraClient {
2905    fn provider_name(&self) -> &'static str {
2906        "jira"
2907    }
2908}
2909
2910#[async_trait]
2911impl Provider for JiraClient {
2912    async fn get_current_user(&self) -> Result<User> {
2913        let url = format!("{}/myself", self.base_url);
2914        let jira_user: JiraUser = self.get(&url).await?;
2915        Ok(map_user(Some(&jira_user)).unwrap_or_default())
2916    }
2917}
2918
2919// Issue #177 — UserProvider. Jira exposes user lookup via /user?accountId
2920// (Cloud) or /user?username (Self-Hosted); email lookup uses /user/search.
2921#[async_trait]
2922impl devboy_core::UserProvider for JiraClient {
2923    fn provider_name(&self) -> &'static str {
2924        "jira"
2925    }
2926
2927    async fn get_user_profile(&self, user_id: &str) -> Result<User> {
2928        let url = match self.flavor {
2929            JiraFlavor::Cloud => format!("{}/user?accountId={}", self.base_url, user_id),
2930            JiraFlavor::SelfHosted => format!("{}/user?username={}", self.base_url, user_id),
2931        };
2932        let jira_user: JiraUser = self.get(&url).await?;
2933        map_user(Some(&jira_user))
2934            .ok_or_else(|| Error::InvalidData("Jira /user returned no user".to_string()))
2935    }
2936
2937    async fn lookup_user_by_email(&self, email: &str) -> Result<Option<User>> {
2938        // /user/search accepts `query=` on Cloud (searches display name /
2939        // email) and `username=` on Self-Hosted. Email is the more useful
2940        // parameter for cross-provider correlation.
2941        let url = match self.flavor {
2942            JiraFlavor::Cloud => format!("{}/user/search?query={}", self.base_url, email),
2943            JiraFlavor::SelfHosted => {
2944                format!("{}/user/search?username={}", self.base_url, email)
2945            }
2946        };
2947        let users: Vec<JiraUser> = self.get(&url).await?;
2948        Ok(users.into_iter().find_map(|u| map_user(Some(&u))))
2949    }
2950}
2951
2952// =============================================================================
2953// Tests
2954// =============================================================================
2955
2956#[cfg(test)]
2957mod tests {
2958    use super::*;
2959    use crate::types::*;
2960    use devboy_core::{CreateCommentInput, MrFilter};
2961
2962    fn token(s: &str) -> SecretString {
2963        SecretString::from(s.to_string())
2964    }
2965
2966    // =========================================================================
2967    // Structure error mapping tests
2968    // =========================================================================
2969
2970    #[test]
2971    fn structure_install_hint_is_single_well_spaced_line() {
2972        // Guard against the previous `"... \` with-indent multi-line literal
2973        // which baked spurious inner whitespace into the error text.
2974        assert!(
2975            !STRUCTURE_PLUGIN_HINT.contains("  "),
2976            "hint contains consecutive spaces: {STRUCTURE_PLUGIN_HINT:?}"
2977        );
2978        assert!(!STRUCTURE_PLUGIN_HINT.contains('\n'));
2979        assert!(STRUCTURE_PLUGIN_HINT.contains("marketplace.atlassian.com"));
2980    }
2981
2982    #[test]
2983    fn structure_404_with_html_returns_soft_endpoint_hint() {
2984        let html = "<!DOCTYPE html><html><body>Oops, you&#39;ve found a dead link.</body></html>";
2985        let err = structure_error_from_status(404, "text/html;charset=UTF-8", html.into());
2986        let msg = err.to_string();
2987        assert!(!msg.contains("<!DOCTYPE"), "HTML leaked into error: {msg}");
2988        // Wording must be soft — the same 404+markup signal can fire if the
2989        // plugin IS installed but the endpoint path was renamed/removed.
2990        assert!(
2991            msg.contains("endpoint not found"),
2992            "expected soft 'endpoint not found' wording: {msg}"
2993        );
2994        assert!(
2995            msg.contains("may not be installed"),
2996            "expected soft install-hint wording: {msg}"
2997        );
2998        assert!(
2999            msg.contains("marketplace.atlassian.com"),
3000            "missing marketplace link: {msg}"
3001        );
3002    }
3003
3004    #[test]
3005    fn structure_500_with_html_strips_body() {
3006        let html = "<html><body>".to_string() + &"x".repeat(20_000) + "</body></html>";
3007        let err = structure_error_from_status(500, "text/html", html);
3008        let msg = err.to_string();
3009        assert!(
3010            !msg.contains("xxxx"),
3011            "raw HTML body leaked: {}",
3012            &msg[..msg.len().min(400)]
3013        );
3014        assert!(
3015            msg.contains("non-JSON"),
3016            "missing short status message: {msg}"
3017        );
3018    }
3019
3020    #[test]
3021    fn structure_json_error_is_forwarded_verbatim() {
3022        let body = r#"{"errorMessages":["Invalid forestVersion"],"errors":{}}"#;
3023        let err = structure_error_from_status(409, "application/json", body.into());
3024        let msg = err.to_string();
3025        assert!(
3026            msg.contains("Invalid forestVersion"),
3027            "JSON body dropped: {msg}"
3028        );
3029    }
3030
3031    #[test]
3032    fn structure_long_text_body_is_truncated() {
3033        let body = "plain text ".repeat(200); // > 500 chars
3034        let err = structure_error_from_status(400, "text/plain", body);
3035        let msg = err.to_string();
3036        assert!(
3037            msg.contains("truncated"),
3038            "truncation marker missing: {msg}"
3039        );
3040    }
3041
3042    #[test]
3043    fn structure_html_detected_by_body_when_content_type_missing() {
3044        assert!(looks_like_html("", "<!DOCTYPE html><html>..."));
3045        assert!(looks_like_html("", "<html lang=\"en\">"));
3046        assert!(!looks_like_html("", "   {\"ok\":true}"));
3047        assert!(!looks_like_html("application/json", "{\"ok\":true}"));
3048    }
3049
3050    #[test]
3051    fn structure_html_detected_by_content_type_only() {
3052        assert!(looks_like_html("text/html; charset=UTF-8", ""));
3053        assert!(looks_like_html("Text/HTML", ""));
3054    }
3055
3056    #[test]
3057    fn structure_xml_body_treated_as_non_json() {
3058        // Structure plugin returns XML 404 for unknown subpaths
3059        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>"#;
3060        assert!(looks_like_html("application/xml", xml));
3061        assert!(looks_like_html("", xml));
3062        let err = structure_error_from_status(404, "application/xml", xml.into());
3063        let msg = err.to_string();
3064        assert!(!msg.contains("<?xml"), "XML leaked into error: {msg}");
3065        assert!(
3066            msg.contains("endpoint not found"),
3067            "expected soft wording: {msg}"
3068        );
3069    }
3070
3071    #[test]
3072    fn structure_parse_preview_redacts_html_body() {
3073        let html = r#"<!DOCTYPE html><html><head><title>Login</title></head><body><form>…</form></body></html>"#;
3074        let preview = structure_parse_preview("text/html; charset=UTF-8", html);
3075        assert!(
3076            !preview.contains("<!DOCTYPE"),
3077            "HTML leaked into parse preview: {preview}"
3078        );
3079        assert!(
3080            !preview.contains("<html"),
3081            "HTML leaked into parse preview: {preview}"
3082        );
3083        assert!(
3084            preview.contains("redacted"),
3085            "expected redaction marker: {preview}"
3086        );
3087        assert!(
3088            preview.contains(&format!("{}", html.len())),
3089            "expected byte count in preview: {preview}"
3090        );
3091    }
3092
3093    #[test]
3094    fn structure_parse_preview_redacts_xml_body() {
3095        let xml = r#"<?xml version="1.0"?><status><code>200</code></status>"#;
3096        let preview = structure_parse_preview("application/xml", xml);
3097        assert!(!preview.contains("<?xml"), "XML leaked: {preview}");
3098        assert!(preview.contains("redacted"));
3099    }
3100
3101    #[test]
3102    fn structure_parse_preview_keeps_short_json_body_verbatim() {
3103        let body = r#"{"broken":"response"#; // missing closing brace
3104        let preview = structure_parse_preview("application/json", body);
3105        assert_eq!(preview, body);
3106    }
3107
3108    #[test]
3109    fn structure_parse_preview_truncates_long_non_markup_body() {
3110        let body = "a".repeat(2000);
3111        let preview = structure_parse_preview("text/plain", &body);
3112        assert!(preview.contains("truncated"));
3113        assert!(preview.len() < body.len());
3114    }
3115
3116    // =========================================================================
3117    // Flavor detection tests
3118    // =========================================================================
3119
3120    #[test]
3121    fn test_flavor_detection_cloud() {
3122        assert_eq!(
3123            detect_flavor("https://company.atlassian.net"),
3124            JiraFlavor::Cloud
3125        );
3126        assert_eq!(
3127            detect_flavor("https://myorg.atlassian.net/"),
3128            JiraFlavor::Cloud
3129        );
3130    }
3131
3132    #[test]
3133    fn test_flavor_detection_self_hosted() {
3134        assert_eq!(
3135            detect_flavor("https://jira.company.com"),
3136            JiraFlavor::SelfHosted
3137        );
3138        assert_eq!(
3139            detect_flavor("https://jira.corp.internal"),
3140            JiraFlavor::SelfHosted
3141        );
3142        assert_eq!(
3143            detect_flavor("http://localhost:8080"),
3144            JiraFlavor::SelfHosted
3145        );
3146    }
3147
3148    // =========================================================================
3149    // API URL tests
3150    // =========================================================================
3151
3152    #[test]
3153    fn test_api_url_cloud() {
3154        assert_eq!(
3155            build_api_base("https://company.atlassian.net", JiraFlavor::Cloud),
3156            "https://company.atlassian.net/rest/api/3"
3157        );
3158    }
3159
3160    #[test]
3161    fn test_api_url_self_hosted() {
3162        assert_eq!(
3163            build_api_base("https://jira.company.com", JiraFlavor::SelfHosted),
3164            "https://jira.company.com/rest/api/2"
3165        );
3166    }
3167
3168    #[test]
3169    fn test_api_url_strips_trailing_slash() {
3170        assert_eq!(
3171            build_api_base("https://company.atlassian.net/", JiraFlavor::Cloud),
3172            "https://company.atlassian.net/rest/api/3"
3173        );
3174    }
3175
3176    // =========================================================================
3177    // Auth header tests
3178    // =========================================================================
3179
3180    #[test]
3181    fn test_auth_header_cloud() {
3182        let client = JiraClient::with_base_url(
3183            "http://localhost",
3184            "PROJ",
3185            "user@example.com",
3186            token("api-token-123"),
3187            true,
3188        );
3189        // Cloud uses Basic auth with email:token
3190        let expected = base64_encode("user@example.com:api-token-123");
3191        let req = client.request(reqwest::Method::GET, "http://localhost/test");
3192        let built = req.build().unwrap();
3193        let auth = built
3194            .headers()
3195            .get("Authorization")
3196            .unwrap()
3197            .to_str()
3198            .unwrap();
3199        assert_eq!(auth, format!("Basic {}", expected));
3200    }
3201
3202    #[test]
3203    fn test_auth_header_self_hosted_bearer() {
3204        let client = JiraClient::with_base_url(
3205            "http://localhost",
3206            "PROJ",
3207            "user@example.com",
3208            token("personal-access-token"),
3209            false,
3210        );
3211        let req = client.request(reqwest::Method::GET, "http://localhost/test");
3212        let built = req.build().unwrap();
3213        let auth = built
3214            .headers()
3215            .get("Authorization")
3216            .unwrap()
3217            .to_str()
3218            .unwrap();
3219        assert_eq!(auth, "Bearer personal-access-token");
3220    }
3221
3222    #[test]
3223    fn test_auth_header_self_hosted_basic() {
3224        let client = JiraClient::with_base_url(
3225            "http://localhost",
3226            "PROJ",
3227            "user@example.com",
3228            token("user:password"),
3229            false,
3230        );
3231        let expected = base64_encode("user:password");
3232        let req = client.request(reqwest::Method::GET, "http://localhost/test");
3233        let built = req.build().unwrap();
3234        let auth = built
3235            .headers()
3236            .get("Authorization")
3237            .unwrap()
3238            .to_str()
3239            .unwrap();
3240        assert_eq!(auth, format!("Basic {}", expected));
3241    }
3242
3243    // =========================================================================
3244    // Base64 encoding tests
3245    // =========================================================================
3246
3247    #[test]
3248    fn test_base64_encode() {
3249        assert_eq!(base64_encode("hello"), "aGVsbG8=");
3250        assert_eq!(base64_encode("user:pass"), "dXNlcjpwYXNz");
3251        assert_eq!(base64_encode(""), "");
3252        assert_eq!(base64_encode("a"), "YQ==");
3253        assert_eq!(base64_encode("ab"), "YWI=");
3254        assert_eq!(base64_encode("abc"), "YWJj");
3255    }
3256
3257    // =========================================================================
3258    // ADF conversion tests
3259    // =========================================================================
3260
3261    #[test]
3262    fn test_text_to_adf_simple() {
3263        let adf = text_to_adf("Hello world");
3264        assert_eq!(adf["type"], "doc");
3265        assert_eq!(adf["version"], 1);
3266        let content = adf["content"].as_array().unwrap();
3267        assert_eq!(content.len(), 1);
3268        assert_eq!(content[0]["type"], "paragraph");
3269        let inline = content[0]["content"].as_array().unwrap();
3270        assert_eq!(inline.len(), 1);
3271        assert_eq!(inline[0]["text"], "Hello world");
3272    }
3273
3274    #[test]
3275    fn test_text_to_adf_multi_paragraph() {
3276        let adf = text_to_adf("First paragraph\n\nSecond paragraph");
3277        let content = adf["content"].as_array().unwrap();
3278        assert_eq!(content.len(), 2);
3279        assert_eq!(content[0]["content"][0]["text"], "First paragraph");
3280        assert_eq!(content[1]["content"][0]["text"], "Second paragraph");
3281    }
3282
3283    #[test]
3284    fn test_text_to_adf_with_line_breaks() {
3285        let adf = text_to_adf("Line 1\nLine 2\nLine 3");
3286        let content = adf["content"].as_array().unwrap();
3287        assert_eq!(content.len(), 1);
3288        let inline = content[0]["content"].as_array().unwrap();
3289        // text, hardBreak, text, hardBreak, text = 5 nodes
3290        assert_eq!(inline.len(), 5);
3291        assert_eq!(inline[0]["text"], "Line 1");
3292        assert_eq!(inline[1]["type"], "hardBreak");
3293        assert_eq!(inline[2]["text"], "Line 2");
3294        assert_eq!(inline[3]["type"], "hardBreak");
3295        assert_eq!(inline[4]["text"], "Line 3");
3296    }
3297
3298    #[test]
3299    fn test_text_to_adf_empty() {
3300        let adf = text_to_adf("");
3301        assert_eq!(adf["type"], "doc");
3302        let content = adf["content"].as_array().unwrap();
3303        assert_eq!(content.len(), 1);
3304        assert_eq!(content[0]["type"], "paragraph");
3305        assert!(content[0]["content"].as_array().unwrap().is_empty());
3306    }
3307
3308    #[test]
3309    fn test_adf_to_text_simple() {
3310        let adf = serde_json::json!({
3311            "version": 1,
3312            "type": "doc",
3313            "content": [{
3314                "type": "paragraph",
3315                "content": [{
3316                    "type": "text",
3317                    "text": "Hello world"
3318                }]
3319            }]
3320        });
3321        assert_eq!(adf_to_text(&adf), "Hello world");
3322    }
3323
3324    #[test]
3325    fn test_adf_to_text_multi() {
3326        let adf = serde_json::json!({
3327            "version": 1,
3328            "type": "doc",
3329            "content": [
3330                {
3331                    "type": "paragraph",
3332                    "content": [{
3333                        "type": "text",
3334                        "text": "First"
3335                    }]
3336                },
3337                {
3338                    "type": "paragraph",
3339                    "content": [{
3340                        "type": "text",
3341                        "text": "Second"
3342                    }]
3343                }
3344            ]
3345        });
3346        assert_eq!(adf_to_text(&adf), "First\n\nSecond");
3347    }
3348
3349    #[test]
3350    fn test_adf_to_text_with_hardbreak() {
3351        let adf = serde_json::json!({
3352            "version": 1,
3353            "type": "doc",
3354            "content": [{
3355                "type": "paragraph",
3356                "content": [
3357                    {"type": "text", "text": "Line 1"},
3358                    {"type": "hardBreak"},
3359                    {"type": "text", "text": "Line 2"}
3360                ]
3361            }]
3362        });
3363        assert_eq!(adf_to_text(&adf), "Line 1\nLine 2");
3364    }
3365
3366    #[test]
3367    fn test_adf_to_text_empty() {
3368        let adf = serde_json::json!({
3369            "version": 1,
3370            "type": "doc",
3371            "content": []
3372        });
3373        assert_eq!(adf_to_text(&adf), "");
3374    }
3375
3376    #[test]
3377    fn test_adf_to_text_non_adf_string() {
3378        let value = serde_json::Value::String("plain text".to_string());
3379        assert_eq!(adf_to_text(&value), "plain text");
3380    }
3381
3382    #[test]
3383    fn test_adf_to_text_null() {
3384        assert_eq!(adf_to_text(&serde_json::Value::Null), "");
3385    }
3386
3387    // =========================================================================
3388    // Mapping tests
3389    // =========================================================================
3390
3391    fn sample_jira_user_cloud() -> JiraUser {
3392        JiraUser {
3393            account_id: Some("5b10a2844c20165700ede21g".to_string()),
3394            name: None,
3395            display_name: Some("John Doe".to_string()),
3396            email_address: Some("john@example.com".to_string()),
3397        }
3398    }
3399
3400    fn sample_jira_user_self_hosted() -> JiraUser {
3401        JiraUser {
3402            account_id: None,
3403            name: Some("jdoe".to_string()),
3404            display_name: Some("John Doe".to_string()),
3405            email_address: Some("john@example.com".to_string()),
3406        }
3407    }
3408
3409    #[test]
3410    fn test_map_user_cloud() {
3411        let user = map_user(Some(&sample_jira_user_cloud())).unwrap();
3412        assert_eq!(user.id, "5b10a2844c20165700ede21g");
3413        assert_eq!(user.username, "5b10a2844c20165700ede21g");
3414        assert_eq!(user.name, Some("John Doe".to_string()));
3415        assert_eq!(user.email, Some("john@example.com".to_string()));
3416    }
3417
3418    #[test]
3419    fn test_map_user_self_hosted() {
3420        let user = map_user(Some(&sample_jira_user_self_hosted())).unwrap();
3421        assert_eq!(user.id, "jdoe");
3422        assert_eq!(user.username, "jdoe");
3423        assert_eq!(user.name, Some("John Doe".to_string()));
3424    }
3425
3426    #[test]
3427    fn test_map_user_none() {
3428        assert!(map_user(None).is_none());
3429    }
3430
3431    #[test]
3432    fn test_map_priority() {
3433        let make_priority = |name: &str| JiraPriority {
3434            name: name.to_string(),
3435        };
3436
3437        assert_eq!(
3438            map_priority(Some(&make_priority("Highest"))),
3439            Some("urgent".to_string())
3440        );
3441        assert_eq!(
3442            map_priority(Some(&make_priority("High"))),
3443            Some("high".to_string())
3444        );
3445        assert_eq!(
3446            map_priority(Some(&make_priority("Medium"))),
3447            Some("normal".to_string())
3448        );
3449        assert_eq!(
3450            map_priority(Some(&make_priority("Low"))),
3451            Some("low".to_string())
3452        );
3453        assert_eq!(
3454            map_priority(Some(&make_priority("Lowest"))),
3455            Some("low".to_string())
3456        );
3457        assert_eq!(
3458            map_priority(Some(&make_priority("Blocker"))),
3459            Some("urgent".to_string())
3460        );
3461        assert_eq!(map_priority(None), None);
3462    }
3463
3464    #[test]
3465    fn test_map_issue() {
3466        let issue = JiraIssue {
3467            id: "10001".to_string(),
3468            key: "PROJ-123".to_string(),
3469            fields: JiraIssueFields {
3470                summary: Some("Fix login bug".to_string()),
3471                description: Some(serde_json::Value::String(
3472                    "Login fails on mobile".to_string(),
3473                )),
3474                status: Some(JiraStatus {
3475                    name: "In Progress".to_string(),
3476                    status_category: None,
3477                }),
3478                priority: Some(JiraPriority {
3479                    name: "High".to_string(),
3480                }),
3481                assignee: Some(sample_jira_user_self_hosted()),
3482                reporter: Some(JiraUser {
3483                    account_id: None,
3484                    name: Some("reporter".to_string()),
3485                    display_name: Some("Reporter".to_string()),
3486                    email_address: None,
3487                }),
3488                labels: vec!["bug".to_string(), "mobile".to_string()],
3489                created: Some("2024-01-01T10:00:00.000+0000".to_string()),
3490                updated: Some("2024-01-02T15:30:00.000+0000".to_string()),
3491                parent: None,
3492                subtasks: vec![],
3493                issuelinks: vec![],
3494                attachment: vec![],
3495            },
3496        };
3497
3498        let mapped = map_issue(&issue, JiraFlavor::SelfHosted, "https://jira.example.com");
3499        assert_eq!(mapped.key, "jira#PROJ-123");
3500        assert_eq!(mapped.title, "Fix login bug");
3501        assert_eq!(
3502            mapped.description,
3503            Some("Login fails on mobile".to_string())
3504        );
3505        assert_eq!(mapped.state, "In Progress");
3506        assert_eq!(mapped.source, "jira");
3507        assert_eq!(mapped.priority, Some("high".to_string()));
3508        assert_eq!(mapped.labels, vec!["bug", "mobile"]);
3509        assert_eq!(mapped.assignees.len(), 1);
3510        assert_eq!(mapped.assignees[0].username, "jdoe");
3511        assert!(mapped.author.is_some());
3512        assert_eq!(mapped.author.unwrap().username, "reporter");
3513        assert_eq!(
3514            mapped.url,
3515            Some("https://jira.example.com/browse/PROJ-123".to_string())
3516        );
3517        assert_eq!(
3518            mapped.created_at,
3519            Some("2024-01-01T10:00:00.000+0000".to_string())
3520        );
3521    }
3522
3523    #[test]
3524    fn test_map_issue_cloud_adf_description() {
3525        let adf_desc = serde_json::json!({
3526            "version": 1,
3527            "type": "doc",
3528            "content": [{
3529                "type": "paragraph",
3530                "content": [{
3531                    "type": "text",
3532                    "text": "ADF description"
3533                }]
3534            }]
3535        });
3536
3537        let issue = JiraIssue {
3538            id: "10001".to_string(),
3539            key: "PROJ-1".to_string(),
3540            fields: JiraIssueFields {
3541                summary: Some("Test".to_string()),
3542                description: Some(adf_desc),
3543                status: None,
3544                priority: None,
3545                assignee: None,
3546                reporter: None,
3547                labels: vec![],
3548                created: None,
3549                updated: None,
3550                parent: None,
3551                subtasks: vec![],
3552                issuelinks: vec![],
3553                attachment: vec![],
3554            },
3555        };
3556
3557        let mapped = map_issue(&issue, JiraFlavor::Cloud, "https://test.atlassian.net");
3558        assert_eq!(mapped.description, Some("ADF description".to_string()));
3559    }
3560
3561    #[test]
3562    fn test_map_issue_self_hosted_plain_description() {
3563        let issue = JiraIssue {
3564            id: "10001".to_string(),
3565            key: "PROJ-1".to_string(),
3566            fields: JiraIssueFields {
3567                summary: Some("Test".to_string()),
3568                description: Some(serde_json::Value::String("Plain text desc".to_string())),
3569                status: None,
3570                priority: None,
3571                assignee: None,
3572                reporter: None,
3573                labels: vec![],
3574                created: None,
3575                updated: None,
3576                parent: None,
3577                subtasks: vec![],
3578                issuelinks: vec![],
3579                attachment: vec![],
3580            },
3581        };
3582
3583        let mapped = map_issue(&issue, JiraFlavor::SelfHosted, "https://jira.example.com");
3584        assert_eq!(mapped.description, Some("Plain text desc".to_string()));
3585    }
3586
3587    #[test]
3588    fn test_map_comment() {
3589        let comment = JiraComment {
3590            id: "100".to_string(),
3591            body: Some(serde_json::Value::String("Nice work!".to_string())),
3592            author: Some(sample_jira_user_self_hosted()),
3593            created: Some("2024-01-01T10:00:00.000+0000".to_string()),
3594            updated: Some("2024-01-01T11:00:00.000+0000".to_string()),
3595        };
3596
3597        let mapped = map_comment(&comment, JiraFlavor::SelfHosted);
3598        assert_eq!(mapped.id, "100");
3599        assert_eq!(mapped.body, "Nice work!");
3600        assert!(mapped.author.is_some());
3601        assert_eq!(mapped.author.unwrap().username, "jdoe");
3602    }
3603
3604    #[test]
3605    fn test_map_comment_cloud_adf() {
3606        let adf_body = serde_json::json!({
3607            "version": 1,
3608            "type": "doc",
3609            "content": [{
3610                "type": "paragraph",
3611                "content": [{
3612                    "type": "text",
3613                    "text": "ADF comment"
3614                }]
3615            }]
3616        });
3617
3618        let comment = JiraComment {
3619            id: "200".to_string(),
3620            body: Some(adf_body),
3621            author: None,
3622            created: None,
3623            updated: None,
3624        };
3625
3626        let mapped = map_comment(&comment, JiraFlavor::Cloud);
3627        assert_eq!(mapped.body, "ADF comment");
3628    }
3629
3630    // =========================================================================
3631    // Provider name test
3632    // =========================================================================
3633
3634    #[test]
3635    fn test_provider_name() {
3636        let client = JiraClient::with_base_url(
3637            "http://localhost",
3638            "PROJ",
3639            "user@example.com",
3640            token("token"),
3641            false,
3642        );
3643        assert_eq!(IssueProvider::provider_name(&client), "jira");
3644        assert_eq!(MergeRequestProvider::provider_name(&client), "jira");
3645    }
3646
3647    // =========================================================================
3648    // Priority mapping tests
3649    // =========================================================================
3650
3651    #[test]
3652    fn test_generic_status_to_category() {
3653        // done category
3654        assert_eq!(generic_status_to_category("closed"), Some("done"));
3655        assert_eq!(generic_status_to_category("done"), Some("done"));
3656        assert_eq!(generic_status_to_category("resolved"), Some("done"));
3657        assert_eq!(generic_status_to_category("canceled"), Some("done"));
3658        assert_eq!(generic_status_to_category("cancelled"), Some("done"));
3659        assert_eq!(generic_status_to_category("CLOSED"), Some("done"));
3660
3661        // new category
3662        assert_eq!(generic_status_to_category("open"), Some("new"));
3663        assert_eq!(generic_status_to_category("new"), Some("new"));
3664        assert_eq!(generic_status_to_category("todo"), Some("new"));
3665        assert_eq!(generic_status_to_category("to do"), Some("new"));
3666        assert_eq!(generic_status_to_category("reopen"), Some("new"));
3667        assert_eq!(generic_status_to_category("reopened"), Some("new"));
3668
3669        // indeterminate category
3670        assert_eq!(
3671            generic_status_to_category("in_progress"),
3672            Some("indeterminate")
3673        );
3674        assert_eq!(
3675            generic_status_to_category("in progress"),
3676            Some("indeterminate")
3677        );
3678        assert_eq!(
3679            generic_status_to_category("in-progress"),
3680            Some("indeterminate")
3681        );
3682
3683        // unknown
3684        assert_eq!(generic_status_to_category("custom status"), None);
3685        assert_eq!(generic_status_to_category("review"), None);
3686    }
3687
3688    #[test]
3689    fn test_priority_to_jira() {
3690        assert_eq!(priority_to_jira("urgent"), "Highest");
3691        assert_eq!(priority_to_jira("high"), "High");
3692        assert_eq!(priority_to_jira("normal"), "Medium");
3693        assert_eq!(priority_to_jira("low"), "Low");
3694        assert_eq!(priority_to_jira("custom"), "custom");
3695    }
3696
3697    // =========================================================================
3698    // Instance URL extraction test
3699    // =========================================================================
3700
3701    #[test]
3702    fn test_instance_url_from_base() {
3703        assert_eq!(
3704            instance_url_from_base("https://company.atlassian.net/rest/api/3"),
3705            "https://company.atlassian.net"
3706        );
3707        assert_eq!(
3708            instance_url_from_base("https://jira.corp.com/rest/api/2"),
3709            "https://jira.corp.com"
3710        );
3711        assert_eq!(
3712            instance_url_from_base("http://localhost:8080"),
3713            "http://localhost:8080"
3714        );
3715    }
3716
3717    // =========================================================================
3718    // Integration tests with httpmock
3719    // =========================================================================
3720
3721    mod integration {
3722        use super::*;
3723        use httpmock::prelude::*;
3724
3725        fn token(s: &str) -> SecretString {
3726            SecretString::from(s.to_string())
3727        }
3728
3729        fn create_self_hosted_client(server: &MockServer) -> JiraClient {
3730            JiraClient::with_base_url(
3731                server.base_url(),
3732                "PROJ",
3733                "user@example.com",
3734                token("pat-token"),
3735                false,
3736            )
3737        }
3738
3739        fn create_cloud_client(server: &MockServer) -> JiraClient {
3740            JiraClient::with_base_url(
3741                server.base_url(),
3742                "PROJ",
3743                "user@example.com",
3744                token("api-token"),
3745                true,
3746            )
3747        }
3748
3749        fn sample_issue_json() -> serde_json::Value {
3750            serde_json::json!({
3751                "id": "10001",
3752                "key": "PROJ-1",
3753                "fields": {
3754                    "summary": "Fix login bug",
3755                    "description": "Login fails on mobile",
3756                    "status": {"name": "Open"},
3757                    "priority": {"name": "High"},
3758                    "assignee": {
3759                        "name": "jdoe",
3760                        "displayName": "John Doe",
3761                        "emailAddress": "john@example.com"
3762                    },
3763                    "reporter": {
3764                        "name": "reporter",
3765                        "displayName": "Reporter"
3766                    },
3767                    "labels": ["bug"],
3768                    "created": "2024-01-01T10:00:00.000+0000",
3769                    "updated": "2024-01-02T15:30:00.000+0000"
3770                }
3771            })
3772        }
3773
3774        fn sample_cloud_issue_json() -> serde_json::Value {
3775            serde_json::json!({
3776                "id": "10001",
3777                "key": "PROJ-1",
3778                "fields": {
3779                    "summary": "Fix login bug",
3780                    "description": {
3781                        "version": 1,
3782                        "type": "doc",
3783                        "content": [{
3784                            "type": "paragraph",
3785                            "content": [{
3786                                "type": "text",
3787                                "text": "Login fails on mobile"
3788                            }]
3789                        }]
3790                    },
3791                    "status": {"name": "Open"},
3792                    "priority": {"name": "High"},
3793                    "assignee": {
3794                        "accountId": "5b10a2844c20165700ede21g",
3795                        "displayName": "John Doe",
3796                        "emailAddress": "john@example.com"
3797                    },
3798                    "reporter": {
3799                        "accountId": "5b10a284reporter",
3800                        "displayName": "Reporter"
3801                    },
3802                    "labels": ["bug"],
3803                    "created": "2024-01-01T10:00:00.000+0000",
3804                    "updated": "2024-01-02T15:30:00.000+0000"
3805                }
3806            })
3807        }
3808
3809        // =================================================================
3810        // Self-Hosted (API v2) tests
3811        // =================================================================
3812
3813        #[tokio::test]
3814        async fn test_get_issues() {
3815            let server = MockServer::start();
3816
3817            server.mock(|when, then| {
3818                when.method(GET).path("/search").query_param_exists("jql");
3819                then.status(200).json_body(serde_json::json!({
3820                    "issues": [sample_issue_json()],
3821                    "startAt": 0,
3822                    "maxResults": 20,
3823                    "total": 1
3824                }));
3825            });
3826
3827            let client = create_self_hosted_client(&server);
3828            let issues = client
3829                .get_issues(IssueFilter::default())
3830                .await
3831                .unwrap()
3832                .items;
3833
3834            assert_eq!(issues.len(), 1);
3835            assert_eq!(issues[0].key, "jira#PROJ-1");
3836            assert_eq!(issues[0].title, "Fix login bug");
3837            assert_eq!(issues[0].source, "jira");
3838            assert_eq!(issues[0].priority, Some("high".to_string()));
3839            assert_eq!(
3840                issues[0].description,
3841                Some("Login fails on mobile".to_string())
3842            );
3843        }
3844
3845        #[tokio::test]
3846        async fn test_get_issues_with_filters() {
3847            let server = MockServer::start();
3848
3849            server.mock(|when, then| {
3850                when.method(GET)
3851                    .path("/search")
3852                    .query_param_includes("jql", "labels = \"bug\"")
3853                    .query_param_includes("jql", "assignee = \"jdoe\"");
3854                then.status(200).json_body(serde_json::json!({
3855                    "issues": [sample_issue_json()],
3856                    "startAt": 0,
3857                    "maxResults": 20,
3858                    "total": 1
3859                }));
3860            });
3861
3862            let client = create_self_hosted_client(&server);
3863            let issues = client
3864                .get_issues(IssueFilter {
3865                    labels: Some(vec!["bug".to_string()]),
3866                    assignee: Some("jdoe".to_string()),
3867                    ..Default::default()
3868                })
3869                .await
3870                .unwrap()
3871                .items;
3872
3873            assert_eq!(issues.len(), 1);
3874        }
3875
3876        #[tokio::test]
3877        async fn test_get_issues_pagination() {
3878            let server = MockServer::start();
3879
3880            server.mock(|when, then| {
3881                when.method(GET)
3882                    .path("/search")
3883                    .query_param("startAt", "5")
3884                    .query_param("maxResults", "10");
3885                then.status(200).json_body(serde_json::json!({
3886                    "issues": [sample_issue_json()],
3887                    "startAt": 5,
3888                    "maxResults": 10,
3889                    "total": 20
3890                }));
3891            });
3892
3893            let client = create_self_hosted_client(&server);
3894            let issues = client
3895                .get_issues(IssueFilter {
3896                    offset: Some(5),
3897                    limit: Some(10),
3898                    ..Default::default()
3899                })
3900                .await
3901                .unwrap()
3902                .items;
3903
3904            assert_eq!(issues.len(), 1);
3905        }
3906
3907        #[tokio::test]
3908        async fn test_get_issues_project_key_override() {
3909            let server = MockServer::start();
3910
3911            server.mock(|when, then| {
3912                when.method(GET)
3913                    .path("/search")
3914                    .query_param_includes("jql", "project = \"OTHER\"");
3915                then.status(200).json_body(serde_json::json!({
3916                    "issues": [sample_issue_json()],
3917                    "startAt": 0,
3918                    "maxResults": 20,
3919                    "total": 1
3920                }));
3921            });
3922
3923            let client = create_self_hosted_client(&server);
3924            let issues = client
3925                .get_issues(IssueFilter {
3926                    project_key: Some("OTHER".to_string()),
3927                    ..Default::default()
3928                })
3929                .await
3930                .unwrap()
3931                .items;
3932
3933            assert_eq!(issues.len(), 1);
3934        }
3935
3936        #[tokio::test]
3937        async fn test_get_issues_native_query_passthrough() {
3938            let server = MockServer::start();
3939
3940            server.mock(|when, then| {
3941                when.method(GET)
3942                    .path("/search")
3943                    .query_param_includes("jql", "project = \"CUSTOM\" AND fixVersion = \"1.0\"");
3944                then.status(200).json_body(serde_json::json!({
3945                    "issues": [sample_issue_json()],
3946                    "startAt": 0,
3947                    "maxResults": 20,
3948                    "total": 1
3949                }));
3950            });
3951
3952            let client = create_self_hosted_client(&server);
3953            let issues = client
3954                .get_issues(IssueFilter {
3955                    native_query: Some("project = \"CUSTOM\" AND fixVersion = \"1.0\"".to_string()),
3956                    ..Default::default()
3957                })
3958                .await
3959                .unwrap()
3960                .items;
3961
3962            assert_eq!(issues.len(), 1);
3963        }
3964
3965        #[tokio::test]
3966        async fn test_get_issues_native_query_auto_injects_project() {
3967            let server = MockServer::start();
3968
3969            // Client is configured with project_key = "PROJ", native_query has no project clause
3970            // → should auto-prepend project = "PROJ"
3971            server.mock(|when, then| {
3972                when.method(GET)
3973                    .path("/search")
3974                    .query_param_includes("jql", "project = \"PROJ\" AND fixVersion = \"2.0\"");
3975                then.status(200).json_body(serde_json::json!({
3976                    "issues": [sample_issue_json()],
3977                    "startAt": 0,
3978                    "maxResults": 20,
3979                    "total": 1
3980                }));
3981            });
3982
3983            let client = create_self_hosted_client(&server);
3984            let issues = client
3985                .get_issues(IssueFilter {
3986                    native_query: Some("fixVersion = \"2.0\"".to_string()),
3987                    ..Default::default()
3988                })
3989                .await
3990                .unwrap()
3991                .items;
3992
3993            assert_eq!(issues.len(), 1);
3994        }
3995
3996        #[tokio::test]
3997        async fn test_get_issues_native_query_with_project_in() {
3998            let server = MockServer::start();
3999
4000            // Native query already has "project IN (...)" — should NOT prepend another project clause
4001            server.mock(|when, then| {
4002                when.method(GET)
4003                    .path("/search")
4004                    .query_param_includes("jql", "project IN (\"A\", \"B\") AND status = \"Open\"");
4005                then.status(200).json_body(serde_json::json!({
4006                    "issues": [sample_issue_json()],
4007                    "startAt": 0,
4008                    "maxResults": 20,
4009                    "total": 1
4010                }));
4011            });
4012
4013            let client = create_self_hosted_client(&server);
4014            let issues = client
4015                .get_issues(IssueFilter {
4016                    native_query: Some(
4017                        "project IN (\"A\", \"B\") AND status = \"Open\"".to_string(),
4018                    ),
4019                    ..Default::default()
4020                })
4021                .await
4022                .unwrap()
4023                .items;
4024
4025            assert_eq!(issues.len(), 1);
4026        }
4027
4028        #[tokio::test]
4029        async fn test_get_issues_project_key_with_native_query() {
4030            let server = MockServer::start();
4031
4032            // project_key override + native_query without project clause
4033            // → should inject the overridden project key, not the default one
4034            server.mock(|when, then| {
4035                when.method(GET)
4036                    .path("/search")
4037                    .query_param_includes("jql", "project = \"OVERRIDE\" AND sprint = 42");
4038                then.status(200).json_body(serde_json::json!({
4039                    "issues": [sample_issue_json()],
4040                    "startAt": 0,
4041                    "maxResults": 20,
4042                    "total": 1
4043                }));
4044            });
4045
4046            let client = create_self_hosted_client(&server); // default project = "PROJ"
4047            let issues = client
4048                .get_issues(IssueFilter {
4049                    project_key: Some("OVERRIDE".to_string()),
4050                    native_query: Some("sprint = 42".to_string()),
4051                    ..Default::default()
4052                })
4053                .await
4054                .unwrap()
4055                .items;
4056
4057            assert_eq!(issues.len(), 1);
4058        }
4059
4060        #[tokio::test]
4061        async fn test_get_issues_empty_native_query_falls_back() {
4062            let server = MockServer::start();
4063
4064            // Empty native_query should fall back to normal filter-based JQL
4065            server.mock(|when, then| {
4066                when.method(GET)
4067                    .path("/search")
4068                    .query_param_includes("jql", "project = \"PROJ\"");
4069                then.status(200).json_body(serde_json::json!({
4070                    "issues": [sample_issue_json()],
4071                    "startAt": 0,
4072                    "maxResults": 20,
4073                    "total": 1
4074                }));
4075            });
4076
4077            let client = create_self_hosted_client(&server);
4078            let issues = client
4079                .get_issues(IssueFilter {
4080                    native_query: Some("".to_string()),
4081                    ..Default::default()
4082                })
4083                .await
4084                .unwrap()
4085                .items;
4086
4087            assert_eq!(issues.len(), 1);
4088        }
4089
4090        #[tokio::test]
4091        async fn test_get_issues_native_query_order_by_only() {
4092            let server = MockServer::start();
4093
4094            // native_query = "ORDER BY created ASC" without filters
4095            // → should produce "project = "PROJ" ORDER BY created ASC" (no AND)
4096            server.mock(|when, then| {
4097                when.method(GET)
4098                    .path("/search")
4099                    .query_param_includes("jql", "project = \"PROJ\" ORDER BY created ASC");
4100                then.status(200).json_body(serde_json::json!({
4101                    "issues": [sample_issue_json()],
4102                    "startAt": 0,
4103                    "maxResults": 20,
4104                    "total": 1
4105                }));
4106            });
4107
4108            let client = create_self_hosted_client(&server);
4109            let issues = client
4110                .get_issues(IssueFilter {
4111                    native_query: Some("ORDER BY created ASC".to_string()),
4112                    ..Default::default()
4113                })
4114                .await
4115                .unwrap()
4116                .items;
4117
4118            assert_eq!(issues.len(), 1);
4119        }
4120
4121        #[tokio::test]
4122        async fn test_get_issue() {
4123            let server = MockServer::start();
4124
4125            server.mock(|when, then| {
4126                when.method(GET).path("/issue/PROJ-1");
4127                then.status(200).json_body(sample_issue_json());
4128            });
4129
4130            let client = create_self_hosted_client(&server);
4131            let issue = client.get_issue("jira#PROJ-1").await.unwrap();
4132
4133            assert_eq!(issue.key, "jira#PROJ-1");
4134            assert_eq!(issue.title, "Fix login bug");
4135        }
4136
4137        #[tokio::test]
4138        async fn test_create_issue() {
4139            let server = MockServer::start();
4140
4141            server.mock(|when, then| {
4142                when.method(POST)
4143                    .path("/issue")
4144                    .body_includes("\"summary\":\"New task\"");
4145                then.status(201).json_body(serde_json::json!({
4146                    "id": "10002",
4147                    "key": "PROJ-2"
4148                }));
4149            });
4150
4151            server.mock(|when, then| {
4152                when.method(GET).path("/issue/PROJ-2");
4153                then.status(200).json_body(serde_json::json!({
4154                    "id": "10002",
4155                    "key": "PROJ-2",
4156                    "fields": {
4157                        "summary": "New task",
4158                        "status": {"name": "Open"},
4159                        "labels": [],
4160                        "created": "2024-01-03T10:00:00.000+0000"
4161                    }
4162                }));
4163            });
4164
4165            let client = create_self_hosted_client(&server);
4166            let issue = client
4167                .create_issue(CreateIssueInput {
4168                    title: "New task".to_string(),
4169                    description: Some("Task description".to_string()),
4170                    ..Default::default()
4171                })
4172                .await
4173                .unwrap();
4174
4175            assert_eq!(issue.key, "jira#PROJ-2");
4176            assert_eq!(issue.title, "New task");
4177        }
4178
4179        #[tokio::test]
4180        async fn test_create_issue_with_project_id_override() {
4181            let server = MockServer::start();
4182
4183            // Verify the payload uses the overridden project key
4184            server.mock(|when, then| {
4185                when.method(POST)
4186                    .path("/issue")
4187                    .body_includes("\"key\":\"OTHER\"");
4188                then.status(201).json_body(serde_json::json!({
4189                    "id": "10003",
4190                    "key": "OTHER-1"
4191                }));
4192            });
4193
4194            server.mock(|when, then| {
4195                when.method(GET).path("/issue/OTHER-1");
4196                then.status(200).json_body(serde_json::json!({
4197                    "id": "10003",
4198                    "key": "OTHER-1",
4199                    "fields": {
4200                        "summary": "Task in other project",
4201                        "status": {"name": "Open"},
4202                        "labels": [],
4203                        "created": "2024-01-03T10:00:00.000+0000"
4204                    }
4205                }));
4206            });
4207
4208            let client = create_self_hosted_client(&server); // default project = "PROJ"
4209            let issue = client
4210                .create_issue(CreateIssueInput {
4211                    title: "Task in other project".to_string(),
4212                    project_id: Some("OTHER".to_string()),
4213                    ..Default::default()
4214                })
4215                .await
4216                .unwrap();
4217
4218            assert_eq!(issue.key, "jira#OTHER-1");
4219        }
4220
4221        #[tokio::test]
4222        async fn test_create_issue_with_issue_type() {
4223            let server = MockServer::start();
4224
4225            // Verify the payload uses the specified issue type, not hardcoded "Task"
4226            server.mock(|when, then| {
4227                when.method(POST)
4228                    .path("/issue")
4229                    .body_includes("\"name\":\"Bug\"");
4230                then.status(201).json_body(serde_json::json!({
4231                    "id": "10004",
4232                    "key": "PROJ-3"
4233                }));
4234            });
4235
4236            server.mock(|when, then| {
4237                when.method(GET).path("/issue/PROJ-3");
4238                then.status(200).json_body(serde_json::json!({
4239                    "id": "10004",
4240                    "key": "PROJ-3",
4241                    "fields": {
4242                        "summary": "Bug report",
4243                        "status": {"name": "Open"},
4244                        "labels": [],
4245                        "created": "2024-01-03T10:00:00.000+0000"
4246                    }
4247                }));
4248            });
4249
4250            let client = create_self_hosted_client(&server);
4251            let issue = client
4252                .create_issue(CreateIssueInput {
4253                    title: "Bug report".to_string(),
4254                    issue_type: Some("Bug".to_string()),
4255                    ..Default::default()
4256                })
4257                .await
4258                .unwrap();
4259
4260            assert_eq!(issue.key, "jira#PROJ-3");
4261        }
4262
4263        #[tokio::test]
4264        async fn test_create_issue_with_custom_fields() {
4265            let server = MockServer::start();
4266
4267            // Verify custom fields are merged into the payload
4268            server.mock(|when, then| {
4269                when.method(POST)
4270                    .path("/issue")
4271                    .body_includes("\"customfield_10001\":8")
4272                    .body_includes("\"customfield_10002\":\"goal-a\"");
4273                then.status(201).json_body(serde_json::json!({
4274                    "id": "10005",
4275                    "key": "PROJ-5"
4276                }));
4277            });
4278
4279            server.mock(|when, then| {
4280                when.method(GET).path("/issue/PROJ-5");
4281                then.status(200).json_body(serde_json::json!({
4282                    "id": "10005",
4283                    "key": "PROJ-5",
4284                    "fields": {
4285                        "summary": "With custom fields",
4286                        "status": {"name": "Open"},
4287                        "labels": [],
4288                        "created": "2024-01-03T10:00:00.000+0000"
4289                    }
4290                }));
4291            });
4292
4293            let client = create_self_hosted_client(&server);
4294            let issue = client
4295                .create_issue(CreateIssueInput {
4296                    title: "With custom fields".to_string(),
4297                    custom_fields: Some(serde_json::json!({
4298                        "customfield_10001": 8,
4299                        "customfield_10002": "goal-a"
4300                    })),
4301                    ..Default::default()
4302                })
4303                .await
4304                .unwrap();
4305
4306            assert_eq!(issue.key, "jira#PROJ-5");
4307        }
4308
4309        #[tokio::test]
4310        async fn test_update_issue_with_custom_fields() {
4311            let server = MockServer::start();
4312
4313            // Verify custom fields are merged into the update payload
4314            server.mock(|when, then| {
4315                when.method(PUT)
4316                    .path("/issue/PROJ-1")
4317                    .body_includes("\"customfield_10001\":5");
4318                then.status(204);
4319            });
4320
4321            server.mock(|when, then| {
4322                when.method(GET).path("/issue/PROJ-1");
4323                then.status(200).json_body(serde_json::json!({
4324                    "id": "10001",
4325                    "key": "PROJ-1",
4326                    "fields": {
4327                        "summary": "Fix login bug",
4328                        "status": {"name": "Open"},
4329                        "labels": [],
4330                        "created": "2024-01-01T10:00:00.000+0000"
4331                    }
4332                }));
4333            });
4334
4335            let client = create_self_hosted_client(&server);
4336            let issue = client
4337                .update_issue(
4338                    "PROJ-1",
4339                    UpdateIssueInput {
4340                        custom_fields: Some(serde_json::json!({
4341                            "customfield_10001": 5
4342                        })),
4343                        ..Default::default()
4344                    },
4345                )
4346                .await
4347                .unwrap();
4348
4349            assert_eq!(issue.key, "jira#PROJ-1");
4350        }
4351
4352        /// Issue #197 — components pass-through on create.
4353        #[tokio::test]
4354        async fn test_create_issue_with_components() {
4355            let server = MockServer::start();
4356
4357            server.mock(|when, then| {
4358                when.method(POST).path("/issue").body_includes(
4359                    "\"components\":[{\"name\":\"Backend\"},{\"name\":\"Frontend\"}]",
4360                );
4361                then.status(201).json_body(serde_json::json!({
4362                    "id": "10010",
4363                    "key": "PROJ-10"
4364                }));
4365            });
4366
4367            server.mock(|when, then| {
4368                when.method(GET).path("/issue/PROJ-10");
4369                then.status(200).json_body(serde_json::json!({
4370                    "id": "10010",
4371                    "key": "PROJ-10",
4372                    "fields": {
4373                        "summary": "With components",
4374                        "status": {"name": "Open"},
4375                        "labels": [],
4376                        "created": "2024-01-05T10:00:00.000+0000"
4377                    }
4378                }));
4379            });
4380
4381            let client = create_self_hosted_client(&server);
4382            let issue = client
4383                .create_issue(CreateIssueInput {
4384                    title: "With components".to_string(),
4385                    components: vec!["Backend".to_string(), "Frontend".to_string()],
4386                    ..Default::default()
4387                })
4388                .await
4389                .unwrap();
4390
4391            assert_eq!(issue.key, "jira#PROJ-10");
4392        }
4393
4394        /// Issue #197 — empty components list on create must not emit a
4395        /// `"components": []` into the payload (confusing to server).
4396        #[tokio::test]
4397        async fn test_create_issue_without_components_omits_field() {
4398            let server = MockServer::start();
4399
4400            server.mock(|when, then| {
4401                when.method(POST).path("/issue").is_true(|req| {
4402                    let body = String::from_utf8_lossy(req.body().as_ref());
4403                    !body.contains("\"components\"")
4404                });
4405                then.status(201).json_body(serde_json::json!({
4406                    "id": "10011",
4407                    "key": "PROJ-11"
4408                }));
4409            });
4410
4411            server.mock(|when, then| {
4412                when.method(GET).path("/issue/PROJ-11");
4413                then.status(200).json_body(serde_json::json!({
4414                    "id": "10011",
4415                    "key": "PROJ-11",
4416                    "fields": {
4417                        "summary": "No components",
4418                        "status": {"name": "Open"},
4419                        "labels": [],
4420                        "created": "2024-01-05T10:00:00.000+0000"
4421                    }
4422                }));
4423            });
4424
4425            let client = create_self_hosted_client(&server);
4426            let issue = client
4427                .create_issue(CreateIssueInput {
4428                    title: "No components".to_string(),
4429                    components: vec![],
4430                    ..Default::default()
4431                })
4432                .await
4433                .unwrap();
4434
4435            assert_eq!(issue.key, "jira#PROJ-11");
4436        }
4437
4438        /// Issue #214 — sub-task creation requires `fields.parent.key`
4439        /// in the payload; the API returns 400 otherwise. The
4440        /// `CreateIssueInput.parent` field must round-trip into the body.
4441        #[tokio::test]
4442        async fn test_create_issue_subtask_includes_parent_in_payload() {
4443            let server = MockServer::start();
4444
4445            server.mock(|when, then| {
4446                when.method(POST).path("/issue").is_true(|req| {
4447                    let body = String::from_utf8_lossy(req.body().as_ref());
4448                    body.contains("\"parent\":{\"key\":\"PROJ-1\"}")
4449                        && body.contains("\"name\":\"Sub-task\"")
4450                });
4451                then.status(201).json_body(serde_json::json!({
4452                    "id": "10010",
4453                    "key": "PROJ-10"
4454                }));
4455            });
4456
4457            server.mock(|when, then| {
4458                when.method(GET).path("/issue/PROJ-10");
4459                then.status(200).json_body(serde_json::json!({
4460                    "id": "10010",
4461                    "key": "PROJ-10",
4462                    "fields": {
4463                        "summary": "Sub task work",
4464                        "status": {"name": "Open"},
4465                        "labels": [],
4466                        "created": "2024-01-06T10:00:00.000+0000"
4467                    }
4468                }));
4469            });
4470
4471            let client = create_self_hosted_client(&server);
4472            let issue = client
4473                .create_issue(CreateIssueInput {
4474                    title: "Sub task work".to_string(),
4475                    issue_type: Some("Sub-task".to_string()),
4476                    parent: Some("PROJ-1".to_string()),
4477                    ..Default::default()
4478                })
4479                .await
4480                .unwrap();
4481
4482            assert_eq!(issue.key, "jira#PROJ-10");
4483        }
4484
4485        /// Without a parent, the body must not include `"parent"` — Jira
4486        /// rejects empty `parent` objects, and we don't want to emit a
4487        /// dangling field for non-sub-task issue types.
4488        #[tokio::test]
4489        async fn test_create_issue_without_parent_omits_field() {
4490            let server = MockServer::start();
4491
4492            server.mock(|when, then| {
4493                when.method(POST).path("/issue").is_true(|req| {
4494                    let body = String::from_utf8_lossy(req.body().as_ref());
4495                    !body.contains("\"parent\"")
4496                });
4497                then.status(201).json_body(serde_json::json!({
4498                    "id": "10011",
4499                    "key": "PROJ-11"
4500                }));
4501            });
4502
4503            server.mock(|when, then| {
4504                when.method(GET).path("/issue/PROJ-11");
4505                then.status(200).json_body(serde_json::json!({
4506                    "id": "10011",
4507                    "key": "PROJ-11",
4508                    "fields": {
4509                        "summary": "Plain task",
4510                        "status": {"name": "Open"},
4511                        "labels": [],
4512                        "created": "2024-01-06T10:00:00.000+0000"
4513                    }
4514                }));
4515            });
4516
4517            let client = create_self_hosted_client(&server);
4518            let issue = client
4519                .create_issue(CreateIssueInput {
4520                    title: "Plain task".to_string(),
4521                    parent: None,
4522                    ..Default::default()
4523                })
4524                .await
4525                .unwrap();
4526
4527            assert_eq!(issue.key, "jira#PROJ-11");
4528        }
4529
4530        /// Issue #197 — update_issue with components replaces them.
4531        /// `Some(vec![])` clears; `None` does not touch (handled upstream).
4532        #[tokio::test]
4533        async fn test_update_issue_replaces_components() {
4534            let server = MockServer::start();
4535
4536            server.mock(|when, then| {
4537                when.method(PUT)
4538                    .path("/issue/PROJ-1")
4539                    .body_includes("\"components\":[{\"name\":\"Backend\"}]");
4540                then.status(204);
4541            });
4542
4543            server.mock(|when, then| {
4544                when.method(GET).path("/issue/PROJ-1");
4545                then.status(200).json_body(serde_json::json!({
4546                    "id": "10001",
4547                    "key": "PROJ-1",
4548                    "fields": {
4549                        "summary": "Updated",
4550                        "status": {"name": "Open"},
4551                        "labels": [],
4552                        "created": "2024-01-01T10:00:00.000+0000"
4553                    }
4554                }));
4555            });
4556
4557            let client = create_self_hosted_client(&server);
4558            let issue = client
4559                .update_issue(
4560                    "PROJ-1",
4561                    UpdateIssueInput {
4562                        components: Some(vec!["Backend".to_string()]),
4563                        ..Default::default()
4564                    },
4565                )
4566                .await
4567                .unwrap();
4568
4569            assert_eq!(issue.key, "jira#PROJ-1");
4570        }
4571
4572        #[tokio::test]
4573        async fn test_update_issue() {
4574            let server = MockServer::start();
4575
4576            server.mock(|when, then| {
4577                when.method(PUT)
4578                    .path("/issue/PROJ-1")
4579                    .body_includes("\"summary\":\"Updated title\"");
4580                then.status(204);
4581            });
4582
4583            server.mock(|when, then| {
4584                when.method(GET).path("/issue/PROJ-1");
4585                then.status(200).json_body(serde_json::json!({
4586                    "id": "10001",
4587                    "key": "PROJ-1",
4588                    "fields": {
4589                        "summary": "Updated title",
4590                        "status": {"name": "Open"},
4591                        "labels": [],
4592                        "created": "2024-01-01T10:00:00.000+0000"
4593                    }
4594                }));
4595            });
4596
4597            let client = create_self_hosted_client(&server);
4598            let issue = client
4599                .update_issue(
4600                    "PROJ-1",
4601                    UpdateIssueInput {
4602                        title: Some("Updated title".to_string()),
4603                        ..Default::default()
4604                    },
4605                )
4606                .await
4607                .unwrap();
4608
4609            assert_eq!(issue.title, "Updated title");
4610        }
4611
4612        #[tokio::test]
4613        async fn test_update_issue_with_status_transition() {
4614            let server = MockServer::start();
4615
4616            // GET transitions
4617            server.mock(|when, then| {
4618                when.method(GET).path("/issue/PROJ-1/transitions");
4619                then.status(200).json_body(serde_json::json!({
4620                    "transitions": [
4621                        {
4622                            "id": "21",
4623                            "name": "Start Progress",
4624                            "to": {"name": "In Progress"}
4625                        },
4626                        {
4627                            "id": "31",
4628                            "name": "Done",
4629                            "to": {"name": "Done"}
4630                        }
4631                    ]
4632                }));
4633            });
4634
4635            // POST transition
4636            server.mock(|when, then| {
4637                when.method(POST)
4638                    .path("/issue/PROJ-1/transitions")
4639                    .body_includes("\"id\":\"31\"");
4640                then.status(204);
4641            });
4642
4643            // GET issue after transition
4644            server.mock(|when, then| {
4645                when.method(GET).path("/issue/PROJ-1");
4646                then.status(200).json_body(serde_json::json!({
4647                    "id": "10001",
4648                    "key": "PROJ-1",
4649                    "fields": {
4650                        "summary": "Test",
4651                        "status": {"name": "Done"},
4652                        "labels": []
4653                    }
4654                }));
4655            });
4656
4657            let client = create_self_hosted_client(&server);
4658            let issue = client
4659                .update_issue(
4660                    "PROJ-1",
4661                    UpdateIssueInput {
4662                        state: Some("Done".to_string()),
4663                        ..Default::default()
4664                    },
4665                )
4666                .await
4667                .unwrap();
4668
4669            assert_eq!(issue.state, "Done");
4670        }
4671
4672        /// Helper: mock project statuses response with custom statuses.
4673        fn mock_project_statuses(server: &MockServer, statuses: serde_json::Value) {
4674            server.mock(|when, then| {
4675                when.method(GET).path("/project/PROJ/statuses");
4676                then.status(200).json_body(statuses);
4677            });
4678        }
4679
4680        /// Helper: standard project statuses with localized names.
4681        fn sample_project_statuses_json() -> serde_json::Value {
4682            serde_json::json!([{
4683                "name": "Task",
4684                "statuses": [
4685                    {"name": "Offen", "id": "1", "statusCategory": {"key": "new"}},
4686                    {"name": "In Bearbeitung", "id": "2", "statusCategory": {"key": "indeterminate"}},
4687                    {"name": "Erledigt", "id": "3", "statusCategory": {"key": "done"}},
4688                    {"name": "Abgebrochen", "id": "4", "statusCategory": {"key": "done"}}
4689                ]
4690            }])
4691        }
4692
4693        #[tokio::test]
4694        async fn test_update_issue_generic_closed_maps_to_done_category() {
4695            let server = MockServer::start();
4696
4697            // GET transitions — include statusCategory
4698            server.mock(|when, then| {
4699                when.method(GET).path("/issue/PROJ-1/transitions");
4700                then.status(200).json_body(serde_json::json!({
4701                    "transitions": [
4702                        {
4703                            "id": "21",
4704                            "name": "Start Progress",
4705                            "to": {
4706                                "name": "In Bearbeitung",
4707                                "statusCategory": {"key": "indeterminate"}
4708                            }
4709                        },
4710                        {
4711                            "id": "31",
4712                            "name": "Erledigt",
4713                            "to": {
4714                                "name": "Erledigt",
4715                                "statusCategory": {"key": "done"}
4716                            }
4717                        }
4718                    ]
4719                }));
4720            });
4721
4722            // Project statuses — used for category resolution
4723            mock_project_statuses(&server, sample_project_statuses_json());
4724
4725            // POST transition — should pick id "31" (done category)
4726            server.mock(|when, then| {
4727                when.method(POST)
4728                    .path("/issue/PROJ-1/transitions")
4729                    .body_includes("\"id\":\"31\"");
4730                then.status(204);
4731            });
4732
4733            // GET issue after transition
4734            server.mock(|when, then| {
4735                when.method(GET).path("/issue/PROJ-1");
4736                then.status(200).json_body(serde_json::json!({
4737                    "id": "10001",
4738                    "key": "PROJ-1",
4739                    "fields": {
4740                        "summary": "Test",
4741                        "status": {"name": "Erledigt"},
4742                        "labels": []
4743                    }
4744                }));
4745            });
4746
4747            let client = create_self_hosted_client(&server);
4748            let issue = client
4749                .update_issue(
4750                    "PROJ-1",
4751                    UpdateIssueInput {
4752                        state: Some("closed".to_string()),
4753                        ..Default::default()
4754                    },
4755                )
4756                .await
4757                .unwrap();
4758
4759            assert_eq!(issue.state, "Erledigt");
4760        }
4761
4762        #[tokio::test]
4763        async fn test_update_issue_generic_open_maps_to_new_category() {
4764            let server = MockServer::start();
4765
4766            server.mock(|when, then| {
4767                when.method(GET).path("/issue/PROJ-1/transitions");
4768                then.status(200).json_body(serde_json::json!({
4769                    "transitions": [
4770                        {
4771                            "id": "11",
4772                            "name": "Offen",
4773                            "to": {
4774                                "name": "Offen",
4775                                "statusCategory": {"key": "new"}
4776                            }
4777                        },
4778                        {
4779                            "id": "21",
4780                            "name": "In Bearbeitung",
4781                            "to": {
4782                                "name": "In Bearbeitung",
4783                                "statusCategory": {"key": "indeterminate"}
4784                            }
4785                        }
4786                    ]
4787                }));
4788            });
4789
4790            mock_project_statuses(&server, sample_project_statuses_json());
4791
4792            server.mock(|when, then| {
4793                when.method(POST)
4794                    .path("/issue/PROJ-1/transitions")
4795                    .body_includes("\"id\":\"11\"");
4796                then.status(204);
4797            });
4798
4799            server.mock(|when, then| {
4800                when.method(GET).path("/issue/PROJ-1");
4801                then.status(200).json_body(serde_json::json!({
4802                    "id": "10001",
4803                    "key": "PROJ-1",
4804                    "fields": {
4805                        "summary": "Test",
4806                        "status": {"name": "Offen"},
4807                        "labels": []
4808                    }
4809                }));
4810            });
4811
4812            let client = create_self_hosted_client(&server);
4813            let issue = client
4814                .update_issue(
4815                    "PROJ-1",
4816                    UpdateIssueInput {
4817                        state: Some("open".to_string()),
4818                        ..Default::default()
4819                    },
4820                )
4821                .await
4822                .unwrap();
4823
4824            assert_eq!(issue.state, "Offen");
4825        }
4826
4827        #[tokio::test]
4828        async fn test_update_issue_canceled_resolves_via_project_statuses() {
4829            let server = MockServer::start();
4830
4831            // Only "Abgebrochen" transition is available (done category)
4832            server.mock(|when, then| {
4833                when.method(GET).path("/issue/PROJ-1/transitions");
4834                then.status(200).json_body(serde_json::json!({
4835                    "transitions": [
4836                        {
4837                            "id": "21",
4838                            "name": "Start Progress",
4839                            "to": {
4840                                "name": "In Bearbeitung",
4841                                "statusCategory": {"key": "indeterminate"}
4842                            }
4843                        },
4844                        {
4845                            "id": "41",
4846                            "name": "Cancel",
4847                            "to": {
4848                                "name": "Abgebrochen",
4849                                "statusCategory": {"key": "done"}
4850                            }
4851                        }
4852                    ]
4853                }));
4854            });
4855
4856            // Project statuses: "Abgebrochen" is in done category
4857            mock_project_statuses(&server, sample_project_statuses_json());
4858
4859            // POST transition — should pick "41" (resolved via project statuses + category)
4860            server.mock(|when, then| {
4861                when.method(POST)
4862                    .path("/issue/PROJ-1/transitions")
4863                    .body_includes("\"id\":\"41\"");
4864                then.status(204);
4865            });
4866
4867            server.mock(|when, then| {
4868                when.method(GET).path("/issue/PROJ-1");
4869                then.status(200).json_body(serde_json::json!({
4870                    "id": "10001",
4871                    "key": "PROJ-1",
4872                    "fields": {
4873                        "summary": "Test",
4874                        "status": {"name": "Abgebrochen"},
4875                        "labels": []
4876                    }
4877                }));
4878            });
4879
4880            let client = create_self_hosted_client(&server);
4881            let issue = client
4882                .update_issue(
4883                    "PROJ-1",
4884                    UpdateIssueInput {
4885                        state: Some("canceled".to_string()),
4886                        ..Default::default()
4887                    },
4888                )
4889                .await
4890                .unwrap();
4891
4892            assert_eq!(issue.state, "Abgebrochen");
4893        }
4894
4895        #[tokio::test]
4896        async fn test_update_issue_exact_project_status_name_match() {
4897            let server = MockServer::start();
4898
4899            // User passes exact project status name "Abgebrochen"
4900            server.mock(|when, then| {
4901                when.method(GET).path("/issue/PROJ-1/transitions");
4902                then.status(200).json_body(serde_json::json!({
4903                    "transitions": [
4904                        {
4905                            "id": "41",
4906                            "name": "Cancel",
4907                            "to": {"name": "Abgebrochen", "statusCategory": {"key": "done"}}
4908                        },
4909                        {
4910                            "id": "31",
4911                            "name": "Done",
4912                            "to": {"name": "Erledigt", "statusCategory": {"key": "done"}}
4913                        }
4914                    ]
4915                }));
4916            });
4917
4918            mock_project_statuses(&server, sample_project_statuses_json());
4919
4920            // Should pick transition to "Abgebrochen" by exact project status name
4921            server.mock(|when, then| {
4922                when.method(POST)
4923                    .path("/issue/PROJ-1/transitions")
4924                    .body_includes("\"id\":\"41\"");
4925                then.status(204);
4926            });
4927
4928            server.mock(|when, then| {
4929                when.method(GET).path("/issue/PROJ-1");
4930                then.status(200).json_body(serde_json::json!({
4931                    "id": "10001",
4932                    "key": "PROJ-1",
4933                    "fields": {
4934                        "summary": "Test",
4935                        "status": {"name": "Abgebrochen"},
4936                        "labels": []
4937                    }
4938                }));
4939            });
4940
4941            let client = create_self_hosted_client(&server);
4942            let issue = client
4943                .update_issue(
4944                    "PROJ-1",
4945                    UpdateIssueInput {
4946                        state: Some("Abgebrochen".to_string()),
4947                        ..Default::default()
4948                    },
4949                )
4950                .await
4951                .unwrap();
4952
4953            assert_eq!(issue.state, "Abgebrochen");
4954        }
4955
4956        #[tokio::test]
4957        async fn test_update_issue_fallback_when_project_statuses_unavailable() {
4958            let server = MockServer::start();
4959
4960            // Transitions with category info
4961            server.mock(|when, then| {
4962                when.method(GET).path("/issue/PROJ-1/transitions");
4963                then.status(200).json_body(serde_json::json!({
4964                    "transitions": [{
4965                        "id": "31",
4966                        "name": "Done",
4967                        "to": {"name": "Done", "statusCategory": {"key": "done"}}
4968                    }]
4969                }));
4970            });
4971
4972            // Project statuses endpoint returns 403 (no permission)
4973            server.mock(|when, then| {
4974                when.method(GET).path("/project/PROJ/statuses");
4975                then.status(403).body("Forbidden");
4976            });
4977
4978            server.mock(|when, then| {
4979                when.method(POST)
4980                    .path("/issue/PROJ-1/transitions")
4981                    .body_includes("\"id\":\"31\"");
4982                then.status(204);
4983            });
4984
4985            server.mock(|when, then| {
4986                when.method(GET).path("/issue/PROJ-1");
4987                then.status(200).json_body(serde_json::json!({
4988                    "id": "10001",
4989                    "key": "PROJ-1",
4990                    "fields": {
4991                        "summary": "Test",
4992                        "status": {"name": "Done"},
4993                        "labels": []
4994                    }
4995                }));
4996            });
4997
4998            let client = create_self_hosted_client(&server);
4999            // "closed" → category "done" → should still work via fallback
5000            let issue = client
5001                .update_issue(
5002                    "PROJ-1",
5003                    UpdateIssueInput {
5004                        state: Some("closed".to_string()),
5005                        ..Default::default()
5006                    },
5007                )
5008                .await
5009                .unwrap();
5010
5011            assert_eq!(issue.state, "Done");
5012        }
5013
5014        #[tokio::test]
5015        async fn test_get_comments() {
5016            let server = MockServer::start();
5017
5018            server.mock(|when, then| {
5019                when.method(GET).path("/issue/PROJ-1/comment");
5020                then.status(200).json_body(serde_json::json!({
5021                    "comments": [{
5022                        "id": "100",
5023                        "body": "Great work!",
5024                        "author": {
5025                            "name": "reviewer",
5026                            "displayName": "Reviewer"
5027                        },
5028                        "created": "2024-01-01T12:00:00.000+0000",
5029                        "updated": "2024-01-01T12:00:00.000+0000"
5030                    }]
5031                }));
5032            });
5033
5034            let client = create_self_hosted_client(&server);
5035            let comments = client.get_comments("PROJ-1").await.unwrap().items;
5036
5037            assert_eq!(comments.len(), 1);
5038            assert_eq!(comments[0].id, "100");
5039            assert_eq!(comments[0].body, "Great work!");
5040            assert_eq!(comments[0].author.as_ref().unwrap().username, "reviewer");
5041        }
5042
5043        #[tokio::test]
5044        async fn test_add_comment() {
5045            let server = MockServer::start();
5046
5047            server.mock(|when, then| {
5048                when.method(POST)
5049                    .path("/issue/PROJ-1/comment")
5050                    .body_includes("\"body\":\"My comment\"");
5051                then.status(201).json_body(serde_json::json!({
5052                    "id": "101",
5053                    "body": "My comment",
5054                    "author": {
5055                        "name": "user",
5056                        "displayName": "User"
5057                    },
5058                    "created": "2024-01-01T13:00:00.000+0000"
5059                }));
5060            });
5061
5062            let client = create_self_hosted_client(&server);
5063            let comment = IssueProvider::add_comment(&client, "PROJ-1", "My comment")
5064                .await
5065                .unwrap();
5066
5067            assert_eq!(comment.id, "101");
5068            assert_eq!(comment.body, "My comment");
5069        }
5070
5071        // =================================================================
5072        // Cloud (API v3) tests
5073        // =================================================================
5074
5075        #[tokio::test]
5076        async fn test_cloud_get_issues() {
5077            let server = MockServer::start();
5078
5079            server.mock(|when, then| {
5080                when.method(GET)
5081                    .path("/search/jql")
5082                    .query_param_exists("jql");
5083                then.status(200).json_body(serde_json::json!({
5084                    "issues": [sample_cloud_issue_json()]
5085                }));
5086            });
5087
5088            let client = create_cloud_client(&server);
5089            let issues = client
5090                .get_issues(IssueFilter::default())
5091                .await
5092                .unwrap()
5093                .items;
5094
5095            assert_eq!(issues.len(), 1);
5096            assert_eq!(issues[0].key, "jira#PROJ-1");
5097            assert_eq!(
5098                issues[0].description,
5099                Some("Login fails on mobile".to_string())
5100            );
5101        }
5102
5103        #[tokio::test]
5104        async fn test_cloud_create_issue_adf() {
5105            let server = MockServer::start();
5106
5107            // Verify ADF in request body
5108            server.mock(|when, then| {
5109                when.method(POST)
5110                    .path("/issue")
5111                    .body_includes("\"type\":\"doc\"")
5112                    .body_includes("\"version\":1");
5113                then.status(201).json_body(serde_json::json!({
5114                    "id": "10003",
5115                    "key": "PROJ-3"
5116                }));
5117            });
5118
5119            server.mock(|when, then| {
5120                when.method(GET).path("/issue/PROJ-3");
5121                then.status(200).json_body(serde_json::json!({
5122                    "id": "10003",
5123                    "key": "PROJ-3",
5124                    "fields": {
5125                        "summary": "Cloud task",
5126                        "description": {
5127                            "version": 1,
5128                            "type": "doc",
5129                            "content": [{
5130                                "type": "paragraph",
5131                                "content": [{"type": "text", "text": "Cloud description"}]
5132                            }]
5133                        },
5134                        "status": {"name": "To Do"},
5135                        "labels": []
5136                    }
5137                }));
5138            });
5139
5140            let client = create_cloud_client(&server);
5141            let issue = client
5142                .create_issue(CreateIssueInput {
5143                    title: "Cloud task".to_string(),
5144                    description: Some("Cloud description".to_string()),
5145                    ..Default::default()
5146                })
5147                .await
5148                .unwrap();
5149
5150            assert_eq!(issue.key, "jira#PROJ-3");
5151            assert_eq!(issue.description, Some("Cloud description".to_string()));
5152        }
5153
5154        #[tokio::test]
5155        async fn test_cloud_add_comment_adf() {
5156            let server = MockServer::start();
5157
5158            server.mock(|when, then| {
5159                when.method(POST)
5160                    .path("/issue/PROJ-1/comment")
5161                    .body_includes("\"type\":\"doc\"");
5162                then.status(201).json_body(serde_json::json!({
5163                    "id": "201",
5164                    "body": {
5165                        "version": 1,
5166                        "type": "doc",
5167                        "content": [{
5168                            "type": "paragraph",
5169                            "content": [{"type": "text", "text": "ADF comment body"}]
5170                        }]
5171                    },
5172                    "author": {
5173                        "accountId": "abc123",
5174                        "displayName": "Commenter"
5175                    },
5176                    "created": "2024-01-02T10:00:00.000+0000"
5177                }));
5178            });
5179
5180            let client = create_cloud_client(&server);
5181            let comment = IssueProvider::add_comment(&client, "PROJ-1", "ADF comment body")
5182                .await
5183                .unwrap();
5184
5185            assert_eq!(comment.id, "201");
5186            assert_eq!(comment.body, "ADF comment body");
5187        }
5188
5189        #[tokio::test]
5190        async fn test_cloud_get_issue_adf_description() {
5191            let server = MockServer::start();
5192
5193            server.mock(|when, then| {
5194                when.method(GET).path("/issue/PROJ-1");
5195                then.status(200).json_body(sample_cloud_issue_json());
5196            });
5197
5198            let client = create_cloud_client(&server);
5199            let issue = client.get_issue("PROJ-1").await.unwrap();
5200
5201            assert_eq!(issue.description, Some("Login fails on mobile".to_string()));
5202        }
5203
5204        // =================================================================
5205        // Error handling tests
5206        // =================================================================
5207
5208        #[tokio::test]
5209        async fn test_handle_401() {
5210            let server = MockServer::start();
5211
5212            server.mock(|when, then| {
5213                when.method(GET).path("/issue/PROJ-1");
5214                then.status(401).body("Unauthorized");
5215            });
5216
5217            let client = create_self_hosted_client(&server);
5218            let result = client.get_issue("PROJ-1").await;
5219
5220            assert!(result.is_err());
5221            assert!(matches!(result.unwrap_err(), Error::Unauthorized(_)));
5222        }
5223
5224        #[tokio::test]
5225        async fn test_handle_404() {
5226            let server = MockServer::start();
5227
5228            server.mock(|when, then| {
5229                when.method(GET).path("/issue/PROJ-999");
5230                then.status(404).body("Issue not found");
5231            });
5232
5233            let client = create_self_hosted_client(&server);
5234            let result = client.get_issue("PROJ-999").await;
5235
5236            assert!(result.is_err());
5237            assert!(matches!(result.unwrap_err(), Error::NotFound(_)));
5238        }
5239
5240        #[tokio::test]
5241        async fn test_handle_500() {
5242            let server = MockServer::start();
5243
5244            server.mock(|when, then| {
5245                when.method(GET).path("/search");
5246                then.status(500).body("Internal Server Error");
5247            });
5248
5249            let client = create_self_hosted_client(&server);
5250            let result = client.get_issues(IssueFilter::default()).await;
5251
5252            assert!(result.is_err());
5253            assert!(matches!(result.unwrap_err(), Error::ServerError { .. }));
5254        }
5255
5256        // =================================================================
5257        // MR methods unsupported test
5258        // =================================================================
5259
5260        #[tokio::test]
5261        async fn test_mr_methods_unsupported() {
5262            let client = JiraClient::with_base_url(
5263                "http://localhost",
5264                "PROJ",
5265                "user@example.com",
5266                token("token"),
5267                false,
5268            );
5269
5270            let result = client.get_merge_requests(MrFilter::default()).await;
5271            assert!(matches!(
5272                result.unwrap_err(),
5273                Error::ProviderUnsupported { .. }
5274            ));
5275
5276            let result = client.get_merge_request("mr#1").await;
5277            assert!(matches!(
5278                result.unwrap_err(),
5279                Error::ProviderUnsupported { .. }
5280            ));
5281
5282            let result = client.get_discussions("mr#1").await;
5283            assert!(matches!(
5284                result.unwrap_err(),
5285                Error::ProviderUnsupported { .. }
5286            ));
5287
5288            let result = client.get_diffs("mr#1").await;
5289            assert!(matches!(
5290                result.unwrap_err(),
5291                Error::ProviderUnsupported { .. }
5292            ));
5293
5294            let result = MergeRequestProvider::add_comment(
5295                &client,
5296                "mr#1",
5297                CreateCommentInput {
5298                    body: "test".to_string(),
5299                    position: None,
5300                    discussion_id: None,
5301                },
5302            )
5303            .await;
5304            assert!(matches!(
5305                result.unwrap_err(),
5306                Error::ProviderUnsupported { .. }
5307            ));
5308        }
5309
5310        // =================================================================
5311        // Current user tests
5312        // =================================================================
5313
5314        #[tokio::test]
5315        async fn test_get_current_user() {
5316            let server = MockServer::start();
5317
5318            server.mock(|when, then| {
5319                when.method(GET).path("/myself");
5320                then.status(200).json_body(serde_json::json!({
5321                    "name": "jdoe",
5322                    "displayName": "John Doe",
5323                    "emailAddress": "john@example.com"
5324                }));
5325            });
5326
5327            let client = create_self_hosted_client(&server);
5328            let user = client.get_current_user().await.unwrap();
5329
5330            assert_eq!(user.username, "jdoe");
5331            assert_eq!(user.name, Some("John Doe".to_string()));
5332            assert_eq!(user.email, Some("john@example.com".to_string()));
5333        }
5334
5335        #[tokio::test]
5336        async fn test_get_current_user_auth_failure() {
5337            let server = MockServer::start();
5338
5339            server.mock(|when, then| {
5340                when.method(GET).path("/myself");
5341                then.status(401).body("Unauthorized");
5342            });
5343
5344            let client = create_self_hosted_client(&server);
5345            let result = client.get_current_user().await;
5346
5347            assert!(result.is_err());
5348            assert!(matches!(result.unwrap_err(), Error::Unauthorized(_)));
5349        }
5350
5351        #[tokio::test]
5352        async fn test_transition_not_found_error_lists_available() {
5353            let server = MockServer::start();
5354
5355            server.mock(|when, then| {
5356                when.method(GET).path("/issue/PROJ-1/transitions");
5357                then.status(200).json_body(serde_json::json!({
5358                    "transitions": [
5359                        {
5360                            "id": "21",
5361                            "name": "Start Progress",
5362                            "to": {
5363                                "name": "In Bearbeitung",
5364                                "statusCategory": {"key": "indeterminate"}
5365                            }
5366                        }
5367                    ]
5368                }));
5369            });
5370
5371            // Project statuses — no matching category for "nonexistent"
5372            mock_project_statuses(&server, sample_project_statuses_json());
5373
5374            let client = create_self_hosted_client(&server);
5375            let result = client
5376                .update_issue(
5377                    "PROJ-1",
5378                    UpdateIssueInput {
5379                        state: Some("nonexistent".to_string()),
5380                        ..Default::default()
5381                    },
5382                )
5383                .await;
5384
5385            assert!(result.is_err());
5386            let err = result.unwrap_err().to_string();
5387            assert!(err.contains("No transition to status"), "got: {}", err);
5388            assert!(
5389                err.contains("In Bearbeitung"),
5390                "should list available: {}",
5391                err
5392            );
5393        }
5394
5395        #[tokio::test]
5396        async fn test_cloud_get_issues_pagination_next_page_token() {
5397            let server = MockServer::start();
5398
5399            // Page 2 mock must be registered first — httpmock matches most specific.
5400            // Page 2: has nextPageToken param, returns 1 issue, no more pages
5401            server.mock(|when, then| {
5402                when.method(GET)
5403                    .path("/search/jql")
5404                    .query_param("nextPageToken", "page2token");
5405                then.status(200).json_body(serde_json::json!({
5406                    "issues": [
5407                        {
5408                            "id": "10003",
5409                            "key": "PROJ-3",
5410                            "fields": {
5411                                "summary": "Issue 3",
5412                                "status": {"name": "Done"},
5413                                "labels": [],
5414                                "created": "2024-01-03T10:00:00.000+0000"
5415                            }
5416                        }
5417                    ]
5418                }));
5419            });
5420
5421            // Page 1: no nextPageToken param, returns 2 issues + nextPageToken
5422            server.mock(|when, then| {
5423                when.method(GET)
5424                    .path("/search/jql")
5425                    .query_param_exists("jql");
5426                then.status(200).json_body(serde_json::json!({
5427                    "issues": [
5428                        {
5429                            "id": "10001",
5430                            "key": "PROJ-1",
5431                            "fields": {
5432                                "summary": "Issue 1",
5433                                "status": {"name": "Open"},
5434                                "labels": [],
5435                                "created": "2024-01-01T10:00:00.000+0000"
5436                            }
5437                        },
5438                        {
5439                            "id": "10002",
5440                            "key": "PROJ-2",
5441                            "fields": {
5442                                "summary": "Issue 2",
5443                                "status": {"name": "Open"},
5444                                "labels": [],
5445                                "created": "2024-01-02T10:00:00.000+0000"
5446                            }
5447                        }
5448                    ],
5449                    "nextPageToken": "page2token"
5450                }));
5451            });
5452
5453            let client = create_cloud_client(&server);
5454            let issues = client
5455                .get_issues(IssueFilter {
5456                    limit: Some(3),
5457                    ..Default::default()
5458                })
5459                .await
5460                .unwrap()
5461                .items;
5462
5463            assert_eq!(issues.len(), 3);
5464            assert_eq!(issues[0].key, "jira#PROJ-1");
5465            assert_eq!(issues[1].key, "jira#PROJ-2");
5466            assert_eq!(issues[2].key, "jira#PROJ-3");
5467        }
5468
5469        #[test]
5470        fn test_escape_jql() {
5471            assert_eq!(escape_jql("simple"), "simple");
5472            assert_eq!(escape_jql(r#"has "quotes""#), r#"has \"quotes\""#);
5473            assert_eq!(escape_jql(r"back\slash"), r"back\\slash");
5474            assert_eq!(
5475                escape_jql(r#"both "and" \ here"#),
5476                r#"both \"and\" \\ here"#
5477            );
5478        }
5479
5480        #[test]
5481        fn test_has_project_clause() {
5482            // Positive cases — standard operators
5483            assert!(has_project_clause("project = \"PROJ\""));
5484            assert!(has_project_clause("project = PROJ AND status = Open"));
5485            assert!(has_project_clause("project IN (\"A\", \"B\")"));
5486            assert!(has_project_clause("project in(A, B)"));
5487            assert!(has_project_clause("PROJECT = KEY")); // case-insensitive
5488            assert!(has_project_clause("status = Open AND project = X"));
5489            assert!(has_project_clause("project ~ KEY")); // contains operator
5490            // Positive cases — negation operators
5491            assert!(has_project_clause("project != \"PROJ\""));
5492            assert!(has_project_clause("project NOT IN (\"A\", \"B\")"));
5493            assert!(has_project_clause("project not in(A)"));
5494            // Negative cases — no project clause
5495            assert!(!has_project_clause("fixVersion = \"1.0\""));
5496            assert!(!has_project_clause("status = Done"));
5497            // Negative cases — "project" inside quoted strings
5498            assert!(!has_project_clause("summary ~ \"project plan\""));
5499            assert!(!has_project_clause("summary ~ \"project information\""));
5500            assert!(!has_project_clause("summary ~ \"project = foo\""));
5501            // Negative cases — underscore word boundary
5502            assert!(!has_project_clause("my_project = X"));
5503        }
5504
5505        // =================================================================
5506        // merge_custom_fields unit tests
5507        // =================================================================
5508
5509        #[test]
5510        fn test_merge_custom_fields_into_payload() {
5511            use crate::types::*;
5512            let payload = CreateIssuePayload {
5513                fields: CreateIssueFields {
5514                    project: ProjectKey { key: "PROJ".into() },
5515                    summary: "Test".into(),
5516                    issuetype: IssueType {
5517                        name: "Task".into(),
5518                    },
5519                    description: None,
5520                    labels: None,
5521                    priority: None,
5522                    assignee: None,
5523                    components: None,
5524                    parent: None,
5525                },
5526            };
5527
5528            let cf = Some(serde_json::json!({"customfield_10001": 8, "customfield_10002": "x"}));
5529            let (merged, count) = merge_custom_fields_into_payload(payload, &cf).unwrap();
5530
5531            let fields = merged.get("fields").unwrap();
5532            assert_eq!(fields["customfield_10001"], 8);
5533            assert_eq!(fields["customfield_10002"], "x");
5534            assert_eq!(count, 2);
5535            assert_eq!(fields["summary"], "Test");
5536            assert_eq!(fields["project"]["key"], "PROJ");
5537        }
5538
5539        #[test]
5540        fn test_merge_custom_fields_none_is_noop() {
5541            use crate::types::*;
5542            let payload = CreateIssuePayload {
5543                fields: CreateIssueFields {
5544                    project: ProjectKey { key: "PROJ".into() },
5545                    summary: "Test".into(),
5546                    issuetype: IssueType {
5547                        name: "Task".into(),
5548                    },
5549                    description: None,
5550                    labels: None,
5551                    priority: None,
5552                    assignee: None,
5553                    components: None,
5554                    parent: None,
5555                },
5556            };
5557
5558            let (merged, count) = merge_custom_fields_into_payload(payload, &None).unwrap();
5559            assert_eq!(count, 0);
5560            let fields = merged.get("fields").unwrap();
5561            assert_eq!(fields["summary"], "Test");
5562            assert!(fields.get("customfield_10001").is_none());
5563        }
5564
5565        #[test]
5566        fn test_merge_custom_fields_rejects_non_custom_keys() {
5567            use crate::types::*;
5568            let payload = CreateIssuePayload {
5569                fields: CreateIssueFields {
5570                    project: ProjectKey { key: "PROJ".into() },
5571                    summary: "Test".into(),
5572                    issuetype: IssueType {
5573                        name: "Task".into(),
5574                    },
5575                    description: None,
5576                    labels: None,
5577                    priority: None,
5578                    assignee: None,
5579                    components: None,
5580                    parent: None,
5581                },
5582            };
5583
5584            // "summary" should be rejected, "customfield_10001" should pass
5585            let cf = Some(serde_json::json!({"summary": "HACKED", "customfield_10001": 5}));
5586            let (merged, count) = merge_custom_fields_into_payload(payload, &cf).unwrap();
5587
5588            let fields = merged.get("fields").unwrap();
5589            assert_eq!(fields["summary"], "Test"); // NOT overwritten
5590            assert_eq!(fields["customfield_10001"], 5); // custom field applied
5591            assert_eq!(count, 1); // only customfield_10001 counted
5592        }
5593
5594        // =================================================================
5595        // get_issue_relations integration test
5596        // =================================================================
5597
5598        #[tokio::test]
5599        async fn test_get_issue_relations() {
5600            let server = MockServer::start();
5601
5602            server.mock(|when, then| {
5603                when.method(GET)
5604                    .path("/issue/PROJ-1")
5605                    .query_param_includes("fields", "parent");
5606                then.status(200).json_body(serde_json::json!({
5607                    "id": "10001",
5608                    "key": "PROJ-1",
5609                    "fields": {
5610                        "summary": "Main issue",
5611                        "status": {"name": "Open"},
5612                        "labels": [],
5613                        "parent": {
5614                            "id": "10000",
5615                            "key": "PROJ-0",
5616                            "fields": {
5617                                "summary": "Parent issue",
5618                                "status": {"name": "Open"},
5619                                "labels": []
5620                            }
5621                        },
5622                        "subtasks": [
5623                            {
5624                                "id": "10002",
5625                                "key": "PROJ-2",
5626                                "fields": {
5627                                    "summary": "Subtask 1",
5628                                    "status": {"name": "In Progress"},
5629                                    "labels": []
5630                                }
5631                            }
5632                        ],
5633                        "issuelinks": [
5634                            {
5635                                "type": {
5636                                    "name": "Blocks",
5637                                    "outward": "blocks",
5638                                    "inward": "is blocked by"
5639                                },
5640                                "outwardIssue": {
5641                                    "id": "10003",
5642                                    "key": "PROJ-3",
5643                                    "fields": {
5644                                        "summary": "Blocked issue",
5645                                        "status": {"name": "Open"},
5646                                        "labels": []
5647                                    }
5648                                }
5649                            }
5650                        ]
5651                    }
5652                }));
5653            });
5654
5655            let client = create_self_hosted_client(&server);
5656            let relations = client.get_issue_relations("jira#PROJ-1").await.unwrap();
5657
5658            assert!(relations.parent.is_some());
5659            assert_eq!(relations.parent.unwrap().key, "jira#PROJ-0");
5660            assert_eq!(relations.subtasks.len(), 1);
5661            assert_eq!(relations.subtasks[0].key, "jira#PROJ-2");
5662            assert_eq!(relations.blocks.len(), 1);
5663            assert_eq!(relations.blocks[0].issue.key, "jira#PROJ-3");
5664        }
5665
5666        // =================================================================
5667        // Attachment tests (Phase 2)
5668        // =================================================================
5669
5670        #[tokio::test]
5671        async fn test_get_issue_attachments_maps_fields() {
5672            let server = MockServer::start();
5673
5674            server.mock(|when, then| {
5675                when.method(GET)
5676                    .path("/issue/PROJ-1")
5677                    .query_param("fields", "attachment");
5678                then.status(200).json_body(serde_json::json!({
5679                    "id": "10001",
5680                    "key": "PROJ-1",
5681                    "fields": {
5682                        "attachment": [
5683                            {
5684                                "id": "42",
5685                                "filename": "crash.log",
5686                                "content": "https://example/rest/api/2/attachment/content/42",
5687                                "size": 2048,
5688                                "mimeType": "text/plain",
5689                                "created": "2024-01-01T00:00:00.000+0000",
5690                                "author": {
5691                                    "name": "uploader",
5692                                    "displayName": "Upload User"
5693                                }
5694                            }
5695                        ]
5696                    }
5697                }));
5698            });
5699
5700            let client = create_self_hosted_client(&server);
5701            let assets = client.get_issue_attachments("jira#PROJ-1").await.unwrap();
5702            assert_eq!(assets.len(), 1);
5703            let a = &assets[0];
5704            assert_eq!(a.id, "42");
5705            assert_eq!(a.filename, "crash.log");
5706            assert_eq!(a.mime_type.as_deref(), Some("text/plain"));
5707            assert_eq!(a.size, Some(2048));
5708            assert_eq!(a.author.as_deref(), Some("Upload User"));
5709        }
5710
5711        #[tokio::test]
5712        async fn test_download_attachment_returns_bytes() {
5713            let server = MockServer::start();
5714
5715            // Self-Hosted: first fetches metadata, then downloads from content URL.
5716            let content_url = server.url("/secure/attachment/42/trace.log");
5717            server.mock(|when, then| {
5718                when.method(GET).path("/attachment/42");
5719                then.status(200).json_body(serde_json::json!({
5720                    "self": "http://localhost/rest/api/2/attachment/42",
5721                    "id": "42",
5722                    "filename": "trace.log",
5723                    "content": content_url,
5724                }));
5725            });
5726            server.mock(|when, then| {
5727                when.method(GET).path("/secure/attachment/42/trace.log");
5728                then.status(200).body("stack trace here");
5729            });
5730
5731            let client = create_self_hosted_client(&server);
5732            let bytes = client
5733                .download_attachment("jira#PROJ-1", "42")
5734                .await
5735                .unwrap();
5736            assert_eq!(bytes, b"stack trace here");
5737        }
5738
5739        #[tokio::test]
5740        async fn test_delete_attachment_ok() {
5741            let server = MockServer::start();
5742
5743            let mock = server.mock(|when, then| {
5744                when.method(DELETE).path("/attachment/42");
5745                then.status(204);
5746            });
5747
5748            let client = create_self_hosted_client(&server);
5749            client.delete_attachment("jira#PROJ-1", "42").await.unwrap();
5750            mock.assert();
5751        }
5752
5753        #[tokio::test]
5754        async fn test_upload_attachment_returns_content_url() {
5755            let server = MockServer::start();
5756
5757            server.mock(|when, then| {
5758                when.method(POST)
5759                    .path("/issue/PROJ-1/attachments")
5760                    .header("X-Atlassian-Token", "no-check");
5761                then.status(200).json_body(serde_json::json!([
5762                    {
5763                        "id": "99",
5764                        "filename": "report.txt",
5765                        "content": "https://example/rest/api/2/attachment/content/99",
5766                        "size": 10
5767                    }
5768                ]));
5769            });
5770
5771            let client = create_self_hosted_client(&server);
5772            let url = client
5773                .upload_attachment("jira#PROJ-1", "report.txt", b"0123456789")
5774                .await
5775                .unwrap();
5776            assert_eq!(url, "https://example/rest/api/2/attachment/content/99");
5777        }
5778
5779        #[tokio::test]
5780        async fn test_jira_asset_capabilities() {
5781            let server = MockServer::start();
5782            let client = create_self_hosted_client(&server);
5783            let caps = client.asset_capabilities();
5784            assert!(caps.issue.upload);
5785            assert!(caps.issue.download);
5786            assert!(caps.issue.delete);
5787            assert!(caps.issue.list);
5788        }
5789    }
5790
5791    // =========================================================================
5792    // map_relations unit tests
5793    // =========================================================================
5794
5795    #[test]
5796    fn test_map_relations_empty() {
5797        let issue = JiraIssue {
5798            id: "10001".to_string(),
5799            key: "PROJ-1".to_string(),
5800            fields: JiraIssueFields {
5801                summary: Some("Test".to_string()),
5802                description: None,
5803                status: None,
5804                priority: None,
5805                assignee: None,
5806                reporter: None,
5807                labels: vec![],
5808                created: None,
5809                updated: None,
5810                parent: None,
5811                subtasks: vec![],
5812                issuelinks: vec![],
5813                attachment: vec![],
5814            },
5815        };
5816
5817        let relations = map_relations(&issue, JiraFlavor::Cloud, "https://test.atlassian.net");
5818
5819        assert!(relations.parent.is_none());
5820        assert!(relations.subtasks.is_empty());
5821        assert!(relations.blocks.is_empty());
5822        assert!(relations.blocked_by.is_empty());
5823        assert!(relations.related_to.is_empty());
5824        assert!(relations.duplicates.is_empty());
5825    }
5826
5827    #[test]
5828    fn test_map_relations_with_parent() {
5829        let parent = Box::new(JiraIssue {
5830            id: "10000".to_string(),
5831            key: "PROJ-0".to_string(),
5832            fields: JiraIssueFields {
5833                summary: Some("Parent Issue".to_string()),
5834                description: None,
5835                status: Some(JiraStatus {
5836                    name: "Open".to_string(),
5837                    status_category: None,
5838                }),
5839                priority: None,
5840                assignee: None,
5841                reporter: None,
5842                labels: vec![],
5843                created: None,
5844                updated: None,
5845                parent: None,
5846                subtasks: vec![],
5847                issuelinks: vec![],
5848                attachment: vec![],
5849            },
5850        });
5851
5852        let issue = JiraIssue {
5853            id: "10001".to_string(),
5854            key: "PROJ-1".to_string(),
5855            fields: JiraIssueFields {
5856                summary: Some("Child Issue".to_string()),
5857                description: None,
5858                status: None,
5859                priority: None,
5860                assignee: None,
5861                reporter: None,
5862                labels: vec![],
5863                created: None,
5864                updated: None,
5865                parent: Some(parent),
5866                subtasks: vec![],
5867                issuelinks: vec![],
5868                attachment: vec![],
5869            },
5870        };
5871
5872        let relations = map_relations(&issue, JiraFlavor::SelfHosted, "https://jira.example.com");
5873
5874        assert!(relations.parent.is_some());
5875        let parent_issue = relations.parent.unwrap();
5876        assert_eq!(parent_issue.key, "jira#PROJ-0");
5877        assert_eq!(parent_issue.title, "Parent Issue");
5878    }
5879
5880    #[test]
5881    fn test_map_relations_with_subtasks() {
5882        let issue = JiraIssue {
5883            id: "10001".to_string(),
5884            key: "PROJ-1".to_string(),
5885            fields: JiraIssueFields {
5886                summary: Some("Epic".to_string()),
5887                description: None,
5888                status: None,
5889                priority: None,
5890                assignee: None,
5891                reporter: None,
5892                labels: vec![],
5893                created: None,
5894                updated: None,
5895                parent: None,
5896                subtasks: vec![
5897                    JiraIssue {
5898                        id: "10002".to_string(),
5899                        key: "PROJ-2".to_string(),
5900                        fields: JiraIssueFields {
5901                            summary: Some("Subtask 1".to_string()),
5902                            description: None,
5903                            status: Some(JiraStatus {
5904                                name: "In Progress".to_string(),
5905                                status_category: None,
5906                            }),
5907                            priority: None,
5908                            assignee: None,
5909                            reporter: None,
5910                            labels: vec![],
5911                            created: None,
5912                            updated: None,
5913                            parent: None,
5914                            subtasks: vec![],
5915                            issuelinks: vec![],
5916                            attachment: vec![],
5917                        },
5918                    },
5919                    JiraIssue {
5920                        id: "10003".to_string(),
5921                        key: "PROJ-3".to_string(),
5922                        fields: JiraIssueFields {
5923                            summary: Some("Subtask 2".to_string()),
5924                            description: None,
5925                            status: None,
5926                            priority: None,
5927                            assignee: None,
5928                            reporter: None,
5929                            labels: vec![],
5930                            created: None,
5931                            updated: None,
5932                            parent: None,
5933                            subtasks: vec![],
5934                            issuelinks: vec![],
5935                            attachment: vec![],
5936                        },
5937                    },
5938                ],
5939                issuelinks: vec![],
5940                attachment: vec![],
5941            },
5942        };
5943
5944        let relations = map_relations(&issue, JiraFlavor::Cloud, "https://test.atlassian.net");
5945
5946        assert_eq!(relations.subtasks.len(), 2);
5947        assert_eq!(relations.subtasks[0].key, "jira#PROJ-2");
5948        assert_eq!(relations.subtasks[0].title, "Subtask 1");
5949        assert_eq!(relations.subtasks[1].key, "jira#PROJ-3");
5950        assert_eq!(relations.subtasks[1].title, "Subtask 2");
5951    }
5952
5953    #[test]
5954    fn test_map_relations_with_issuelinks_blocks() {
5955        let issue = JiraIssue {
5956            id: "10001".to_string(),
5957            key: "PROJ-1".to_string(),
5958            fields: JiraIssueFields {
5959                summary: Some("Test".to_string()),
5960                description: None,
5961                status: None,
5962                priority: None,
5963                assignee: None,
5964                reporter: None,
5965                labels: vec![],
5966                created: None,
5967                updated: None,
5968                parent: None,
5969                subtasks: vec![],
5970                issuelinks: vec![
5971                    // Outward "blocks" link
5972                    JiraIssueLink {
5973                        id: Some("1".to_string()),
5974                        link_type: JiraIssueLinkType {
5975                            name: "Blocks".to_string(),
5976                            outward: Some("blocks".to_string()),
5977                            inward: Some("is blocked by".to_string()),
5978                        },
5979                        outward_issue: Some(Box::new(JiraIssue {
5980                            id: "10002".to_string(),
5981                            key: "PROJ-2".to_string(),
5982                            fields: JiraIssueFields {
5983                                summary: Some("Blocked".to_string()),
5984                                description: None,
5985                                status: None,
5986                                priority: None,
5987                                assignee: None,
5988                                reporter: None,
5989                                labels: vec![],
5990                                created: None,
5991                                updated: None,
5992                                parent: None,
5993                                subtasks: vec![],
5994                                issuelinks: vec![],
5995                                attachment: vec![],
5996                            },
5997                        })),
5998                        inward_issue: None,
5999                    },
6000                    // Inward "is blocked by" link
6001                    JiraIssueLink {
6002                        id: Some("2".to_string()),
6003                        link_type: JiraIssueLinkType {
6004                            name: "Blocks".to_string(),
6005                            outward: Some("blocks".to_string()),
6006                            inward: Some("is blocked by".to_string()),
6007                        },
6008                        outward_issue: None,
6009                        inward_issue: Some(Box::new(JiraIssue {
6010                            id: "10003".to_string(),
6011                            key: "PROJ-3".to_string(),
6012                            fields: JiraIssueFields {
6013                                summary: Some("Blocker".to_string()),
6014                                description: None,
6015                                status: None,
6016                                priority: None,
6017                                assignee: None,
6018                                reporter: None,
6019                                labels: vec![],
6020                                created: None,
6021                                updated: None,
6022                                parent: None,
6023                                subtasks: vec![],
6024                                issuelinks: vec![],
6025                                attachment: vec![],
6026                            },
6027                        })),
6028                    },
6029                ],
6030                attachment: vec![],
6031            },
6032        };
6033
6034        let relations = map_relations(&issue, JiraFlavor::Cloud, "https://test.atlassian.net");
6035
6036        assert_eq!(relations.blocks.len(), 1);
6037        assert_eq!(relations.blocks[0].issue.key, "jira#PROJ-2");
6038        assert_eq!(relations.blocks[0].link_type, "Blocks");
6039        assert_eq!(relations.blocked_by.len(), 1);
6040        assert_eq!(relations.blocked_by[0].issue.key, "jira#PROJ-3");
6041    }
6042
6043    #[test]
6044    fn test_map_relations_with_issuelinks_duplicates() {
6045        let issue = JiraIssue {
6046            id: "10001".to_string(),
6047            key: "PROJ-1".to_string(),
6048            fields: JiraIssueFields {
6049                summary: Some("Test".to_string()),
6050                description: None,
6051                status: None,
6052                priority: None,
6053                assignee: None,
6054                reporter: None,
6055                labels: vec![],
6056                created: None,
6057                updated: None,
6058                parent: None,
6059                subtasks: vec![],
6060                issuelinks: vec![
6061                    // Outward "duplicate" link
6062                    JiraIssueLink {
6063                        id: Some("1".to_string()),
6064                        link_type: JiraIssueLinkType {
6065                            name: "Duplicate".to_string(),
6066                            outward: Some("duplicates".to_string()),
6067                            inward: Some("is duplicated by".to_string()),
6068                        },
6069                        outward_issue: Some(Box::new(JiraIssue {
6070                            id: "10002".to_string(),
6071                            key: "PROJ-2".to_string(),
6072                            fields: JiraIssueFields {
6073                                summary: Some("Dup outward".to_string()),
6074                                description: None,
6075                                status: None,
6076                                priority: None,
6077                                assignee: None,
6078                                reporter: None,
6079                                labels: vec![],
6080                                created: None,
6081                                updated: None,
6082                                parent: None,
6083                                subtasks: vec![],
6084                                issuelinks: vec![],
6085                                attachment: vec![],
6086                            },
6087                        })),
6088                        inward_issue: None,
6089                    },
6090                    // Inward "duplicate" link
6091                    JiraIssueLink {
6092                        id: Some("2".to_string()),
6093                        link_type: JiraIssueLinkType {
6094                            name: "Duplicate".to_string(),
6095                            outward: Some("duplicates".to_string()),
6096                            inward: Some("is duplicated by".to_string()),
6097                        },
6098                        outward_issue: None,
6099                        inward_issue: Some(Box::new(JiraIssue {
6100                            id: "10003".to_string(),
6101                            key: "PROJ-3".to_string(),
6102                            fields: JiraIssueFields {
6103                                summary: Some("Dup inward".to_string()),
6104                                description: None,
6105                                status: None,
6106                                priority: None,
6107                                assignee: None,
6108                                reporter: None,
6109                                labels: vec![],
6110                                created: None,
6111                                updated: None,
6112                                parent: None,
6113                                subtasks: vec![],
6114                                issuelinks: vec![],
6115                                attachment: vec![],
6116                            },
6117                        })),
6118                    },
6119                ],
6120                attachment: vec![],
6121            },
6122        };
6123
6124        let relations = map_relations(&issue, JiraFlavor::Cloud, "https://test.atlassian.net");
6125
6126        // Both outward and inward duplicates go to `duplicates`
6127        assert_eq!(relations.duplicates.len(), 2);
6128        assert_eq!(relations.duplicates[0].issue.key, "jira#PROJ-2");
6129        assert_eq!(relations.duplicates[1].issue.key, "jira#PROJ-3");
6130    }
6131
6132    #[test]
6133    fn test_map_relations_with_issuelinks_relates() {
6134        let issue = JiraIssue {
6135            id: "10001".to_string(),
6136            key: "PROJ-1".to_string(),
6137            fields: JiraIssueFields {
6138                summary: Some("Test".to_string()),
6139                description: None,
6140                status: None,
6141                priority: None,
6142                assignee: None,
6143                reporter: None,
6144                labels: vec![],
6145                created: None,
6146                updated: None,
6147                parent: None,
6148                subtasks: vec![],
6149                issuelinks: vec![JiraIssueLink {
6150                    id: Some("1".to_string()),
6151                    link_type: JiraIssueLinkType {
6152                        name: "Relates".to_string(),
6153                        outward: Some("relates to".to_string()),
6154                        inward: Some("relates to".to_string()),
6155                    },
6156                    outward_issue: Some(Box::new(JiraIssue {
6157                        id: "10002".to_string(),
6158                        key: "PROJ-2".to_string(),
6159                        fields: JiraIssueFields {
6160                            summary: Some("Related".to_string()),
6161                            description: None,
6162                            status: None,
6163                            priority: None,
6164                            assignee: None,
6165                            reporter: None,
6166                            labels: vec![],
6167                            created: None,
6168                            updated: None,
6169                            parent: None,
6170                            subtasks: vec![],
6171                            issuelinks: vec![],
6172                            attachment: vec![],
6173                        },
6174                    })),
6175                    inward_issue: None,
6176                }],
6177                attachment: vec![],
6178            },
6179        };
6180
6181        let relations = map_relations(&issue, JiraFlavor::Cloud, "https://test.atlassian.net");
6182
6183        assert_eq!(relations.related_to.len(), 1);
6184        assert_eq!(relations.related_to[0].issue.key, "jira#PROJ-2");
6185        assert_eq!(relations.related_to[0].link_type, "Relates");
6186    }
6187
6188    #[test]
6189    fn test_map_relations_mixed() {
6190        let issue = JiraIssue {
6191            id: "10001".to_string(),
6192            key: "PROJ-1".to_string(),
6193            fields: JiraIssueFields {
6194                summary: Some("Main".to_string()),
6195                description: None,
6196                status: None,
6197                priority: None,
6198                assignee: None,
6199                reporter: None,
6200                labels: vec![],
6201                created: None,
6202                updated: None,
6203                parent: Some(Box::new(JiraIssue {
6204                    id: "10000".to_string(),
6205                    key: "PROJ-0".to_string(),
6206                    fields: JiraIssueFields {
6207                        summary: Some("Parent".to_string()),
6208                        description: None,
6209                        status: None,
6210                        priority: None,
6211                        assignee: None,
6212                        reporter: None,
6213                        labels: vec![],
6214                        created: None,
6215                        updated: None,
6216                        parent: None,
6217                        subtasks: vec![],
6218                        issuelinks: vec![],
6219                        attachment: vec![],
6220                    },
6221                })),
6222                subtasks: vec![JiraIssue {
6223                    id: "10002".to_string(),
6224                    key: "PROJ-2".to_string(),
6225                    fields: JiraIssueFields {
6226                        summary: Some("Sub".to_string()),
6227                        description: None,
6228                        status: None,
6229                        priority: None,
6230                        assignee: None,
6231                        reporter: None,
6232                        labels: vec![],
6233                        created: None,
6234                        updated: None,
6235                        parent: None,
6236                        subtasks: vec![],
6237                        issuelinks: vec![],
6238                        attachment: vec![],
6239                    },
6240                }],
6241                issuelinks: vec![JiraIssueLink {
6242                    id: Some("1".to_string()),
6243                    link_type: JiraIssueLinkType {
6244                        name: "Blocks".to_string(),
6245                        outward: Some("blocks".to_string()),
6246                        inward: Some("is blocked by".to_string()),
6247                    },
6248                    outward_issue: Some(Box::new(JiraIssue {
6249                        id: "10003".to_string(),
6250                        key: "PROJ-3".to_string(),
6251                        fields: JiraIssueFields {
6252                            summary: Some("Blocked".to_string()),
6253                            description: None,
6254                            status: None,
6255                            priority: None,
6256                            assignee: None,
6257                            reporter: None,
6258                            labels: vec![],
6259                            created: None,
6260                            updated: None,
6261                            parent: None,
6262                            subtasks: vec![],
6263                            issuelinks: vec![],
6264                            attachment: vec![],
6265                        },
6266                    })),
6267                    inward_issue: None,
6268                }],
6269                attachment: vec![],
6270            },
6271        };
6272
6273        let relations = map_relations(&issue, JiraFlavor::Cloud, "https://test.atlassian.net");
6274
6275        assert!(relations.parent.is_some());
6276        assert_eq!(relations.parent.unwrap().key, "jira#PROJ-0");
6277        assert_eq!(relations.subtasks.len(), 1);
6278        assert_eq!(relations.subtasks[0].key, "jira#PROJ-2");
6279        assert_eq!(relations.blocks.len(), 1);
6280        assert_eq!(relations.blocks[0].issue.key, "jira#PROJ-3");
6281        assert!(relations.blocked_by.is_empty());
6282        assert!(relations.related_to.is_empty());
6283        assert!(relations.duplicates.is_empty());
6284    }
6285
6286    // =========================================================================
6287    // Structure: build_forest_tree tests
6288    // =========================================================================
6289
6290    #[test]
6291    fn test_build_forest_tree_empty() {
6292        let tree = build_forest_tree(&[], &[]).unwrap();
6293        assert!(tree.is_empty());
6294    }
6295
6296    #[test]
6297    fn test_build_forest_tree_flat() {
6298        let rows = vec![
6299            JiraForestRow {
6300                id: 1,
6301                item_id: Some("PROJ-1".into()),
6302                item_type: Some("issue".into()),
6303            },
6304            JiraForestRow {
6305                id: 2,
6306                item_id: Some("PROJ-2".into()),
6307                item_type: Some("issue".into()),
6308            },
6309        ];
6310        let depths = vec![0, 0];
6311        let tree = build_forest_tree(&rows, &depths).unwrap();
6312        assert_eq!(tree.len(), 2);
6313        assert_eq!(tree[0].row_id, 1);
6314        assert_eq!(tree[1].row_id, 2);
6315        assert!(tree[0].children.is_empty());
6316        assert!(tree[1].children.is_empty());
6317    }
6318
6319    #[test]
6320    fn test_build_forest_tree_rejects_mismatched_lengths() {
6321        let rows = vec![JiraForestRow {
6322            id: 1,
6323            item_id: Some("PROJ-1".into()),
6324            item_type: None,
6325        }];
6326        let depths = vec![0, 1];
6327        let err = build_forest_tree(&rows, &depths).expect_err("mismatch must be rejected");
6328        assert!(
6329            matches!(err, Error::InvalidData(ref msg) if msg.contains("1 rows but 2 depths")),
6330            "unexpected error: {err:?}"
6331        );
6332    }
6333
6334    #[test]
6335    fn test_build_forest_tree_nested() {
6336        // PROJ-1
6337        //   PROJ-2
6338        //     PROJ-3
6339        //   PROJ-4
6340        let rows = vec![
6341            JiraForestRow {
6342                id: 1,
6343                item_id: Some("PROJ-1".into()),
6344                item_type: None,
6345            },
6346            JiraForestRow {
6347                id: 2,
6348                item_id: Some("PROJ-2".into()),
6349                item_type: None,
6350            },
6351            JiraForestRow {
6352                id: 3,
6353                item_id: Some("PROJ-3".into()),
6354                item_type: None,
6355            },
6356            JiraForestRow {
6357                id: 4,
6358                item_id: Some("PROJ-4".into()),
6359                item_type: None,
6360            },
6361        ];
6362        let depths = vec![0, 1, 2, 1];
6363        let tree = build_forest_tree(&rows, &depths).unwrap();
6364
6365        assert_eq!(tree.len(), 1);
6366        assert_eq!(tree[0].row_id, 1);
6367        assert_eq!(tree[0].children.len(), 2);
6368        assert_eq!(tree[0].children[0].row_id, 2);
6369        assert_eq!(tree[0].children[0].children.len(), 1);
6370        assert_eq!(tree[0].children[0].children[0].row_id, 3);
6371        assert_eq!(tree[0].children[1].row_id, 4);
6372        assert!(tree[0].children[1].children.is_empty());
6373    }
6374
6375    #[test]
6376    fn test_build_forest_tree_multiple_roots() {
6377        let rows = vec![
6378            JiraForestRow {
6379                id: 1,
6380                item_id: Some("PROJ-1".into()),
6381                item_type: None,
6382            },
6383            JiraForestRow {
6384                id: 2,
6385                item_id: Some("PROJ-2".into()),
6386                item_type: None,
6387            },
6388            JiraForestRow {
6389                id: 3,
6390                item_id: Some("PROJ-3".into()),
6391                item_type: None,
6392            },
6393            JiraForestRow {
6394                id: 4,
6395                item_id: Some("PROJ-4".into()),
6396                item_type: None,
6397            },
6398        ];
6399        let depths = vec![0, 1, 0, 1];
6400        let tree = build_forest_tree(&rows, &depths).unwrap();
6401
6402        assert_eq!(tree.len(), 2);
6403        assert_eq!(tree[0].children.len(), 1);
6404        assert_eq!(tree[1].children.len(), 1);
6405    }
6406
6407    // =========================================================================
6408    // Structure: httpmock integration tests
6409    // =========================================================================
6410
6411    mod structure_integration {
6412        use super::*;
6413        use devboy_core::StructureRowItem;
6414        use httpmock::prelude::*;
6415
6416        fn token(s: &str) -> SecretString {
6417            SecretString::from(s.to_string())
6418        }
6419
6420        fn create_client(server: &MockServer) -> JiraClient {
6421            // with_base_url sets base_url WITHOUT /rest/api/N,
6422            // but Structure uses instance_url. Adjust:
6423
6424            // instance_url is set to base_url by with_base_url
6425            JiraClient::with_base_url(
6426                server.base_url(),
6427                "PROJ",
6428                "user@example.com",
6429                token("token"),
6430                false,
6431            )
6432        }
6433
6434        #[tokio::test]
6435        async fn test_get_structures() {
6436            let server = MockServer::start();
6437
6438            server.mock(|when, then| {
6439                when.method(GET).path("/rest/structure/2.0/structure");
6440                then.status(200).json_body(serde_json::json!({
6441                    "structures": [
6442                        {"id": 1, "name": "Q1 Planning", "description": "Quarter 1"},
6443                        {"id": 2, "name": "Sprint Board"}
6444                    ]
6445                }));
6446            });
6447
6448            let client = create_client(&server);
6449            let result = client.get_structures().await.unwrap();
6450            assert_eq!(result.items.len(), 2);
6451            assert_eq!(result.items[0].name, "Q1 Planning");
6452            assert_eq!(result.items[1].id, 2);
6453        }
6454
6455        #[tokio::test]
6456        async fn test_get_structure_forest() {
6457            let server = MockServer::start();
6458
6459            server.mock(|when, then| {
6460                when.method(POST).path("/rest/structure/2.0/forest/1/spec");
6461                then.status(200).json_body(serde_json::json!({
6462                    "version": 42,
6463                    "rows": [
6464                        {"id": 100, "itemId": "PROJ-1", "itemType": "issue"},
6465                        {"id": 101, "itemId": "PROJ-2", "itemType": "issue"},
6466                        {"id": 102, "itemId": "PROJ-3", "itemType": "issue"}
6467                    ],
6468                    "depths": [0, 1, 1],
6469                    "totalCount": 3
6470                }));
6471            });
6472
6473            let client = create_client(&server);
6474            let forest = client
6475                .get_structure_forest(
6476                    1,
6477                    GetForestOptions {
6478                        offset: None,
6479                        limit: Some(200),
6480                    },
6481                )
6482                .await
6483                .unwrap();
6484
6485            assert_eq!(forest.version, 42);
6486            assert_eq!(forest.structure_id, 1);
6487            assert_eq!(forest.total_count, Some(3));
6488            assert_eq!(forest.tree.len(), 1); // one root
6489            assert_eq!(forest.tree[0].item_id, Some("PROJ-1".into()));
6490            assert_eq!(forest.tree[0].children.len(), 2);
6491        }
6492
6493        #[tokio::test]
6494        async fn test_create_structure() {
6495            let server = MockServer::start();
6496
6497            server.mock(|when, then| {
6498                when.method(POST).path("/rest/structure/2.0/structure");
6499                then.status(200).json_body(serde_json::json!({
6500                    "id": 99,
6501                    "name": "New Structure",
6502                    "description": "Test"
6503                }));
6504            });
6505
6506            let client = create_client(&server);
6507            let result = client
6508                .create_structure(CreateStructureInput {
6509                    name: "New Structure".into(),
6510                    description: Some("Test".into()),
6511                })
6512                .await
6513                .unwrap();
6514
6515            assert_eq!(result.id, 99);
6516            assert_eq!(result.name, "New Structure");
6517        }
6518
6519        #[tokio::test]
6520        async fn test_remove_structure_row() {
6521            let server = MockServer::start();
6522
6523            server.mock(|when, then| {
6524                when.method(DELETE)
6525                    .path("/rest/structure/2.0/forest/1/item/100");
6526                then.status(204);
6527            });
6528
6529            let client = create_client(&server);
6530            client.remove_structure_row(1, 100).await.unwrap();
6531        }
6532
6533        #[tokio::test]
6534        async fn test_get_structure_views() {
6535            let server = MockServer::start();
6536
6537            server.mock(|when, then| {
6538                when.method(GET)
6539                    .path("/rest/structure/2.0/view")
6540                    .query_param("structureId", "1");
6541                then.status(200).json_body(serde_json::json!({
6542                    "views": [
6543                        {"id": 10, "name": "Default View", "structureId": 1, "columns": []},
6544                        {"id": 11, "name": "Sprint View", "structureId": 1, "columns": [
6545                            {"field": "summary"},
6546                            {"field": "status"},
6547                            {"formula": "SUM(\"Story Points\")"}
6548                        ]}
6549                    ]
6550                }));
6551            });
6552
6553            let client = create_client(&server);
6554            let views = client.get_structure_views(1, None).await.unwrap();
6555            assert_eq!(views.len(), 2);
6556            assert_eq!(views[1].columns.len(), 3);
6557        }
6558
6559        #[tokio::test]
6560        async fn test_get_structure_views_by_id_accepts_matching_structure() {
6561            let server = MockServer::start();
6562            server.mock(|when, then| {
6563                when.method(GET).path("/rest/structure/2.0/view/10");
6564                then.status(200).json_body(serde_json::json!({
6565                    "id": 10,
6566                    "name": "Default View",
6567                    "structureId": 1,
6568                    "columns": []
6569                }));
6570            });
6571
6572            let client = create_client(&server);
6573            let views = client.get_structure_views(1, Some(10)).await.unwrap();
6574            assert_eq!(views.len(), 1);
6575            assert_eq!(views[0].id, 10);
6576        }
6577
6578        #[tokio::test]
6579        async fn test_get_structure_views_by_id_rejects_cross_structure_view() {
6580            // View 99 actually lives in structure 7 — a caller who asked
6581            // for `structureId=1` must see InvalidData, not a surprise
6582            // view from a different structure.
6583            let server = MockServer::start();
6584            server.mock(|when, then| {
6585                when.method(GET).path("/rest/structure/2.0/view/99");
6586                then.status(200).json_body(serde_json::json!({
6587                    "id": 99,
6588                    "name": "Sibling view",
6589                    "structureId": 7,
6590                    "columns": []
6591                }));
6592            });
6593
6594            let client = create_client(&server);
6595            let err = client
6596                .get_structure_views(1, Some(99))
6597                .await
6598                .expect_err("mismatched structure must error");
6599            match err {
6600                Error::InvalidData(msg) => {
6601                    assert!(msg.contains("belongs to structure 7"), "got: {msg}");
6602                    assert!(msg.contains("but 1 was requested"), "got: {msg}");
6603                }
6604                other => panic!("expected InvalidData, got {other:?}"),
6605            }
6606        }
6607
6608        // -----------------------------------------------------------------
6609        // End-to-end error-body sanitisation through handle_structure_response
6610        // -----------------------------------------------------------------
6611
6612        #[tokio::test]
6613        async fn test_structure_api_404_html_is_sanitised_end_to_end() {
6614            // Jira returns its generic 404 HTML page when the Structure
6615            // plugin endpoint is missing. Full flow must come back as
6616            // NotFound with soft wording + no HTML leak.
6617            let server = MockServer::start();
6618            let jira_404_html =
6619                "<!DOCTYPE html><html><head><title>Oops, you've found a dead link.</title>"
6620                    .to_string()
6621                    + &"<script>var a=1;</script>".repeat(100)
6622                    + "</head><body>404</body></html>";
6623            server.mock(|when, then| {
6624                when.method(GET).path("/rest/structure/2.0/structure");
6625                then.status(404)
6626                    .header("content-type", "text/html;charset=UTF-8")
6627                    .body(jira_404_html.clone());
6628            });
6629
6630            let client = create_client(&server);
6631            let err = client
6632                .get_structures()
6633                .await
6634                .expect_err("404 must error out");
6635            let msg = err.to_string();
6636            assert!(
6637                !msg.contains("<!DOCTYPE") && !msg.contains("<script>"),
6638                "HTML leaked into error message: {}",
6639                &msg[..msg.len().min(400)]
6640            );
6641            assert!(
6642                msg.contains("endpoint not found"),
6643                "expected soft wording: {msg}"
6644            );
6645        }
6646
6647        #[tokio::test]
6648        async fn test_structure_api_xml_404_is_sanitised_end_to_end() {
6649            // Structure plugin (when installed but endpoint path changed)
6650            // returns an XML 404 envelope.
6651            let server = MockServer::start();
6652            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>"#;
6653            server.mock(|when, then| {
6654                when.method(GET).path("/rest/structure/2.0/structure");
6655                then.status(404)
6656                    .header("content-type", "application/xml")
6657                    .body(xml);
6658            });
6659
6660            let client = create_client(&server);
6661            let err = client
6662                .get_structures()
6663                .await
6664                .expect_err("XML 404 must error out");
6665            let msg = err.to_string();
6666            assert!(!msg.contains("<?xml"), "XML leaked: {msg}");
6667            assert!(msg.contains("endpoint not found"));
6668        }
6669
6670        #[tokio::test]
6671        async fn test_structure_api_json_error_forwarded_verbatim() {
6672            // Concurrent-modification errors from Structure plugin come back
6673            // as JSON. That body is the real diagnostic — must not be
6674            // trimmed or replaced with the install hint.
6675            let server = MockServer::start();
6676            server.mock(|when, then| {
6677                when.method(PUT).path("/rest/structure/2.0/forest/1/item");
6678                then.status(409).json_body(serde_json::json!({
6679                    "errorMessages": ["Forest version conflict"],
6680                    "errors": {}
6681                }));
6682            });
6683
6684            let client = create_client(&server);
6685            let err = client
6686                .add_structure_rows(
6687                    1,
6688                    AddStructureRowsInput {
6689                        items: vec![StructureRowItem {
6690                            item_id: "PROJ-1".into(),
6691                            item_type: None,
6692                        }],
6693                        under: None,
6694                        after: None,
6695                        forest_version: Some(100),
6696                    },
6697                )
6698                .await
6699                .expect_err("409 must error out");
6700            let msg = err.to_string();
6701            assert!(
6702                msg.contains("Forest version conflict"),
6703                "JSON dropped: {msg}"
6704            );
6705        }
6706
6707        #[tokio::test]
6708        async fn test_structure_api_200_with_html_body_does_not_leak() {
6709            // Rare: Jira replies 200 with an SSO redirect HTML page instead
6710            // of JSON. Parse-error path must redact the body rather than
6711            // echo up to 300 chars of HTML.
6712            let server = MockServer::start();
6713            let html = "<!DOCTYPE html><html><body>".to_string()
6714                + &"password=secret".repeat(50)
6715                + "</body></html>";
6716            server.mock(|when, then| {
6717                when.method(GET).path("/rest/structure/2.0/structure");
6718                then.status(200)
6719                    .header("content-type", "text/html;charset=UTF-8")
6720                    .body(html.clone());
6721            });
6722
6723            let client = create_client(&server);
6724            let err = client
6725                .get_structures()
6726                .await
6727                .expect_err("HTML body must fail to parse");
6728            let msg = err.to_string();
6729            assert!(
6730                !msg.contains("password=secret") && !msg.contains("<!DOCTYPE"),
6731                "HTML body leaked into parse-error message: {}",
6732                &msg[..msg.len().min(400)]
6733            );
6734            assert!(msg.contains("redacted"), "missing redaction marker: {msg}");
6735        }
6736
6737        // -----------------------------------------------------------------
6738        // list_structures_for_metadata — graceful-degrade variant used by
6739        // the metadata-assembly pipeline (swallows "plugin missing" 404,
6740        // propagates auth/network failures).
6741        // -----------------------------------------------------------------
6742
6743        #[tokio::test]
6744        async fn test_list_structures_for_metadata_maps_response() {
6745            let server = MockServer::start();
6746            server.mock(|when, then| {
6747                when.method(GET).path("/rest/structure/2.0/structure");
6748                then.status(200).json_body(serde_json::json!({
6749                    "structures": [
6750                        {"id": 1, "name": "Q1 Planning", "description": "Quarter 1 plan"},
6751                        {"id": 2, "name": "Sprint Board"}
6752                    ]
6753                }));
6754            });
6755
6756            let client = create_client(&server);
6757            let refs = client.list_structures_for_metadata().await.unwrap();
6758
6759            assert_eq!(refs.len(), 2);
6760            assert_eq!(refs[0].id, 1);
6761            assert_eq!(refs[0].name, "Q1 Planning");
6762            assert_eq!(refs[0].description.as_deref(), Some("Quarter 1 plan"));
6763            assert_eq!(refs[1].id, 2);
6764            assert_eq!(refs[1].description, None);
6765        }
6766
6767        #[tokio::test]
6768        async fn test_list_structures_for_metadata_returns_empty_on_plugin_missing() {
6769            // Structure plugin uninstalled → Jira returns 404 HTML page.
6770            // `structure_error_from_status` maps this to `Error::NotFound`,
6771            // which `list_structures_for_metadata` swallows into `Ok(vec![])`
6772            // so a broader metadata build can continue without try/catch.
6773            let server = MockServer::start();
6774            server.mock(|when, then| {
6775                when.method(GET).path("/rest/structure/2.0/structure");
6776                then.status(404)
6777                    .header("content-type", "text/html;charset=UTF-8")
6778                    .body("<!DOCTYPE html><html><title>Oops</title></html>");
6779            });
6780
6781            let client = create_client(&server);
6782            let refs = client.list_structures_for_metadata().await.unwrap();
6783            assert!(refs.is_empty());
6784        }
6785
6786        #[tokio::test]
6787        async fn test_list_structures_for_metadata_returns_empty_on_200_empty_list() {
6788            let server = MockServer::start();
6789            server.mock(|when, then| {
6790                when.method(GET).path("/rest/structure/2.0/structure");
6791                then.status(200)
6792                    .json_body(serde_json::json!({ "structures": [] }));
6793            });
6794
6795            let client = create_client(&server);
6796            let refs = client.list_structures_for_metadata().await.unwrap();
6797            assert!(refs.is_empty());
6798        }
6799
6800        #[tokio::test]
6801        async fn test_list_structures_for_metadata_propagates_401() {
6802            // Bad / expired credentials must surface as an error — otherwise
6803            // the caller would silently record "no structures" and swallow
6804            // a real integration misconfiguration.
6805            let server = MockServer::start();
6806            server.mock(|when, then| {
6807                when.method(GET).path("/rest/structure/2.0/structure");
6808                then.status(401).body("Unauthorized");
6809            });
6810
6811            let client = create_client(&server);
6812            let err = client
6813                .list_structures_for_metadata()
6814                .await
6815                .expect_err("401 must not be swallowed");
6816            assert!(matches!(err, Error::Unauthorized(_)), "got {err:?}");
6817        }
6818
6819        #[tokio::test]
6820        async fn test_list_structures_for_metadata_propagates_403() {
6821            let server = MockServer::start();
6822            server.mock(|when, then| {
6823                when.method(GET).path("/rest/structure/2.0/structure");
6824                then.status(403).body("Forbidden");
6825            });
6826
6827            let client = create_client(&server);
6828            let err = client
6829                .list_structures_for_metadata()
6830                .await
6831                .expect_err("403 must not be swallowed");
6832            assert!(matches!(err, Error::Forbidden(_)), "got {err:?}");
6833        }
6834
6835        // =====================================================================
6836        // Structure generators — issue #179
6837        // =====================================================================
6838
6839        #[tokio::test]
6840        async fn test_structure_generator_lifecycle() {
6841            let server = MockServer::start();
6842
6843            // GET list
6844            server.mock(|when, then| {
6845                when.method(GET)
6846                    .path("/rest/structure/2.0/structure/1/generator");
6847                then.status(200).json_body(serde_json::json!({
6848                    "generators": [
6849                        { "id": "g1", "type": "jql", "spec": {"query": "project = PROJ"} }
6850                    ]
6851                }));
6852            });
6853            // POST add
6854            server.mock(|when, then| {
6855                when.method(POST)
6856                    .path("/rest/structure/2.0/structure/1/generator")
6857                    .body_includes("\"type\":\"agile-board\"");
6858                then.status(200).json_body(serde_json::json!({
6859                    "id": "g2",
6860                    "type": "agile-board",
6861                    "spec": {"boardId": 42}
6862                }));
6863            });
6864            // POST sync
6865            server.mock(|when, then| {
6866                when.method(POST)
6867                    .path("/rest/structure/2.0/structure/1/generator/g2/sync");
6868                then.status(200).json_body(serde_json::json!({}));
6869            });
6870
6871            let client = create_client(&server);
6872
6873            let list = client.get_structure_generators(1).await.unwrap();
6874            assert_eq!(list.items.len(), 1);
6875            assert_eq!(list.items[0].generator_type, "jql");
6876
6877            let added = client
6878                .add_structure_generator(devboy_core::AddStructureGeneratorInput {
6879                    structure_id: 1,
6880                    generator_type: "agile-board".into(),
6881                    spec: serde_json::json!({"boardId": 42}),
6882                })
6883                .await
6884                .unwrap();
6885            assert_eq!(added.id, "g2");
6886
6887            client
6888                .sync_structure_generator(devboy_core::SyncStructureGeneratorInput {
6889                    structure_id: 1,
6890                    generator_id: "g2".into(),
6891                })
6892                .await
6893                .unwrap();
6894        }
6895
6896        // =====================================================================
6897        // Structure delete + automation — issue #180
6898        // =====================================================================
6899
6900        #[tokio::test]
6901        async fn test_delete_structure() {
6902            let server = MockServer::start();
6903            server.mock(|when, then| {
6904                when.method(DELETE).path("/rest/structure/2.0/structure/7");
6905                then.status(204);
6906            });
6907
6908            let client = create_client(&server);
6909            client.delete_structure(7).await.unwrap();
6910        }
6911
6912        #[tokio::test]
6913        async fn test_structure_automation() {
6914            let server = MockServer::start();
6915
6916            server.mock(|when, then| {
6917                when.method(PUT)
6918                    .path("/rest/structure/2.0/structure/5/automation")
6919                    .body_includes("\"enabled\":true");
6920                then.status(200).json_body(serde_json::json!({}));
6921            });
6922            server.mock(|when, then| {
6923                when.method(POST)
6924                    .path("/rest/structure/2.0/structure/5/automation/run");
6925                then.status(200).json_body(serde_json::json!({}));
6926            });
6927
6928            let client = create_client(&server);
6929            // `automation_id: None` → replaces the whole automation set.
6930            client
6931                .update_structure_automation(devboy_core::UpdateStructureAutomationInput {
6932                    structure_id: 5,
6933                    automation_id: None,
6934                    config: serde_json::json!({"enabled": true}),
6935                })
6936                .await
6937                .unwrap();
6938            client.trigger_structure_automation(5).await.unwrap();
6939        }
6940
6941        /// Rule-scoped automation — `automation_id: Some(..)` routes to
6942        /// `/automation/{id}` (Copilot review on PR #205).
6943        #[tokio::test]
6944        async fn test_structure_automation_rule_scoped() {
6945            let server = MockServer::start();
6946            server.mock(|when, then| {
6947                when.method(PUT)
6948                    .path("/rest/structure/2.0/structure/5/automation/rule-7")
6949                    .body_includes("\"action\":\"move\"");
6950                then.status(200).json_body(serde_json::json!({}));
6951            });
6952
6953            let client = create_client(&server);
6954            client
6955                .update_structure_automation(devboy_core::UpdateStructureAutomationInput {
6956                    structure_id: 5,
6957                    automation_id: Some("rule-7".into()),
6958                    config: serde_json::json!({"action": "move"}),
6959                })
6960                .await
6961                .unwrap();
6962        }
6963    }
6964
6965    // =====================================================================
6966    // Agile / Sprint — issue #198
6967    // =====================================================================
6968    mod agile_integration {
6969        use super::*;
6970        use httpmock::prelude::*;
6971
6972        fn token(s: &str) -> SecretString {
6973            SecretString::from(s.to_string())
6974        }
6975
6976        fn create_client(server: &MockServer) -> JiraClient {
6977            JiraClient::with_base_url(
6978                server.base_url(),
6979                "PROJ",
6980                "user@example.com",
6981                token("token"),
6982                false,
6983            )
6984        }
6985
6986        #[tokio::test]
6987        async fn test_get_board_sprints_active() {
6988            let server = MockServer::start();
6989            server.mock(|when, then| {
6990                when.method(GET)
6991                    .path("/rest/agile/1.0/board/10/sprint")
6992                    .query_param("state", "active");
6993                then.status(200).json_body(serde_json::json!({
6994                    "isLast": true,
6995                    "values": [
6996                        {
6997                            "id": 1,
6998                            "name": "Sprint 1",
6999                            "state": "active",
7000                            "originBoardId": 10,
7001                            "startDate": "2026-04-01T00:00:00.000Z"
7002                        }
7003                    ]
7004                }));
7005            });
7006
7007            let client = create_client(&server);
7008            let sprints = client
7009                .get_board_sprints(10, devboy_core::SprintState::Active)
7010                .await
7011                .unwrap();
7012            assert_eq!(sprints.items.len(), 1);
7013            assert_eq!(sprints.items[0].state, "active");
7014            assert_eq!(sprints.items[0].origin_board_id, Some(10));
7015        }
7016
7017        /// Codex P2 review on PR #205 — `/board/{id}/sprint` is paginated;
7018        /// we must walk `startAt` until `isLast: true`.
7019        #[tokio::test]
7020        async fn test_get_board_sprints_walks_pagination() {
7021            let server = MockServer::start();
7022            server.mock(|when, then| {
7023                when.method(GET)
7024                    .path("/rest/agile/1.0/board/10/sprint")
7025                    .query_param("startAt", "0");
7026                then.status(200).json_body(serde_json::json!({
7027                    "isLast": false,
7028                    "values": [
7029                        {"id": 1, "name": "S1", "state": "closed"},
7030                        {"id": 2, "name": "S2", "state": "closed"}
7031                    ]
7032                }));
7033            });
7034            server.mock(|when, then| {
7035                when.method(GET)
7036                    .path("/rest/agile/1.0/board/10/sprint")
7037                    .query_param("startAt", "2");
7038                then.status(200).json_body(serde_json::json!({
7039                    "isLast": true,
7040                    "values": [
7041                        {"id": 3, "name": "S3", "state": "active"}
7042                    ]
7043                }));
7044            });
7045
7046            let client = create_client(&server);
7047            let sprints = client
7048                .get_board_sprints(10, devboy_core::SprintState::All)
7049                .await
7050                .unwrap();
7051            assert_eq!(sprints.items.len(), 3);
7052            assert_eq!(sprints.items[2].name, "S3");
7053        }
7054
7055        #[tokio::test]
7056        async fn test_get_board_sprints_all_omits_state() {
7057            let server = MockServer::start();
7058            server.mock(|when, then| {
7059                when.method(GET)
7060                    .path("/rest/agile/1.0/board/10/sprint")
7061                    .is_true(|req| req.query_params().iter().all(|(k, _)| k != "state"));
7062                then.status(200)
7063                    .json_body(serde_json::json!({"values": []}));
7064            });
7065
7066            let client = create_client(&server);
7067            let sprints = client
7068                .get_board_sprints(10, devboy_core::SprintState::All)
7069                .await
7070                .unwrap();
7071            assert_eq!(sprints.items.len(), 0);
7072        }
7073
7074        #[tokio::test]
7075        async fn test_assign_to_sprint_strips_jira_prefix() {
7076            let server = MockServer::start();
7077            server.mock(|when, then| {
7078                when.method(POST)
7079                    .path("/rest/agile/1.0/sprint/42/issue")
7080                    .body_includes("\"issues\":[\"PROJ-1\",\"PROJ-2\"]");
7081                then.status(204);
7082            });
7083
7084            let client = create_client(&server);
7085            client
7086                .assign_to_sprint(devboy_core::AssignToSprintInput {
7087                    sprint_id: 42,
7088                    issue_keys: vec!["jira#PROJ-1".to_string(), "PROJ-2".to_string()],
7089                })
7090                .await
7091                .unwrap();
7092        }
7093    }
7094
7095    // =====================================================================
7096    // Project Versions / fixVersion — issue #238
7097    // =====================================================================
7098    mod versions_integration {
7099        use super::*;
7100        use devboy_core::{ListProjectVersionsParams, UpsertProjectVersionInput};
7101        use httpmock::prelude::*;
7102
7103        fn token(s: &str) -> SecretString {
7104            SecretString::from(s.to_string())
7105        }
7106
7107        fn create_client(server: &MockServer) -> JiraClient {
7108            JiraClient::with_base_url(
7109                server.base_url(),
7110                "PROJ",
7111                "user@example.com",
7112                token("pat-token"),
7113                false,
7114            )
7115        }
7116
7117        fn create_cloud_client(server: &MockServer) -> JiraClient {
7118            JiraClient::with_base_url(
7119                server.base_url(),
7120                "PROJ",
7121                "user@example.com",
7122                token("api-token"),
7123                true,
7124            )
7125        }
7126
7127        fn version_dto(
7128            id: &str,
7129            name: &str,
7130            release_date: Option<&str>,
7131            released: bool,
7132            archived: bool,
7133        ) -> serde_json::Value {
7134            let mut v = serde_json::json!({
7135                "id": id,
7136                "name": name,
7137                "project": "PROJ",
7138                "released": released,
7139                "archived": archived,
7140            });
7141            if let Some(d) = release_date {
7142                v["releaseDate"] = serde_json::json!(d);
7143            }
7144            v
7145        }
7146
7147        #[tokio::test]
7148        async fn list_project_versions_returns_rich_payload() {
7149            let server = MockServer::start();
7150            server.mock(|when, then| {
7151                when.method(GET).path("/project/PROJ/versions");
7152                then.status(200).json_body(serde_json::json!([
7153                    {
7154                        "id": "10001",
7155                        "name": "1.0.0",
7156                        "project": "PROJ",
7157                        "description": "Initial release",
7158                        "startDate": "2025-01-01",
7159                        "releaseDate": "2025-02-01",
7160                        "released": true,
7161                        "archived": false,
7162                        "overdue": false,
7163                    },
7164                    version_dto("10002", "2.0.0", Some("2026-04-01"), false, false),
7165                    version_dto("10003", "0.9.0", Some("2024-06-01"), true, true),
7166                ]));
7167            });
7168
7169            let client = create_client(&server);
7170            let result = client
7171                .list_project_versions(ListProjectVersionsParams {
7172                    project: "PROJ".into(),
7173                    released: None,
7174                    archived: None,
7175                    limit: None,
7176                    include_issue_count: false,
7177                })
7178                .await
7179                .unwrap();
7180
7181            assert_eq!(result.items.len(), 3);
7182            // Sorted by release_date desc
7183            assert_eq!(result.items[0].name, "2.0.0");
7184            assert_eq!(result.items[1].name, "1.0.0");
7185            assert_eq!(result.items[2].name, "0.9.0");
7186            assert_eq!(
7187                result.items[1].description.as_deref(),
7188                Some("Initial release")
7189            );
7190            assert_eq!(result.items[1].source, "jira");
7191        }
7192
7193        #[tokio::test]
7194        async fn list_project_versions_filters_archived_and_released() {
7195            let server = MockServer::start();
7196            server.mock(|when, then| {
7197                when.method(GET).path("/project/PROJ/versions");
7198                then.status(200).json_body(serde_json::json!([
7199                    version_dto("1", "current", Some("2026-04-01"), false, false),
7200                    version_dto("2", "shipped", Some("2025-12-01"), true, false),
7201                    version_dto("3", "old", Some("2024-01-01"), true, true),
7202                ]));
7203            });
7204
7205            let client = create_client(&server);
7206
7207            let unreleased_only = client
7208                .list_project_versions(ListProjectVersionsParams {
7209                    project: "PROJ".into(),
7210                    released: Some(false),
7211                    archived: Some(false),
7212                    limit: None,
7213                    include_issue_count: false,
7214                })
7215                .await
7216                .unwrap();
7217            assert_eq!(unreleased_only.items.len(), 1);
7218            assert_eq!(unreleased_only.items[0].name, "current");
7219
7220            // Re-mock for the second call (httpmock mocks are per-server,
7221            // and the previous mock matches all GETs to that path).
7222        }
7223
7224        #[tokio::test]
7225        async fn list_project_versions_applies_limit_and_keeps_most_recent() {
7226            let server = MockServer::start();
7227            server.mock(|when, then| {
7228                when.method(GET).path("/project/PROJ/versions");
7229                then.status(200).json_body(serde_json::json!([
7230                    version_dto("1", "v1", Some("2024-01-01"), true, false),
7231                    version_dto("2", "v2", Some("2025-01-01"), true, false),
7232                    version_dto("3", "v3", Some("2026-01-01"), true, false),
7233                    version_dto("4", "v4", Some("2026-02-01"), false, false),
7234                ]));
7235            });
7236
7237            let client = create_client(&server);
7238            let result = client
7239                .list_project_versions(ListProjectVersionsParams {
7240                    project: "PROJ".into(),
7241                    released: None,
7242                    archived: None,
7243                    limit: Some(2),
7244                    include_issue_count: false,
7245                })
7246                .await
7247                .unwrap();
7248            assert_eq!(result.items.len(), 2);
7249            assert_eq!(result.items[0].name, "v4");
7250            assert_eq!(result.items[1].name, "v3");
7251        }
7252
7253        #[tokio::test]
7254        async fn list_project_versions_passes_expand_query_on_cloud() {
7255            // Cloud responds to `?expand=issuesstatus` with a per-status
7256            // breakdown we can sum into `issue_count`. Server/DC ignores
7257            // the param, so the gate applies only to Cloud — see the
7258            // sibling `omits_expand_on_self_hosted` test below.
7259            let server = MockServer::start();
7260            let mock = server.mock(|when, then| {
7261                when.method(GET)
7262                    .path("/project/PROJ/versions")
7263                    .query_param("expand", "issuesstatus");
7264                then.status(200).json_body(serde_json::json!([
7265                    {
7266                        "id": "1",
7267                        "name": "v1",
7268                        "released": false,
7269                        "archived": false,
7270                        "issuesStatusForFixVersion": {
7271                            "unmapped": 0,
7272                            "toDo": 5,
7273                            "inProgress": 3,
7274                            "done": 2
7275                        }
7276                    }
7277                ]));
7278            });
7279
7280            let client = create_cloud_client(&server);
7281            let result = client
7282                .list_project_versions(ListProjectVersionsParams {
7283                    project: "PROJ".into(),
7284                    released: None,
7285                    archived: None,
7286                    limit: None,
7287                    include_issue_count: true,
7288                })
7289                .await
7290                .unwrap();
7291            mock.assert();
7292            assert_eq!(result.items.len(), 1);
7293            assert_eq!(result.items[0].issue_count, Some(10));
7294        }
7295
7296        #[tokio::test]
7297        async fn list_project_versions_omits_expand_on_self_hosted() {
7298            // Copilot review on PR #239 — the expand parameter is a Cloud
7299            // payload extension. We don't want to bake "Server/DC silently
7300            // ignores Cloud query params" into the URL contract, so on
7301            // Self-Hosted the client must not append `?expand=...` even
7302            // when the caller asks for issue counts.
7303            let server = MockServer::start();
7304            let bare_mock = server.mock(|when, then| {
7305                when.method(GET).path("/project/PROJ/versions");
7306                then.status(200).json_body(serde_json::json!([{
7307                    "id": "1",
7308                    "name": "v1",
7309                    "released": false,
7310                    "archived": false,
7311                    "issuesUnresolvedCount": 4,
7312                }]));
7313            });
7314            let expanded_mock = server.mock(|when, then| {
7315                when.method(GET)
7316                    .path("/project/PROJ/versions")
7317                    .query_param("expand", "issuesstatus");
7318                then.status(500); // would fail the test if we hit it
7319            });
7320
7321            let client = create_client(&server); // self-hosted
7322            let result = client
7323                .list_project_versions(ListProjectVersionsParams {
7324                    project: "PROJ".into(),
7325                    released: None,
7326                    archived: None,
7327                    limit: None,
7328                    include_issue_count: true,
7329                })
7330                .await
7331                .unwrap();
7332            bare_mock.assert();
7333            expanded_mock.assert_calls(0);
7334            // On Self-Hosted we don't have a true total — it goes into
7335            // `unresolved_issue_count` rather than `issue_count` to keep
7336            // the two flavors comparable (Codex review on PR #239).
7337            assert_eq!(result.items[0].issue_count, None);
7338            assert_eq!(result.items[0].unresolved_issue_count, Some(4));
7339        }
7340
7341        #[tokio::test]
7342        async fn list_project_versions_orders_unreleased_first_then_recent() {
7343            // Copilot review #3 on PR #239 — unreleased versions are the
7344            // ones the agent is actually working on, they must surface
7345            // before history regardless of date.
7346            let server = MockServer::start();
7347            server.mock(|when, then| {
7348                when.method(GET).path("/project/PROJ/versions");
7349                then.status(200).json_body(serde_json::json!([
7350                    version_dto("1", "9.10.0", Some("2026-04-01"), true, false),
7351                    version_dto("2", "10.0.0", Some("2026-04-02"), false, false),
7352                    version_dto("3", "next", None, false, false),
7353                    version_dto("4", "1.0.0", Some("2024-01-01"), true, true),
7354                ]));
7355            });
7356
7357            let client = create_client(&server);
7358            let result = client
7359                .list_project_versions(ListProjectVersionsParams {
7360                    project: "PROJ".into(),
7361                    released: None,
7362                    archived: None,
7363                    limit: None,
7364                    include_issue_count: false,
7365                })
7366                .await
7367                .unwrap();
7368            // Unreleased first: undated `next` then dated `10.0.0`.
7369            // Released after: `9.10.0` (newer date) then `1.0.0` (older).
7370            let names: Vec<_> = result.items.iter().map(|v| v.name.as_str()).collect();
7371            assert_eq!(names, vec!["next", "10.0.0", "9.10.0", "1.0.0"]);
7372        }
7373
7374        #[tokio::test]
7375        async fn list_project_versions_pagination_reflects_truncation() {
7376            // Copilot review #4 on PR #239 — without total/has_more the
7377            // formatter can't render a "+N more" hint.
7378            let server = MockServer::start();
7379            server.mock(|when, then| {
7380                when.method(GET).path("/project/PROJ/versions");
7381                then.status(200).json_body(serde_json::json!([
7382                    version_dto("1", "v1", Some("2024-01-01"), true, false),
7383                    version_dto("2", "v2", Some("2025-01-01"), true, false),
7384                    version_dto("3", "v3", Some("2026-01-01"), true, false),
7385                ]));
7386            });
7387
7388            let client = create_client(&server);
7389            let result = client
7390                .list_project_versions(ListProjectVersionsParams {
7391                    project: "PROJ".into(),
7392                    released: None,
7393                    archived: None,
7394                    limit: Some(2),
7395                    include_issue_count: false,
7396                })
7397                .await
7398                .unwrap();
7399            let p = result.pagination.expect("pagination must be set");
7400            assert_eq!(p.total, Some(3));
7401            assert_eq!(p.limit, 2);
7402            assert!(p.has_more);
7403
7404            // No truncation → has_more is false.
7405            let server2 = MockServer::start();
7406            server2.mock(|when, then| {
7407                when.method(GET).path("/project/PROJ/versions");
7408                then.status(200).json_body(serde_json::json!([version_dto(
7409                    "1",
7410                    "v1",
7411                    Some("2024-01-01"),
7412                    true,
7413                    false
7414                ),]));
7415            });
7416            let client2 = create_client(&server2);
7417            let result2 = client2
7418                .list_project_versions(ListProjectVersionsParams {
7419                    project: "PROJ".into(),
7420                    released: None,
7421                    archived: None,
7422                    limit: Some(20),
7423                    include_issue_count: false,
7424                })
7425                .await
7426                .unwrap();
7427            let p2 = result2.pagination.unwrap();
7428            assert_eq!(p2.total, Some(1));
7429            assert!(!p2.has_more);
7430        }
7431
7432        #[test]
7433        fn compare_version_names_handles_semver_and_alpha() {
7434            use std::cmp::Ordering;
7435            assert_eq!(compare_version_names("10.0.0", "9.10.0"), Ordering::Greater);
7436            assert_eq!(compare_version_names("1.0.0", "1.0.0"), Ordering::Equal);
7437            assert_eq!(compare_version_names("1.0.10", "1.0.2"), Ordering::Greater);
7438            // Pre-release < release at the same numeric prefix.
7439            assert_eq!(compare_version_names("1.0.0-rc1", "1.0.0"), Ordering::Less);
7440            // Non-semver names fall back to lexicographic / token compare,
7441            // but at minimum they must be a total order.
7442            let _ = compare_version_names("Sprint 42 cleanup", "Sprint 9 cleanup");
7443        }
7444
7445        #[tokio::test]
7446        async fn upsert_project_version_creates_when_missing() {
7447            let server = MockServer::start();
7448            // 1) list returns no match
7449            server.mock(|when, then| {
7450                when.method(GET).path("/project/PROJ/versions");
7451                then.status(200).json_body(serde_json::json!([version_dto(
7452                    "99",
7453                    "1.0.0",
7454                    Some("2025-01-01"),
7455                    true,
7456                    false
7457                ),]));
7458            });
7459            // 2) POST /version creates
7460            server.mock(|when, then| {
7461                when.method(POST)
7462                    .path("/version")
7463                    .body_includes("\"name\":\"3.18.0\"")
7464                    .body_includes("\"project\":\"PROJ\"")
7465                    .body_includes("\"description\":\"Release notes draft\"");
7466                then.status(201).json_body(serde_json::json!({
7467                    "id": "10500",
7468                    "name": "3.18.0",
7469                    "project": "PROJ",
7470                    "description": "Release notes draft",
7471                    "released": false,
7472                    "archived": false,
7473                }));
7474            });
7475
7476            let client = create_client(&server);
7477            let v = client
7478                .upsert_project_version(UpsertProjectVersionInput {
7479                    project: "PROJ".into(),
7480                    name: "3.18.0".into(),
7481                    description: Some("Release notes draft".into()),
7482                    start_date: None,
7483                    release_date: None,
7484                    released: None,
7485                    archived: None,
7486                })
7487                .await
7488                .unwrap();
7489            assert_eq!(v.id, "10500");
7490            assert_eq!(v.name, "3.18.0");
7491            assert_eq!(v.description.as_deref(), Some("Release notes draft"));
7492        }
7493
7494        #[tokio::test]
7495        async fn upsert_project_version_updates_when_present() {
7496            let server = MockServer::start();
7497            // 1) list returns match
7498            server.mock(|when, then| {
7499                when.method(GET).path("/project/PROJ/versions");
7500                then.status(200).json_body(serde_json::json!([version_dto(
7501                    "777", "3.18.0", None, false, false
7502                ),]));
7503            });
7504            // 2) PUT /version/{id}
7505            server.mock(|when, then| {
7506                when.method(PUT)
7507                    .path("/version/777")
7508                    .body_includes("\"description\":\"final notes\"")
7509                    .body_includes("\"released\":true")
7510                    .body_includes("\"releaseDate\":\"2026-05-01\"");
7511                then.status(200).json_body(serde_json::json!({
7512                    "id": "777",
7513                    "name": "3.18.0",
7514                    "project": "PROJ",
7515                    "description": "final notes",
7516                    "releaseDate": "2026-05-01",
7517                    "released": true,
7518                    "archived": false,
7519                }));
7520            });
7521
7522            let client = create_client(&server);
7523            let v = client
7524                .upsert_project_version(UpsertProjectVersionInput {
7525                    project: "PROJ".into(),
7526                    name: "3.18.0".into(),
7527                    description: Some("final notes".into()),
7528                    start_date: None,
7529                    release_date: Some("2026-05-01".into()),
7530                    released: Some(true),
7531                    archived: None,
7532                })
7533                .await
7534                .unwrap();
7535            assert_eq!(v.id, "777");
7536            assert!(v.released);
7537            assert_eq!(v.release_date.as_deref(), Some("2026-05-01"));
7538        }
7539
7540        #[tokio::test]
7541        async fn upsert_project_version_partial_update_sends_only_description() {
7542            let server = MockServer::start();
7543            server.mock(|when, then| {
7544                when.method(GET).path("/project/PROJ/versions");
7545                then.status(200).json_body(serde_json::json!([version_dto(
7546                    "42",
7547                    "2.0.0",
7548                    Some("2026-01-01"),
7549                    false,
7550                    false
7551                ),]));
7552            });
7553            // PUT body should include description; `name`, `released`,
7554            // `archived`, and date fields stay out (serde skip_if = None).
7555            let put_mock = server.mock(|when, then| {
7556                when.method(PUT)
7557                    .path("/version/42")
7558                    .body_includes("\"description\":\"draft\"")
7559                    .body_excludes("\"name\":")
7560                    .body_excludes("\"released\":")
7561                    .body_excludes("\"archived\":")
7562                    .body_excludes("\"releaseDate\":");
7563                then.status(200).json_body(serde_json::json!({
7564                    "id": "42",
7565                    "name": "2.0.0",
7566                    "project": "PROJ",
7567                    "description": "draft",
7568                    "releaseDate": "2026-01-01",
7569                    "released": false,
7570                    "archived": false,
7571                }));
7572            });
7573
7574            let client = create_client(&server);
7575            client
7576                .upsert_project_version(UpsertProjectVersionInput {
7577                    project: "PROJ".into(),
7578                    name: "2.0.0".into(),
7579                    description: Some("draft".into()),
7580                    start_date: None,
7581                    release_date: None,
7582                    released: None,
7583                    archived: None,
7584                })
7585                .await
7586                .unwrap();
7587            put_mock.assert();
7588        }
7589
7590        #[tokio::test]
7591        async fn upsert_project_version_rejects_empty_name() {
7592            let server = MockServer::start();
7593            let client = create_client(&server);
7594            let err = client
7595                .upsert_project_version(UpsertProjectVersionInput {
7596                    project: "PROJ".into(),
7597                    name: "  ".into(),
7598                    ..Default::default()
7599                })
7600                .await
7601                .unwrap_err();
7602            assert!(matches!(err, devboy_core::Error::InvalidData(_)));
7603        }
7604
7605        #[tokio::test]
7606        async fn upsert_project_version_rejects_overlong_name() {
7607            // Codex review on PR #239 — Jira caps version names at 255
7608            // chars; failing client-side gives a clearer error than
7609            // letting the server return a 400.
7610            let server = MockServer::start();
7611            let client = create_client(&server);
7612            let err = client
7613                .upsert_project_version(UpsertProjectVersionInput {
7614                    project: "PROJ".into(),
7615                    name: "x".repeat(256),
7616                    ..Default::default()
7617                })
7618                .await
7619                .unwrap_err();
7620            assert!(matches!(err, devboy_core::Error::InvalidData(_)));
7621        }
7622
7623        #[test]
7624        fn duplicate_version_error_classifier_matches_jira_phrasing() {
7625            // Codex review on PR #239 — race recovery hangs on this
7626            // classifier. Pin the strings Jira actually returns so a
7627            // copy-paste regression in the wording table doesn't
7628            // silently turn duplicate errors into hard failures.
7629            let dup1 = devboy_core::Error::Api {
7630                status: 400,
7631                message: "A version with this name already exists in this project.".into(),
7632            };
7633            let dup2 = devboy_core::Error::Api {
7634                status: 400,
7635                message: "Name is already used by another version in this project.".into(),
7636            };
7637            let unrelated = devboy_core::Error::Api {
7638                status: 400,
7639                message: "releaseDate is in the wrong format.".into(),
7640            };
7641            assert!(is_duplicate_version_error(&dup1));
7642            assert!(is_duplicate_version_error(&dup2));
7643            assert!(!is_duplicate_version_error(&unrelated));
7644        }
7645
7646        #[tokio::test]
7647        async fn upsert_project_version_propagates_non_duplicate_400() {
7648            // Make sure the duplicate-recovery path doesn't swallow
7649            // unrelated 400s — only "already exists" is retried.
7650            let server = MockServer::start();
7651            server.mock(|when, then| {
7652                when.method(GET).path("/project/PROJ/versions");
7653                then.status(200).json_body(serde_json::json!([]));
7654            });
7655            server.mock(|when, then| {
7656                when.method(POST).path("/version");
7657                then.status(400).json_body(serde_json::json!({
7658                    "errorMessages": ["releaseDate is in the wrong format."]
7659                }));
7660            });
7661            let client = create_client(&server);
7662            let err = client
7663                .upsert_project_version(UpsertProjectVersionInput {
7664                    project: "PROJ".into(),
7665                    name: "3.18.0".into(),
7666                    release_date: Some("not-a-date".into()),
7667                    ..Default::default()
7668                })
7669                .await
7670                .unwrap_err();
7671            // 400 → Error::Api (see Error::from_status).
7672            assert!(matches!(err, devboy_core::Error::Api { .. }));
7673        }
7674
7675        #[tokio::test]
7676        async fn upsert_project_version_works_on_cloud_flavor() {
7677            // Codex review on PR #239 — coverage gap: every upsert test
7678            // ran against self-hosted. Pin Cloud insert path to make
7679            // sure the same code works against Cloud's response shape.
7680            let server = MockServer::start();
7681            server.mock(|when, then| {
7682                when.method(GET).path("/project/CLOUDPROJ/versions");
7683                then.status(200).json_body(serde_json::json!([]));
7684            });
7685            let post_mock = server.mock(|when, then| {
7686                when.method(POST)
7687                    .path("/version")
7688                    .body_includes("\"name\":\"4.0.0\"")
7689                    .body_includes("\"project\":\"CLOUDPROJ\"");
7690                then.status(201).json_body(serde_json::json!({
7691                    "id": "30001",
7692                    "name": "4.0.0",
7693                    "project": "CLOUDPROJ",
7694                    "description": "Cloud release",
7695                    "released": false,
7696                    "archived": false,
7697                    // Cloud-shaped issuesStatusForFixVersion would normally
7698                    // not appear on the create response — the field
7699                    // surfaces via list_project_versions(includeIssueCount).
7700                }));
7701            });
7702
7703            let client = create_cloud_client(&server);
7704            let v = client
7705                .upsert_project_version(UpsertProjectVersionInput {
7706                    project: "CLOUDPROJ".into(),
7707                    name: "4.0.0".into(),
7708                    description: Some("Cloud release".into()),
7709                    ..Default::default()
7710                })
7711                .await
7712                .unwrap();
7713            post_mock.assert();
7714            assert_eq!(v.id, "30001");
7715            assert_eq!(v.project, "CLOUDPROJ");
7716            assert_eq!(v.description.as_deref(), Some("Cloud release"));
7717        }
7718    }
7719}