Skip to main content

jira_core/
client.rs

1use std::time::Duration;
2
3use reqwest::{
4    header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE},
5    Client, Response, StatusCode,
6};
7use serde_json::{json, Value};
8use tracing::{debug, warn};
9
10use crate::{
11    adf::markdown_to_adf,
12    config::{JiraAuthType, JiraConfig},
13    error::{JiraError, Result},
14    model::{
15        attachment::Attachment,
16        comment::Comment,
17        component::Component,
18        field::Field,
19        issue::{
20            CreateIssueRequest, CreateIssueRequestV2, Issue, RawIssue, RawSearchResponse,
21            SearchResult, UpdateIssueRequest,
22        },
23        issue_type::IssueType,
24        link::{IssueLink, IssueLinkType},
25        remote_link::RemoteLink,
26        sprint::Sprint,
27        transition::Transition,
28        user::JiraUser,
29        version::{CreateProjectVersionRequest, ProjectVersion, UpdateProjectVersionRequest},
30        watcher::Watchers,
31        worklog::Worklog,
32    },
33};
34
35const MAX_RETRIES: u32 = 3;
36
37#[derive(serde::Deserialize)]
38struct MyselfResponse {
39    #[serde(rename = "accountId")]
40    account_id: Option<String>,
41    #[serde(rename = "timeZone")]
42    time_zone: Option<String>,
43}
44
45#[derive(Clone)]
46pub struct JiraClient {
47    http: Client,
48    config: JiraConfig,
49}
50
51impl JiraClient {
52    pub fn new(config: JiraConfig) -> Self {
53        let http = Client::builder()
54            .timeout(Duration::from_secs(config.timeout_secs))
55            .build()
56            .expect("Failed to build HTTP client");
57
58        Self { http, config }
59    }
60
61    pub fn base_url(&self) -> &str {
62        &self.config.base_url
63    }
64
65    fn platform_url(&self, path: &str) -> String {
66        format!(
67            "{}/rest/api/{}{}",
68            self.config.base_url.trim_end_matches('/'),
69            self.config.api_version,
70            path
71        )
72    }
73
74    fn platform_path(&self, path: &str) -> String {
75        format!("/rest/api/{}{}", self.config.api_version, path)
76    }
77
78    fn auth_headers(&self) -> Result<HeaderMap> {
79        self.build_auth_headers(true)
80    }
81
82    /// Auth headers without Content-Type — required for multipart uploads.
83    fn auth_headers_no_content_type(&self) -> Result<HeaderMap> {
84        self.build_auth_headers(false)
85    }
86
87    fn build_auth_headers(&self, include_content_type: bool) -> Result<HeaderMap> {
88        let token = self.config.token.as_deref().ok_or_else(|| {
89            JiraError::Auth("No token configured. Run `jirac auth login` first.".into())
90        })?;
91
92        let auth_value = match self.config.auth_type {
93            JiraAuthType::CloudApiToken | JiraAuthType::DataCenterBasic => {
94                let credentials = base64_encode(&format!("{}:{}", self.config.email, token));
95                format!("Basic {credentials}")
96            }
97            JiraAuthType::DataCenterPat => format!("Bearer {token}"),
98        };
99
100        let mut headers = HeaderMap::new();
101        headers.insert(
102            AUTHORIZATION,
103            HeaderValue::from_str(&auth_value)
104                .map_err(|e| JiraError::Auth(format!("Invalid auth header: {e}")))?,
105        );
106        if include_content_type {
107            headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
108        }
109        Ok(headers)
110    }
111
112    async fn get_myself_info(&self) -> Result<MyselfResponse> {
113        let headers = self.auth_headers()?;
114        let url = self.platform_url("/myself");
115
116        let http = &self.http;
117        self.request(|| http.get(&url).headers(headers.clone()))
118            .await
119    }
120
121    /// Get the current authenticated user's accountId.
122    pub async fn get_myself(&self) -> Result<String> {
123        let me = self.get_myself_info().await?;
124        me.account_id.ok_or_else(|| JiraError::Api {
125            status: 0,
126            message: "Could not get accountId from /myself".into(),
127        })
128    }
129
130    /// Get the current authenticated user's Jira timezone (IANA name), if available.
131    pub async fn get_myself_timezone(&self) -> Result<Option<String>> {
132        Ok(self.get_myself_info().await?.time_zone)
133    }
134
135    /// Resolve an assignee string to a Jira accountId.
136    ///
137    /// - `"me"` → current user's accountId via /myself
138    /// - contains `@` → search by email, return first match's accountId
139    /// - anything else → treated as a raw accountId and returned as-is
140    async fn resolve_assignee_account_id(&self, s: &str) -> Result<String> {
141        if s == "me" {
142            return self.get_myself().await;
143        }
144        if !s.contains('@') {
145            return Ok(s.to_string());
146        }
147        // Resolve email → accountId via user search
148        let users = self.search_users(s).await?;
149        users
150            .iter()
151            .find(|u| {
152                u.email_address
153                    .as_deref()
154                    .map(|e| e.eq_ignore_ascii_case(s))
155                    .unwrap_or(false)
156            })
157            .or_else(|| users.first())
158            .map(|u| u.account_id.clone())
159            .ok_or_else(|| JiraError::Api {
160                status: 0,
161                message: format!("User not found: {s}"),
162            })
163    }
164
165    /// Send a request, retrying on HTTP 429 according to the `Retry-After` header.
166    /// Returns the first non-429 response, or `RateLimit` after `MAX_RETRIES` attempts.
167    async fn execute_with_retry(
168        &self,
169        builder_fn: impl Fn() -> reqwest::RequestBuilder,
170    ) -> Result<reqwest::Response> {
171        let mut attempt = 0u32;
172        loop {
173            attempt += 1;
174            let response = builder_fn().send().await?;
175
176            if response.status() != StatusCode::TOO_MANY_REQUESTS {
177                return Ok(response);
178            }
179
180            let retry_after = response
181                .headers()
182                .get("Retry-After")
183                .and_then(|v| v.to_str().ok())
184                .and_then(|v| v.parse::<u64>().ok())
185                .unwrap_or(60);
186
187            warn!("Rate limited. Retrying after {}s", retry_after);
188
189            if attempt >= MAX_RETRIES {
190                return Err(JiraError::RateLimit { retry_after });
191            }
192
193            tokio::time::sleep(Duration::from_secs(retry_after)).await;
194        }
195    }
196
197    /// Core request method with rate-limit retry logic.
198    async fn request<T>(&self, builder_fn: impl Fn() -> reqwest::RequestBuilder) -> Result<T>
199    where
200        T: serde::de::DeserializeOwned,
201    {
202        let response = self.execute_with_retry(builder_fn).await?;
203        handle_response(response).await
204    }
205
206    /// Core request method for responses with no body (204 No Content).
207    async fn request_no_body(
208        &self,
209        builder_fn: impl Fn() -> reqwest::RequestBuilder,
210    ) -> Result<()> {
211        let response = self.execute_with_retry(builder_fn).await?;
212        let status = response.status();
213        if status.is_success() {
214            return Ok(());
215        }
216        let body = response
217            .text()
218            .await
219            .unwrap_or_else(|e| format!("<failed to read response body: {e}>"));
220        if status == StatusCode::NOT_FOUND {
221            return Err(JiraError::NotFound(body));
222        }
223        Err(JiraError::Api {
224            status: status.as_u16(),
225            message: body,
226        })
227    }
228
229    /// Multipart request with rate-limit retry (for attachment uploads).
230    async fn request_multipart<T>(
231        &self,
232        builder_fn: impl Fn() -> reqwest::RequestBuilder,
233    ) -> Result<T>
234    where
235        T: serde::de::DeserializeOwned,
236    {
237        let response = self.execute_with_retry(builder_fn).await?;
238        handle_response(response).await
239    }
240
241    /// Search issues using JQL with cursor-based pagination.
242    pub async fn search_issues(
243        &self,
244        jql: &str,
245        next_page_token: Option<&str>,
246        max_results: Option<u32>,
247    ) -> Result<SearchResult> {
248        const DEFAULT_FIELDS: &[&str] = &[
249            "summary",
250            "status",
251            "assignee",
252            "reporter",
253            "priority",
254            "issuetype",
255            "project",
256            "created",
257            "updated",
258            "description",
259        ];
260        self.search_issues_with_fields(jql, next_page_token, max_results, DEFAULT_FIELDS)
261            .await
262    }
263
264    /// Search issues with an explicit `fields` list (e.g. `["*all"]`, `["*navigable"]`,
265    /// or specific field IDs like `["summary", "components", "customfield_10010"]`).
266    pub async fn search_issues_with_fields(
267        &self,
268        jql: &str,
269        next_page_token: Option<&str>,
270        max_results: Option<u32>,
271        fields: &[&str],
272    ) -> Result<SearchResult> {
273        let headers = self.auth_headers()?;
274        let url = self.platform_url("/search/jql");
275
276        let mut body = json!({
277            "jql": jql,
278            "maxResults": max_results.unwrap_or(50),
279            "fields": fields,
280        });
281
282        if let Some(token) = next_page_token {
283            body["nextPageToken"] = json!(token);
284        }
285
286        debug!("Searching JQL: {}", jql);
287
288        let http = &self.http;
289        let raw: RawSearchResponse = self
290            .request(|| http.post(&url).headers(headers.clone()).json(&body))
291            .await?;
292
293        Ok(SearchResult {
294            issues: raw.issues.into_iter().map(|r| r.into_issue()).collect(),
295            next_page_token: raw.next_page_token,
296            total: raw.total,
297        })
298    }
299
300    /// Fetch all visible fields (system + custom) from the Jira instance.
301    /// Endpoint: `GET /rest/api/3/field`.
302    pub async fn list_fields(&self) -> Result<Vec<Field>> {
303        let headers = self.auth_headers()?;
304        let url = self.platform_url("/field");
305
306        #[derive(serde::Deserialize)]
307        struct FieldEntry {
308            id: String,
309            name: String,
310            #[serde(default)]
311            schema: Option<Value>,
312        }
313
314        let http = &self.http;
315        let raw: Vec<FieldEntry> = self
316            .request(|| http.get(&url).headers(headers.clone()))
317            .await?;
318
319        Ok(raw
320            .into_iter()
321            .map(|f| {
322                let field_type = f
323                    .schema
324                    .as_ref()
325                    .and_then(|s| s.get("type"))
326                    .and_then(|v| v.as_str())
327                    .unwrap_or("")
328                    .to_string();
329                Field {
330                    id: f.id,
331                    name: f.name,
332                    field_type,
333                    required: false,
334                    schema: f.schema,
335                    allowed_values: None,
336                }
337            })
338            .collect())
339    }
340
341    /// Fetch a single issue by key.
342    pub async fn get_issue(&self, key: &str) -> Result<Issue> {
343        let headers = self.auth_headers()?;
344        let url = self.platform_url(&format!("/issue/{key}"));
345
346        let http = &self.http;
347        let raw: RawIssue = self
348            .request(|| http.get(&url).headers(headers.clone()))
349            .await?;
350
351        Ok(raw.into_issue())
352    }
353
354    /// Create a new issue.
355    ///
356    /// Deprecated in favor of [`JiraClient::create_issue_v2`], which supports
357    /// custom fields, labels, components, parent, fix versions, and ADF
358    /// descriptions. This method is retained for backward compatibility and
359    /// will be removed in a future release.
360    #[deprecated(
361        since = "0.40.0",
362        note = "Use `create_issue_v2` — supports custom fields, labels, components, parent, fix versions"
363    )]
364    pub async fn create_issue(&self, req: CreateIssueRequest) -> Result<Issue> {
365        let headers = self.auth_headers()?;
366        let url = self.platform_url("/issue");
367
368        let description_adf = req.description.as_deref().map(markdown_to_adf);
369
370        let mut fields = json!({
371            "project": { "key": req.project_key },
372            "summary": req.summary,
373            "issuetype": { "name": req.issue_type }
374        });
375
376        if let Some(adf) = description_adf {
377            fields["description"] = adf;
378        }
379
380        if let Some(assignee) = &req.assignee {
381            let account_id = self.resolve_assignee_account_id(assignee).await?;
382            fields["assignee"] = json!({ "accountId": account_id });
383        }
384
385        if let Some(priority) = &req.priority {
386            fields["priority"] = json!({ "name": priority });
387        }
388
389        let body = json!({ "fields": fields });
390
391        #[derive(serde::Deserialize)]
392        struct CreateResponse {
393            key: String,
394        }
395
396        let http = &self.http;
397        let resp: CreateResponse = self
398            .request(|| http.post(&url).headers(headers.clone()).json(&body))
399            .await?;
400
401        // Fetch the full issue after creation
402        self.get_issue(&resp.key).await
403    }
404
405    /// Update an existing issue.
406    pub async fn update_issue(&self, key: &str, req: UpdateIssueRequest) -> Result<()> {
407        let headers = self.auth_headers()?;
408        let url = self.platform_url(&format!("/issue/{key}"));
409
410        let mut fields = json!({});
411
412        if let Some(summary) = &req.summary {
413            fields["summary"] = json!(summary);
414        }
415        if let Some(adf) = &req.description_adf {
416            fields["description"] = adf.clone();
417        } else if let Some(description) = &req.description {
418            fields["description"] = markdown_to_adf(description);
419        }
420        if let Some(assignee) = &req.assignee {
421            let account_id = self.resolve_assignee_account_id(assignee).await?;
422            fields["assignee"] = json!({ "accountId": account_id });
423        }
424        if let Some(priority) = &req.priority {
425            fields["priority"] = json!({ "name": priority });
426        }
427        if let Some(labels) = &req.labels {
428            fields["labels"] = json!(labels);
429        }
430        if let Some(components) = &req.components {
431            fields["components"] = json!(components
432                .iter()
433                .map(|c| json!({"name": c}))
434                .collect::<Vec<_>>());
435        }
436        if let Some(fix_versions) = &req.fix_versions {
437            fields["fixVersions"] = json!(fix_versions
438                .iter()
439                .map(|v| json!({"name": v}))
440                .collect::<Vec<_>>());
441        }
442        if let Some(parent) = &req.parent {
443            fields["parent"] = json!({ "key": parent });
444        }
445        for (field_id, value) in &req.custom_fields {
446            fields[field_id] = value.to_api_json();
447        }
448
449        let body = json!({ "fields": fields });
450
451        let http = &self.http;
452        self.request_no_body(|| http.put(&url).headers(headers.clone()).json(&body))
453            .await
454    }
455
456    /// Delete an issue.
457    pub async fn delete_issue(&self, key: &str) -> Result<()> {
458        let headers = self.auth_headers()?;
459        let url = self.platform_url(&format!("/issue/{key}"));
460
461        let http = &self.http;
462        self.request_no_body(|| http.delete(&url).headers(headers.clone()))
463            .await
464    }
465
466    /// Get fields available for a project (runtime field resolution — no hardcoding).
467    ///
468    /// The `createmeta/{project}/issuetypes` LIST endpoint only returns issue
469    /// types (no per-type `fields`), so fields are resolved per issue type via
470    /// the `createmeta/{project}/issuetypes/{id}` endpoint and merged.
471    pub async fn get_project_fields(&self, project_key: &str) -> Result<Vec<Field>> {
472        let issue_types = self.get_issue_types(project_key).await?;
473
474        let mut fields: Vec<Field> = Vec::new();
475        let mut seen = std::collections::HashSet::new();
476
477        for it in issue_types {
478            // ponytail: first-seen wins on the required flag — a field required
479            // in only some issue types reports the first type's flag, which is
480            // fine for a project-wide field list.
481            for field in self.get_fields_for_issue_type(project_key, &it.id).await? {
482                if seen.insert(field.id.clone()) {
483                    fields.push(field);
484                }
485            }
486        }
487
488        Ok(fields)
489    }
490
491    /// Get server info (used to detect Jira tier).
492    pub async fn get_server_info(&self) -> Result<Value> {
493        let headers = self.auth_headers()?;
494        let url = self.platform_url("/serverInfo");
495
496        let http = &self.http;
497        self.request(|| http.get(&url).headers(headers.clone()))
498            .await
499    }
500
501    /// Transition an issue to a new status.
502    pub async fn transition_issue(&self, key: &str, transition_id: &str) -> Result<()> {
503        let headers = self.auth_headers()?;
504        let url = self.platform_url(&format!("/issue/{key}/transitions"));
505
506        let body = json!({
507            "transition": { "id": transition_id }
508        });
509
510        let http = &self.http;
511        self.request_no_body(|| http.post(&url).headers(headers.clone()).json(&body))
512            .await
513    }
514
515    /// Get available transitions for an issue.
516    pub async fn get_transitions(&self, key: &str) -> Result<Vec<Transition>> {
517        let headers = self.auth_headers()?;
518        let url = self.platform_url(&format!("/issue/{key}/transitions"));
519
520        #[derive(serde::Deserialize)]
521        struct TransitionsResponse {
522            transitions: Vec<Transition>,
523        }
524
525        let http = &self.http;
526        let resp: TransitionsResponse = self
527            .request(|| http.get(&url).headers(headers.clone()))
528            .await?;
529
530        Ok(resp.transitions)
531    }
532
533    /// Add a watcher to an issue. Jira REST expects the request body to be a
534    /// raw JSON string containing the accountId (not an object).
535    pub async fn add_watcher(&self, key: &str, account_id: &str) -> Result<()> {
536        let headers = self.auth_headers()?;
537        let url = self.platform_url(&format!("/issue/{key}/watchers"));
538        let body = Value::String(account_id.to_string());
539
540        let http = &self.http;
541        self.request_no_body(|| http.post(&url).headers(headers.clone()).json(&body))
542            .await
543    }
544
545    /// Remove a watcher from an issue (accountId passed as query parameter).
546    pub async fn remove_watcher(&self, key: &str, account_id: &str) -> Result<()> {
547        let headers = self.auth_headers()?;
548        let url = self.platform_url(&format!("/issue/{key}/watchers"));
549
550        let http = &self.http;
551        self.request_no_body(|| {
552            http.delete(&url)
553                .headers(headers.clone())
554                .query(&[("accountId", account_id)])
555        })
556        .await
557    }
558
559    /// List all watchers on an issue.
560    pub async fn list_watchers(&self, key: &str) -> Result<Watchers> {
561        let headers = self.auth_headers()?;
562        let url = self.platform_url(&format!("/issue/{key}/watchers"));
563
564        let http = &self.http;
565        let raw: Value = self
566            .request(|| http.get(&url).headers(headers.clone()))
567            .await?;
568        Watchers::from_value(&raw, key).ok_or_else(|| JiraError::Api {
569            status: 0,
570            message: "Failed to parse watchers".into(),
571        })
572    }
573
574    /// Get available issue types for a project (id + name).
575    pub async fn get_issue_types(&self, project_key: &str) -> Result<Vec<IssueType>> {
576        let headers = self.auth_headers()?;
577        let url = self.platform_url(&format!("/issue/createmeta/{project_key}/issuetypes"));
578
579        #[derive(serde::Deserialize)]
580        struct MetaResponse {
581            #[serde(rename = "issueTypes")]
582            issue_types: Vec<IssueType>,
583        }
584
585        let http = &self.http;
586        let resp: MetaResponse = self
587            .request(|| http.get(&url).headers(headers.clone()))
588            .await?;
589
590        Ok(resp.issue_types)
591    }
592
593    /// Resolve an issue type by exact or case-insensitive name within a project.
594    pub async fn get_issue_type_by_name(
595        &self,
596        project_key: &str,
597        issue_type_name: &str,
598    ) -> Result<IssueType> {
599        let issue_types = self.get_issue_types(project_key).await?;
600
601        issue_types
602            .iter()
603            .find(|it| it.name == issue_type_name)
604            .or_else(|| {
605                issue_types
606                    .iter()
607                    .find(|it| it.name.eq_ignore_ascii_case(issue_type_name))
608            })
609            .cloned()
610            .ok_or_else(|| JiraError::Api {
611                status: 0,
612                message: format!(
613                    "Issue type '{}' not found in project {}",
614                    issue_type_name, project_key
615                ),
616            })
617    }
618
619    /// Get fields for a specific issue type within a project (with allowed values).
620    pub async fn get_fields_for_issue_type(
621        &self,
622        project_key: &str,
623        issue_type_id: &str,
624    ) -> Result<Vec<Field>> {
625        let headers = self.auth_headers()?;
626        let url = self.platform_url(&format!(
627            "/issue/createmeta/{project_key}/issuetypes/{issue_type_id}"
628        ));
629
630        #[derive(serde::Deserialize)]
631        struct FieldMetaResponse {
632            fields: FieldCollection,
633        }
634
635        #[derive(serde::Deserialize)]
636        #[serde(untagged)]
637        enum FieldCollection {
638            Map(std::collections::HashMap<String, FieldMetaMap>),
639            List(Vec<FieldMetaEntry>),
640        }
641
642        #[derive(serde::Deserialize)]
643        struct FieldMetaMap {
644            name: String,
645            required: bool,
646            schema: Option<Value>,
647            #[serde(rename = "allowedValues")]
648            allowed_values: Option<Vec<Value>>,
649        }
650
651        #[derive(serde::Deserialize)]
652        struct FieldMetaEntry {
653            #[serde(rename = "fieldId")]
654            field_id: Option<String>,
655            key: Option<String>,
656            name: String,
657            required: bool,
658            schema: Option<Value>,
659            #[serde(rename = "allowedValues")]
660            allowed_values: Option<Vec<Value>>,
661        }
662
663        let http = &self.http;
664        let resp: FieldMetaResponse = self
665            .request(|| http.get(&url).headers(headers.clone()))
666            .await?;
667
668        let fields = match resp.fields {
669            FieldCollection::Map(fields) => fields
670                .into_iter()
671                .map(|(id, meta)| {
672                    let field_type = meta
673                        .schema
674                        .as_ref()
675                        .and_then(|s| s.get("type"))
676                        .and_then(|v| v.as_str())
677                        .unwrap_or("unknown")
678                        .to_string();
679
680                    Field {
681                        id,
682                        name: meta.name,
683                        field_type,
684                        required: meta.required,
685                        schema: meta.schema,
686                        allowed_values: meta.allowed_values,
687                    }
688                })
689                .collect(),
690            FieldCollection::List(fields) => fields
691                .into_iter()
692                .map(|meta| {
693                    let field_type = meta
694                        .schema
695                        .as_ref()
696                        .and_then(|s| s.get("type"))
697                        .and_then(|v| v.as_str())
698                        .unwrap_or("unknown")
699                        .to_string();
700
701                    let id = meta.field_id.or(meta.key).unwrap_or_default();
702
703                    Field {
704                        id,
705                        name: meta.name,
706                        field_type,
707                        required: meta.required,
708                        schema: meta.schema,
709                        allowed_values: meta.allowed_values,
710                    }
711                })
712                .collect(),
713        };
714
715        Ok(fields)
716    }
717
718    /// Search Jira users by query string (for User field autocomplete).
719    pub async fn search_users(&self, query: &str) -> Result<Vec<JiraUser>> {
720        let headers = self.auth_headers()?;
721        let url = self.platform_url("/user/search");
722
723        let http = &self.http;
724        self.request(|| {
725            http.get(&url)
726                .headers(headers.clone())
727                .query(&[("query", query), ("maxResults", "20")])
728        })
729        .await
730    }
731
732    /// List components available within a project.
733    pub async fn get_project_components(&self, project_key: &str) -> Result<Vec<Component>> {
734        let headers = self.auth_headers()?;
735        let url = self.platform_url(&format!("/project/{project_key}/components"));
736
737        let http = &self.http;
738        self.request(|| http.get(&url).headers(headers.clone()))
739            .await
740    }
741
742    /// List fix versions available within a project.
743    pub async fn get_project_versions(&self, project_key: &str) -> Result<Vec<ProjectVersion>> {
744        let headers = self.auth_headers()?;
745        let url = self.platform_url(&format!("/project/{project_key}/versions"));
746
747        let http = &self.http;
748        let versions: Vec<ProjectVersion> = self
749            .request(|| http.get(&url).headers(headers.clone()))
750            .await?;
751
752        Ok(versions)
753    }
754
755    /// Create a project version in Jira.
756    pub async fn create_project_version(
757        &self,
758        request: &CreateProjectVersionRequest,
759    ) -> Result<ProjectVersion> {
760        let path = format!("/rest/api/{}/version", self.config.api_version);
761        let body = serde_json::to_value(request)?;
762        let value = self
763            .raw_request("POST", &path, Some(body))
764            .await?
765            .ok_or_else(|| JiraError::Api {
766                status: 0,
767                message: "Empty response when creating project version".into(),
768            })?;
769        let version: ProjectVersion =
770            serde_json::from_value(value).map_err(|e| JiraError::Api {
771                status: 0,
772                message: format!("Failed to parse created project version: {e}"),
773            })?;
774        Ok(version)
775    }
776
777    /// Update project version metadata like release/start dates or release state.
778    pub async fn update_project_version(
779        &self,
780        version_id: &str,
781        request: &UpdateProjectVersionRequest,
782    ) -> Result<ProjectVersion> {
783        let path = format!(
784            "/rest/api/{}/version/{}",
785            self.config.api_version, version_id
786        );
787        let body = serde_json::to_value(request)?;
788        let value = self
789            .raw_request("PUT", &path, Some(body))
790            .await?
791            .ok_or_else(|| JiraError::Api {
792                status: 0,
793                message: "Empty response when updating project version".into(),
794            })?;
795        let version: ProjectVersion =
796            serde_json::from_value(value).map_err(|e| JiraError::Api {
797                status: 0,
798                message: format!("Failed to parse updated project version: {e}"),
799            })?;
800        Ok(version)
801    }
802
803    fn parse_sprint_value(&self, value: &Value, board_id: Option<u64>) -> Option<Sprint> {
804        let id = value.get("id").and_then(|v| v.as_u64())?;
805        Some(Sprint {
806            id,
807            name: value
808                .get("name")
809                .and_then(|v| v.as_str())
810                .unwrap_or("")
811                .to_string(),
812            state: value
813                .get("state")
814                .and_then(|v| v.as_str())
815                .unwrap_or("")
816                .to_string(),
817            board_id,
818            goal: value
819                .get("goal")
820                .and_then(|v| v.as_str())
821                .map(str::to_owned),
822            start_date: value
823                .get("startDate")
824                .and_then(|v| v.as_str())
825                .map(str::to_owned),
826            end_date: value
827                .get("endDate")
828                .and_then(|v| v.as_str())
829                .map(str::to_owned),
830            complete_date: value
831                .get("completeDate")
832                .and_then(|v| v.as_str())
833                .map(str::to_owned),
834        })
835    }
836
837    fn paged_values(response: &Value) -> &[Value] {
838        response
839            .get("values")
840            .and_then(|v| v.as_array())
841            .map(Vec::as_slice)
842            .unwrap_or(&[])
843    }
844
845    fn page_is_last(response: &Value, fetched_count: usize) -> bool {
846        // Empty page = definitely last. Prevents infinite loops when API returns
847        // a quirky page with no records but also no metadata.
848        if fetched_count == 0 {
849            return true;
850        }
851        if let Some(is_last) = response.get("isLast").and_then(|v| v.as_bool()) {
852            return is_last;
853        }
854        if let Some(total) = response.get("total").and_then(|v| v.as_u64()) {
855            let start_at = response
856                .get("startAt")
857                .and_then(|v| v.as_u64())
858                .unwrap_or(0);
859            return start_at + fetched_count as u64 >= total;
860        }
861        if let Some(max_results) = response.get("maxResults").and_then(|v| v.as_u64()) {
862            return fetched_count < max_results as usize;
863        }
864        // Last resort: no metadata at all. Treat as last page to avoid an
865        // unbounded loop; callers should also enforce MAX_PAGINATION_ITERATIONS.
866        true
867    }
868
869    /// List project sprints for the given sprint states via the Agile API.
870    pub async fn list_sprints_for_project_with_states(
871        &self,
872        project_key: &str,
873        states: &[&str],
874    ) -> Result<Vec<Sprint>> {
875        const MAX_PAGES: u32 = 500;
876
877        let mut board_ids = Vec::new();
878        let mut board_start_at = 0u64;
879        let mut iterations = 0u32;
880
881        loop {
882            iterations += 1;
883            if iterations > MAX_PAGES {
884                return Err(JiraError::Api {
885                    status: 500,
886                    message: "board pagination exceeded MAX_PAGES safeguard".to_string(),
887                });
888            }
889            let boards_path = format!(
890                "/rest/agile/1.0/board?projectKeyOrId={project_key}&maxResults=100&startAt={board_start_at}"
891            );
892            let boards_resp = self
893                .raw_request("GET", &boards_path, None)
894                .await?
895                .unwrap_or_default();
896            let boards = Self::paged_values(&boards_resp);
897
898            // Only scrum boards have sprints. Kanban/simple boards reject the
899            // /sprint endpoint with an API error, which would otherwise abort
900            // the whole listing. Keep boards whose type is scrum or unspecified.
901            board_ids.extend(boards.iter().filter_map(|b| {
902                let is_scrum = b
903                    .get("type")
904                    .and_then(|t| t.as_str())
905                    .map(|t| t.eq_ignore_ascii_case("scrum"))
906                    .unwrap_or(true);
907                is_scrum
908                    .then(|| b.get("id").and_then(|id| id.as_u64()))
909                    .flatten()
910            }));
911
912            if Self::page_is_last(&boards_resp, boards.len()) {
913                break;
914            }
915            board_start_at += boards.len() as u64;
916        }
917
918        let mut seen: std::collections::HashSet<u64> = std::collections::HashSet::new();
919        let mut sprints: Vec<Sprint> = Vec::new();
920        let state_filter = states.join(",");
921
922        for board_id in board_ids {
923            let mut sprint_start_at = 0u64;
924            let mut sprint_iterations = 0u32;
925            loop {
926                sprint_iterations += 1;
927                if sprint_iterations > MAX_PAGES {
928                    return Err(JiraError::Api {
929                        status: 500,
930                        message: format!(
931                            "sprint pagination exceeded MAX_PAGES safeguard for board {board_id}"
932                        ),
933                    });
934                }
935                let sprint_path = format!(
936                    "/rest/agile/1.0/board/{board_id}/sprint?state={state_filter}&maxResults=200&startAt={sprint_start_at}"
937                );
938                let resp = match self.raw_request("GET", &sprint_path, None).await {
939                    Ok(Some(resp)) => resp,
940                    Ok(None) => break,
941                    Err(JiraError::NotFound(_)) => break,
942                    Err(err) => return Err(err),
943                };
944                let values = Self::paged_values(&resp);
945                for value in values {
946                    let Some(sprint) = self.parse_sprint_value(value, Some(board_id)) else {
947                        continue;
948                    };
949                    if !seen.insert(sprint.id) {
950                        continue;
951                    }
952                    sprints.push(sprint);
953                }
954
955                if Self::page_is_last(&resp, values.len()) {
956                    break;
957                }
958                sprint_start_at += values.len() as u64;
959            }
960        }
961
962        sprints.sort_by(|a, b| {
963            let order = |s: &str| match s {
964                "active" => 0u8,
965                "future" => 1u8,
966                _ => 2u8,
967            };
968            order(&a.state)
969                .cmp(&order(&b.state))
970                .then(a.name.cmp(&b.name))
971        });
972
973        Ok(sprints)
974    }
975
976    /// List active and future sprints for a project via the Agile API.
977    pub async fn list_sprints_for_project(&self, project_key: &str) -> Result<Vec<Sprint>> {
978        self.list_sprints_for_project_with_states(project_key, &["active", "future"])
979            .await
980    }
981
982    /// List Agile boards, optionally filtered by project key and board type.
983    pub async fn list_boards(
984        &self,
985        project_key: Option<&str>,
986        board_type: Option<&str>,
987    ) -> Result<Vec<crate::model::Board>> {
988        const MAX_PAGES: u32 = 500;
989        let mut boards: Vec<crate::model::Board> = Vec::new();
990        let mut start_at = 0u64;
991        let mut iterations = 0u32;
992
993        loop {
994            iterations += 1;
995            if iterations > MAX_PAGES {
996                return Err(JiraError::Api {
997                    status: 500,
998                    message: "board pagination exceeded MAX_PAGES safeguard".into(),
999                });
1000            }
1001            let mut query = format!("maxResults=50&startAt={start_at}");
1002            if let Some(p) = project_key {
1003                query.push_str(&format!("&projectKeyOrId={p}"));
1004            }
1005            if let Some(t) = board_type {
1006                query.push_str(&format!("&type={t}"));
1007            }
1008            let path = format!("/rest/agile/1.0/board?{query}");
1009            let resp = self
1010                .raw_request("GET", &path, None)
1011                .await?
1012                .unwrap_or_default();
1013            let values = Self::paged_values(&resp);
1014            for v in values {
1015                if let Some(b) = crate::model::Board::from_value(v) {
1016                    boards.push(b);
1017                }
1018            }
1019            if Self::page_is_last(&resp, values.len()) {
1020                break;
1021            }
1022            start_at += values.len() as u64;
1023        }
1024
1025        Ok(boards)
1026    }
1027
1028    /// Fetch a single Agile board by ID.
1029    pub async fn get_board(&self, board_id: u64) -> Result<crate::model::Board> {
1030        let path = format!("/rest/agile/1.0/board/{board_id}");
1031        let resp = self
1032            .raw_request("GET", &path, None)
1033            .await?
1034            .ok_or_else(|| JiraError::NotFound(format!("board {board_id}")))?;
1035        crate::model::Board::from_value(&resp).ok_or_else(|| JiraError::Api {
1036            status: 500,
1037            message: "could not parse board response".into(),
1038        })
1039    }
1040
1041    async fn board_issue_list(
1042        &self,
1043        board_id: u64,
1044        endpoint: &str,
1045        jql: Option<&str>,
1046        max_results: Option<u32>,
1047    ) -> Result<Vec<Issue>> {
1048        let max = max_results.unwrap_or(50).min(1000);
1049        let mut path =
1050            format!("/rest/agile/1.0/board/{board_id}/{endpoint}?maxResults={max}&startAt=0");
1051        if let Some(j) = jql {
1052            path.push_str(&format!("&jql={}", url_encode_component(j)));
1053        }
1054        let resp = self
1055            .raw_request("GET", &path, None)
1056            .await?
1057            .unwrap_or_default();
1058        let issues = resp
1059            .get("issues")
1060            .and_then(|v| v.as_array())
1061            .cloned()
1062            .unwrap_or_default();
1063        Ok(issues
1064            .into_iter()
1065            .filter_map(|v| serde_json::from_value::<RawIssue>(v).ok())
1066            .map(|r| r.into_issue())
1067            .collect())
1068    }
1069
1070    /// List issues on a board (optionally filtered by JQL).
1071    pub async fn board_issues(
1072        &self,
1073        board_id: u64,
1074        jql: Option<&str>,
1075        max_results: Option<u32>,
1076    ) -> Result<Vec<Issue>> {
1077        self.board_issue_list(board_id, "issue", jql, max_results)
1078            .await
1079    }
1080
1081    /// List backlog issues on a board (issues not in an active or future sprint).
1082    pub async fn board_backlog(
1083        &self,
1084        board_id: u64,
1085        jql: Option<&str>,
1086        max_results: Option<u32>,
1087    ) -> Result<Vec<Issue>> {
1088        self.board_issue_list(board_id, "backlog", jql, max_results)
1089            .await
1090    }
1091
1092    /// Create a sprint on a Jira board.
1093    pub async fn create_sprint(
1094        &self,
1095        board_id: u64,
1096        name: &str,
1097        start_date: Option<&str>,
1098        end_date: Option<&str>,
1099        goal: Option<&str>,
1100    ) -> Result<Sprint> {
1101        let body = json!({
1102            "name": name,
1103            "originBoardId": board_id,
1104            "startDate": start_date,
1105            "endDate": end_date,
1106            "goal": goal,
1107        });
1108        let value = self
1109            .raw_request("POST", "/rest/agile/1.0/sprint", Some(body))
1110            .await?
1111            .ok_or_else(|| JiraError::Api {
1112                status: 0,
1113                message: "Empty response when creating sprint".into(),
1114            })?;
1115        self.parse_sprint_value(&value, Some(board_id))
1116            .ok_or_else(|| JiraError::Api {
1117                status: 0,
1118                message: "Failed to parse created sprint".into(),
1119            })
1120    }
1121
1122    /// Update sprint metadata or lifecycle state.
1123    pub async fn update_sprint(&self, sprint_id: u64, body: Value) -> Result<Sprint> {
1124        let value = self
1125            .raw_request(
1126                "PUT",
1127                &format!("/rest/agile/1.0/sprint/{sprint_id}"),
1128                Some(body),
1129            )
1130            .await?
1131            .ok_or_else(|| JiraError::Api {
1132                status: 0,
1133                message: "Empty response when updating sprint".into(),
1134            })?;
1135        self.parse_sprint_value(&value, None)
1136            .ok_or_else(|| JiraError::Api {
1137                status: 0,
1138                message: "Failed to parse updated sprint".into(),
1139            })
1140    }
1141
1142    /// Delete a sprint.
1143    pub async fn delete_sprint(&self, sprint_id: u64) -> Result<()> {
1144        self.raw_request(
1145            "DELETE",
1146            &format!("/rest/agile/1.0/sprint/{sprint_id}"),
1147            None,
1148        )
1149        .await?;
1150        Ok(())
1151    }
1152
1153    /// Add an issue to a sprint (Agile API).
1154    pub async fn add_issue_to_sprint(&self, sprint_id: u64, issue_key: &str) -> Result<()> {
1155        let path = format!("/rest/agile/1.0/sprint/{sprint_id}/issue");
1156        let body = json!({ "issues": [issue_key] });
1157        self.raw_request("POST", &path, Some(body)).await?;
1158        Ok(())
1159    }
1160
1161    /// Add a comment using pre-built ADF JSON (skips Markdown conversion).
1162    pub async fn add_comment_adf(&self, issue_key: &str, adf: Value) -> Result<Comment> {
1163        let headers = self.auth_headers()?;
1164        let url = self.platform_url(&format!("/issue/{issue_key}/comment"));
1165        let payload = json!({ "body": adf });
1166        let http = &self.http;
1167        let raw: Value = self
1168            .request(|| http.post(&url).headers(headers.clone()).json(&payload))
1169            .await?;
1170        Comment::from_value(&raw, issue_key).ok_or_else(|| JiraError::Api {
1171            status: 0,
1172            message: "Failed to parse comment".into(),
1173        })
1174    }
1175
1176    /// Upload a file as an attachment to an issue.
1177    pub async fn upload_attachment(
1178        &self,
1179        issue_key: &str,
1180        file_path: &std::path::Path,
1181    ) -> Result<Vec<Attachment>> {
1182        let file_name = file_path
1183            .file_name()
1184            .and_then(|n| n.to_str())
1185            .unwrap_or("attachment")
1186            .to_string();
1187        let bytes = std::fs::read(file_path)?;
1188        let mime = mime_guess::from_path(file_path)
1189            .first_or_octet_stream()
1190            .to_string();
1191
1192        self.upload_attachment_bytes(issue_key, &file_name, bytes, Some(&mime))
1193            .await
1194    }
1195
1196    /// Upload an in-memory attachment to an issue.
1197    pub async fn upload_attachment_bytes(
1198        &self,
1199        issue_key: &str,
1200        file_name: &str,
1201        bytes: Vec<u8>,
1202        media_type: Option<&str>,
1203    ) -> Result<Vec<Attachment>> {
1204        use reqwest::{header::HeaderValue, multipart};
1205
1206        let headers = self.auth_headers_no_content_type()?;
1207        let url = self.platform_url(&format!("/issue/{issue_key}/attachments"));
1208        let mime = media_type
1209            .map(|value| value.to_string())
1210            .or_else(|| {
1211                mime_guess::from_path(file_name)
1212                    .first_raw()
1213                    .map(str::to_string)
1214            })
1215            .unwrap_or_else(|| "application/octet-stream".to_string());
1216
1217        let http = &self.http;
1218        let raw_attachments: Vec<Value> = self
1219            .request_multipart(|| {
1220                let part = multipart::Part::bytes(bytes.clone())
1221                    .file_name(file_name.to_string())
1222                    .mime_str(&mime)
1223                    .expect("invalid mime type");
1224                let form = multipart::Form::new().part("file", part);
1225
1226                let mut req_headers = headers.clone();
1227                req_headers.insert("X-Atlassian-Token", HeaderValue::from_static("no-check"));
1228
1229                http.post(&url).headers(req_headers).multipart(form)
1230            })
1231            .await?;
1232
1233        Ok(raw_attachments
1234            .iter()
1235            .filter_map(Attachment::from_value)
1236            .collect())
1237    }
1238
1239    /// Create a new issue with dynamic custom fields.
1240    pub async fn create_issue_v2(&self, req: CreateIssueRequestV2) -> Result<Issue> {
1241        let headers = self.auth_headers()?;
1242        let url = self.platform_url("/issue");
1243
1244        let description_adf = req
1245            .description_adf
1246            .or_else(|| req.description.as_deref().map(markdown_to_adf));
1247
1248        let mut fields = json!({
1249            "project": { "key": req.project_key },
1250            "summary": req.summary,
1251            "issuetype": { "name": req.issue_type }
1252        });
1253
1254        if let Some(adf) = description_adf {
1255            fields["description"] = adf;
1256        }
1257        if let Some(assignee) = &req.assignee {
1258            let account_id = self.resolve_assignee_account_id(assignee).await?;
1259            fields["assignee"] = json!({ "accountId": account_id });
1260        }
1261        if let Some(priority) = &req.priority {
1262            fields["priority"] = json!({ "name": priority });
1263        }
1264        if !req.labels.is_empty() {
1265            fields["labels"] = json!(req.labels);
1266        }
1267        if !req.components.is_empty() {
1268            fields["components"] = json!(req
1269                .components
1270                .iter()
1271                .map(|c| json!({"name": c}))
1272                .collect::<Vec<_>>());
1273        }
1274        if let Some(parent) = &req.parent {
1275            fields["parent"] = json!({ "key": parent });
1276        }
1277        if !req.fix_versions.is_empty() {
1278            fields["fixVersions"] = json!(req
1279                .fix_versions
1280                .iter()
1281                .map(|v| json!({"name": v}))
1282                .collect::<Vec<_>>());
1283        }
1284        for (field_id, value) in &req.custom_fields {
1285            fields[field_id] = value.to_api_json();
1286        }
1287
1288        let body = json!({ "fields": fields });
1289
1290        #[derive(serde::Deserialize)]
1291        struct CreateResponse {
1292            key: String,
1293        }
1294
1295        let http = &self.http;
1296        let resp: CreateResponse = self
1297            .request(|| http.post(&url).headers(headers.clone()).json(&body))
1298            .await?;
1299
1300        self.get_issue(&resp.key).await
1301    }
1302
1303    // ── Comments ─────────────────────────────────────────────────────────────
1304
1305    /// List all comments for an issue.
1306    pub async fn get_comments(&self, issue_key: &str) -> Result<Vec<Comment>> {
1307        let headers = self.auth_headers()?;
1308        let url = self.platform_url(&format!("/issue/{issue_key}/comment"));
1309
1310        #[derive(serde::Deserialize)]
1311        struct CommentResponse {
1312            comments: Vec<Value>,
1313        }
1314
1315        let http = &self.http;
1316        let resp: CommentResponse = self
1317            .request(|| http.get(&url).headers(headers.clone()))
1318            .await?;
1319
1320        Ok(resp
1321            .comments
1322            .iter()
1323            .filter_map(|v| Comment::from_value(v, issue_key))
1324            .collect())
1325    }
1326
1327    /// Add a comment to an issue.
1328    pub async fn add_comment(&self, issue_key: &str, body: &str) -> Result<Comment> {
1329        let headers = self.auth_headers()?;
1330        let url = self.platform_url(&format!("/issue/{issue_key}/comment"));
1331
1332        let payload = json!({
1333            "body": markdown_to_adf(body)
1334        });
1335
1336        let http = &self.http;
1337        let raw: Value = self
1338            .request(|| http.post(&url).headers(headers.clone()).json(&payload))
1339            .await?;
1340
1341        Comment::from_value(&raw, issue_key).ok_or_else(|| JiraError::Api {
1342            status: 0,
1343            message: "Failed to parse comment".into(),
1344        })
1345    }
1346
1347    // ── Worklog ──────────────────────────────────────────────────────────────
1348
1349    /// List all worklogs for an issue.
1350    pub async fn get_worklogs(&self, issue_key: &str) -> Result<Vec<Worklog>> {
1351        let headers = self.auth_headers()?;
1352        let url = self.platform_url(&format!("/issue/{issue_key}/worklog"));
1353
1354        #[derive(serde::Deserialize)]
1355        struct WorklogResponse {
1356            worklogs: Vec<Value>,
1357        }
1358
1359        let http = &self.http;
1360        let resp: WorklogResponse = self
1361            .request(|| http.get(&url).headers(headers.clone()))
1362            .await?;
1363
1364        Ok(resp
1365            .worklogs
1366            .iter()
1367            .filter_map(|v| Worklog::from_value(v, issue_key))
1368            .collect())
1369    }
1370
1371    /// Add a worklog entry to an issue.
1372    /// `time_spent` uses Jira format: "2h 30m", "1d", "45m"
1373    /// `started` is optional ISO 8601 timestamp; defaults to now if None.
1374    pub async fn add_worklog(
1375        &self,
1376        issue_key: &str,
1377        time_spent: &str,
1378        comment: Option<&str>,
1379        started: Option<&str>,
1380    ) -> Result<Worklog> {
1381        let headers = self.auth_headers()?;
1382        let url = self.platform_url(&format!("/issue/{issue_key}/worklog"));
1383
1384        // Jira requires started in "2006-01-02T15:04:05.000+0000" format
1385        let started_str = started
1386            .map(|s| s.to_string())
1387            .unwrap_or_else(current_jira_timestamp);
1388
1389        let mut body = json!({
1390            "timeSpent": time_spent,
1391            "started": started_str,
1392        });
1393
1394        if let Some(c) = comment {
1395            body["comment"] = markdown_to_adf(c);
1396        }
1397
1398        let http = &self.http;
1399        let raw: Value = self
1400            .request(|| http.post(&url).headers(headers.clone()).json(&body))
1401            .await?;
1402
1403        Worklog::from_value(&raw, issue_key).ok_or_else(|| JiraError::Api {
1404            status: 0,
1405            message: "Failed to parse worklog".into(),
1406        })
1407    }
1408
1409    /// Delete a worklog entry.
1410    pub async fn delete_worklog(&self, issue_key: &str, worklog_id: &str) -> Result<()> {
1411        let headers = self.auth_headers()?;
1412        let url = self.platform_url(&format!("/issue/{issue_key}/worklog/{worklog_id}"));
1413
1414        let http = &self.http;
1415        self.request_no_body(|| http.delete(&url).headers(headers.clone()))
1416            .await
1417    }
1418
1419    /// Delete a comment from an issue.
1420    pub async fn delete_comment(&self, issue_key: &str, comment_id: &str) -> Result<()> {
1421        let headers = self.auth_headers()?;
1422        let url = self.platform_url(&format!("/issue/{issue_key}/comment/{comment_id}"));
1423
1424        let http = &self.http;
1425        self.request_no_body(|| http.delete(&url).headers(headers.clone()))
1426            .await
1427    }
1428
1429    /// List attachments on an issue.
1430    pub async fn list_attachments(&self, issue_key: &str) -> Result<Vec<Attachment>> {
1431        let headers = self.auth_headers()?;
1432        let url = self.platform_url(&format!("/issue/{issue_key}?fields=attachment"));
1433
1434        let http = &self.http;
1435        let raw: Value = self
1436            .request(|| http.get(&url).headers(headers.clone()))
1437            .await?;
1438
1439        let arr = raw
1440            .get("fields")
1441            .and_then(|f| f.get("attachment"))
1442            .and_then(|a| a.as_array())
1443            .cloned()
1444            .unwrap_or_default();
1445
1446        Ok(arr
1447            .iter()
1448            .filter_map(crate::model::attachment::Attachment::from_value)
1449            .collect())
1450    }
1451
1452    /// Download an attachment by ID.
1453    ///
1454    /// Returns `(filename, bytes, mime_type)`. The filename comes from the
1455    /// `Content-Disposition` header when present, otherwise the trailing
1456    /// path segment of the redirect URL, otherwise `"attachment-<id>"`.
1457    pub async fn download_attachment(
1458        &self,
1459        attachment_id: &str,
1460    ) -> Result<(String, Vec<u8>, String)> {
1461        let headers = self.auth_headers_no_content_type()?;
1462        let url = self.platform_url(&format!("/attachment/content/{attachment_id}"));
1463
1464        let http = &self.http;
1465        let response = self
1466            .execute_with_retry(|| http.get(&url).headers(headers.clone()))
1467            .await?;
1468
1469        let status = response.status();
1470        if !status.is_success() {
1471            let body = response
1472                .text()
1473                .await
1474                .unwrap_or_else(|e| format!("<failed to read response body: {e}>"));
1475            if status == StatusCode::NOT_FOUND {
1476                return Err(JiraError::NotFound(body));
1477            }
1478            return Err(JiraError::Api {
1479                status: status.as_u16(),
1480                message: body,
1481            });
1482        }
1483
1484        let mime_type = response
1485            .headers()
1486            .get(reqwest::header::CONTENT_TYPE)
1487            .and_then(|v| v.to_str().ok())
1488            .unwrap_or("application/octet-stream")
1489            .to_string();
1490
1491        let filename = response
1492            .headers()
1493            .get(reqwest::header::CONTENT_DISPOSITION)
1494            .and_then(|v| v.to_str().ok())
1495            .and_then(parse_content_disposition_filename)
1496            .or_else(|| {
1497                response
1498                    .url()
1499                    .path_segments()
1500                    .and_then(|mut s| s.next_back().map(|s| s.to_string()))
1501                    .filter(|s| !s.is_empty())
1502            })
1503            .unwrap_or_else(|| format!("attachment-{attachment_id}"));
1504
1505        let bytes = response.bytes().await?.to_vec();
1506        Ok((filename, bytes, mime_type))
1507    }
1508
1509    /// Delete an attachment by ID.
1510    pub async fn delete_attachment(&self, attachment_id: &str) -> Result<()> {
1511        let headers = self.auth_headers()?;
1512        let url = self.platform_url(&format!("/attachment/{attachment_id}"));
1513
1514        let http = &self.http;
1515        self.request_no_body(|| http.delete(&url).headers(headers.clone()))
1516            .await
1517    }
1518
1519    /// List remote links on an issue.
1520    pub async fn get_remote_links(&self, issue_key: &str) -> Result<Vec<RemoteLink>> {
1521        let headers = self.auth_headers()?;
1522        let url = self.platform_url(&format!("/issue/{issue_key}/remotelink"));
1523
1524        let http = &self.http;
1525        self.request(|| http.get(&url).headers(headers.clone()))
1526            .await
1527    }
1528
1529    /// Add a remote link to an issue.
1530    pub async fn add_remote_link(
1531        &self,
1532        issue_key: &str,
1533        url_str: &str,
1534        title: &str,
1535    ) -> Result<Value> {
1536        let headers = self.auth_headers()?;
1537        let url = self.platform_url(&format!("/issue/{issue_key}/remotelink"));
1538
1539        let payload = json!({
1540            "object": {
1541                "url": url_str,
1542                "title": title,
1543            }
1544        });
1545
1546        let http = &self.http;
1547        self.request(|| http.post(&url).headers(headers.clone()).json(&payload))
1548            .await
1549    }
1550
1551    /// Delete a remote link from an issue.
1552    pub async fn delete_remote_link(&self, issue_key: &str, link_id: &str) -> Result<()> {
1553        let headers = self.auth_headers()?;
1554        let url = self.platform_url(&format!("/issue/{issue_key}/remotelink/{link_id}"));
1555
1556        let http = &self.http;
1557        self.request_no_body(|| http.delete(&url).headers(headers.clone()))
1558            .await
1559    }
1560
1561    // ── Bulk ops ─────────────────────────────────────────────────────────────
1562
1563    /// Fetch ALL issues matching a JQL query using cursor-based pagination.
1564    /// Respects the Atlassian safeguard: max 500 pages.
1565    pub async fn get_all_issues(&self, jql: &str) -> Result<Vec<Issue>> {
1566        let mut all_issues = Vec::new();
1567        let mut next_page_token: Option<String> = None;
1568        let mut iterations = 0u32;
1569        const MAX_ITERATIONS: u32 = 500;
1570
1571        loop {
1572            iterations += 1;
1573            if iterations > MAX_ITERATIONS {
1574                break;
1575            }
1576
1577            let result = self
1578                .search_issues(jql, next_page_token.as_deref(), Some(100))
1579                .await?;
1580
1581            all_issues.extend(result.issues);
1582
1583            match result.next_page_token {
1584                Some(token) => next_page_token = Some(token),
1585                None => break,
1586            }
1587        }
1588
1589        Ok(all_issues)
1590    }
1591
1592    /// Archive a batch of issues by key. Jira accepts up to 1000 per request.
1593    pub async fn archive_issues(&self, issue_keys: &[String]) -> Result<()> {
1594        if issue_keys.is_empty() {
1595            return Ok(());
1596        }
1597        let headers = self.auth_headers()?;
1598        let url = self.platform_url("/issue/archive");
1599
1600        // Batch in chunks of 1000
1601        for chunk in issue_keys.chunks(1000) {
1602            let body = json!({ "issueIdsOrKeys": chunk });
1603            let http = &self.http;
1604            // Archive returns 200 with a body — use request() not request_no_body()
1605            let _: Value = self
1606                .request(|| http.put(&url).headers(headers.clone()).json(&body))
1607                .await?;
1608        }
1609
1610        Ok(())
1611    }
1612
1613    /// Move an issue using Jira's native bulk move API.
1614    pub async fn move_issue(
1615        &self,
1616        issue_key: &str,
1617        target_project_key: &str,
1618        target_issue_type_id: &str,
1619        target_parent: Option<&str>,
1620    ) -> Result<Issue> {
1621        let mapping_key = match target_parent {
1622            Some(parent) => format!("{target_project_key},{target_issue_type_id},{parent}"),
1623            None => format!("{target_project_key},{target_issue_type_id}"),
1624        };
1625
1626        let body = json!({
1627            "sendBulkNotification": true,
1628            "targetToSourcesMapping": {
1629                mapping_key: {
1630                    "inferClassificationDefaults": true,
1631                    "inferFieldDefaults": true,
1632                    "inferStatusDefaults": true,
1633                    "inferSubtaskTypeDefault": true,
1634                    "issueIdsOrKeys": [issue_key]
1635                }
1636            }
1637        });
1638
1639        let bulk_move_path = self.platform_path("/bulk/issues/move");
1640        let submitted = self
1641            .raw_request("POST", &bulk_move_path, Some(body))
1642            .await?
1643            .ok_or_else(|| JiraError::Api {
1644                status: 0,
1645                message: "Bulk move returned an empty response".into(),
1646            })?;
1647
1648        let task_id = submitted
1649            .get("taskId")
1650            .and_then(|v| v.as_str())
1651            .ok_or_else(|| JiraError::Api {
1652                status: 0,
1653                message: "Bulk move response did not include a taskId".into(),
1654            })?;
1655
1656        const MAX_POLLS: usize = 60;
1657        for _ in 0..MAX_POLLS {
1658            tokio::time::sleep(Duration::from_secs(2)).await;
1659
1660            let progress_path = self.platform_path(&format!("/bulk/queue/{task_id}"));
1661            let progress = self
1662                .raw_request("GET", &progress_path, None)
1663                .await?
1664                .ok_or_else(|| JiraError::Api {
1665                    status: 0,
1666                    message: "Bulk move progress returned an empty response".into(),
1667                })?;
1668
1669            match progress
1670                .get("status")
1671                .and_then(|v| v.as_str())
1672                .unwrap_or("")
1673            {
1674                "COMPLETE" => return self.get_issue(issue_key).await,
1675                "FAILED" | "DEAD" | "CANCELLED" => {
1676                    return Err(JiraError::Api {
1677                        status: 0,
1678                        message: progress.to_string(),
1679                    });
1680                }
1681                _ => {}
1682            }
1683        }
1684
1685        Err(JiraError::Api {
1686            status: 0,
1687            message: format!("Timed out waiting for Jira bulk move task {task_id}"),
1688        })
1689    }
1690
1691    // ── Raw API passthrough ───────────────────────────────────────────────────
1692
1693    /// Execute an arbitrary Jira REST API call and return the raw JSON response.
1694    /// Returns `None` for 204 No Content responses (success with no body).
1695    /// `path` should start with `/rest/...`
1696    pub async fn raw_request(
1697        &self,
1698        method: &str,
1699        path: &str,
1700        body: Option<Value>,
1701    ) -> Result<Option<Value>> {
1702        let headers = self.auth_headers()?;
1703        let url = format!("{}{}", self.config.base_url.trim_end_matches('/'), path);
1704
1705        let http = &self.http;
1706        let response = self
1707            .execute_with_retry(|| {
1708                let req = match method.to_uppercase().as_str() {
1709                    "GET" => http.get(&url),
1710                    "POST" => http.post(&url),
1711                    "PUT" => http.put(&url),
1712                    "DELETE" => http.delete(&url),
1713                    "PATCH" => http.patch(&url),
1714                    _ => http.get(&url),
1715                };
1716                let req = req.headers(headers.clone());
1717                if let Some(b) = &body {
1718                    req.json(b)
1719                } else {
1720                    req
1721                }
1722            })
1723            .await?;
1724
1725        let status = response.status();
1726
1727        // 204 No Content — success with empty body
1728        if status == StatusCode::NO_CONTENT {
1729            return Ok(None);
1730        }
1731
1732        if status.is_success() {
1733            let value: Value = response.json().await?;
1734            return Ok(Some(value));
1735        }
1736
1737        let body_text = response.text().await.unwrap_or_default();
1738        Err(match status {
1739            StatusCode::NOT_FOUND => JiraError::NotFound(body_text),
1740            StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => {
1741                JiraError::Auth(format!("HTTP {status}: {body_text}"))
1742            }
1743            _ => JiraError::Api {
1744                status: status.as_u16(),
1745                message: body_text,
1746            },
1747        })
1748    }
1749
1750    // ── Plans API (Jira Premium) ──────────────────────────────────────────────
1751
1752    /// Check if this Jira instance is Premium tier.
1753    pub async fn is_premium(&self) -> bool {
1754        match self.get_server_info().await {
1755            Ok(info) => {
1756                let license = info
1757                    .get("deploymentType")
1758                    .and_then(|v| v.as_str())
1759                    .unwrap_or("");
1760                // "Cloud" with advanced features, or check licenseInfo
1761                let _ = license;
1762                // Simplest heuristic: try to call the plans endpoint
1763                let headers = match self.auth_headers() {
1764                    Ok(h) => h,
1765                    Err(_) => return false,
1766                };
1767                let url = self.platform_url("/plans/plan");
1768                let http = &self.http;
1769                matches!(
1770                    http.get(&url).headers(headers).send().await,
1771                    Ok(r) if r.status().is_success()
1772                )
1773            }
1774            Err(_) => false,
1775        }
1776    }
1777
1778    /// List Jira Plans (requires Jira Premium / Advanced Roadmaps).
1779    pub async fn get_plans(&self) -> Result<Vec<Value>> {
1780        let headers = self.auth_headers()?;
1781        let url = self.platform_url("/plans/plan");
1782
1783        #[derive(serde::Deserialize)]
1784        struct PlansResponse {
1785            values: Vec<Value>,
1786        }
1787
1788        let http = &self.http;
1789        let resp: PlansResponse = self
1790            .request(|| http.get(&url).headers(headers.clone()))
1791            .await?;
1792
1793        Ok(resp.values)
1794    }
1795
1796    // ── Issue Links ──────────────────────────────────────────────────────────
1797
1798    /// List available issue link types.
1799    pub async fn list_issue_link_types(&self) -> Result<Vec<IssueLinkType>> {
1800        let headers = self.auth_headers()?;
1801        let url = self.platform_url("/issueLinkType");
1802
1803        #[derive(serde::Deserialize)]
1804        struct LinkTypesResponse {
1805            #[serde(rename = "issueLinkTypes")]
1806            issue_link_types: Vec<IssueLinkType>,
1807        }
1808
1809        let http = &self.http;
1810        let resp: LinkTypesResponse = self
1811            .request(|| http.get(&url).headers(headers.clone()))
1812            .await?;
1813
1814        Ok(resp.issue_link_types)
1815    }
1816
1817    /// Create a link between two issues.
1818    /// `type_name` is the name of the link type (e.g., "Blocks").
1819    /// `outward_key` is the issue that is doing the action (e.g., the blocker).
1820    /// `inward_key` is the issue that is receiving the action (e.g., the blocked issue).
1821    pub async fn link_issues(
1822        &self,
1823        outward_key: &str,
1824        inward_key: &str,
1825        type_name: &str,
1826        comment: Option<&str>,
1827    ) -> Result<()> {
1828        let headers = self.auth_headers()?;
1829        let url = self.platform_url("/issueLink");
1830
1831        let mut payload = json!({
1832            "type": { "name": type_name },
1833            "inwardIssue": { "key": inward_key },
1834            "outwardIssue": { "key": outward_key }
1835        });
1836
1837        if let Some(c) = comment {
1838            payload["comment"] = json!({
1839                "body": crate::adf::markdown_to_adf(c)
1840            });
1841        }
1842
1843        let http = &self.http;
1844        self.request_no_body(|| http.post(&url).headers(headers.clone()).json(&payload))
1845            .await
1846    }
1847
1848    /// Get a specific issue link by ID.
1849    pub async fn get_issue_link(&self, link_id: &str) -> Result<IssueLink> {
1850        let headers = self.auth_headers()?;
1851        let url = self.platform_url(&format!("/issueLink/{link_id}"));
1852
1853        let http = &self.http;
1854        self.request(|| http.get(&url).headers(headers.clone()))
1855            .await
1856    }
1857
1858    /// Delete an issue link by ID.
1859    pub async fn delete_issue_link(&self, link_id: &str) -> Result<()> {
1860        let headers = self.auth_headers()?;
1861        let url = self.platform_url(&format!("/issueLink/{link_id}"));
1862
1863        let http = &self.http;
1864        self.request_no_body(|| http.delete(&url).headers(headers.clone()))
1865            .await
1866    }
1867}
1868
1869async fn handle_response<T>(response: Response) -> Result<T>
1870where
1871    T: serde::de::DeserializeOwned,
1872{
1873    let status = response.status();
1874
1875    if status.is_success() {
1876        // 204/205: no body — callers expecting a body should use request_no_body().
1877        // Defensive: try to deserialize from null (works for Value and Option<T>).
1878        if status == StatusCode::NO_CONTENT || status == StatusCode::RESET_CONTENT {
1879            return serde_json::from_value(serde_json::Value::Null).map_err(|_| JiraError::Api {
1880                status: status.as_u16(),
1881                message: "Unexpected empty response body".into(),
1882            });
1883        }
1884        let value: T = response.json().await?;
1885        return Ok(value);
1886    }
1887
1888    let body = response.text().await.unwrap_or_default();
1889
1890    match status {
1891        StatusCode::NOT_FOUND => Err(JiraError::NotFound(body)),
1892        StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => {
1893            Err(JiraError::Auth(format!("HTTP {status}: {body}")))
1894        }
1895        _ => Err(JiraError::Api {
1896            status: status.as_u16(),
1897            message: body,
1898        }),
1899    }
1900}
1901
1902/// Parse `filename` (or RFC 5987 `filename*`) from a Content-Disposition header.
1903fn parse_content_disposition_filename(value: &str) -> Option<String> {
1904    for part in value.split(';') {
1905        let part = part.trim();
1906        if let Some(rest) = part.strip_prefix("filename*=") {
1907            // RFC 5987: charset'lang'percent-encoded-value
1908            let payload = rest.splitn(3, '\'').nth(2)?;
1909            return Some(percent_decode(payload));
1910        }
1911        if let Some(rest) = part.strip_prefix("filename=") {
1912            let trimmed = rest.trim_matches('"').to_string();
1913            if !trimmed.is_empty() {
1914                return Some(trimmed);
1915            }
1916        }
1917    }
1918    None
1919}
1920
1921fn percent_decode(s: &str) -> String {
1922    let bytes = s.as_bytes();
1923    let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
1924    let mut i = 0;
1925    while i < bytes.len() {
1926        if bytes[i] == b'%' && i + 2 < bytes.len() {
1927            if let (Some(h), Some(l)) = (
1928                (bytes[i + 1] as char).to_digit(16),
1929                (bytes[i + 2] as char).to_digit(16),
1930            ) {
1931                out.push(((h << 4) | l) as u8);
1932                i += 3;
1933                continue;
1934            }
1935        }
1936        out.push(bytes[i]);
1937        i += 1;
1938    }
1939    String::from_utf8(out).unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned())
1940}
1941
1942/// Percent-encode a query-string component (unreserved chars per RFC 3986 are left as-is).
1943fn url_encode_component(s: &str) -> String {
1944    let mut out = String::with_capacity(s.len());
1945    for b in s.as_bytes() {
1946        match *b {
1947            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1948                out.push(*b as char);
1949            }
1950            _ => out.push_str(&format!("%{:02X}", b)),
1951        }
1952    }
1953    out
1954}
1955
1956/// Returns current UTC time in Jira worklog format: "2006-01-02T15:04:05.000+0000"
1957fn current_jira_timestamp() -> String {
1958    chrono::Utc::now()
1959        .format("%Y-%m-%dT%H:%M:%S%.3f%z")
1960        .to_string()
1961}
1962
1963fn base64_encode(input: &str) -> String {
1964    use std::fmt::Write;
1965    const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
1966    let bytes = input.as_bytes();
1967    let mut result = String::new();
1968    let mut i = 0;
1969    while i < bytes.len() {
1970        let b0 = bytes[i] as u32;
1971        let b1 = if i + 1 < bytes.len() {
1972            bytes[i + 1] as u32
1973        } else {
1974            0
1975        };
1976        let b2 = if i + 2 < bytes.len() {
1977            bytes[i + 2] as u32
1978        } else {
1979            0
1980        };
1981
1982        let _ = write!(result, "{}", CHARS[((b0 >> 2) & 0x3F) as usize] as char);
1983        let _ = write!(
1984            result,
1985            "{}",
1986            CHARS[(((b0 & 0x3) << 4) | ((b1 >> 4) & 0xF)) as usize] as char
1987        );
1988        if i + 1 < bytes.len() {
1989            let _ = write!(
1990                result,
1991                "{}",
1992                CHARS[(((b1 & 0xF) << 2) | ((b2 >> 6) & 0x3)) as usize] as char
1993            );
1994        } else {
1995            result.push('=');
1996        }
1997        if i + 2 < bytes.len() {
1998            let _ = write!(result, "{}", CHARS[(b2 & 0x3F) as usize] as char);
1999        } else {
2000            result.push('=');
2001        }
2002        i += 3;
2003    }
2004    result
2005}
2006
2007#[cfg(test)]
2008mod tests {
2009    use super::*;
2010    use crate::{
2011        config::{JiraAuthType, JiraDeployment},
2012        model::FieldValue,
2013    };
2014    use wiremock::{
2015        matchers::{body_json, header, method, path, query_param},
2016        Mock, MockServer, ResponseTemplate,
2017    };
2018
2019    fn cloud_client(server: &MockServer) -> JiraClient {
2020        JiraClient::new(JiraConfig {
2021            profile_name: Some("cloud-main".into()),
2022            base_url: server.uri(),
2023            email: "dev@example.com".into(),
2024            token: Some("cloud-token".into()),
2025            project: None,
2026            timeout_secs: 30,
2027            deployment: JiraDeployment::Cloud,
2028            auth_type: JiraAuthType::CloudApiToken,
2029            api_version: 3,
2030        })
2031    }
2032
2033    fn cloud_auth() -> String {
2034        format!("Basic {}", base64_encode("dev@example.com:cloud-token"))
2035    }
2036
2037    #[tokio::test]
2038    async fn data_center_pat_uses_bearer_and_api_v2() {
2039        let server = MockServer::start().await;
2040
2041        Mock::given(method("GET"))
2042            .and(path("/rest/api/2/serverInfo"))
2043            .and(header("authorization", "Bearer dc-token"))
2044            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2045                "deploymentType": "Data Center",
2046                "version": "10.0.0"
2047            })))
2048            .mount(&server)
2049            .await;
2050
2051        let client = JiraClient::new(JiraConfig {
2052            profile_name: Some("dc-main".into()),
2053            base_url: server.uri(),
2054            email: String::new(),
2055            token: Some("dc-token".into()),
2056            project: None,
2057            timeout_secs: 30,
2058            deployment: JiraDeployment::DataCenter,
2059            auth_type: JiraAuthType::DataCenterPat,
2060            api_version: 2,
2061        });
2062
2063        let info = client.get_server_info().await.expect("server info");
2064        assert_eq!(info["deploymentType"], Value::String("Data Center".into()));
2065    }
2066
2067    #[tokio::test]
2068    async fn cloud_auth_uses_basic_and_api_v3() {
2069        let server = MockServer::start().await;
2070        let expected = format!("Basic {}", base64_encode("dev@example.com:cloud-token"));
2071
2072        Mock::given(method("GET"))
2073            .and(path("/rest/api/3/serverInfo"))
2074            .and(header("authorization", expected.as_str()))
2075            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2076                "deploymentType": "Cloud",
2077                "version": "1001.0.0"
2078            })))
2079            .mount(&server)
2080            .await;
2081
2082        let client = JiraClient::new(JiraConfig {
2083            profile_name: Some("cloud-main".into()),
2084            base_url: server.uri(),
2085            email: "dev@example.com".into(),
2086            token: Some("cloud-token".into()),
2087            project: None,
2088            timeout_secs: 30,
2089            deployment: JiraDeployment::Cloud,
2090            auth_type: JiraAuthType::CloudApiToken,
2091            api_version: 3,
2092        });
2093
2094        let info = client.get_server_info().await.expect("server info");
2095        assert_eq!(info["deploymentType"], Value::String("Cloud".into()));
2096    }
2097
2098    #[tokio::test]
2099    async fn get_fields_for_issue_type_supports_map_response() {
2100        let server = MockServer::start().await;
2101        let expected = format!("Basic {}", base64_encode("dev@example.com:cloud-token"));
2102
2103        Mock::given(method("GET"))
2104            .and(path("/rest/api/3/issue/createmeta/TEST/issuetypes/10001"))
2105            .and(header("authorization", expected.as_str()))
2106            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2107                "fields": {
2108                    "summary": {
2109                        "name": "Summary",
2110                        "required": true,
2111                        "schema": { "type": "string" }
2112                    }
2113                }
2114            })))
2115            .mount(&server)
2116            .await;
2117
2118        let client = JiraClient::new(JiraConfig {
2119            profile_name: Some("cloud-main".into()),
2120            base_url: server.uri(),
2121            email: "dev@example.com".into(),
2122            token: Some("cloud-token".into()),
2123            project: None,
2124            timeout_secs: 30,
2125            deployment: JiraDeployment::Cloud,
2126            auth_type: JiraAuthType::CloudApiToken,
2127            api_version: 3,
2128        });
2129
2130        let fields = client
2131            .get_fields_for_issue_type("TEST", "10001")
2132            .await
2133            .expect("map response should parse");
2134
2135        assert_eq!(fields.len(), 1);
2136        assert_eq!(fields[0].id, "summary");
2137        assert_eq!(fields[0].name, "Summary");
2138        assert!(fields[0].required);
2139        assert_eq!(fields[0].field_type, "string");
2140    }
2141
2142    #[tokio::test]
2143    async fn get_fields_for_issue_type_supports_list_response() {
2144        let server = MockServer::start().await;
2145        let expected = format!("Basic {}", base64_encode("dev@example.com:cloud-token"));
2146
2147        Mock::given(method("GET"))
2148            .and(path("/rest/api/3/issue/createmeta/TEST/issuetypes/10002"))
2149            .and(header("authorization", expected.as_str()))
2150            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2151                "fields": [
2152                    {
2153                        "fieldId": "customfield_10553",
2154                        "key": "customfield_10553",
2155                        "name": "Labels (OSS)",
2156                        "required": true,
2157                        "schema": {
2158                            "custom": "com.atlassian.jira.plugin.system.customfieldtypes:labels",
2159                            "items": "string",
2160                            "type": "array"
2161                        },
2162                        "allowedValues": []
2163                    }
2164                ]
2165            })))
2166            .mount(&server)
2167            .await;
2168
2169        let client = JiraClient::new(JiraConfig {
2170            profile_name: Some("cloud-main".into()),
2171            base_url: server.uri(),
2172            email: "dev@example.com".into(),
2173            token: Some("cloud-token".into()),
2174            project: None,
2175            timeout_secs: 30,
2176            deployment: JiraDeployment::Cloud,
2177            auth_type: JiraAuthType::CloudApiToken,
2178            api_version: 3,
2179        });
2180
2181        let fields = client
2182            .get_fields_for_issue_type("TEST", "10002")
2183            .await
2184            .expect("list response should parse");
2185
2186        assert_eq!(fields.len(), 1);
2187        assert_eq!(fields[0].id, "customfield_10553");
2188        assert_eq!(fields[0].name, "Labels (OSS)");
2189        assert!(fields[0].required);
2190        assert_eq!(fields[0].field_type, "array");
2191    }
2192
2193    #[tokio::test]
2194    async fn search_users_supports_empty_query_and_no_results() {
2195        let server = MockServer::start().await;
2196        let expected_auth = cloud_auth();
2197
2198        Mock::given(method("GET"))
2199            .and(path("/rest/api/3/user/search"))
2200            .and(header("authorization", expected_auth.as_str()))
2201            .and(query_param("query", ""))
2202            .and(query_param("maxResults", "20"))
2203            .respond_with(ResponseTemplate::new(200).set_body_json(json!([])))
2204            .mount(&server)
2205            .await;
2206
2207        let client = cloud_client(&server);
2208        let users = client
2209            .search_users("")
2210            .await
2211            .expect("empty query should work");
2212
2213        assert!(users.is_empty());
2214    }
2215
2216    #[tokio::test]
2217    async fn search_users_preserves_users_without_email() {
2218        let server = MockServer::start().await;
2219        let expected_auth = cloud_auth();
2220
2221        Mock::given(method("GET"))
2222            .and(path("/rest/api/3/user/search"))
2223            .and(header("authorization", expected_auth.as_str()))
2224            .and(query_param("query", "alice"))
2225            .and(query_param("maxResults", "20"))
2226            .respond_with(ResponseTemplate::new(200).set_body_json(json!([
2227                {
2228                    "accountId": "acct-1",
2229                    "displayName": "Alice Example"
2230                }
2231            ])))
2232            .mount(&server)
2233            .await;
2234
2235        let client = cloud_client(&server);
2236        let users = client
2237            .search_users("alice")
2238            .await
2239            .expect("search should parse");
2240
2241        assert_eq!(users.len(), 1);
2242        assert_eq!(users[0].account_id, "acct-1");
2243        assert_eq!(users[0].display_name.as_deref(), Some("Alice Example"));
2244        assert!(users[0].email_address.is_none());
2245    }
2246
2247    #[tokio::test]
2248    async fn add_issue_to_sprint_posts_expected_payload() {
2249        let server = MockServer::start().await;
2250        let expected_auth = cloud_auth();
2251
2252        Mock::given(method("POST"))
2253            .and(path("/rest/agile/1.0/sprint/42/issue"))
2254            .and(header("authorization", expected_auth.as_str()))
2255            .and(body_json(json!({ "issues": ["TEST-123"] })))
2256            .respond_with(ResponseTemplate::new(200).set_body_json(json!({})))
2257            .mount(&server)
2258            .await;
2259
2260        let client = cloud_client(&server);
2261        client
2262            .add_issue_to_sprint(42, "TEST-123")
2263            .await
2264            .expect("add to sprint should succeed");
2265    }
2266
2267    #[tokio::test]
2268    async fn add_issue_to_sprint_propagates_non_success_response() {
2269        let server = MockServer::start().await;
2270        let expected_auth = cloud_auth();
2271
2272        Mock::given(method("POST"))
2273            .and(path("/rest/agile/1.0/sprint/42/issue"))
2274            .and(header("authorization", expected_auth.as_str()))
2275            .respond_with(ResponseTemplate::new(400).set_body_string("bad sprint request"))
2276            .mount(&server)
2277            .await;
2278
2279        let client = cloud_client(&server);
2280        let err = client
2281            .add_issue_to_sprint(42, "TEST-123")
2282            .await
2283            .expect_err("non-success should return error");
2284
2285        match err {
2286            JiraError::Api { status, message } => {
2287                assert_eq!(status, 400);
2288                assert!(message.contains("bad sprint request"));
2289            }
2290            other => panic!("expected JiraError::Api, got {other:?}"),
2291        }
2292    }
2293
2294    #[tokio::test]
2295    async fn list_sprints_for_project_returns_empty_when_no_boards_exist() {
2296        let server = MockServer::start().await;
2297        let expected_auth = cloud_auth();
2298
2299        Mock::given(method("GET"))
2300            .and(path("/rest/agile/1.0/board"))
2301            .and(header("authorization", expected_auth.as_str()))
2302            .and(query_param("projectKeyOrId", "TEST"))
2303            .and(query_param("maxResults", "100"))
2304            .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "values": [] })))
2305            .mount(&server)
2306            .await;
2307
2308        let client = cloud_client(&server);
2309        let sprints = client
2310            .list_sprints_for_project("TEST")
2311            .await
2312            .expect("no boards should not error");
2313
2314        assert!(sprints.is_empty());
2315    }
2316
2317    #[tokio::test]
2318    async fn list_sprints_for_project_dedups_sorts_and_skips_missing_board_sprints() {
2319        let server = MockServer::start().await;
2320        let expected_auth = cloud_auth();
2321
2322        Mock::given(method("GET"))
2323            .and(path("/rest/agile/1.0/board"))
2324            .and(header("authorization", expected_auth.as_str()))
2325            .and(query_param("projectKeyOrId", "TEST"))
2326            .and(query_param("maxResults", "100"))
2327            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2328                "values": [
2329                    { "id": 1 },
2330                    { "id": 2 },
2331                    { "id": 3 }
2332                ]
2333            })))
2334            .mount(&server)
2335            .await;
2336
2337        Mock::given(method("GET"))
2338            .and(path("/rest/agile/1.0/board/1/sprint"))
2339            .and(header("authorization", expected_auth.as_str()))
2340            .and(query_param("state", "active,future"))
2341            .and(query_param("maxResults", "200"))
2342            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2343                "values": [
2344                    { "id": 20, "name": "Future Sprint", "state": "future" },
2345                    { "id": 10, "name": "Active Sprint", "state": "active" }
2346                ]
2347            })))
2348            .mount(&server)
2349            .await;
2350
2351        Mock::given(method("GET"))
2352            .and(path("/rest/agile/1.0/board/2/sprint"))
2353            .and(header("authorization", expected_auth.as_str()))
2354            .and(query_param("state", "active,future"))
2355            .and(query_param("maxResults", "200"))
2356            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2357                "values": [
2358                    { "id": 10, "name": "Active Sprint", "state": "active" },
2359                    { "id": 30, "name": "Alpha Future", "state": "future" }
2360                ]
2361            })))
2362            .mount(&server)
2363            .await;
2364
2365        Mock::given(method("GET"))
2366            .and(path("/rest/agile/1.0/board/3/sprint"))
2367            .and(header("authorization", expected_auth.as_str()))
2368            .and(query_param("state", "active,future"))
2369            .and(query_param("maxResults", "200"))
2370            .respond_with(ResponseTemplate::new(404).set_body_string("board not found"))
2371            .mount(&server)
2372            .await;
2373
2374        let client = cloud_client(&server);
2375        let sprints = client
2376            .list_sprints_for_project("TEST")
2377            .await
2378            .expect("404 on one board should be skipped");
2379
2380        assert_eq!(sprints.len(), 3);
2381        assert_eq!(sprints[0].id, 10);
2382        assert_eq!(sprints[0].state, "active");
2383        assert_eq!(sprints[1].id, 30);
2384        assert_eq!(sprints[1].state, "future");
2385        assert_eq!(sprints[2].id, 20);
2386        assert_eq!(sprints[2].state, "future");
2387    }
2388
2389    #[tokio::test]
2390    async fn list_sprints_for_project_skips_kanban_boards() {
2391        let server = MockServer::start().await;
2392        let expected_auth = cloud_auth();
2393
2394        Mock::given(method("GET"))
2395            .and(path("/rest/agile/1.0/board"))
2396            .and(header("authorization", expected_auth.as_str()))
2397            .and(query_param("projectKeyOrId", "TEST"))
2398            .and(query_param("maxResults", "100"))
2399            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2400                "values": [
2401                    { "id": 1, "type": "scrum" },
2402                    { "id": 2, "type": "kanban" }
2403                ]
2404            })))
2405            .mount(&server)
2406            .await;
2407
2408        Mock::given(method("GET"))
2409            .and(path("/rest/agile/1.0/board/1/sprint"))
2410            .and(header("authorization", expected_auth.as_str()))
2411            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2412                "values": [
2413                    { "id": 10, "name": "Active Sprint", "state": "active" }
2414                ]
2415            })))
2416            .mount(&server)
2417            .await;
2418
2419        // Kanban board must never be queried — a 500 here would surface as an
2420        // error if the filter failed.
2421        Mock::given(method("GET"))
2422            .and(path("/rest/agile/1.0/board/2/sprint"))
2423            .and(header("authorization", expected_auth.as_str()))
2424            .respond_with(ResponseTemplate::new(500).set_body_string("kanban has no sprints"))
2425            .mount(&server)
2426            .await;
2427
2428        let client = cloud_client(&server);
2429        let sprints = client
2430            .list_sprints_for_project("TEST")
2431            .await
2432            .expect("kanban board should be skipped, not error");
2433
2434        assert_eq!(sprints.len(), 1);
2435        assert_eq!(sprints[0].id, 10);
2436    }
2437
2438    #[tokio::test]
2439    async fn get_project_fields_aggregates_across_issue_types() {
2440        let server = MockServer::start().await;
2441        let expected_auth = cloud_auth();
2442
2443        Mock::given(method("GET"))
2444            .and(path("/rest/api/3/issue/createmeta/TEST/issuetypes"))
2445            .and(header("authorization", expected_auth.as_str()))
2446            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2447                "issueTypes": [
2448                    { "id": "10001", "name": "Story" },
2449                    { "id": "10002", "name": "Task" }
2450                ]
2451            })))
2452            .mount(&server)
2453            .await;
2454
2455        Mock::given(method("GET"))
2456            .and(path("/rest/api/3/issue/createmeta/TEST/issuetypes/10001"))
2457            .and(header("authorization", expected_auth.as_str()))
2458            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2459                "fields": {
2460                    "summary": { "name": "Summary", "required": true, "schema": { "type": "string" } },
2461                    "customfield_1": { "name": "Story Points", "required": false, "schema": { "type": "number" } }
2462                }
2463            })))
2464            .mount(&server)
2465            .await;
2466
2467        Mock::given(method("GET"))
2468            .and(path("/rest/api/3/issue/createmeta/TEST/issuetypes/10002"))
2469            .and(header("authorization", expected_auth.as_str()))
2470            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2471                "fields": {
2472                    "summary": { "name": "Summary", "required": true, "schema": { "type": "string" } },
2473                    "customfield_2": { "name": "Component", "required": false, "schema": { "type": "string" } }
2474                }
2475            })))
2476            .mount(&server)
2477            .await;
2478
2479        let client = cloud_client(&server);
2480        let fields = client
2481            .get_project_fields("TEST")
2482            .await
2483            .expect("project fields should resolve per issue type");
2484
2485        let mut ids: Vec<&str> = fields.iter().map(|f| f.id.as_str()).collect();
2486        ids.sort_unstable();
2487        assert_eq!(ids, vec!["customfield_1", "customfield_2", "summary"]);
2488    }
2489
2490    #[tokio::test]
2491    async fn list_sprints_for_project_handles_large_sprint_pages() {
2492        let server = MockServer::start().await;
2493        let expected_auth = cloud_auth();
2494
2495        let sprint_values: Vec<Value> = (1..=60)
2496            .map(|id| {
2497                json!({
2498                    "id": id,
2499                    "name": format!("Sprint {id:02}"),
2500                    "state": if id == 1 { "active" } else { "future" }
2501                })
2502            })
2503            .collect();
2504
2505        Mock::given(method("GET"))
2506            .and(path("/rest/agile/1.0/board"))
2507            .and(header("authorization", expected_auth.as_str()))
2508            .and(query_param("projectKeyOrId", "TEST"))
2509            .and(query_param("maxResults", "100"))
2510            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2511                "values": [{ "id": 9 }]
2512            })))
2513            .mount(&server)
2514            .await;
2515
2516        Mock::given(method("GET"))
2517            .and(path("/rest/agile/1.0/board/9/sprint"))
2518            .and(header("authorization", expected_auth.as_str()))
2519            .and(query_param("state", "active,future"))
2520            .and(query_param("maxResults", "200"))
2521            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2522                "values": sprint_values
2523            })))
2524            .mount(&server)
2525            .await;
2526
2527        let client = cloud_client(&server);
2528        let sprints = client
2529            .list_sprints_for_project("TEST")
2530            .await
2531            .expect(">50 sprints in one response should parse");
2532
2533        assert_eq!(sprints.len(), 60);
2534        assert_eq!(sprints[0].id, 1);
2535        assert_eq!(sprints[0].state, "active");
2536        assert_eq!(sprints[59].id, 60);
2537    }
2538
2539    #[tokio::test]
2540    async fn list_sprints_for_project_paginates_boards_and_sprints() {
2541        let server = MockServer::start().await;
2542        let expected_auth = cloud_auth();
2543
2544        Mock::given(method("GET"))
2545            .and(path("/rest/agile/1.0/board"))
2546            .and(header("authorization", expected_auth.as_str()))
2547            .and(query_param("projectKeyOrId", "TEST"))
2548            .and(query_param("maxResults", "100"))
2549            .and(query_param("startAt", "0"))
2550            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2551                "values": [{ "id": 1 }],
2552                "isLast": false
2553            })))
2554            .mount(&server)
2555            .await;
2556
2557        Mock::given(method("GET"))
2558            .and(path("/rest/agile/1.0/board"))
2559            .and(header("authorization", expected_auth.as_str()))
2560            .and(query_param("projectKeyOrId", "TEST"))
2561            .and(query_param("maxResults", "100"))
2562            .and(query_param("startAt", "1"))
2563            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2564                "values": [{ "id": 2 }],
2565                "isLast": true
2566            })))
2567            .mount(&server)
2568            .await;
2569
2570        Mock::given(method("GET"))
2571            .and(path("/rest/agile/1.0/board/1/sprint"))
2572            .and(header("authorization", expected_auth.as_str()))
2573            .and(query_param("state", "active,future"))
2574            .and(query_param("maxResults", "200"))
2575            .and(query_param("startAt", "0"))
2576            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2577                "values": [{
2578                    "id": 10,
2579                    "name": "Sprint 10",
2580                    "state": "active"
2581                }],
2582                "isLast": false
2583            })))
2584            .mount(&server)
2585            .await;
2586
2587        Mock::given(method("GET"))
2588            .and(path("/rest/agile/1.0/board/1/sprint"))
2589            .and(header("authorization", expected_auth.as_str()))
2590            .and(query_param("state", "active,future"))
2591            .and(query_param("maxResults", "200"))
2592            .and(query_param("startAt", "1"))
2593            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2594                "values": [{
2595                    "id": 11,
2596                    "name": "Sprint 11",
2597                    "state": "future"
2598                }],
2599                "isLast": true
2600            })))
2601            .mount(&server)
2602            .await;
2603
2604        Mock::given(method("GET"))
2605            .and(path("/rest/agile/1.0/board/2/sprint"))
2606            .and(header("authorization", expected_auth.as_str()))
2607            .and(query_param("state", "active,future"))
2608            .and(query_param("maxResults", "200"))
2609            .and(query_param("startAt", "0"))
2610            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2611                "values": [{
2612                    "id": 12,
2613                    "name": "Sprint 12",
2614                    "state": "future"
2615                }],
2616                "isLast": true
2617            })))
2618            .mount(&server)
2619            .await;
2620
2621        let client = cloud_client(&server);
2622        let sprints = client
2623            .list_sprints_for_project("TEST")
2624            .await
2625            .expect("pagination should collect all pages");
2626
2627        assert_eq!(sprints.len(), 3);
2628        assert_eq!(sprints[0].id, 10);
2629        assert_eq!(sprints[1].id, 11);
2630        assert_eq!(sprints[2].id, 12);
2631    }
2632
2633    #[tokio::test]
2634    async fn list_sprints_for_project_propagates_non_not_found_board_errors() {
2635        let server = MockServer::start().await;
2636        let expected_auth = cloud_auth();
2637
2638        Mock::given(method("GET"))
2639            .and(path("/rest/agile/1.0/board"))
2640            .and(header("authorization", expected_auth.as_str()))
2641            .and(query_param("projectKeyOrId", "TEST"))
2642            .and(query_param("maxResults", "100"))
2643            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2644                "values": [{ "id": 9 }]
2645            })))
2646            .mount(&server)
2647            .await;
2648
2649        Mock::given(method("GET"))
2650            .and(path("/rest/agile/1.0/board/9/sprint"))
2651            .and(header("authorization", expected_auth.as_str()))
2652            .and(query_param("state", "active,future"))
2653            .and(query_param("maxResults", "200"))
2654            .respond_with(ResponseTemplate::new(500).set_body_string("jira exploded"))
2655            .mount(&server)
2656            .await;
2657
2658        let client = cloud_client(&server);
2659        let err = client
2660            .list_sprints_for_project("TEST")
2661            .await
2662            .expect_err("500 should propagate");
2663
2664        match err {
2665            JiraError::Api { status, message } => {
2666                assert_eq!(status, 500);
2667                assert!(message.contains("jira exploded"));
2668            }
2669            other => panic!("expected JiraError::Api, got {other:?}"),
2670        }
2671    }
2672
2673    #[tokio::test]
2674    async fn create_sprint_posts_expected_payload() {
2675        let server = MockServer::start().await;
2676        let expected_auth = cloud_auth();
2677
2678        Mock::given(method("POST"))
2679            .and(path("/rest/agile/1.0/sprint"))
2680            .and(header("authorization", expected_auth.as_str()))
2681            .and(body_json(json!({
2682                "name": "Sprint 42",
2683                "originBoardId": 7,
2684                "startDate": "2026-05-20T00:00:00.000Z",
2685                "endDate": "2026-05-27T00:00:00.000Z",
2686                "goal": "Ship sprint lifecycle support"
2687            })))
2688            .respond_with(ResponseTemplate::new(201).set_body_json(json!({
2689                "id": 42,
2690                "name": "Sprint 42",
2691                "state": "future",
2692                "goal": "Ship sprint lifecycle support",
2693                "startDate": "2026-05-20T00:00:00.000Z",
2694                "endDate": "2026-05-27T00:00:00.000Z"
2695            })))
2696            .mount(&server)
2697            .await;
2698
2699        let client = cloud_client(&server);
2700        let sprint = client
2701            .create_sprint(
2702                7,
2703                "Sprint 42",
2704                Some("2026-05-20T00:00:00.000Z"),
2705                Some("2026-05-27T00:00:00.000Z"),
2706                Some("Ship sprint lifecycle support"),
2707            )
2708            .await
2709            .expect("create sprint should succeed");
2710
2711        assert_eq!(sprint.id, 42);
2712        assert_eq!(sprint.board_id, Some(7));
2713        assert_eq!(sprint.state, "future");
2714        assert_eq!(
2715            sprint.goal.as_deref(),
2716            Some("Ship sprint lifecycle support")
2717        );
2718    }
2719
2720    #[tokio::test]
2721    async fn update_sprint_puts_expected_payload() {
2722        let server = MockServer::start().await;
2723        let expected_auth = cloud_auth();
2724
2725        Mock::given(method("PUT"))
2726            .and(path("/rest/agile/1.0/sprint/42"))
2727            .and(header("authorization", expected_auth.as_str()))
2728            .and(body_json(json!({
2729                "state": "active",
2730                "startDate": "2026-05-20T00:00:00.000Z",
2731                "endDate": "2026-05-27T00:00:00.000Z"
2732            })))
2733            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2734                "id": 42,
2735                "name": "Sprint 42",
2736                "state": "active",
2737                "startDate": "2026-05-20T00:00:00.000Z",
2738                "endDate": "2026-05-27T00:00:00.000Z"
2739            })))
2740            .mount(&server)
2741            .await;
2742
2743        let client = cloud_client(&server);
2744        let sprint = client
2745            .update_sprint(
2746                42,
2747                json!({
2748                    "state": "active",
2749                    "startDate": "2026-05-20T00:00:00.000Z",
2750                    "endDate": "2026-05-27T00:00:00.000Z"
2751                }),
2752            )
2753            .await
2754            .expect("update sprint should succeed");
2755
2756        assert_eq!(sprint.id, 42);
2757        assert_eq!(sprint.state, "active");
2758    }
2759
2760    #[tokio::test]
2761    async fn delete_sprint_uses_delete_endpoint() {
2762        let server = MockServer::start().await;
2763        let expected_auth = cloud_auth();
2764
2765        Mock::given(method("DELETE"))
2766            .and(path("/rest/agile/1.0/sprint/42"))
2767            .and(header("authorization", expected_auth.as_str()))
2768            .respond_with(ResponseTemplate::new(204))
2769            .mount(&server)
2770            .await;
2771
2772        let client = cloud_client(&server);
2773        client
2774            .delete_sprint(42)
2775            .await
2776            .expect("delete sprint should succeed");
2777    }
2778
2779    #[tokio::test]
2780    async fn create_issue_v2_prefers_adf_and_builds_extended_fields() {
2781        let server = MockServer::start().await;
2782        let expected_auth = cloud_auth();
2783        let description_adf = json!({
2784            "type": "doc",
2785            "version": 1,
2786            "content": [{
2787                "type": "paragraph",
2788                "content": [{ "type": "text", "text": "ADF body" }]
2789            }]
2790        });
2791
2792        Mock::given(method("GET"))
2793            .and(path("/rest/api/3/user/search"))
2794            .and(header("authorization", expected_auth.as_str()))
2795            .and(query_param("query", "alice@example.com"))
2796            .and(query_param("maxResults", "20"))
2797            .respond_with(ResponseTemplate::new(200).set_body_json(json!([
2798                {
2799                    "accountId": "acct-1",
2800                    "emailAddress": "alice@example.com",
2801                    "displayName": "Alice"
2802                }
2803            ])))
2804            .mount(&server)
2805            .await;
2806
2807        Mock::given(method("POST"))
2808            .and(path("/rest/api/3/issue"))
2809            .and(header("authorization", expected_auth.as_str()))
2810            .and(body_json(json!({
2811                "fields": {
2812                    "project": { "key": "TEST" },
2813                    "summary": "Ship feature",
2814                    "issuetype": { "name": "Task" },
2815                    "description": description_adf,
2816                    "assignee": { "accountId": "acct-1" },
2817                    "priority": { "name": "High" },
2818                    "labels": ["backend", "urgent"],
2819                    "components": [{ "name": "api" }, { "name": "worker" }],
2820                    "parent": { "key": "TEST-1" },
2821                    "fixVersions": [{ "name": "v1.0" }, { "name": "v1.1" }],
2822                    "customfield_10010": "hello",
2823                    "customfield_10011": { "value": "Blue" },
2824                    "customfield_10012": ["triage"]
2825                }
2826            })))
2827            .respond_with(ResponseTemplate::new(201).set_body_json(json!({ "key": "TEST-123" })))
2828            .mount(&server)
2829            .await;
2830
2831        Mock::given(method("GET"))
2832            .and(path("/rest/api/3/issue/TEST-123"))
2833            .and(header("authorization", expected_auth.as_str()))
2834            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2835                "id": "10001",
2836                "key": "TEST-123",
2837                "fields": {
2838                    "summary": "Ship feature",
2839                    "description": description_adf,
2840                    "status": { "name": "To Do" },
2841                    "issuetype": { "name": "Task" },
2842                    "project": { "key": "TEST" },
2843                    "created": "2023-01-01T00:00:00.000+0000",
2844                    "updated": "2023-01-01T00:00:00.000+0000"
2845                }
2846            })))
2847            .mount(&server)
2848            .await;
2849
2850        let client = cloud_client(&server);
2851        let mut custom_fields = std::collections::HashMap::new();
2852        custom_fields.insert(
2853            "customfield_10010".to_string(),
2854            FieldValue::Text("hello".into()),
2855        );
2856        custom_fields.insert(
2857            "customfield_10011".to_string(),
2858            FieldValue::SelectName("Blue".into()),
2859        );
2860        custom_fields.insert(
2861            "customfield_10012".to_string(),
2862            FieldValue::Labels(vec!["triage".into()]),
2863        );
2864
2865        let issue = client
2866            .create_issue_v2(CreateIssueRequestV2 {
2867                project_key: "TEST".into(),
2868                summary: "Ship feature".into(),
2869                description: Some("markdown body should be ignored".into()),
2870                description_adf: Some(description_adf.clone()),
2871                issue_type: "Task".into(),
2872                assignee: Some("alice@example.com".into()),
2873                priority: Some("High".into()),
2874                labels: vec!["backend".into(), "urgent".into()],
2875                components: vec!["api".into(), "worker".into()],
2876                parent: Some("TEST-1".into()),
2877                fix_versions: vec!["v1.0".into(), "v1.1".into()],
2878                custom_fields,
2879            })
2880            .await
2881            .expect("create issue v2 should succeed");
2882
2883        assert_eq!(issue.key, "TEST-123");
2884        assert_eq!(issue.summary, "Ship feature");
2885        assert_eq!(issue.description, Some(description_adf));
2886    }
2887
2888    #[tokio::test]
2889    async fn create_issue_v2_uses_markdown_description_when_adf_missing() {
2890        let server = MockServer::start().await;
2891        let expected_auth = cloud_auth();
2892        let markdown_adf = markdown_to_adf("hello world");
2893
2894        Mock::given(method("POST"))
2895            .and(path("/rest/api/3/issue"))
2896            .and(header("authorization", expected_auth.as_str()))
2897            .and(body_json(json!({
2898                "fields": {
2899                    "project": { "key": "TEST" },
2900                    "summary": "Plain issue",
2901                    "issuetype": { "name": "Task" },
2902                    "description": markdown_adf
2903                }
2904            })))
2905            .respond_with(ResponseTemplate::new(201).set_body_json(json!({ "key": "TEST-124" })))
2906            .mount(&server)
2907            .await;
2908
2909        Mock::given(method("GET"))
2910            .and(path("/rest/api/3/issue/TEST-124"))
2911            .and(header("authorization", expected_auth.as_str()))
2912            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2913                "id": "10002",
2914                "key": "TEST-124",
2915                "fields": {
2916                    "summary": "Plain issue",
2917                    "description": markdown_to_adf("hello world"),
2918                    "status": { "name": "To Do" },
2919                    "issuetype": { "name": "Task" },
2920                    "project": { "key": "TEST" },
2921                    "created": "2023-01-01T00:00:00.000+0000",
2922                    "updated": "2023-01-01T00:00:00.000+0000"
2923                }
2924            })))
2925            .mount(&server)
2926            .await;
2927
2928        let client = cloud_client(&server);
2929        let issue = client
2930            .create_issue_v2(CreateIssueRequestV2 {
2931                project_key: "TEST".into(),
2932                summary: "Plain issue".into(),
2933                description: Some("hello world".into()),
2934                description_adf: None,
2935                issue_type: "Task".into(),
2936                assignee: None,
2937                priority: None,
2938                labels: vec![],
2939                components: vec![],
2940                parent: None,
2941                fix_versions: vec![],
2942                custom_fields: std::collections::HashMap::new(),
2943            })
2944            .await
2945            .expect("markdown fallback should succeed");
2946
2947        assert_eq!(issue.key, "TEST-124");
2948        assert_eq!(issue.summary, "Plain issue");
2949        assert_eq!(issue.description, Some(markdown_to_adf("hello world")));
2950    }
2951
2952    #[tokio::test]
2953    async fn get_comments_parses_comment_text_and_mentions() {
2954        let server = MockServer::start().await;
2955        let expected_auth = cloud_auth();
2956
2957        Mock::given(method("GET"))
2958            .and(path("/rest/api/3/issue/TEST-1/comment"))
2959            .and(header("authorization", expected_auth.as_str()))
2960            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2961                "comments": [
2962                    {
2963                        "id": "10001",
2964                        "author": {
2965                            "displayName": "Alice",
2966                            "accountId": "acct-1"
2967                        },
2968                        "body": {
2969                            "type": "doc",
2970                            "version": 1,
2971                            "content": [{
2972                                "type": "paragraph",
2973                                "content": [
2974                                    { "type": "text", "text": "Hi " },
2975                                    { "type": "mention", "attrs": { "id": "acct-2", "text": "@Bob" } }
2976                                ]
2977                            }]
2978                        },
2979                        "created": "2023-01-01T00:00:00.000+0000",
2980                        "updated": "2023-01-01T00:00:00.000+0000"
2981                    }
2982                ]
2983            })))
2984            .mount(&server)
2985            .await;
2986
2987        let client = cloud_client(&server);
2988        let comments = client
2989            .get_comments("TEST-1")
2990            .await
2991            .expect("comments should parse");
2992
2993        assert_eq!(comments.len(), 1);
2994        assert_eq!(comments[0].id, "10001");
2995        assert_eq!(comments[0].author.as_deref(), Some("Alice"));
2996        assert_eq!(comments[0].author_account_id.as_deref(), Some("acct-1"));
2997        assert_eq!(comments[0].body.as_deref(), Some("Hi @Bob"));
2998        assert_eq!(comments[0].mentions, vec!["acct-2"]);
2999    }
3000
3001    #[tokio::test]
3002    async fn add_comment_adf_posts_prebuilt_adf_payload() {
3003        let server = MockServer::start().await;
3004        let expected_auth = cloud_auth();
3005        let adf = json!({
3006            "type": "doc",
3007            "version": 1,
3008            "content": [{
3009                "type": "paragraph",
3010                "content": [
3011                    { "type": "text", "text": "Hi " },
3012                    { "type": "mention", "attrs": { "id": "acct-2", "text": "@Bob" } }
3013                ]
3014            }]
3015        });
3016
3017        Mock::given(method("POST"))
3018            .and(path("/rest/api/3/issue/TEST-1/comment"))
3019            .and(header("authorization", expected_auth.as_str()))
3020            .and(body_json(json!({ "body": adf.clone() })))
3021            .respond_with(ResponseTemplate::new(201).set_body_json(json!({
3022                "id": "10002",
3023                "author": {
3024                    "displayName": "Alice",
3025                    "accountId": "acct-1"
3026                },
3027                "body": adf,
3028                "created": "2023-01-01T00:00:00.000+0000",
3029                "updated": "2023-01-01T00:00:00.000+0000"
3030            })))
3031            .mount(&server)
3032            .await;
3033
3034        let client = cloud_client(&server);
3035        let comment = client
3036            .add_comment_adf(
3037                "TEST-1",
3038                json!({
3039                    "type": "doc",
3040                    "version": 1,
3041                    "content": [{
3042                        "type": "paragraph",
3043                        "content": [
3044                            { "type": "text", "text": "Hi " },
3045                            { "type": "mention", "attrs": { "id": "acct-2", "text": "@Bob" } }
3046                        ]
3047                    }]
3048                }),
3049            )
3050            .await
3051            .expect("add comment adf should succeed");
3052
3053        assert_eq!(comment.id, "10002");
3054        assert_eq!(comment.body.as_deref(), Some("Hi @Bob"));
3055        assert_eq!(comment.mentions, vec!["acct-2"]);
3056    }
3057
3058    #[tokio::test]
3059    async fn search_users_retries_after_429_then_succeeds() {
3060        let server = MockServer::start().await;
3061        let expected_auth = cloud_auth();
3062
3063        Mock::given(method("GET"))
3064            .and(path("/rest/api/3/user/search"))
3065            .and(header("authorization", expected_auth.as_str()))
3066            .and(query_param("query", "alice"))
3067            .and(query_param("maxResults", "20"))
3068            .respond_with(ResponseTemplate::new(429).insert_header("Retry-After", "0"))
3069            .up_to_n_times(1)
3070            .mount(&server)
3071            .await;
3072
3073        Mock::given(method("GET"))
3074            .and(path("/rest/api/3/user/search"))
3075            .and(header("authorization", expected_auth.as_str()))
3076            .and(query_param("query", "alice"))
3077            .and(query_param("maxResults", "20"))
3078            .respond_with(ResponseTemplate::new(200).set_body_json(json!([
3079                {
3080                    "accountId": "acct-1",
3081                    "displayName": "Alice Example"
3082                }
3083            ])))
3084            .mount(&server)
3085            .await;
3086
3087        let client = cloud_client(&server);
3088        let users = client
3089            .search_users("alice")
3090            .await
3091            .expect("request should retry and succeed");
3092
3093        assert_eq!(users.len(), 1);
3094        assert_eq!(users[0].account_id, "acct-1");
3095    }
3096
3097    #[tokio::test]
3098    async fn search_users_returns_rate_limit_after_max_retries() {
3099        let server = MockServer::start().await;
3100        let expected_auth = cloud_auth();
3101
3102        Mock::given(method("GET"))
3103            .and(path("/rest/api/3/user/search"))
3104            .and(header("authorization", expected_auth.as_str()))
3105            .and(query_param("query", "alice"))
3106            .and(query_param("maxResults", "20"))
3107            .respond_with(ResponseTemplate::new(429).insert_header("Retry-After", "0"))
3108            .mount(&server)
3109            .await;
3110
3111        let client = cloud_client(&server);
3112        let err = client
3113            .search_users("alice")
3114            .await
3115            .expect_err("request should fail after max retries");
3116
3117        match err {
3118            JiraError::RateLimit { retry_after } => assert_eq!(retry_after, 0),
3119            other => panic!("expected JiraError::RateLimit, got {other:?}"),
3120        }
3121    }
3122
3123    #[tokio::test]
3124    async fn move_issue_submits_bulk_move_and_fetches_issue_on_complete() {
3125        let server = MockServer::start().await;
3126        let expected_auth = cloud_auth();
3127
3128        Mock::given(method("POST"))
3129            .and(path("/rest/api/3/bulk/issues/move"))
3130            .and(header("authorization", expected_auth.as_str()))
3131            .and(body_json(json!({
3132                "sendBulkNotification": true,
3133                "targetToSourcesMapping": {
3134                    "NEW,10002": {
3135                        "inferClassificationDefaults": true,
3136                        "inferFieldDefaults": true,
3137                        "inferStatusDefaults": true,
3138                        "inferSubtaskTypeDefault": true,
3139                        "issueIdsOrKeys": ["TEST-1"]
3140                    }
3141                }
3142            })))
3143            .respond_with(ResponseTemplate::new(201).set_body_json(json!({ "taskId": "task-1" })))
3144            .mount(&server)
3145            .await;
3146
3147        Mock::given(method("GET"))
3148            .and(path("/rest/api/3/bulk/queue/task-1"))
3149            .and(header("authorization", expected_auth.as_str()))
3150            .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "status": "COMPLETE" })))
3151            .mount(&server)
3152            .await;
3153
3154        Mock::given(method("GET"))
3155            .and(path("/rest/api/3/issue/TEST-1"))
3156            .and(header("authorization", expected_auth.as_str()))
3157            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
3158                "id": "10001",
3159                "key": "TEST-1",
3160                "fields": {
3161                    "summary": "Moved issue",
3162                    "status": { "name": "To Do" },
3163                    "issuetype": { "name": "Task" },
3164                    "project": { "key": "NEW" },
3165                    "created": "2023-01-01T00:00:00.000+0000",
3166                    "updated": "2023-01-01T00:00:00.000+0000"
3167                }
3168            })))
3169            .mount(&server)
3170            .await;
3171
3172        let client = cloud_client(&server);
3173        let issue = client
3174            .move_issue("TEST-1", "NEW", "10002", None)
3175            .await
3176            .expect("move issue should complete");
3177
3178        assert_eq!(issue.key, "TEST-1");
3179        assert_eq!(issue.project_key, "NEW");
3180    }
3181
3182    #[tokio::test]
3183    async fn move_issue_returns_api_error_when_bulk_task_fails() {
3184        let server = MockServer::start().await;
3185        let expected_auth = cloud_auth();
3186
3187        Mock::given(method("POST"))
3188            .and(path("/rest/api/3/bulk/issues/move"))
3189            .and(header("authorization", expected_auth.as_str()))
3190            .respond_with(ResponseTemplate::new(201).set_body_json(json!({ "taskId": "task-2" })))
3191            .mount(&server)
3192            .await;
3193
3194        Mock::given(method("GET"))
3195            .and(path("/rest/api/3/bulk/queue/task-2"))
3196            .and(header("authorization", expected_auth.as_str()))
3197            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
3198                "status": "FAILED",
3199                "errors": ["cannot move issue"]
3200            })))
3201            .mount(&server)
3202            .await;
3203
3204        let client = cloud_client(&server);
3205        let err = client
3206            .move_issue("TEST-1", "NEW", "10002", None)
3207            .await
3208            .expect_err("failed bulk task should return error");
3209
3210        match err {
3211            JiraError::Api { message, .. } => assert!(message.contains("FAILED")),
3212            other => panic!("expected JiraError::Api, got {other:?}"),
3213        }
3214    }
3215
3216    #[tokio::test]
3217    async fn move_issue_uses_configured_api_version_for_data_center() {
3218        let server = MockServer::start().await;
3219
3220        Mock::given(method("POST"))
3221            .and(path("/rest/api/2/bulk/issues/move"))
3222            .and(header("authorization", "Bearer dc-token"))
3223            .respond_with(ResponseTemplate::new(201).set_body_json(json!({ "taskId": "task-dc" })))
3224            .mount(&server)
3225            .await;
3226
3227        Mock::given(method("GET"))
3228            .and(path("/rest/api/2/bulk/queue/task-dc"))
3229            .and(header("authorization", "Bearer dc-token"))
3230            .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "status": "COMPLETE" })))
3231            .mount(&server)
3232            .await;
3233
3234        Mock::given(method("GET"))
3235            .and(path("/rest/api/2/issue/DC-1"))
3236            .and(header("authorization", "Bearer dc-token"))
3237            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
3238                "id": "20001",
3239                "key": "DC-1",
3240                "fields": {
3241                    "summary": "Moved issue",
3242                    "status": { "name": "Done" },
3243                    "issuetype": { "name": "Task" },
3244                    "project": { "key": "OPS" },
3245                    "created": "2023-01-01T00:00:00.000+0000",
3246                    "updated": "2023-01-01T00:00:00.000+0000"
3247                }
3248            })))
3249            .mount(&server)
3250            .await;
3251
3252        let client = JiraClient::new(JiraConfig {
3253            profile_name: Some("dc-main".into()),
3254            base_url: server.uri(),
3255            email: String::new(),
3256            token: Some("dc-token".into()),
3257            project: None,
3258            timeout_secs: 30,
3259            deployment: JiraDeployment::DataCenter,
3260            auth_type: JiraAuthType::DataCenterPat,
3261            api_version: 2,
3262        });
3263
3264        let issue = client
3265            .move_issue("DC-1", "OPS", "10002", None)
3266            .await
3267            .expect("data center move should use api v2");
3268
3269        assert_eq!(issue.key, "DC-1");
3270        assert_eq!(issue.project_key, "OPS");
3271    }
3272
3273    #[tokio::test]
3274    async fn issue_link_integration() {
3275        let server = MockServer::start().await;
3276        let expected_auth = format!("Basic {}", base64_encode("dev@example.com:cloud-token"));
3277
3278        // 1. Mock list_issue_link_types
3279        Mock::given(method("GET"))
3280            .and(path("/rest/api/3/issueLinkType"))
3281            .and(header("authorization", expected_auth.as_str()))
3282            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
3283                "issueLinkTypes": [
3284                    {
3285                        "id": "10000",
3286                        "name": "Blocks",
3287                        "inward": "is blocked by",
3288                        "outward": "blocks"
3289                    }
3290                ]
3291            })))
3292            .mount(&server)
3293            .await;
3294
3295        // 2. Mock link_issues
3296        Mock::given(method("POST"))
3297            .and(path("/rest/api/3/issueLink"))
3298            .and(header("authorization", expected_auth.as_str()))
3299            .respond_with(ResponseTemplate::new(201))
3300            .mount(&server)
3301            .await;
3302
3303        let client = JiraClient::new(JiraConfig {
3304            profile_name: Some("cloud-main".into()),
3305            base_url: server.uri(),
3306            email: "dev@example.com".into(),
3307            token: Some("cloud-token".into()),
3308            project: None,
3309            timeout_secs: 30,
3310            deployment: JiraDeployment::Cloud,
3311            auth_type: JiraAuthType::CloudApiToken,
3312            api_version: 3,
3313        });
3314
3315        // Test list
3316        let link_types = client.list_issue_link_types().await.expect("list types");
3317        assert_eq!(link_types.len(), 1);
3318        assert_eq!(link_types[0].name, "Blocks");
3319
3320        // Test link
3321        client
3322            .link_issues("TEST-1", "TEST-2", "Blocks", Some("Adding dependency"))
3323            .await
3324            .expect("link issues");
3325    }
3326
3327    #[tokio::test]
3328    async fn get_issue_parses_links() {
3329        let server = MockServer::start().await;
3330        let expected_auth = format!("Basic {}", base64_encode("dev@example.com:cloud-token"));
3331
3332        Mock::given(method("GET"))
3333            .and(path("/rest/api/3/issue/TEST-1"))
3334            .and(header("authorization", expected_auth.as_str()))
3335            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
3336                "id": "10001",
3337                "key": "TEST-1",
3338                "fields": {
3339                    "summary": "Main Issue",
3340                    "status": { "name": "To Do" },
3341                    "issuetype": { "name": "Task" },
3342                    "project": { "key": "TEST" },
3343                    "created": "2023-01-01T00:00:00.000+0000",
3344                    "updated": "2023-01-01T00:00:00.000+0000",
3345                    "issuelinks": [
3346                        {
3347                            "id": "20000",
3348                            "type": {
3349                                "id": "10000",
3350                                "name": "Blocks",
3351                                "inward": "is blocked by",
3352                                "outward": "blocks"
3353                            },
3354                            "outwardIssue": {
3355                                "id": "10002",
3356                                "key": "TEST-2",
3357                                "fields": {
3358                                    "summary": "Blocked Issue",
3359                                    "status": { "name": "Open" },
3360                                    "priority": { "name": "High" },
3361                                    "issuetype": { "name": "Bug" }
3362                                }
3363                            }
3364                        }
3365                    ]
3366                }
3367            })))
3368            .mount(&server)
3369            .await;
3370
3371        let client = JiraClient::new(JiraConfig {
3372            profile_name: Some("cloud-main".into()),
3373            base_url: server.uri(),
3374            email: "dev@example.com".into(),
3375            token: Some("cloud-token".into()),
3376            project: None,
3377            timeout_secs: 30,
3378            deployment: JiraDeployment::Cloud,
3379            auth_type: JiraAuthType::CloudApiToken,
3380            api_version: 3,
3381        });
3382
3383        let issue = client.get_issue("TEST-1").await.expect("get issue");
3384        assert_eq!(issue.links.len(), 1);
3385        let link = &issue.links[0];
3386        assert_eq!(link.link_type.name, "Blocks");
3387        assert!(link.outward_issue.is_some());
3388        assert_eq!(link.outward_issue.as_ref().unwrap().key, "TEST-2");
3389        assert_eq!(
3390            link.outward_issue.as_ref().unwrap().summary,
3391            "Blocked Issue"
3392        );
3393    }
3394
3395    #[tokio::test]
3396    async fn get_remote_links_parses_object_title_and_url() {
3397        let server = MockServer::start().await;
3398
3399        Mock::given(method("GET"))
3400            .and(path("/rest/api/3/issue/TEST-1/remotelink"))
3401            .and(header("authorization", cloud_auth().as_str()))
3402            .respond_with(ResponseTemplate::new(200).set_body_json(json!([
3403                {
3404                    "id": 10001,
3405                    "self": "https://jira.example.com/rest/api/3/issue/TEST-1/remotelink/10001",
3406                    "globalId": "system=https://docs.example.com&id=42",
3407                    "relationship": "Wiki Page",
3408                    "object": {
3409                        "title": "Design Doc",
3410                        "url": "https://docs.example.com/design",
3411                        "summary": "Architecture overview"
3412                    }
3413                },
3414                {
3415                    "id": 10002,
3416                    "object": {
3417                        "title": "Tracking ticket",
3418                        "url": "https://tracker.example.com/4242"
3419                    }
3420                }
3421            ])))
3422            .mount(&server)
3423            .await;
3424
3425        let client = cloud_client(&server);
3426        let links = client
3427            .get_remote_links("TEST-1")
3428            .await
3429            .expect("remote links should parse");
3430
3431        assert_eq!(links.len(), 2);
3432        assert_eq!(links[0].id, 10001);
3433        assert_eq!(
3434            links[0].global_id.as_deref(),
3435            Some("system=https://docs.example.com&id=42")
3436        );
3437        assert_eq!(links[0].object.title, "Design Doc");
3438        assert_eq!(links[0].object.url, "https://docs.example.com/design");
3439        assert_eq!(
3440            links[0].object.summary.as_deref(),
3441            Some("Architecture overview")
3442        );
3443        assert_eq!(links[1].id, 10002);
3444        assert!(links[1].global_id.is_none());
3445        assert!(links[1].object.summary.is_none());
3446    }
3447
3448    #[tokio::test]
3449    async fn get_remote_links_returns_empty_when_no_links() {
3450        let server = MockServer::start().await;
3451
3452        Mock::given(method("GET"))
3453            .and(path("/rest/api/3/issue/TEST-1/remotelink"))
3454            .respond_with(ResponseTemplate::new(200).set_body_json(json!([])))
3455            .mount(&server)
3456            .await;
3457
3458        let client = cloud_client(&server);
3459        let links = client.get_remote_links("TEST-1").await.expect("parse");
3460        assert!(links.is_empty());
3461    }
3462
3463    #[tokio::test]
3464    async fn get_project_components_parses_id_and_name() {
3465        let server = MockServer::start().await;
3466
3467        Mock::given(method("GET"))
3468            .and(path("/rest/api/3/project/PROJ/components"))
3469            .and(header("authorization", cloud_auth().as_str()))
3470            .respond_with(ResponseTemplate::new(200).set_body_json(json!([
3471                {
3472                    "id": "10100",
3473                    "name": "Backend",
3474                    "description": "Server-side code",
3475                    "self": "https://jira.example.com/rest/api/3/component/10100"
3476                },
3477                {
3478                    "id": "10101",
3479                    "name": "Frontend"
3480                }
3481            ])))
3482            .mount(&server)
3483            .await;
3484
3485        let client = cloud_client(&server);
3486        let components = client
3487            .get_project_components("PROJ")
3488            .await
3489            .expect("components should parse");
3490
3491        assert_eq!(components.len(), 2);
3492        assert_eq!(components[0].id, "10100");
3493        assert_eq!(components[0].name, "Backend");
3494        assert_eq!(
3495            components[0].description.as_deref(),
3496            Some("Server-side code")
3497        );
3498        assert!(components[0].self_url.is_some());
3499        assert_eq!(components[1].name, "Frontend");
3500        assert!(components[1].description.is_none());
3501    }
3502
3503    #[tokio::test]
3504    async fn get_transitions_parses_id_name_and_preserves_extra_fields() {
3505        let server = MockServer::start().await;
3506
3507        Mock::given(method("GET"))
3508            .and(path("/rest/api/3/issue/TEST-1/transitions"))
3509            .and(header("authorization", cloud_auth().as_str()))
3510            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
3511                "expand": "transitions",
3512                "transitions": [
3513                    {
3514                        "id": "21",
3515                        "name": "In Progress",
3516                        "hasScreen": false,
3517                        "isGlobal": false,
3518                        "isInitial": false,
3519                        "isAvailable": true,
3520                        "to": {
3521                            "id": "3",
3522                            "name": "In Progress",
3523                            "statusCategory": { "key": "indeterminate" }
3524                        }
3525                    },
3526                    {
3527                        "id": "31",
3528                        "name": "Done",
3529                        "to": { "id": "10001", "name": "Done" }
3530                    }
3531                ]
3532            })))
3533            .mount(&server)
3534            .await;
3535
3536        let client = cloud_client(&server);
3537        let transitions = client
3538            .get_transitions("TEST-1")
3539            .await
3540            .expect("transitions should parse");
3541
3542        assert_eq!(transitions.len(), 2);
3543        assert_eq!(transitions[0].id, "21");
3544        assert_eq!(transitions[0].name, "In Progress");
3545        assert_eq!(
3546            transitions[0].to.as_ref().and_then(|t| t.name.as_deref()),
3547            Some("In Progress")
3548        );
3549
3550        // Extra Jira fields (hasScreen, isGlobal, ...) must round-trip via #[serde(flatten)]
3551        // so MCP consumers re-serializing the struct keep the full payload shape.
3552        let reserialized = serde_json::to_value(&transitions[0]).expect("serialize");
3553        assert_eq!(reserialized["hasScreen"], json!(false));
3554        assert_eq!(reserialized["isAvailable"], json!(true));
3555
3556        assert_eq!(transitions[1].id, "31");
3557        assert!(transitions[1].to.is_some());
3558    }
3559
3560    #[test]
3561    fn parses_content_disposition_plain() {
3562        assert_eq!(
3563            parse_content_disposition_filename("attachment; filename=\"report.pdf\""),
3564            Some("report.pdf".to_string())
3565        );
3566        assert_eq!(
3567            parse_content_disposition_filename("attachment; filename=plain.txt"),
3568            Some("plain.txt".to_string())
3569        );
3570    }
3571
3572    #[test]
3573    fn parses_content_disposition_rfc5987() {
3574        assert_eq!(
3575            parse_content_disposition_filename(
3576                "attachment; filename*=UTF-8''na%C3%AFve%20file.txt"
3577            ),
3578            Some("naïve file.txt".to_string())
3579        );
3580    }
3581
3582    #[tokio::test]
3583    async fn list_attachments_extracts_fields() {
3584        let server = MockServer::start().await;
3585        Mock::given(method("GET"))
3586            .and(path("/rest/api/3/issue/TEST-1"))
3587            .and(query_param("fields", "attachment"))
3588            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
3589                "fields": {
3590                    "attachment": [
3591                        {
3592                            "id": "10001",
3593                            "filename": "a.txt",
3594                            "size": 12,
3595                            "mimeType": "text/plain",
3596                            "content": "https://example/c/10001",
3597                            "created": "2026-06-14T00:00:00.000+0000"
3598                        }
3599                    ]
3600                }
3601            })))
3602            .mount(&server)
3603            .await;
3604        let client = cloud_client(&server);
3605        let items = client.list_attachments("TEST-1").await.expect("ok");
3606        assert_eq!(items.len(), 1);
3607        assert_eq!(items[0].id, "10001");
3608        assert_eq!(items[0].filename, "a.txt");
3609    }
3610
3611    #[tokio::test]
3612    async fn download_attachment_returns_bytes_and_filename() {
3613        let server = MockServer::start().await;
3614        Mock::given(method("GET"))
3615            .and(path("/rest/api/3/attachment/content/42"))
3616            .respond_with(
3617                ResponseTemplate::new(200)
3618                    .insert_header("Content-Type", "application/pdf")
3619                    .insert_header("Content-Disposition", "attachment; filename=\"file.pdf\"")
3620                    .set_body_bytes(vec![0x25u8, 0x50, 0x44, 0x46]),
3621            )
3622            .mount(&server)
3623            .await;
3624        let client = cloud_client(&server);
3625        let (name, bytes, mime) = client.download_attachment("42").await.expect("ok");
3626        assert_eq!(name, "file.pdf");
3627        assert_eq!(mime, "application/pdf");
3628        assert_eq!(bytes, vec![0x25, 0x50, 0x44, 0x46]);
3629    }
3630
3631    #[tokio::test]
3632    async fn download_attachment_404_returns_not_found() {
3633        let server = MockServer::start().await;
3634        Mock::given(method("GET"))
3635            .and(path("/rest/api/3/attachment/content/99"))
3636            .respond_with(ResponseTemplate::new(404).set_body_string("missing"))
3637            .mount(&server)
3638            .await;
3639        let client = cloud_client(&server);
3640        let err = client.download_attachment("99").await.expect_err("err");
3641        assert!(matches!(err, JiraError::NotFound(_)));
3642    }
3643
3644    #[tokio::test]
3645    async fn list_boards_paginates() {
3646        let server = MockServer::start().await;
3647        Mock::given(method("GET"))
3648            .and(path("/rest/agile/1.0/board"))
3649            .and(query_param("startAt", "0"))
3650            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
3651                "maxResults": 50, "startAt": 0, "isLast": false,
3652                "values": [
3653                    { "id": 1, "name": "B1", "type": "scrum" },
3654                    { "id": 2, "name": "B2", "type": "kanban",
3655                      "location": { "projectKey": "ABC", "projectId": 10 } }
3656                ]
3657            })))
3658            .mount(&server)
3659            .await;
3660        Mock::given(method("GET"))
3661            .and(path("/rest/agile/1.0/board"))
3662            .and(query_param("startAt", "2"))
3663            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
3664                "maxResults": 50, "startAt": 2, "isLast": true,
3665                "values": []
3666            })))
3667            .mount(&server)
3668            .await;
3669        let client = cloud_client(&server);
3670        let boards = client.list_boards(None, None).await.expect("ok");
3671        assert_eq!(boards.len(), 2);
3672        assert_eq!(boards[1].project_key.as_deref(), Some("ABC"));
3673    }
3674
3675    #[tokio::test]
3676    async fn get_board_parses_single() {
3677        let server = MockServer::start().await;
3678        Mock::given(method("GET"))
3679            .and(path("/rest/agile/1.0/board/9"))
3680            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
3681                "id": 9, "name": "X", "type": "scrum"
3682            })))
3683            .mount(&server)
3684            .await;
3685        let client = cloud_client(&server);
3686        let b = client.get_board(9).await.expect("ok");
3687        assert_eq!(b.id, 9);
3688        assert_eq!(b.name, "X");
3689    }
3690
3691    #[tokio::test]
3692    async fn board_issues_returns_issues() {
3693        let server = MockServer::start().await;
3694        Mock::given(method("GET"))
3695            .and(path("/rest/agile/1.0/board/5/issue"))
3696            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
3697                "issues": [
3698                    { "id": "1", "key": "ABC-1", "fields": { "summary": "s",
3699                      "status": { "name": "To Do" }, "issuetype": { "name": "Task" },
3700                      "project": { "key": "ABC" }, "created": "", "updated": "" } }
3701                ]
3702            })))
3703            .mount(&server)
3704            .await;
3705        let client = cloud_client(&server);
3706        let issues = client.board_issues(5, None, Some(50)).await.expect("ok");
3707        assert_eq!(issues.len(), 1);
3708        assert_eq!(issues[0].key, "ABC-1");
3709    }
3710
3711    #[tokio::test]
3712    async fn board_backlog_passes_jql() {
3713        let server = MockServer::start().await;
3714        Mock::given(method("GET"))
3715            .and(path("/rest/agile/1.0/board/5/backlog"))
3716            .and(query_param("jql", "labels = \"x\""))
3717            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
3718                "issues": []
3719            })))
3720            .mount(&server)
3721            .await;
3722        let client = cloud_client(&server);
3723        let issues = client
3724            .board_backlog(5, Some("labels = \"x\""), None)
3725            .await
3726            .expect("ok");
3727        assert!(issues.is_empty());
3728    }
3729
3730    #[tokio::test]
3731    async fn get_transitions_returns_empty_when_no_transitions_available() {
3732        let server = MockServer::start().await;
3733
3734        Mock::given(method("GET"))
3735            .and(path("/rest/api/3/issue/TEST-1/transitions"))
3736            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
3737                "transitions": []
3738            })))
3739            .mount(&server)
3740            .await;
3741
3742        let client = cloud_client(&server);
3743        let transitions = client.get_transitions("TEST-1").await.expect("parse");
3744        assert!(transitions.is_empty());
3745    }
3746}