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