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        worklog::Worklog,
31    },
32};
33
34const AGILE_BASE: &str = "/rest/agile/1.0";
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    #[allow(dead_code)]
75    fn agile_url(&self, path: &str) -> String {
76        format!(
77            "{}{}{}",
78            self.config.base_url.trim_end_matches('/'),
79            AGILE_BASE,
80            path
81        )
82    }
83
84    fn auth_headers(&self) -> Result<HeaderMap> {
85        self.build_auth_headers(true)
86    }
87
88    /// Auth headers without Content-Type — required for multipart uploads.
89    fn auth_headers_no_content_type(&self) -> Result<HeaderMap> {
90        self.build_auth_headers(false)
91    }
92
93    fn build_auth_headers(&self, include_content_type: bool) -> Result<HeaderMap> {
94        let token = self.config.token.as_deref().ok_or_else(|| {
95            JiraError::Auth("No token configured. Run `jirac auth login` first.".into())
96        })?;
97
98        let auth_value = match self.config.auth_type {
99            JiraAuthType::CloudApiToken | JiraAuthType::DataCenterBasic => {
100                let credentials = base64_encode(&format!("{}:{}", self.config.email, token));
101                format!("Basic {credentials}")
102            }
103            JiraAuthType::DataCenterPat => format!("Bearer {token}"),
104        };
105
106        let mut headers = HeaderMap::new();
107        headers.insert(
108            AUTHORIZATION,
109            HeaderValue::from_str(&auth_value)
110                .map_err(|e| JiraError::Auth(format!("Invalid auth header: {e}")))?,
111        );
112        if include_content_type {
113            headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
114        }
115        Ok(headers)
116    }
117
118    async fn get_myself_info(&self) -> Result<MyselfResponse> {
119        let headers = self.auth_headers()?;
120        let url = self.platform_url("/myself");
121
122        let http = &self.http;
123        self.request(|| http.get(&url).headers(headers.clone()))
124            .await
125    }
126
127    /// Get the current authenticated user's accountId.
128    pub async fn get_myself(&self) -> Result<String> {
129        let me = self.get_myself_info().await?;
130        me.account_id.ok_or_else(|| JiraError::Api {
131            status: 0,
132            message: "Could not get accountId from /myself".into(),
133        })
134    }
135
136    /// Get the current authenticated user's Jira timezone (IANA name), if available.
137    pub async fn get_myself_timezone(&self) -> Result<Option<String>> {
138        Ok(self.get_myself_info().await?.time_zone)
139    }
140
141    /// Resolve an assignee string to a Jira accountId.
142    ///
143    /// - `"me"` → current user's accountId via /myself
144    /// - contains `@` → search by email, return first match's accountId
145    /// - anything else → treated as a raw accountId and returned as-is
146    async fn resolve_assignee_account_id(&self, s: &str) -> Result<String> {
147        if s == "me" {
148            return self.get_myself().await;
149        }
150        if !s.contains('@') {
151            return Ok(s.to_string());
152        }
153        // Resolve email → accountId via user search
154        let users = self.search_users(s).await?;
155        users
156            .iter()
157            .find(|u| {
158                u.email_address
159                    .as_deref()
160                    .map(|e| e.eq_ignore_ascii_case(s))
161                    .unwrap_or(false)
162            })
163            .or_else(|| users.first())
164            .map(|u| u.account_id.clone())
165            .ok_or_else(|| JiraError::Api {
166                status: 0,
167                message: format!("User not found: {s}"),
168            })
169    }
170
171    /// Send a request, retrying on HTTP 429 according to the `Retry-After` header.
172    /// Returns the first non-429 response, or `RateLimit` after `MAX_RETRIES` attempts.
173    async fn execute_with_retry(
174        &self,
175        builder_fn: impl Fn() -> reqwest::RequestBuilder,
176    ) -> Result<reqwest::Response> {
177        let mut attempt = 0u32;
178        loop {
179            attempt += 1;
180            let response = builder_fn().send().await?;
181
182            if response.status() != StatusCode::TOO_MANY_REQUESTS {
183                return Ok(response);
184            }
185
186            let retry_after = response
187                .headers()
188                .get("Retry-After")
189                .and_then(|v| v.to_str().ok())
190                .and_then(|v| v.parse::<u64>().ok())
191                .unwrap_or(60);
192
193            warn!("Rate limited. Retrying after {}s", retry_after);
194
195            if attempt >= MAX_RETRIES {
196                return Err(JiraError::RateLimit { retry_after });
197            }
198
199            tokio::time::sleep(Duration::from_secs(retry_after)).await;
200        }
201    }
202
203    /// Core request method with rate-limit retry logic.
204    async fn request<T>(&self, builder_fn: impl Fn() -> reqwest::RequestBuilder) -> Result<T>
205    where
206        T: serde::de::DeserializeOwned,
207    {
208        let response = self.execute_with_retry(builder_fn).await?;
209        handle_response(response).await
210    }
211
212    /// Core request method for responses with no body (204 No Content).
213    async fn request_no_body(
214        &self,
215        builder_fn: impl Fn() -> reqwest::RequestBuilder,
216    ) -> Result<()> {
217        let response = self.execute_with_retry(builder_fn).await?;
218        let status = response.status();
219        if status.is_success() {
220            return Ok(());
221        }
222        let body = response.text().await.unwrap_or_default();
223        if status == StatusCode::NOT_FOUND {
224            return Err(JiraError::NotFound(body));
225        }
226        Err(JiraError::Api {
227            status: status.as_u16(),
228            message: body,
229        })
230    }
231
232    /// Multipart request with rate-limit retry (for attachment uploads).
233    async fn request_multipart<T>(
234        &self,
235        builder_fn: impl Fn() -> reqwest::RequestBuilder,
236    ) -> Result<T>
237    where
238        T: serde::de::DeserializeOwned,
239    {
240        let response = self.execute_with_retry(builder_fn).await?;
241        handle_response(response).await
242    }
243
244    /// Search issues using JQL with cursor-based pagination.
245    pub async fn search_issues(
246        &self,
247        jql: &str,
248        next_page_token: Option<&str>,
249        max_results: Option<u32>,
250    ) -> Result<SearchResult> {
251        const DEFAULT_FIELDS: &[&str] = &[
252            "summary",
253            "status",
254            "assignee",
255            "reporter",
256            "priority",
257            "issuetype",
258            "project",
259            "created",
260            "updated",
261            "description",
262        ];
263        self.search_issues_with_fields(jql, next_page_token, max_results, DEFAULT_FIELDS)
264            .await
265    }
266
267    /// Search issues with an explicit `fields` list (e.g. `["*all"]`, `["*navigable"]`,
268    /// or specific field IDs like `["summary", "components", "customfield_10010"]`).
269    pub async fn search_issues_with_fields(
270        &self,
271        jql: &str,
272        next_page_token: Option<&str>,
273        max_results: Option<u32>,
274        fields: &[&str],
275    ) -> Result<SearchResult> {
276        let headers = self.auth_headers()?;
277        let url = self.platform_url("/search/jql");
278
279        let mut body = json!({
280            "jql": jql,
281            "maxResults": max_results.unwrap_or(50),
282            "fields": fields,
283        });
284
285        if let Some(token) = next_page_token {
286            body["nextPageToken"] = json!(token);
287        }
288
289        debug!("Searching JQL: {}", jql);
290
291        let http = &self.http;
292        let raw: RawSearchResponse = self
293            .request(|| http.post(&url).headers(headers.clone()).json(&body))
294            .await?;
295
296        Ok(SearchResult {
297            issues: raw.issues.into_iter().map(|r| r.into_issue()).collect(),
298            next_page_token: raw.next_page_token,
299            total: raw.total,
300        })
301    }
302
303    /// Fetch all visible fields (system + custom) from the Jira instance.
304    /// Endpoint: `GET /rest/api/3/field`.
305    pub async fn list_fields(&self) -> Result<Vec<Field>> {
306        let headers = self.auth_headers()?;
307        let url = self.platform_url("/field");
308
309        #[derive(serde::Deserialize)]
310        struct FieldEntry {
311            id: String,
312            name: String,
313            #[serde(default)]
314            schema: Option<Value>,
315        }
316
317        let http = &self.http;
318        let raw: Vec<FieldEntry> = self
319            .request(|| http.get(&url).headers(headers.clone()))
320            .await?;
321
322        Ok(raw
323            .into_iter()
324            .map(|f| {
325                let field_type = f
326                    .schema
327                    .as_ref()
328                    .and_then(|s| s.get("type"))
329                    .and_then(|v| v.as_str())
330                    .unwrap_or("")
331                    .to_string();
332                Field {
333                    id: f.id,
334                    name: f.name,
335                    field_type,
336                    required: false,
337                    schema: f.schema,
338                    allowed_values: None,
339                }
340            })
341            .collect())
342    }
343
344    /// Fetch a single issue by key.
345    pub async fn get_issue(&self, key: &str) -> Result<Issue> {
346        let headers = self.auth_headers()?;
347        let url = self.platform_url(&format!("/issue/{key}"));
348
349        let http = &self.http;
350        let raw: RawIssue = self
351            .request(|| http.get(&url).headers(headers.clone()))
352            .await?;
353
354        Ok(raw.into_issue())
355    }
356
357    /// Create a new issue.
358    ///
359    /// Deprecated in favor of [`JiraClient::create_issue_v2`], which supports
360    /// custom fields, labels, components, parent, fix versions, and ADF
361    /// descriptions. This method is retained for backward compatibility and
362    /// will be removed in a future release.
363    #[deprecated(
364        since = "0.40.0",
365        note = "Use `create_issue_v2` — supports custom fields, labels, components, parent, fix versions"
366    )]
367    pub async fn create_issue(&self, req: CreateIssueRequest) -> Result<Issue> {
368        let headers = self.auth_headers()?;
369        let url = self.platform_url("/issue");
370
371        let description_adf = req.description.as_deref().map(markdown_to_adf);
372
373        let mut fields = json!({
374            "project": { "key": req.project_key },
375            "summary": req.summary,
376            "issuetype": { "name": req.issue_type }
377        });
378
379        if let Some(adf) = description_adf {
380            fields["description"] = adf;
381        }
382
383        if let Some(assignee) = &req.assignee {
384            let account_id = self.resolve_assignee_account_id(assignee).await?;
385            fields["assignee"] = json!({ "accountId": account_id });
386        }
387
388        if let Some(priority) = &req.priority {
389            fields["priority"] = json!({ "name": priority });
390        }
391
392        let body = json!({ "fields": fields });
393
394        #[derive(serde::Deserialize)]
395        struct CreateResponse {
396            key: String,
397        }
398
399        let http = &self.http;
400        let resp: CreateResponse = self
401            .request(|| http.post(&url).headers(headers.clone()).json(&body))
402            .await?;
403
404        // Fetch the full issue after creation
405        self.get_issue(&resp.key).await
406    }
407
408    /// Update an existing issue.
409    pub async fn update_issue(&self, key: &str, req: UpdateIssueRequest) -> Result<()> {
410        let headers = self.auth_headers()?;
411        let url = self.platform_url(&format!("/issue/{key}"));
412
413        let mut fields = json!({});
414
415        if let Some(summary) = &req.summary {
416            fields["summary"] = json!(summary);
417        }
418        if let Some(adf) = &req.description_adf {
419            fields["description"] = adf.clone();
420        } else if let Some(description) = &req.description {
421            fields["description"] = markdown_to_adf(description);
422        }
423        if let Some(assignee) = &req.assignee {
424            let account_id = self.resolve_assignee_account_id(assignee).await?;
425            fields["assignee"] = json!({ "accountId": account_id });
426        }
427        if let Some(priority) = &req.priority {
428            fields["priority"] = json!({ "name": priority });
429        }
430        if let Some(labels) = &req.labels {
431            fields["labels"] = json!(labels);
432        }
433        if let Some(components) = &req.components {
434            fields["components"] = json!(components
435                .iter()
436                .map(|c| json!({"name": c}))
437                .collect::<Vec<_>>());
438        }
439        if let Some(fix_versions) = &req.fix_versions {
440            fields["fixVersions"] = json!(fix_versions
441                .iter()
442                .map(|v| json!({"name": v}))
443                .collect::<Vec<_>>());
444        }
445        if let Some(parent) = &req.parent {
446            fields["parent"] = json!({ "key": parent });
447        }
448        for (field_id, value) in &req.custom_fields {
449            fields[field_id] = value.to_api_json();
450        }
451
452        let body = json!({ "fields": fields });
453
454        let http = &self.http;
455        self.request_no_body(|| http.put(&url).headers(headers.clone()).json(&body))
456            .await
457    }
458
459    /// Delete an issue.
460    pub async fn delete_issue(&self, key: &str) -> Result<()> {
461        let headers = self.auth_headers()?;
462        let url = self.platform_url(&format!("/issue/{key}"));
463
464        let http = &self.http;
465        self.request_no_body(|| http.delete(&url).headers(headers.clone()))
466            .await
467    }
468
469    /// Get fields available for a project (runtime field resolution — no hardcoding).
470    pub async fn get_project_fields(&self, project_key: &str) -> Result<Vec<Field>> {
471        let headers = self.auth_headers()?;
472        let url = self.platform_url(&format!("/issue/createmeta/{project_key}/issuetypes"));
473
474        #[derive(serde::Deserialize)]
475        struct IssueTypeMeta {
476            #[serde(rename = "issueTypes")]
477            issue_types: Vec<IssueTypeDetail>,
478        }
479
480        #[derive(serde::Deserialize)]
481        struct IssueTypeDetail {
482            fields: Option<std::collections::HashMap<String, FieldMeta>>,
483        }
484
485        #[derive(serde::Deserialize)]
486        struct FieldMeta {
487            name: String,
488            required: bool,
489            schema: Option<Value>,
490        }
491
492        let http = &self.http;
493        let meta: IssueTypeMeta = self
494            .request(|| http.get(&url).headers(headers.clone()))
495            .await?;
496
497        let mut fields: Vec<Field> = Vec::new();
498        let mut seen = std::collections::HashSet::new();
499
500        for it in meta.issue_types {
501            if let Some(field_map) = it.fields {
502                for (id, meta) in field_map {
503                    if seen.insert(id.clone()) {
504                        let field_type = meta
505                            .schema
506                            .as_ref()
507                            .and_then(|s| s.get("type"))
508                            .and_then(|v| v.as_str())
509                            .unwrap_or("unknown")
510                            .to_string();
511
512                        fields.push(Field {
513                            id,
514                            name: meta.name,
515                            field_type,
516                            required: meta.required,
517                            schema: meta.schema,
518                            allowed_values: None,
519                        });
520                    }
521                }
522            }
523        }
524
525        Ok(fields)
526    }
527
528    /// Get server info (used to detect Jira tier).
529    pub async fn get_server_info(&self) -> Result<Value> {
530        let headers = self.auth_headers()?;
531        let url = self.platform_url("/serverInfo");
532
533        let http = &self.http;
534        self.request(|| http.get(&url).headers(headers.clone()))
535            .await
536    }
537
538    /// Transition an issue to a new status.
539    pub async fn transition_issue(&self, key: &str, transition_id: &str) -> Result<()> {
540        let headers = self.auth_headers()?;
541        let url = self.platform_url(&format!("/issue/{key}/transitions"));
542
543        let body = json!({
544            "transition": { "id": transition_id }
545        });
546
547        let http = &self.http;
548        self.request_no_body(|| http.post(&url).headers(headers.clone()).json(&body))
549            .await
550    }
551
552    /// Get available transitions for an issue.
553    pub async fn get_transitions(&self, key: &str) -> Result<Vec<Transition>> {
554        let headers = self.auth_headers()?;
555        let url = self.platform_url(&format!("/issue/{key}/transitions"));
556
557        #[derive(serde::Deserialize)]
558        struct TransitionsResponse {
559            transitions: Vec<Transition>,
560        }
561
562        let http = &self.http;
563        let resp: TransitionsResponse = self
564            .request(|| http.get(&url).headers(headers.clone()))
565            .await?;
566
567        Ok(resp.transitions)
568    }
569
570    /// Get available issue types for a project (id + name).
571    pub async fn get_issue_types(&self, project_key: &str) -> Result<Vec<IssueType>> {
572        let headers = self.auth_headers()?;
573        let url = self.platform_url(&format!("/issue/createmeta/{project_key}/issuetypes"));
574
575        #[derive(serde::Deserialize)]
576        struct MetaResponse {
577            #[serde(rename = "issueTypes")]
578            issue_types: Vec<IssueType>,
579        }
580
581        let http = &self.http;
582        let resp: MetaResponse = self
583            .request(|| http.get(&url).headers(headers.clone()))
584            .await?;
585
586        Ok(resp.issue_types)
587    }
588
589    /// Resolve an issue type by exact or case-insensitive name within a project.
590    pub async fn get_issue_type_by_name(
591        &self,
592        project_key: &str,
593        issue_type_name: &str,
594    ) -> Result<IssueType> {
595        let issue_types = self.get_issue_types(project_key).await?;
596
597        issue_types
598            .iter()
599            .find(|it| it.name == issue_type_name)
600            .or_else(|| {
601                issue_types
602                    .iter()
603                    .find(|it| it.name.eq_ignore_ascii_case(issue_type_name))
604            })
605            .cloned()
606            .ok_or_else(|| JiraError::Api {
607                status: 0,
608                message: format!(
609                    "Issue type '{}' not found in project {}",
610                    issue_type_name, project_key
611                ),
612            })
613    }
614
615    /// Get fields for a specific issue type within a project (with allowed values).
616    pub async fn get_fields_for_issue_type(
617        &self,
618        project_key: &str,
619        issue_type_id: &str,
620    ) -> Result<Vec<Field>> {
621        let headers = self.auth_headers()?;
622        let url = self.platform_url(&format!(
623            "/issue/createmeta/{project_key}/issuetypes/{issue_type_id}"
624        ));
625
626        #[derive(serde::Deserialize)]
627        struct FieldMetaResponse {
628            fields: FieldCollection,
629        }
630
631        #[derive(serde::Deserialize)]
632        #[serde(untagged)]
633        enum FieldCollection {
634            Map(std::collections::HashMap<String, FieldMetaMap>),
635            List(Vec<FieldMetaEntry>),
636        }
637
638        #[derive(serde::Deserialize)]
639        struct FieldMetaMap {
640            name: String,
641            required: bool,
642            schema: Option<Value>,
643            #[serde(rename = "allowedValues")]
644            allowed_values: Option<Vec<Value>>,
645        }
646
647        #[derive(serde::Deserialize)]
648        struct FieldMetaEntry {
649            #[serde(rename = "fieldId")]
650            field_id: Option<String>,
651            key: Option<String>,
652            name: String,
653            required: bool,
654            schema: Option<Value>,
655            #[serde(rename = "allowedValues")]
656            allowed_values: Option<Vec<Value>>,
657        }
658
659        let http = &self.http;
660        let resp: FieldMetaResponse = self
661            .request(|| http.get(&url).headers(headers.clone()))
662            .await?;
663
664        let fields = match resp.fields {
665            FieldCollection::Map(fields) => fields
666                .into_iter()
667                .map(|(id, meta)| {
668                    let field_type = meta
669                        .schema
670                        .as_ref()
671                        .and_then(|s| s.get("type"))
672                        .and_then(|v| v.as_str())
673                        .unwrap_or("unknown")
674                        .to_string();
675
676                    Field {
677                        id,
678                        name: meta.name,
679                        field_type,
680                        required: meta.required,
681                        schema: meta.schema,
682                        allowed_values: meta.allowed_values,
683                    }
684                })
685                .collect(),
686            FieldCollection::List(fields) => fields
687                .into_iter()
688                .map(|meta| {
689                    let field_type = meta
690                        .schema
691                        .as_ref()
692                        .and_then(|s| s.get("type"))
693                        .and_then(|v| v.as_str())
694                        .unwrap_or("unknown")
695                        .to_string();
696
697                    let id = meta.field_id.or(meta.key).unwrap_or_default();
698
699                    Field {
700                        id,
701                        name: meta.name,
702                        field_type,
703                        required: meta.required,
704                        schema: meta.schema,
705                        allowed_values: meta.allowed_values,
706                    }
707                })
708                .collect(),
709        };
710
711        Ok(fields)
712    }
713
714    /// Search Jira users by query string (for User field autocomplete).
715    pub async fn search_users(&self, query: &str) -> Result<Vec<JiraUser>> {
716        let headers = self.auth_headers()?;
717        let url = self.platform_url("/user/search");
718
719        let http = &self.http;
720        self.request(|| {
721            http.get(&url)
722                .headers(headers.clone())
723                .query(&[("query", query), ("maxResults", "20")])
724        })
725        .await
726    }
727
728    /// List components available within a project.
729    pub async fn get_project_components(&self, project_key: &str) -> Result<Vec<Component>> {
730        let headers = self.auth_headers()?;
731        let url = self.platform_url(&format!("/project/{project_key}/components"));
732
733        let http = &self.http;
734        self.request(|| http.get(&url).headers(headers.clone()))
735            .await
736    }
737
738    /// List fix versions available within a project.
739    pub async fn get_project_versions(&self, project_key: &str) -> Result<Vec<ProjectVersion>> {
740        let headers = self.auth_headers()?;
741        let url = self.platform_url(&format!("/project/{project_key}/versions"));
742
743        let http = &self.http;
744        let versions: Vec<ProjectVersion> = self
745            .request(|| http.get(&url).headers(headers.clone()))
746            .await?;
747
748        Ok(versions)
749    }
750
751    /// Create a project version in Jira.
752    pub async fn create_project_version(
753        &self,
754        request: &CreateProjectVersionRequest,
755    ) -> Result<ProjectVersion> {
756        let path = format!("/rest/api/{}/version", self.config.api_version);
757        let body = serde_json::to_value(request)?;
758        let value = self
759            .raw_request("POST", &path, Some(body))
760            .await?
761            .ok_or_else(|| JiraError::Api {
762                status: 0,
763                message: "Empty response when creating project version".into(),
764            })?;
765        let version: ProjectVersion =
766            serde_json::from_value(value).map_err(|e| JiraError::Api {
767                status: 0,
768                message: format!("Failed to parse created project version: {e}"),
769            })?;
770        Ok(version)
771    }
772
773    /// Update project version metadata like release/start dates or release state.
774    pub async fn update_project_version(
775        &self,
776        version_id: &str,
777        request: &UpdateProjectVersionRequest,
778    ) -> Result<ProjectVersion> {
779        let path = format!(
780            "/rest/api/{}/version/{}",
781            self.config.api_version, version_id
782        );
783        let body = serde_json::to_value(request)?;
784        let value = self
785            .raw_request("PUT", &path, Some(body))
786            .await?
787            .ok_or_else(|| JiraError::Api {
788                status: 0,
789                message: "Empty response when updating project version".into(),
790            })?;
791        let version: ProjectVersion =
792            serde_json::from_value(value).map_err(|e| JiraError::Api {
793                status: 0,
794                message: format!("Failed to parse updated project version: {e}"),
795            })?;
796        Ok(version)
797    }
798
799    fn parse_sprint_value(&self, value: &Value, board_id: Option<u64>) -> Option<Sprint> {
800        let id = value.get("id").and_then(|v| v.as_u64())?;
801        Some(Sprint {
802            id,
803            name: value
804                .get("name")
805                .and_then(|v| v.as_str())
806                .unwrap_or("")
807                .to_string(),
808            state: value
809                .get("state")
810                .and_then(|v| v.as_str())
811                .unwrap_or("")
812                .to_string(),
813            board_id,
814            goal: value
815                .get("goal")
816                .and_then(|v| v.as_str())
817                .map(str::to_owned),
818            start_date: value
819                .get("startDate")
820                .and_then(|v| v.as_str())
821                .map(str::to_owned),
822            end_date: value
823                .get("endDate")
824                .and_then(|v| v.as_str())
825                .map(str::to_owned),
826            complete_date: value
827                .get("completeDate")
828                .and_then(|v| v.as_str())
829                .map(str::to_owned),
830        })
831    }
832
833    /// List project sprints for the given sprint states via the Agile API.
834    pub async fn list_sprints_for_project_with_states(
835        &self,
836        project_key: &str,
837        states: &[&str],
838    ) -> Result<Vec<Sprint>> {
839        let boards_path =
840            format!("/rest/agile/1.0/board?projectKeyOrId={project_key}&maxResults=100");
841        let boards_resp = self
842            .raw_request("GET", &boards_path, None)
843            .await?
844            .unwrap_or_default();
845
846        let board_ids: Vec<u64> = boards_resp
847            .get("values")
848            .and_then(|v| v.as_array())
849            .map(|boards| {
850                boards
851                    .iter()
852                    .filter_map(|b| b.get("id").and_then(|id| id.as_u64()))
853                    .collect()
854            })
855            .unwrap_or_default();
856
857        let mut seen: std::collections::HashSet<u64> = std::collections::HashSet::new();
858        let mut sprints: Vec<Sprint> = Vec::new();
859        let state_filter = states.join(",");
860
861        for board_id in board_ids {
862            let sprint_path = format!(
863                "/rest/agile/1.0/board/{board_id}/sprint?state={state_filter}&maxResults=200"
864            );
865            let Ok(Some(resp)) = self.raw_request("GET", &sprint_path, None).await else {
866                continue;
867            };
868            if let Some(values) = resp.get("values").and_then(|v| v.as_array()) {
869                for value in values {
870                    let Some(sprint) = self.parse_sprint_value(value, Some(board_id)) else {
871                        continue;
872                    };
873                    if !seen.insert(sprint.id) {
874                        continue;
875                    }
876                    sprints.push(sprint);
877                }
878            }
879        }
880
881        sprints.sort_by(|a, b| {
882            let order = |s: &str| match s {
883                "active" => 0u8,
884                "future" => 1u8,
885                _ => 2u8,
886            };
887            order(&a.state)
888                .cmp(&order(&b.state))
889                .then(a.name.cmp(&b.name))
890        });
891
892        Ok(sprints)
893    }
894
895    /// List active and future sprints for a project via the Agile API.
896    pub async fn list_sprints_for_project(&self, project_key: &str) -> Result<Vec<Sprint>> {
897        self.list_sprints_for_project_with_states(project_key, &["active", "future"])
898            .await
899    }
900
901    /// Create a sprint on a Jira board.
902    pub async fn create_sprint(
903        &self,
904        board_id: u64,
905        name: &str,
906        start_date: Option<&str>,
907        end_date: Option<&str>,
908        goal: Option<&str>,
909    ) -> Result<Sprint> {
910        let body = json!({
911            "name": name,
912            "originBoardId": board_id,
913            "startDate": start_date,
914            "endDate": end_date,
915            "goal": goal,
916        });
917        let value = self
918            .raw_request("POST", "/rest/agile/1.0/sprint", Some(body))
919            .await?
920            .ok_or_else(|| JiraError::Api {
921                status: 0,
922                message: "Empty response when creating sprint".into(),
923            })?;
924        self.parse_sprint_value(&value, Some(board_id))
925            .ok_or_else(|| JiraError::Api {
926                status: 0,
927                message: "Failed to parse created sprint".into(),
928            })
929    }
930
931    /// Update sprint metadata or lifecycle state.
932    pub async fn update_sprint(&self, sprint_id: u64, body: Value) -> Result<Sprint> {
933        let value = self
934            .raw_request(
935                "PUT",
936                &format!("/rest/agile/1.0/sprint/{sprint_id}"),
937                Some(body),
938            )
939            .await?
940            .ok_or_else(|| JiraError::Api {
941                status: 0,
942                message: "Empty response when updating sprint".into(),
943            })?;
944        self.parse_sprint_value(&value, None)
945            .ok_or_else(|| JiraError::Api {
946                status: 0,
947                message: "Failed to parse updated sprint".into(),
948            })
949    }
950
951    /// Delete a sprint.
952    pub async fn delete_sprint(&self, sprint_id: u64) -> Result<()> {
953        self.raw_request(
954            "DELETE",
955            &format!("/rest/agile/1.0/sprint/{sprint_id}"),
956            None,
957        )
958        .await?;
959        Ok(())
960    }
961
962    /// Add an issue to a sprint (Agile API).
963    pub async fn add_issue_to_sprint(&self, sprint_id: u64, issue_key: &str) -> Result<()> {
964        let path = format!("/rest/agile/1.0/sprint/{sprint_id}/issue");
965        let body = json!({ "issues": [issue_key] });
966        self.raw_request("POST", &path, Some(body)).await?;
967        Ok(())
968    }
969
970    /// Add a comment using pre-built ADF JSON (skips Markdown conversion).
971    pub async fn add_comment_adf(&self, issue_key: &str, adf: Value) -> Result<Comment> {
972        let headers = self.auth_headers()?;
973        let url = self.platform_url(&format!("/issue/{issue_key}/comment"));
974        let payload = json!({ "body": adf });
975        let http = &self.http;
976        let raw: Value = self
977            .request(|| http.post(&url).headers(headers.clone()).json(&payload))
978            .await?;
979        Comment::from_value(&raw, issue_key).ok_or_else(|| JiraError::Api {
980            status: 0,
981            message: "Failed to parse comment".into(),
982        })
983    }
984
985    /// Upload a file as an attachment to an issue.
986    pub async fn upload_attachment(
987        &self,
988        issue_key: &str,
989        file_path: &std::path::Path,
990    ) -> Result<Vec<Attachment>> {
991        let file_name = file_path
992            .file_name()
993            .and_then(|n| n.to_str())
994            .unwrap_or("attachment")
995            .to_string();
996        let bytes = std::fs::read(file_path)?;
997        let mime = mime_guess::from_path(file_path)
998            .first_or_octet_stream()
999            .to_string();
1000
1001        self.upload_attachment_bytes(issue_key, &file_name, bytes, Some(&mime))
1002            .await
1003    }
1004
1005    /// Upload an in-memory attachment to an issue.
1006    pub async fn upload_attachment_bytes(
1007        &self,
1008        issue_key: &str,
1009        file_name: &str,
1010        bytes: Vec<u8>,
1011        media_type: Option<&str>,
1012    ) -> Result<Vec<Attachment>> {
1013        use reqwest::{header::HeaderValue, multipart};
1014
1015        let headers = self.auth_headers_no_content_type()?;
1016        let url = self.platform_url(&format!("/issue/{issue_key}/attachments"));
1017        let mime = media_type
1018            .map(|value| value.to_string())
1019            .or_else(|| {
1020                mime_guess::from_path(file_name)
1021                    .first_raw()
1022                    .map(str::to_string)
1023            })
1024            .unwrap_or_else(|| "application/octet-stream".to_string());
1025
1026        let http = &self.http;
1027        let raw_attachments: Vec<Value> = self
1028            .request_multipart(|| {
1029                let part = multipart::Part::bytes(bytes.clone())
1030                    .file_name(file_name.to_string())
1031                    .mime_str(&mime)
1032                    .expect("invalid mime type");
1033                let form = multipart::Form::new().part("file", part);
1034
1035                let mut req_headers = headers.clone();
1036                req_headers.insert("X-Atlassian-Token", HeaderValue::from_static("no-check"));
1037
1038                http.post(&url).headers(req_headers).multipart(form)
1039            })
1040            .await?;
1041
1042        Ok(raw_attachments
1043            .iter()
1044            .filter_map(Attachment::from_value)
1045            .collect())
1046    }
1047
1048    /// Create a new issue with dynamic custom fields.
1049    pub async fn create_issue_v2(&self, req: CreateIssueRequestV2) -> Result<Issue> {
1050        let headers = self.auth_headers()?;
1051        let url = self.platform_url("/issue");
1052
1053        let description_adf = req
1054            .description_adf
1055            .or_else(|| req.description.as_deref().map(markdown_to_adf));
1056
1057        let mut fields = json!({
1058            "project": { "key": req.project_key },
1059            "summary": req.summary,
1060            "issuetype": { "name": req.issue_type }
1061        });
1062
1063        if let Some(adf) = description_adf {
1064            fields["description"] = adf;
1065        }
1066        if let Some(assignee) = &req.assignee {
1067            let account_id = self.resolve_assignee_account_id(assignee).await?;
1068            fields["assignee"] = json!({ "accountId": account_id });
1069        }
1070        if let Some(priority) = &req.priority {
1071            fields["priority"] = json!({ "name": priority });
1072        }
1073        if !req.labels.is_empty() {
1074            fields["labels"] = json!(req.labels);
1075        }
1076        if !req.components.is_empty() {
1077            fields["components"] = json!(req
1078                .components
1079                .iter()
1080                .map(|c| json!({"name": c}))
1081                .collect::<Vec<_>>());
1082        }
1083        if let Some(parent) = &req.parent {
1084            fields["parent"] = json!({ "key": parent });
1085        }
1086        if !req.fix_versions.is_empty() {
1087            fields["fixVersions"] = json!(req
1088                .fix_versions
1089                .iter()
1090                .map(|v| json!({"name": v}))
1091                .collect::<Vec<_>>());
1092        }
1093        for (field_id, value) in &req.custom_fields {
1094            fields[field_id] = value.to_api_json();
1095        }
1096
1097        let body = json!({ "fields": fields });
1098
1099        #[derive(serde::Deserialize)]
1100        struct CreateResponse {
1101            key: String,
1102        }
1103
1104        let http = &self.http;
1105        let resp: CreateResponse = self
1106            .request(|| http.post(&url).headers(headers.clone()).json(&body))
1107            .await?;
1108
1109        self.get_issue(&resp.key).await
1110    }
1111
1112    // ── Comments ─────────────────────────────────────────────────────────────
1113
1114    /// List all comments for an issue.
1115    pub async fn get_comments(&self, issue_key: &str) -> Result<Vec<Comment>> {
1116        let headers = self.auth_headers()?;
1117        let url = self.platform_url(&format!("/issue/{issue_key}/comment"));
1118
1119        #[derive(serde::Deserialize)]
1120        struct CommentResponse {
1121            comments: Vec<Value>,
1122        }
1123
1124        let http = &self.http;
1125        let resp: CommentResponse = self
1126            .request(|| http.get(&url).headers(headers.clone()))
1127            .await?;
1128
1129        Ok(resp
1130            .comments
1131            .iter()
1132            .filter_map(|v| Comment::from_value(v, issue_key))
1133            .collect())
1134    }
1135
1136    /// Add a comment to an issue.
1137    pub async fn add_comment(&self, issue_key: &str, body: &str) -> Result<Comment> {
1138        let headers = self.auth_headers()?;
1139        let url = self.platform_url(&format!("/issue/{issue_key}/comment"));
1140
1141        let payload = json!({
1142            "body": markdown_to_adf(body)
1143        });
1144
1145        let http = &self.http;
1146        let raw: Value = self
1147            .request(|| http.post(&url).headers(headers.clone()).json(&payload))
1148            .await?;
1149
1150        Comment::from_value(&raw, issue_key).ok_or_else(|| JiraError::Api {
1151            status: 0,
1152            message: "Failed to parse comment".into(),
1153        })
1154    }
1155
1156    // ── Worklog ──────────────────────────────────────────────────────────────
1157
1158    /// List all worklogs for an issue.
1159    pub async fn get_worklogs(&self, issue_key: &str) -> Result<Vec<Worklog>> {
1160        let headers = self.auth_headers()?;
1161        let url = self.platform_url(&format!("/issue/{issue_key}/worklog"));
1162
1163        #[derive(serde::Deserialize)]
1164        struct WorklogResponse {
1165            worklogs: Vec<Value>,
1166        }
1167
1168        let http = &self.http;
1169        let resp: WorklogResponse = self
1170            .request(|| http.get(&url).headers(headers.clone()))
1171            .await?;
1172
1173        Ok(resp
1174            .worklogs
1175            .iter()
1176            .filter_map(|v| Worklog::from_value(v, issue_key))
1177            .collect())
1178    }
1179
1180    /// Add a worklog entry to an issue.
1181    /// `time_spent` uses Jira format: "2h 30m", "1d", "45m"
1182    /// `started` is optional ISO 8601 timestamp; defaults to now if None.
1183    pub async fn add_worklog(
1184        &self,
1185        issue_key: &str,
1186        time_spent: &str,
1187        comment: Option<&str>,
1188        started: Option<&str>,
1189    ) -> Result<Worklog> {
1190        let headers = self.auth_headers()?;
1191        let url = self.platform_url(&format!("/issue/{issue_key}/worklog"));
1192
1193        // Jira requires started in "2006-01-02T15:04:05.000+0000" format
1194        let started_str = started
1195            .map(|s| s.to_string())
1196            .unwrap_or_else(current_jira_timestamp);
1197
1198        let mut body = json!({
1199            "timeSpent": time_spent,
1200            "started": started_str,
1201        });
1202
1203        if let Some(c) = comment {
1204            body["comment"] = markdown_to_adf(c);
1205        }
1206
1207        let http = &self.http;
1208        let raw: Value = self
1209            .request(|| http.post(&url).headers(headers.clone()).json(&body))
1210            .await?;
1211
1212        Worklog::from_value(&raw, issue_key).ok_or_else(|| JiraError::Api {
1213            status: 0,
1214            message: "Failed to parse worklog".into(),
1215        })
1216    }
1217
1218    /// Delete a worklog entry.
1219    pub async fn delete_worklog(&self, issue_key: &str, worklog_id: &str) -> Result<()> {
1220        let headers = self.auth_headers()?;
1221        let url = self.platform_url(&format!("/issue/{issue_key}/worklog/{worklog_id}"));
1222
1223        let http = &self.http;
1224        self.request_no_body(|| http.delete(&url).headers(headers.clone()))
1225            .await
1226    }
1227
1228    /// Delete a comment from an issue.
1229    pub async fn delete_comment(&self, issue_key: &str, comment_id: &str) -> Result<()> {
1230        let headers = self.auth_headers()?;
1231        let url = self.platform_url(&format!("/issue/{issue_key}/comment/{comment_id}"));
1232
1233        let http = &self.http;
1234        self.request_no_body(|| http.delete(&url).headers(headers.clone()))
1235            .await
1236    }
1237
1238    /// Delete an attachment by ID.
1239    pub async fn delete_attachment(&self, attachment_id: &str) -> Result<()> {
1240        let headers = self.auth_headers()?;
1241        let url = self.platform_url(&format!("/attachment/{attachment_id}"));
1242
1243        let http = &self.http;
1244        self.request_no_body(|| http.delete(&url).headers(headers.clone()))
1245            .await
1246    }
1247
1248    /// List remote links on an issue.
1249    pub async fn get_remote_links(&self, issue_key: &str) -> Result<Vec<RemoteLink>> {
1250        let headers = self.auth_headers()?;
1251        let url = self.platform_url(&format!("/issue/{issue_key}/remotelink"));
1252
1253        let http = &self.http;
1254        self.request(|| http.get(&url).headers(headers.clone()))
1255            .await
1256    }
1257
1258    /// Add a remote link to an issue.
1259    pub async fn add_remote_link(
1260        &self,
1261        issue_key: &str,
1262        url_str: &str,
1263        title: &str,
1264    ) -> Result<Value> {
1265        let headers = self.auth_headers()?;
1266        let url = self.platform_url(&format!("/issue/{issue_key}/remotelink"));
1267
1268        let payload = json!({
1269            "object": {
1270                "url": url_str,
1271                "title": title,
1272            }
1273        });
1274
1275        let http = &self.http;
1276        self.request(|| http.post(&url).headers(headers.clone()).json(&payload))
1277            .await
1278    }
1279
1280    /// Delete a remote link from an issue.
1281    pub async fn delete_remote_link(&self, issue_key: &str, link_id: &str) -> Result<()> {
1282        let headers = self.auth_headers()?;
1283        let url = self.platform_url(&format!("/issue/{issue_key}/remotelink/{link_id}"));
1284
1285        let http = &self.http;
1286        self.request_no_body(|| http.delete(&url).headers(headers.clone()))
1287            .await
1288    }
1289
1290    // ── Bulk ops ─────────────────────────────────────────────────────────────
1291
1292    /// Fetch ALL issues matching a JQL query using cursor-based pagination.
1293    /// Respects the Atlassian safeguard: max 500 pages.
1294    pub async fn get_all_issues(&self, jql: &str) -> Result<Vec<Issue>> {
1295        let mut all_issues = Vec::new();
1296        let mut next_page_token: Option<String> = None;
1297        let mut iterations = 0u32;
1298        const MAX_ITERATIONS: u32 = 500;
1299
1300        loop {
1301            iterations += 1;
1302            if iterations > MAX_ITERATIONS {
1303                break;
1304            }
1305
1306            let result = self
1307                .search_issues(jql, next_page_token.as_deref(), Some(100))
1308                .await?;
1309
1310            all_issues.extend(result.issues);
1311
1312            match result.next_page_token {
1313                Some(token) => next_page_token = Some(token),
1314                None => break,
1315            }
1316        }
1317
1318        Ok(all_issues)
1319    }
1320
1321    /// Archive a batch of issues by key. Jira accepts up to 1000 per request.
1322    pub async fn archive_issues(&self, issue_keys: &[String]) -> Result<()> {
1323        if issue_keys.is_empty() {
1324            return Ok(());
1325        }
1326        let headers = self.auth_headers()?;
1327        let url = self.platform_url("/issue/archive");
1328
1329        // Batch in chunks of 1000
1330        for chunk in issue_keys.chunks(1000) {
1331            let body = json!({ "issueIdsOrKeys": chunk });
1332            let http = &self.http;
1333            // Archive returns 200 with a body — use request() not request_no_body()
1334            let _: Value = self
1335                .request(|| http.put(&url).headers(headers.clone()).json(&body))
1336                .await?;
1337        }
1338
1339        Ok(())
1340    }
1341
1342    /// Move an issue using Jira's native bulk move API.
1343    pub async fn move_issue(
1344        &self,
1345        issue_key: &str,
1346        target_project_key: &str,
1347        target_issue_type_id: &str,
1348        target_parent: Option<&str>,
1349    ) -> Result<Issue> {
1350        let mapping_key = match target_parent {
1351            Some(parent) => format!("{target_project_key},{target_issue_type_id},{parent}"),
1352            None => format!("{target_project_key},{target_issue_type_id}"),
1353        };
1354
1355        let body = json!({
1356            "sendBulkNotification": true,
1357            "targetToSourcesMapping": {
1358                mapping_key: {
1359                    "inferClassificationDefaults": true,
1360                    "inferFieldDefaults": true,
1361                    "inferStatusDefaults": true,
1362                    "inferSubtaskTypeDefault": true,
1363                    "issueIdsOrKeys": [issue_key]
1364                }
1365            }
1366        });
1367
1368        let submitted = self
1369            .raw_request("POST", "/rest/api/3/bulk/issues/move", Some(body))
1370            .await?
1371            .ok_or_else(|| JiraError::Api {
1372                status: 0,
1373                message: "Bulk move returned an empty response".into(),
1374            })?;
1375
1376        let task_id = submitted
1377            .get("taskId")
1378            .and_then(|v| v.as_str())
1379            .ok_or_else(|| JiraError::Api {
1380                status: 0,
1381                message: "Bulk move response did not include a taskId".into(),
1382            })?;
1383
1384        const MAX_POLLS: usize = 60;
1385        for _ in 0..MAX_POLLS {
1386            tokio::time::sleep(Duration::from_secs(2)).await;
1387
1388            let progress = self
1389                .raw_request("GET", &format!("/rest/api/3/bulk/queue/{task_id}"), None)
1390                .await?
1391                .ok_or_else(|| JiraError::Api {
1392                    status: 0,
1393                    message: "Bulk move progress returned an empty response".into(),
1394                })?;
1395
1396            match progress
1397                .get("status")
1398                .and_then(|v| v.as_str())
1399                .unwrap_or("")
1400            {
1401                "COMPLETE" => return self.get_issue(issue_key).await,
1402                "FAILED" | "DEAD" | "CANCELLED" => {
1403                    return Err(JiraError::Api {
1404                        status: 0,
1405                        message: progress.to_string(),
1406                    });
1407                }
1408                _ => {}
1409            }
1410        }
1411
1412        Err(JiraError::Api {
1413            status: 0,
1414            message: format!("Timed out waiting for Jira bulk move task {task_id}"),
1415        })
1416    }
1417
1418    // ── Raw API passthrough ───────────────────────────────────────────────────
1419
1420    /// Execute an arbitrary Jira REST API call and return the raw JSON response.
1421    /// Returns `None` for 204 No Content responses (success with no body).
1422    /// `path` should start with `/rest/...`
1423    pub async fn raw_request(
1424        &self,
1425        method: &str,
1426        path: &str,
1427        body: Option<Value>,
1428    ) -> Result<Option<Value>> {
1429        let headers = self.auth_headers()?;
1430        let url = format!("{}{}", self.config.base_url.trim_end_matches('/'), path);
1431
1432        let http = &self.http;
1433        let response = self
1434            .execute_with_retry(|| {
1435                let req = match method.to_uppercase().as_str() {
1436                    "GET" => http.get(&url),
1437                    "POST" => http.post(&url),
1438                    "PUT" => http.put(&url),
1439                    "DELETE" => http.delete(&url),
1440                    "PATCH" => http.patch(&url),
1441                    _ => http.get(&url),
1442                };
1443                let req = req.headers(headers.clone());
1444                if let Some(b) = &body {
1445                    req.json(b)
1446                } else {
1447                    req
1448                }
1449            })
1450            .await?;
1451
1452        let status = response.status();
1453
1454        // 204 No Content — success with empty body
1455        if status == StatusCode::NO_CONTENT {
1456            return Ok(None);
1457        }
1458
1459        if status.is_success() {
1460            let value: Value = response.json().await?;
1461            return Ok(Some(value));
1462        }
1463
1464        let body_text = response.text().await.unwrap_or_default();
1465        Err(match status {
1466            StatusCode::NOT_FOUND => JiraError::NotFound(body_text),
1467            StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => {
1468                JiraError::Auth(format!("HTTP {status}: {body_text}"))
1469            }
1470            _ => JiraError::Api {
1471                status: status.as_u16(),
1472                message: body_text,
1473            },
1474        })
1475    }
1476
1477    // ── Plans API (Jira Premium) ──────────────────────────────────────────────
1478
1479    /// Check if this Jira instance is Premium tier.
1480    pub async fn is_premium(&self) -> bool {
1481        match self.get_server_info().await {
1482            Ok(info) => {
1483                let license = info
1484                    .get("deploymentType")
1485                    .and_then(|v| v.as_str())
1486                    .unwrap_or("");
1487                // "Cloud" with advanced features, or check licenseInfo
1488                let _ = license;
1489                // Simplest heuristic: try to call the plans endpoint
1490                let headers = match self.auth_headers() {
1491                    Ok(h) => h,
1492                    Err(_) => return false,
1493                };
1494                let url = self.platform_url("/plans/plan");
1495                let http = &self.http;
1496                matches!(
1497                    http.get(&url).headers(headers).send().await,
1498                    Ok(r) if r.status().is_success()
1499                )
1500            }
1501            Err(_) => false,
1502        }
1503    }
1504
1505    /// List Jira Plans (requires Jira Premium / Advanced Roadmaps).
1506    pub async fn get_plans(&self) -> Result<Vec<Value>> {
1507        let headers = self.auth_headers()?;
1508        let url = self.platform_url("/plans/plan");
1509
1510        #[derive(serde::Deserialize)]
1511        struct PlansResponse {
1512            values: Vec<Value>,
1513        }
1514
1515        let http = &self.http;
1516        let resp: PlansResponse = self
1517            .request(|| http.get(&url).headers(headers.clone()))
1518            .await?;
1519
1520        Ok(resp.values)
1521    }
1522
1523    // ── Issue Links ──────────────────────────────────────────────────────────
1524
1525    /// List available issue link types.
1526    pub async fn list_issue_link_types(&self) -> Result<Vec<IssueLinkType>> {
1527        let headers = self.auth_headers()?;
1528        let url = self.platform_url("/issueLinkType");
1529
1530        #[derive(serde::Deserialize)]
1531        struct LinkTypesResponse {
1532            #[serde(rename = "issueLinkTypes")]
1533            issue_link_types: Vec<IssueLinkType>,
1534        }
1535
1536        let http = &self.http;
1537        let resp: LinkTypesResponse = self
1538            .request(|| http.get(&url).headers(headers.clone()))
1539            .await?;
1540
1541        Ok(resp.issue_link_types)
1542    }
1543
1544    /// Create a link between two issues.
1545    /// `type_name` is the name of the link type (e.g., "Blocks").
1546    /// `outward_key` is the issue that is doing the action (e.g., the blocker).
1547    /// `inward_key` is the issue that is receiving the action (e.g., the blocked issue).
1548    pub async fn link_issues(
1549        &self,
1550        outward_key: &str,
1551        inward_key: &str,
1552        type_name: &str,
1553        comment: Option<&str>,
1554    ) -> Result<()> {
1555        let headers = self.auth_headers()?;
1556        let url = self.platform_url("/issueLink");
1557
1558        let mut payload = json!({
1559            "type": { "name": type_name },
1560            "inwardIssue": { "key": inward_key },
1561            "outwardIssue": { "key": outward_key }
1562        });
1563
1564        if let Some(c) = comment {
1565            payload["comment"] = json!({
1566                "body": crate::adf::markdown_to_adf(c)
1567            });
1568        }
1569
1570        let http = &self.http;
1571        self.request_no_body(|| http.post(&url).headers(headers.clone()).json(&payload))
1572            .await
1573    }
1574
1575    /// Get a specific issue link by ID.
1576    pub async fn get_issue_link(&self, link_id: &str) -> Result<IssueLink> {
1577        let headers = self.auth_headers()?;
1578        let url = self.platform_url(&format!("/issueLink/{link_id}"));
1579
1580        let http = &self.http;
1581        self.request(|| http.get(&url).headers(headers.clone()))
1582            .await
1583    }
1584
1585    /// Delete an issue link by ID.
1586    pub async fn delete_issue_link(&self, link_id: &str) -> Result<()> {
1587        let headers = self.auth_headers()?;
1588        let url = self.platform_url(&format!("/issueLink/{link_id}"));
1589
1590        let http = &self.http;
1591        self.request_no_body(|| http.delete(&url).headers(headers.clone()))
1592            .await
1593    }
1594}
1595
1596async fn handle_response<T>(response: Response) -> Result<T>
1597where
1598    T: serde::de::DeserializeOwned,
1599{
1600    let status = response.status();
1601
1602    if status.is_success() {
1603        // 204/205: no body — callers expecting a body should use request_no_body().
1604        // Defensive: try to deserialize from null (works for Value and Option<T>).
1605        if status == StatusCode::NO_CONTENT || status == StatusCode::RESET_CONTENT {
1606            return serde_json::from_value(serde_json::Value::Null).map_err(|_| JiraError::Api {
1607                status: status.as_u16(),
1608                message: "Unexpected empty response body".into(),
1609            });
1610        }
1611        let value: T = response.json().await?;
1612        return Ok(value);
1613    }
1614
1615    let body = response.text().await.unwrap_or_default();
1616
1617    match status {
1618        StatusCode::NOT_FOUND => Err(JiraError::NotFound(body)),
1619        StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => {
1620            Err(JiraError::Auth(format!("HTTP {status}: {body}")))
1621        }
1622        _ => Err(JiraError::Api {
1623            status: status.as_u16(),
1624            message: body,
1625        }),
1626    }
1627}
1628
1629/// Returns current UTC time in Jira worklog format: "2006-01-02T15:04:05.000+0000"
1630fn current_jira_timestamp() -> String {
1631    chrono::Utc::now()
1632        .format("%Y-%m-%dT%H:%M:%S%.3f%z")
1633        .to_string()
1634}
1635
1636fn base64_encode(input: &str) -> String {
1637    use std::fmt::Write;
1638    const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
1639    let bytes = input.as_bytes();
1640    let mut result = String::new();
1641    let mut i = 0;
1642    while i < bytes.len() {
1643        let b0 = bytes[i] as u32;
1644        let b1 = if i + 1 < bytes.len() {
1645            bytes[i + 1] as u32
1646        } else {
1647            0
1648        };
1649        let b2 = if i + 2 < bytes.len() {
1650            bytes[i + 2] as u32
1651        } else {
1652            0
1653        };
1654
1655        let _ = write!(result, "{}", CHARS[((b0 >> 2) & 0x3F) as usize] as char);
1656        let _ = write!(
1657            result,
1658            "{}",
1659            CHARS[(((b0 & 0x3) << 4) | ((b1 >> 4) & 0xF)) as usize] as char
1660        );
1661        if i + 1 < bytes.len() {
1662            let _ = write!(
1663                result,
1664                "{}",
1665                CHARS[(((b1 & 0xF) << 2) | ((b2 >> 6) & 0x3)) as usize] as char
1666            );
1667        } else {
1668            result.push('=');
1669        }
1670        if i + 2 < bytes.len() {
1671            let _ = write!(result, "{}", CHARS[(b2 & 0x3F) as usize] as char);
1672        } else {
1673            result.push('=');
1674        }
1675        i += 3;
1676    }
1677    result
1678}
1679
1680#[cfg(test)]
1681mod tests {
1682    use super::*;
1683    use crate::{
1684        config::{JiraAuthType, JiraDeployment},
1685        model::FieldValue,
1686    };
1687    use wiremock::{
1688        matchers::{body_json, header, method, path, query_param},
1689        Mock, MockServer, ResponseTemplate,
1690    };
1691
1692    fn cloud_client(server: &MockServer) -> JiraClient {
1693        JiraClient::new(JiraConfig {
1694            profile_name: Some("cloud-main".into()),
1695            base_url: server.uri(),
1696            email: "dev@example.com".into(),
1697            token: Some("cloud-token".into()),
1698            project: None,
1699            timeout_secs: 30,
1700            deployment: JiraDeployment::Cloud,
1701            auth_type: JiraAuthType::CloudApiToken,
1702            api_version: 3,
1703        })
1704    }
1705
1706    fn cloud_auth() -> String {
1707        format!("Basic {}", base64_encode("dev@example.com:cloud-token"))
1708    }
1709
1710    #[tokio::test]
1711    async fn data_center_pat_uses_bearer_and_api_v2() {
1712        let server = MockServer::start().await;
1713
1714        Mock::given(method("GET"))
1715            .and(path("/rest/api/2/serverInfo"))
1716            .and(header("authorization", "Bearer dc-token"))
1717            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1718                "deploymentType": "Data Center",
1719                "version": "10.0.0"
1720            })))
1721            .mount(&server)
1722            .await;
1723
1724        let client = JiraClient::new(JiraConfig {
1725            profile_name: Some("dc-main".into()),
1726            base_url: server.uri(),
1727            email: String::new(),
1728            token: Some("dc-token".into()),
1729            project: None,
1730            timeout_secs: 30,
1731            deployment: JiraDeployment::DataCenter,
1732            auth_type: JiraAuthType::DataCenterPat,
1733            api_version: 2,
1734        });
1735
1736        let info = client.get_server_info().await.expect("server info");
1737        assert_eq!(info["deploymentType"], Value::String("Data Center".into()));
1738    }
1739
1740    #[tokio::test]
1741    async fn cloud_auth_uses_basic_and_api_v3() {
1742        let server = MockServer::start().await;
1743        let expected = format!("Basic {}", base64_encode("dev@example.com:cloud-token"));
1744
1745        Mock::given(method("GET"))
1746            .and(path("/rest/api/3/serverInfo"))
1747            .and(header("authorization", expected.as_str()))
1748            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1749                "deploymentType": "Cloud",
1750                "version": "1001.0.0"
1751            })))
1752            .mount(&server)
1753            .await;
1754
1755        let client = JiraClient::new(JiraConfig {
1756            profile_name: Some("cloud-main".into()),
1757            base_url: server.uri(),
1758            email: "dev@example.com".into(),
1759            token: Some("cloud-token".into()),
1760            project: None,
1761            timeout_secs: 30,
1762            deployment: JiraDeployment::Cloud,
1763            auth_type: JiraAuthType::CloudApiToken,
1764            api_version: 3,
1765        });
1766
1767        let info = client.get_server_info().await.expect("server info");
1768        assert_eq!(info["deploymentType"], Value::String("Cloud".into()));
1769    }
1770
1771    #[tokio::test]
1772    async fn get_fields_for_issue_type_supports_map_response() {
1773        let server = MockServer::start().await;
1774        let expected = format!("Basic {}", base64_encode("dev@example.com:cloud-token"));
1775
1776        Mock::given(method("GET"))
1777            .and(path("/rest/api/3/issue/createmeta/TEST/issuetypes/10001"))
1778            .and(header("authorization", expected.as_str()))
1779            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1780                "fields": {
1781                    "summary": {
1782                        "name": "Summary",
1783                        "required": true,
1784                        "schema": { "type": "string" }
1785                    }
1786                }
1787            })))
1788            .mount(&server)
1789            .await;
1790
1791        let client = JiraClient::new(JiraConfig {
1792            profile_name: Some("cloud-main".into()),
1793            base_url: server.uri(),
1794            email: "dev@example.com".into(),
1795            token: Some("cloud-token".into()),
1796            project: None,
1797            timeout_secs: 30,
1798            deployment: JiraDeployment::Cloud,
1799            auth_type: JiraAuthType::CloudApiToken,
1800            api_version: 3,
1801        });
1802
1803        let fields = client
1804            .get_fields_for_issue_type("TEST", "10001")
1805            .await
1806            .expect("map response should parse");
1807
1808        assert_eq!(fields.len(), 1);
1809        assert_eq!(fields[0].id, "summary");
1810        assert_eq!(fields[0].name, "Summary");
1811        assert!(fields[0].required);
1812        assert_eq!(fields[0].field_type, "string");
1813    }
1814
1815    #[tokio::test]
1816    async fn get_fields_for_issue_type_supports_list_response() {
1817        let server = MockServer::start().await;
1818        let expected = format!("Basic {}", base64_encode("dev@example.com:cloud-token"));
1819
1820        Mock::given(method("GET"))
1821            .and(path("/rest/api/3/issue/createmeta/TEST/issuetypes/10002"))
1822            .and(header("authorization", expected.as_str()))
1823            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1824                "fields": [
1825                    {
1826                        "fieldId": "customfield_10553",
1827                        "key": "customfield_10553",
1828                        "name": "Labels (OSS)",
1829                        "required": true,
1830                        "schema": {
1831                            "custom": "com.atlassian.jira.plugin.system.customfieldtypes:labels",
1832                            "items": "string",
1833                            "type": "array"
1834                        },
1835                        "allowedValues": []
1836                    }
1837                ]
1838            })))
1839            .mount(&server)
1840            .await;
1841
1842        let client = JiraClient::new(JiraConfig {
1843            profile_name: Some("cloud-main".into()),
1844            base_url: server.uri(),
1845            email: "dev@example.com".into(),
1846            token: Some("cloud-token".into()),
1847            project: None,
1848            timeout_secs: 30,
1849            deployment: JiraDeployment::Cloud,
1850            auth_type: JiraAuthType::CloudApiToken,
1851            api_version: 3,
1852        });
1853
1854        let fields = client
1855            .get_fields_for_issue_type("TEST", "10002")
1856            .await
1857            .expect("list response should parse");
1858
1859        assert_eq!(fields.len(), 1);
1860        assert_eq!(fields[0].id, "customfield_10553");
1861        assert_eq!(fields[0].name, "Labels (OSS)");
1862        assert!(fields[0].required);
1863        assert_eq!(fields[0].field_type, "array");
1864    }
1865
1866    #[tokio::test]
1867    async fn search_users_supports_empty_query_and_no_results() {
1868        let server = MockServer::start().await;
1869        let expected_auth = cloud_auth();
1870
1871        Mock::given(method("GET"))
1872            .and(path("/rest/api/3/user/search"))
1873            .and(header("authorization", expected_auth.as_str()))
1874            .and(query_param("query", ""))
1875            .and(query_param("maxResults", "20"))
1876            .respond_with(ResponseTemplate::new(200).set_body_json(json!([])))
1877            .mount(&server)
1878            .await;
1879
1880        let client = cloud_client(&server);
1881        let users = client
1882            .search_users("")
1883            .await
1884            .expect("empty query should work");
1885
1886        assert!(users.is_empty());
1887    }
1888
1889    #[tokio::test]
1890    async fn search_users_preserves_users_without_email() {
1891        let server = MockServer::start().await;
1892        let expected_auth = cloud_auth();
1893
1894        Mock::given(method("GET"))
1895            .and(path("/rest/api/3/user/search"))
1896            .and(header("authorization", expected_auth.as_str()))
1897            .and(query_param("query", "alice"))
1898            .and(query_param("maxResults", "20"))
1899            .respond_with(ResponseTemplate::new(200).set_body_json(json!([
1900                {
1901                    "accountId": "acct-1",
1902                    "displayName": "Alice Example"
1903                }
1904            ])))
1905            .mount(&server)
1906            .await;
1907
1908        let client = cloud_client(&server);
1909        let users = client
1910            .search_users("alice")
1911            .await
1912            .expect("search should parse");
1913
1914        assert_eq!(users.len(), 1);
1915        assert_eq!(users[0].account_id, "acct-1");
1916        assert_eq!(users[0].display_name.as_deref(), Some("Alice Example"));
1917        assert!(users[0].email_address.is_none());
1918    }
1919
1920    #[tokio::test]
1921    async fn add_issue_to_sprint_posts_expected_payload() {
1922        let server = MockServer::start().await;
1923        let expected_auth = cloud_auth();
1924
1925        Mock::given(method("POST"))
1926            .and(path("/rest/agile/1.0/sprint/42/issue"))
1927            .and(header("authorization", expected_auth.as_str()))
1928            .and(body_json(json!({ "issues": ["TEST-123"] })))
1929            .respond_with(ResponseTemplate::new(200).set_body_json(json!({})))
1930            .mount(&server)
1931            .await;
1932
1933        let client = cloud_client(&server);
1934        client
1935            .add_issue_to_sprint(42, "TEST-123")
1936            .await
1937            .expect("add to sprint should succeed");
1938    }
1939
1940    #[tokio::test]
1941    async fn add_issue_to_sprint_propagates_non_success_response() {
1942        let server = MockServer::start().await;
1943        let expected_auth = cloud_auth();
1944
1945        Mock::given(method("POST"))
1946            .and(path("/rest/agile/1.0/sprint/42/issue"))
1947            .and(header("authorization", expected_auth.as_str()))
1948            .respond_with(ResponseTemplate::new(400).set_body_string("bad sprint request"))
1949            .mount(&server)
1950            .await;
1951
1952        let client = cloud_client(&server);
1953        let err = client
1954            .add_issue_to_sprint(42, "TEST-123")
1955            .await
1956            .expect_err("non-success should return error");
1957
1958        match err {
1959            JiraError::Api { status, message } => {
1960                assert_eq!(status, 400);
1961                assert!(message.contains("bad sprint request"));
1962            }
1963            other => panic!("expected JiraError::Api, got {other:?}"),
1964        }
1965    }
1966
1967    #[tokio::test]
1968    async fn list_sprints_for_project_returns_empty_when_no_boards_exist() {
1969        let server = MockServer::start().await;
1970        let expected_auth = cloud_auth();
1971
1972        Mock::given(method("GET"))
1973            .and(path("/rest/agile/1.0/board"))
1974            .and(header("authorization", expected_auth.as_str()))
1975            .and(query_param("projectKeyOrId", "TEST"))
1976            .and(query_param("maxResults", "100"))
1977            .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "values": [] })))
1978            .mount(&server)
1979            .await;
1980
1981        let client = cloud_client(&server);
1982        let sprints = client
1983            .list_sprints_for_project("TEST")
1984            .await
1985            .expect("no boards should not error");
1986
1987        assert!(sprints.is_empty());
1988    }
1989
1990    #[tokio::test]
1991    async fn list_sprints_for_project_dedups_sorts_and_skips_missing_board_sprints() {
1992        let server = MockServer::start().await;
1993        let expected_auth = cloud_auth();
1994
1995        Mock::given(method("GET"))
1996            .and(path("/rest/agile/1.0/board"))
1997            .and(header("authorization", expected_auth.as_str()))
1998            .and(query_param("projectKeyOrId", "TEST"))
1999            .and(query_param("maxResults", "100"))
2000            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2001                "values": [
2002                    { "id": 1 },
2003                    { "id": 2 },
2004                    { "id": 3 }
2005                ]
2006            })))
2007            .mount(&server)
2008            .await;
2009
2010        Mock::given(method("GET"))
2011            .and(path("/rest/agile/1.0/board/1/sprint"))
2012            .and(header("authorization", expected_auth.as_str()))
2013            .and(query_param("state", "active,future"))
2014            .and(query_param("maxResults", "200"))
2015            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2016                "values": [
2017                    { "id": 20, "name": "Future Sprint", "state": "future" },
2018                    { "id": 10, "name": "Active Sprint", "state": "active" }
2019                ]
2020            })))
2021            .mount(&server)
2022            .await;
2023
2024        Mock::given(method("GET"))
2025            .and(path("/rest/agile/1.0/board/2/sprint"))
2026            .and(header("authorization", expected_auth.as_str()))
2027            .and(query_param("state", "active,future"))
2028            .and(query_param("maxResults", "200"))
2029            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2030                "values": [
2031                    { "id": 10, "name": "Active Sprint", "state": "active" },
2032                    { "id": 30, "name": "Alpha Future", "state": "future" }
2033                ]
2034            })))
2035            .mount(&server)
2036            .await;
2037
2038        Mock::given(method("GET"))
2039            .and(path("/rest/agile/1.0/board/3/sprint"))
2040            .and(header("authorization", expected_auth.as_str()))
2041            .and(query_param("state", "active,future"))
2042            .and(query_param("maxResults", "200"))
2043            .respond_with(ResponseTemplate::new(404).set_body_string("board not found"))
2044            .mount(&server)
2045            .await;
2046
2047        let client = cloud_client(&server);
2048        let sprints = client
2049            .list_sprints_for_project("TEST")
2050            .await
2051            .expect("404 on one board should be skipped");
2052
2053        assert_eq!(sprints.len(), 3);
2054        assert_eq!(sprints[0].id, 10);
2055        assert_eq!(sprints[0].state, "active");
2056        assert_eq!(sprints[1].id, 30);
2057        assert_eq!(sprints[1].state, "future");
2058        assert_eq!(sprints[2].id, 20);
2059        assert_eq!(sprints[2].state, "future");
2060    }
2061
2062    #[tokio::test]
2063    async fn list_sprints_for_project_handles_large_sprint_pages() {
2064        let server = MockServer::start().await;
2065        let expected_auth = cloud_auth();
2066
2067        let sprint_values: Vec<Value> = (1..=60)
2068            .map(|id| {
2069                json!({
2070                    "id": id,
2071                    "name": format!("Sprint {id:02}"),
2072                    "state": if id == 1 { "active" } else { "future" }
2073                })
2074            })
2075            .collect();
2076
2077        Mock::given(method("GET"))
2078            .and(path("/rest/agile/1.0/board"))
2079            .and(header("authorization", expected_auth.as_str()))
2080            .and(query_param("projectKeyOrId", "TEST"))
2081            .and(query_param("maxResults", "100"))
2082            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2083                "values": [{ "id": 9 }]
2084            })))
2085            .mount(&server)
2086            .await;
2087
2088        Mock::given(method("GET"))
2089            .and(path("/rest/agile/1.0/board/9/sprint"))
2090            .and(header("authorization", expected_auth.as_str()))
2091            .and(query_param("state", "active,future"))
2092            .and(query_param("maxResults", "200"))
2093            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2094                "values": sprint_values
2095            })))
2096            .mount(&server)
2097            .await;
2098
2099        let client = cloud_client(&server);
2100        let sprints = client
2101            .list_sprints_for_project("TEST")
2102            .await
2103            .expect(">50 sprints in one response should parse");
2104
2105        assert_eq!(sprints.len(), 60);
2106        assert_eq!(sprints[0].id, 1);
2107        assert_eq!(sprints[0].state, "active");
2108        assert_eq!(sprints[59].id, 60);
2109    }
2110
2111    #[tokio::test]
2112    async fn create_sprint_posts_expected_payload() {
2113        let server = MockServer::start().await;
2114        let expected_auth = cloud_auth();
2115
2116        Mock::given(method("POST"))
2117            .and(path("/rest/agile/1.0/sprint"))
2118            .and(header("authorization", expected_auth.as_str()))
2119            .and(body_json(json!({
2120                "name": "Sprint 42",
2121                "originBoardId": 7,
2122                "startDate": "2026-05-20T00:00:00.000Z",
2123                "endDate": "2026-05-27T00:00:00.000Z",
2124                "goal": "Ship sprint lifecycle support"
2125            })))
2126            .respond_with(ResponseTemplate::new(201).set_body_json(json!({
2127                "id": 42,
2128                "name": "Sprint 42",
2129                "state": "future",
2130                "goal": "Ship sprint lifecycle support",
2131                "startDate": "2026-05-20T00:00:00.000Z",
2132                "endDate": "2026-05-27T00:00:00.000Z"
2133            })))
2134            .mount(&server)
2135            .await;
2136
2137        let client = cloud_client(&server);
2138        let sprint = client
2139            .create_sprint(
2140                7,
2141                "Sprint 42",
2142                Some("2026-05-20T00:00:00.000Z"),
2143                Some("2026-05-27T00:00:00.000Z"),
2144                Some("Ship sprint lifecycle support"),
2145            )
2146            .await
2147            .expect("create sprint should succeed");
2148
2149        assert_eq!(sprint.id, 42);
2150        assert_eq!(sprint.board_id, Some(7));
2151        assert_eq!(sprint.state, "future");
2152        assert_eq!(
2153            sprint.goal.as_deref(),
2154            Some("Ship sprint lifecycle support")
2155        );
2156    }
2157
2158    #[tokio::test]
2159    async fn update_sprint_puts_expected_payload() {
2160        let server = MockServer::start().await;
2161        let expected_auth = cloud_auth();
2162
2163        Mock::given(method("PUT"))
2164            .and(path("/rest/agile/1.0/sprint/42"))
2165            .and(header("authorization", expected_auth.as_str()))
2166            .and(body_json(json!({
2167                "state": "active",
2168                "startDate": "2026-05-20T00:00:00.000Z",
2169                "endDate": "2026-05-27T00:00:00.000Z"
2170            })))
2171            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2172                "id": 42,
2173                "name": "Sprint 42",
2174                "state": "active",
2175                "startDate": "2026-05-20T00:00:00.000Z",
2176                "endDate": "2026-05-27T00:00:00.000Z"
2177            })))
2178            .mount(&server)
2179            .await;
2180
2181        let client = cloud_client(&server);
2182        let sprint = client
2183            .update_sprint(
2184                42,
2185                json!({
2186                    "state": "active",
2187                    "startDate": "2026-05-20T00:00:00.000Z",
2188                    "endDate": "2026-05-27T00:00:00.000Z"
2189                }),
2190            )
2191            .await
2192            .expect("update sprint should succeed");
2193
2194        assert_eq!(sprint.id, 42);
2195        assert_eq!(sprint.state, "active");
2196    }
2197
2198    #[tokio::test]
2199    async fn delete_sprint_uses_delete_endpoint() {
2200        let server = MockServer::start().await;
2201        let expected_auth = cloud_auth();
2202
2203        Mock::given(method("DELETE"))
2204            .and(path("/rest/agile/1.0/sprint/42"))
2205            .and(header("authorization", expected_auth.as_str()))
2206            .respond_with(ResponseTemplate::new(204))
2207            .mount(&server)
2208            .await;
2209
2210        let client = cloud_client(&server);
2211        client
2212            .delete_sprint(42)
2213            .await
2214            .expect("delete sprint should succeed");
2215    }
2216
2217    #[tokio::test]
2218    async fn create_issue_v2_prefers_adf_and_builds_extended_fields() {
2219        let server = MockServer::start().await;
2220        let expected_auth = cloud_auth();
2221        let description_adf = json!({
2222            "type": "doc",
2223            "version": 1,
2224            "content": [{
2225                "type": "paragraph",
2226                "content": [{ "type": "text", "text": "ADF body" }]
2227            }]
2228        });
2229
2230        Mock::given(method("GET"))
2231            .and(path("/rest/api/3/user/search"))
2232            .and(header("authorization", expected_auth.as_str()))
2233            .and(query_param("query", "alice@example.com"))
2234            .and(query_param("maxResults", "20"))
2235            .respond_with(ResponseTemplate::new(200).set_body_json(json!([
2236                {
2237                    "accountId": "acct-1",
2238                    "emailAddress": "alice@example.com",
2239                    "displayName": "Alice"
2240                }
2241            ])))
2242            .mount(&server)
2243            .await;
2244
2245        Mock::given(method("POST"))
2246            .and(path("/rest/api/3/issue"))
2247            .and(header("authorization", expected_auth.as_str()))
2248            .and(body_json(json!({
2249                "fields": {
2250                    "project": { "key": "TEST" },
2251                    "summary": "Ship feature",
2252                    "issuetype": { "name": "Task" },
2253                    "description": description_adf,
2254                    "assignee": { "accountId": "acct-1" },
2255                    "priority": { "name": "High" },
2256                    "labels": ["backend", "urgent"],
2257                    "components": [{ "name": "api" }, { "name": "worker" }],
2258                    "parent": { "key": "TEST-1" },
2259                    "fixVersions": [{ "name": "v1.0" }, { "name": "v1.1" }],
2260                    "customfield_10010": "hello",
2261                    "customfield_10011": { "value": "Blue" },
2262                    "customfield_10012": ["triage"]
2263                }
2264            })))
2265            .respond_with(ResponseTemplate::new(201).set_body_json(json!({ "key": "TEST-123" })))
2266            .mount(&server)
2267            .await;
2268
2269        Mock::given(method("GET"))
2270            .and(path("/rest/api/3/issue/TEST-123"))
2271            .and(header("authorization", expected_auth.as_str()))
2272            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2273                "id": "10001",
2274                "key": "TEST-123",
2275                "fields": {
2276                    "summary": "Ship feature",
2277                    "description": description_adf,
2278                    "status": { "name": "To Do" },
2279                    "issuetype": { "name": "Task" },
2280                    "project": { "key": "TEST" },
2281                    "created": "2023-01-01T00:00:00.000+0000",
2282                    "updated": "2023-01-01T00:00:00.000+0000"
2283                }
2284            })))
2285            .mount(&server)
2286            .await;
2287
2288        let client = cloud_client(&server);
2289        let mut custom_fields = std::collections::HashMap::new();
2290        custom_fields.insert(
2291            "customfield_10010".to_string(),
2292            FieldValue::Text("hello".into()),
2293        );
2294        custom_fields.insert(
2295            "customfield_10011".to_string(),
2296            FieldValue::SelectName("Blue".into()),
2297        );
2298        custom_fields.insert(
2299            "customfield_10012".to_string(),
2300            FieldValue::Labels(vec!["triage".into()]),
2301        );
2302
2303        let issue = client
2304            .create_issue_v2(CreateIssueRequestV2 {
2305                project_key: "TEST".into(),
2306                summary: "Ship feature".into(),
2307                description: Some("markdown body should be ignored".into()),
2308                description_adf: Some(description_adf.clone()),
2309                issue_type: "Task".into(),
2310                assignee: Some("alice@example.com".into()),
2311                priority: Some("High".into()),
2312                labels: vec!["backend".into(), "urgent".into()],
2313                components: vec!["api".into(), "worker".into()],
2314                parent: Some("TEST-1".into()),
2315                fix_versions: vec!["v1.0".into(), "v1.1".into()],
2316                custom_fields,
2317            })
2318            .await
2319            .expect("create issue v2 should succeed");
2320
2321        assert_eq!(issue.key, "TEST-123");
2322        assert_eq!(issue.summary, "Ship feature");
2323        assert_eq!(issue.description, Some(description_adf));
2324    }
2325
2326    #[tokio::test]
2327    async fn create_issue_v2_uses_markdown_description_when_adf_missing() {
2328        let server = MockServer::start().await;
2329        let expected_auth = cloud_auth();
2330        let markdown_adf = markdown_to_adf("hello world");
2331
2332        Mock::given(method("POST"))
2333            .and(path("/rest/api/3/issue"))
2334            .and(header("authorization", expected_auth.as_str()))
2335            .and(body_json(json!({
2336                "fields": {
2337                    "project": { "key": "TEST" },
2338                    "summary": "Plain issue",
2339                    "issuetype": { "name": "Task" },
2340                    "description": markdown_adf
2341                }
2342            })))
2343            .respond_with(ResponseTemplate::new(201).set_body_json(json!({ "key": "TEST-124" })))
2344            .mount(&server)
2345            .await;
2346
2347        Mock::given(method("GET"))
2348            .and(path("/rest/api/3/issue/TEST-124"))
2349            .and(header("authorization", expected_auth.as_str()))
2350            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2351                "id": "10002",
2352                "key": "TEST-124",
2353                "fields": {
2354                    "summary": "Plain issue",
2355                    "description": markdown_to_adf("hello world"),
2356                    "status": { "name": "To Do" },
2357                    "issuetype": { "name": "Task" },
2358                    "project": { "key": "TEST" },
2359                    "created": "2023-01-01T00:00:00.000+0000",
2360                    "updated": "2023-01-01T00:00:00.000+0000"
2361                }
2362            })))
2363            .mount(&server)
2364            .await;
2365
2366        let client = cloud_client(&server);
2367        let issue = client
2368            .create_issue_v2(CreateIssueRequestV2 {
2369                project_key: "TEST".into(),
2370                summary: "Plain issue".into(),
2371                description: Some("hello world".into()),
2372                description_adf: None,
2373                issue_type: "Task".into(),
2374                assignee: None,
2375                priority: None,
2376                labels: vec![],
2377                components: vec![],
2378                parent: None,
2379                fix_versions: vec![],
2380                custom_fields: std::collections::HashMap::new(),
2381            })
2382            .await
2383            .expect("markdown fallback should succeed");
2384
2385        assert_eq!(issue.key, "TEST-124");
2386        assert_eq!(issue.summary, "Plain issue");
2387        assert_eq!(issue.description, Some(markdown_to_adf("hello world")));
2388    }
2389
2390    #[tokio::test]
2391    async fn get_comments_parses_comment_text_and_mentions() {
2392        let server = MockServer::start().await;
2393        let expected_auth = cloud_auth();
2394
2395        Mock::given(method("GET"))
2396            .and(path("/rest/api/3/issue/TEST-1/comment"))
2397            .and(header("authorization", expected_auth.as_str()))
2398            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2399                "comments": [
2400                    {
2401                        "id": "10001",
2402                        "author": {
2403                            "displayName": "Alice",
2404                            "accountId": "acct-1"
2405                        },
2406                        "body": {
2407                            "type": "doc",
2408                            "version": 1,
2409                            "content": [{
2410                                "type": "paragraph",
2411                                "content": [
2412                                    { "type": "text", "text": "Hi " },
2413                                    { "type": "mention", "attrs": { "id": "acct-2", "text": "@Bob" } }
2414                                ]
2415                            }]
2416                        },
2417                        "created": "2023-01-01T00:00:00.000+0000",
2418                        "updated": "2023-01-01T00:00:00.000+0000"
2419                    }
2420                ]
2421            })))
2422            .mount(&server)
2423            .await;
2424
2425        let client = cloud_client(&server);
2426        let comments = client
2427            .get_comments("TEST-1")
2428            .await
2429            .expect("comments should parse");
2430
2431        assert_eq!(comments.len(), 1);
2432        assert_eq!(comments[0].id, "10001");
2433        assert_eq!(comments[0].author.as_deref(), Some("Alice"));
2434        assert_eq!(comments[0].author_account_id.as_deref(), Some("acct-1"));
2435        assert_eq!(comments[0].body.as_deref(), Some("Hi @Bob"));
2436        assert_eq!(comments[0].mentions, vec!["acct-2"]);
2437    }
2438
2439    #[tokio::test]
2440    async fn add_comment_adf_posts_prebuilt_adf_payload() {
2441        let server = MockServer::start().await;
2442        let expected_auth = cloud_auth();
2443        let adf = json!({
2444            "type": "doc",
2445            "version": 1,
2446            "content": [{
2447                "type": "paragraph",
2448                "content": [
2449                    { "type": "text", "text": "Hi " },
2450                    { "type": "mention", "attrs": { "id": "acct-2", "text": "@Bob" } }
2451                ]
2452            }]
2453        });
2454
2455        Mock::given(method("POST"))
2456            .and(path("/rest/api/3/issue/TEST-1/comment"))
2457            .and(header("authorization", expected_auth.as_str()))
2458            .and(body_json(json!({ "body": adf.clone() })))
2459            .respond_with(ResponseTemplate::new(201).set_body_json(json!({
2460                "id": "10002",
2461                "author": {
2462                    "displayName": "Alice",
2463                    "accountId": "acct-1"
2464                },
2465                "body": adf,
2466                "created": "2023-01-01T00:00:00.000+0000",
2467                "updated": "2023-01-01T00:00:00.000+0000"
2468            })))
2469            .mount(&server)
2470            .await;
2471
2472        let client = cloud_client(&server);
2473        let comment = client
2474            .add_comment_adf(
2475                "TEST-1",
2476                json!({
2477                    "type": "doc",
2478                    "version": 1,
2479                    "content": [{
2480                        "type": "paragraph",
2481                        "content": [
2482                            { "type": "text", "text": "Hi " },
2483                            { "type": "mention", "attrs": { "id": "acct-2", "text": "@Bob" } }
2484                        ]
2485                    }]
2486                }),
2487            )
2488            .await
2489            .expect("add comment adf should succeed");
2490
2491        assert_eq!(comment.id, "10002");
2492        assert_eq!(comment.body.as_deref(), Some("Hi @Bob"));
2493        assert_eq!(comment.mentions, vec!["acct-2"]);
2494    }
2495
2496    #[tokio::test]
2497    async fn search_users_retries_after_429_then_succeeds() {
2498        let server = MockServer::start().await;
2499        let expected_auth = cloud_auth();
2500
2501        Mock::given(method("GET"))
2502            .and(path("/rest/api/3/user/search"))
2503            .and(header("authorization", expected_auth.as_str()))
2504            .and(query_param("query", "alice"))
2505            .and(query_param("maxResults", "20"))
2506            .respond_with(ResponseTemplate::new(429).insert_header("Retry-After", "0"))
2507            .up_to_n_times(1)
2508            .mount(&server)
2509            .await;
2510
2511        Mock::given(method("GET"))
2512            .and(path("/rest/api/3/user/search"))
2513            .and(header("authorization", expected_auth.as_str()))
2514            .and(query_param("query", "alice"))
2515            .and(query_param("maxResults", "20"))
2516            .respond_with(ResponseTemplate::new(200).set_body_json(json!([
2517                {
2518                    "accountId": "acct-1",
2519                    "displayName": "Alice Example"
2520                }
2521            ])))
2522            .mount(&server)
2523            .await;
2524
2525        let client = cloud_client(&server);
2526        let users = client
2527            .search_users("alice")
2528            .await
2529            .expect("request should retry and succeed");
2530
2531        assert_eq!(users.len(), 1);
2532        assert_eq!(users[0].account_id, "acct-1");
2533    }
2534
2535    #[tokio::test]
2536    async fn search_users_returns_rate_limit_after_max_retries() {
2537        let server = MockServer::start().await;
2538        let expected_auth = cloud_auth();
2539
2540        Mock::given(method("GET"))
2541            .and(path("/rest/api/3/user/search"))
2542            .and(header("authorization", expected_auth.as_str()))
2543            .and(query_param("query", "alice"))
2544            .and(query_param("maxResults", "20"))
2545            .respond_with(ResponseTemplate::new(429).insert_header("Retry-After", "0"))
2546            .mount(&server)
2547            .await;
2548
2549        let client = cloud_client(&server);
2550        let err = client
2551            .search_users("alice")
2552            .await
2553            .expect_err("request should fail after max retries");
2554
2555        match err {
2556            JiraError::RateLimit { retry_after } => assert_eq!(retry_after, 0),
2557            other => panic!("expected JiraError::RateLimit, got {other:?}"),
2558        }
2559    }
2560
2561    #[tokio::test]
2562    async fn move_issue_submits_bulk_move_and_fetches_issue_on_complete() {
2563        let server = MockServer::start().await;
2564        let expected_auth = cloud_auth();
2565
2566        Mock::given(method("POST"))
2567            .and(path("/rest/api/3/bulk/issues/move"))
2568            .and(header("authorization", expected_auth.as_str()))
2569            .and(body_json(json!({
2570                "sendBulkNotification": true,
2571                "targetToSourcesMapping": {
2572                    "NEW,10002": {
2573                        "inferClassificationDefaults": true,
2574                        "inferFieldDefaults": true,
2575                        "inferStatusDefaults": true,
2576                        "inferSubtaskTypeDefault": true,
2577                        "issueIdsOrKeys": ["TEST-1"]
2578                    }
2579                }
2580            })))
2581            .respond_with(ResponseTemplate::new(201).set_body_json(json!({ "taskId": "task-1" })))
2582            .mount(&server)
2583            .await;
2584
2585        Mock::given(method("GET"))
2586            .and(path("/rest/api/3/bulk/queue/task-1"))
2587            .and(header("authorization", expected_auth.as_str()))
2588            .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "status": "COMPLETE" })))
2589            .mount(&server)
2590            .await;
2591
2592        Mock::given(method("GET"))
2593            .and(path("/rest/api/3/issue/TEST-1"))
2594            .and(header("authorization", expected_auth.as_str()))
2595            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2596                "id": "10001",
2597                "key": "TEST-1",
2598                "fields": {
2599                    "summary": "Moved issue",
2600                    "status": { "name": "To Do" },
2601                    "issuetype": { "name": "Task" },
2602                    "project": { "key": "NEW" },
2603                    "created": "2023-01-01T00:00:00.000+0000",
2604                    "updated": "2023-01-01T00:00:00.000+0000"
2605                }
2606            })))
2607            .mount(&server)
2608            .await;
2609
2610        let client = cloud_client(&server);
2611        let issue = client
2612            .move_issue("TEST-1", "NEW", "10002", None)
2613            .await
2614            .expect("move issue should complete");
2615
2616        assert_eq!(issue.key, "TEST-1");
2617        assert_eq!(issue.project_key, "NEW");
2618    }
2619
2620    #[tokio::test]
2621    async fn move_issue_returns_api_error_when_bulk_task_fails() {
2622        let server = MockServer::start().await;
2623        let expected_auth = cloud_auth();
2624
2625        Mock::given(method("POST"))
2626            .and(path("/rest/api/3/bulk/issues/move"))
2627            .and(header("authorization", expected_auth.as_str()))
2628            .respond_with(ResponseTemplate::new(201).set_body_json(json!({ "taskId": "task-2" })))
2629            .mount(&server)
2630            .await;
2631
2632        Mock::given(method("GET"))
2633            .and(path("/rest/api/3/bulk/queue/task-2"))
2634            .and(header("authorization", expected_auth.as_str()))
2635            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2636                "status": "FAILED",
2637                "errors": ["cannot move issue"]
2638            })))
2639            .mount(&server)
2640            .await;
2641
2642        let client = cloud_client(&server);
2643        let err = client
2644            .move_issue("TEST-1", "NEW", "10002", None)
2645            .await
2646            .expect_err("failed bulk task should return error");
2647
2648        match err {
2649            JiraError::Api { message, .. } => assert!(message.contains("FAILED")),
2650            other => panic!("expected JiraError::Api, got {other:?}"),
2651        }
2652    }
2653
2654    #[tokio::test]
2655    async fn issue_link_integration() {
2656        let server = MockServer::start().await;
2657        let expected_auth = format!("Basic {}", base64_encode("dev@example.com:cloud-token"));
2658
2659        // 1. Mock list_issue_link_types
2660        Mock::given(method("GET"))
2661            .and(path("/rest/api/3/issueLinkType"))
2662            .and(header("authorization", expected_auth.as_str()))
2663            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2664                "issueLinkTypes": [
2665                    {
2666                        "id": "10000",
2667                        "name": "Blocks",
2668                        "inward": "is blocked by",
2669                        "outward": "blocks"
2670                    }
2671                ]
2672            })))
2673            .mount(&server)
2674            .await;
2675
2676        // 2. Mock link_issues
2677        Mock::given(method("POST"))
2678            .and(path("/rest/api/3/issueLink"))
2679            .and(header("authorization", expected_auth.as_str()))
2680            .respond_with(ResponseTemplate::new(201))
2681            .mount(&server)
2682            .await;
2683
2684        let client = JiraClient::new(JiraConfig {
2685            profile_name: Some("cloud-main".into()),
2686            base_url: server.uri(),
2687            email: "dev@example.com".into(),
2688            token: Some("cloud-token".into()),
2689            project: None,
2690            timeout_secs: 30,
2691            deployment: JiraDeployment::Cloud,
2692            auth_type: JiraAuthType::CloudApiToken,
2693            api_version: 3,
2694        });
2695
2696        // Test list
2697        let link_types = client.list_issue_link_types().await.expect("list types");
2698        assert_eq!(link_types.len(), 1);
2699        assert_eq!(link_types[0].name, "Blocks");
2700
2701        // Test link
2702        client
2703            .link_issues("TEST-1", "TEST-2", "Blocks", Some("Adding dependency"))
2704            .await
2705            .expect("link issues");
2706    }
2707
2708    #[tokio::test]
2709    async fn get_issue_parses_links() {
2710        let server = MockServer::start().await;
2711        let expected_auth = format!("Basic {}", base64_encode("dev@example.com:cloud-token"));
2712
2713        Mock::given(method("GET"))
2714            .and(path("/rest/api/3/issue/TEST-1"))
2715            .and(header("authorization", expected_auth.as_str()))
2716            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2717                "id": "10001",
2718                "key": "TEST-1",
2719                "fields": {
2720                    "summary": "Main Issue",
2721                    "status": { "name": "To Do" },
2722                    "issuetype": { "name": "Task" },
2723                    "project": { "key": "TEST" },
2724                    "created": "2023-01-01T00:00:00.000+0000",
2725                    "updated": "2023-01-01T00:00:00.000+0000",
2726                    "issuelinks": [
2727                        {
2728                            "id": "20000",
2729                            "type": {
2730                                "id": "10000",
2731                                "name": "Blocks",
2732                                "inward": "is blocked by",
2733                                "outward": "blocks"
2734                            },
2735                            "outwardIssue": {
2736                                "id": "10002",
2737                                "key": "TEST-2",
2738                                "fields": {
2739                                    "summary": "Blocked Issue",
2740                                    "status": { "name": "Open" },
2741                                    "priority": { "name": "High" },
2742                                    "issuetype": { "name": "Bug" }
2743                                }
2744                            }
2745                        }
2746                    ]
2747                }
2748            })))
2749            .mount(&server)
2750            .await;
2751
2752        let client = JiraClient::new(JiraConfig {
2753            profile_name: Some("cloud-main".into()),
2754            base_url: server.uri(),
2755            email: "dev@example.com".into(),
2756            token: Some("cloud-token".into()),
2757            project: None,
2758            timeout_secs: 30,
2759            deployment: JiraDeployment::Cloud,
2760            auth_type: JiraAuthType::CloudApiToken,
2761            api_version: 3,
2762        });
2763
2764        let issue = client.get_issue("TEST-1").await.expect("get issue");
2765        assert_eq!(issue.links.len(), 1);
2766        let link = &issue.links[0];
2767        assert_eq!(link.link_type.name, "Blocks");
2768        assert!(link.outward_issue.is_some());
2769        assert_eq!(link.outward_issue.as_ref().unwrap().key, "TEST-2");
2770        assert_eq!(
2771            link.outward_issue.as_ref().unwrap().summary,
2772            "Blocked Issue"
2773        );
2774    }
2775
2776    #[tokio::test]
2777    async fn get_remote_links_parses_object_title_and_url() {
2778        let server = MockServer::start().await;
2779
2780        Mock::given(method("GET"))
2781            .and(path("/rest/api/3/issue/TEST-1/remotelink"))
2782            .and(header("authorization", cloud_auth().as_str()))
2783            .respond_with(ResponseTemplate::new(200).set_body_json(json!([
2784                {
2785                    "id": 10001,
2786                    "self": "https://jira.example.com/rest/api/3/issue/TEST-1/remotelink/10001",
2787                    "globalId": "system=https://docs.example.com&id=42",
2788                    "relationship": "Wiki Page",
2789                    "object": {
2790                        "title": "Design Doc",
2791                        "url": "https://docs.example.com/design",
2792                        "summary": "Architecture overview"
2793                    }
2794                },
2795                {
2796                    "id": 10002,
2797                    "object": {
2798                        "title": "Tracking ticket",
2799                        "url": "https://tracker.example.com/4242"
2800                    }
2801                }
2802            ])))
2803            .mount(&server)
2804            .await;
2805
2806        let client = cloud_client(&server);
2807        let links = client
2808            .get_remote_links("TEST-1")
2809            .await
2810            .expect("remote links should parse");
2811
2812        assert_eq!(links.len(), 2);
2813        assert_eq!(links[0].id, 10001);
2814        assert_eq!(
2815            links[0].global_id.as_deref(),
2816            Some("system=https://docs.example.com&id=42")
2817        );
2818        assert_eq!(links[0].object.title, "Design Doc");
2819        assert_eq!(links[0].object.url, "https://docs.example.com/design");
2820        assert_eq!(
2821            links[0].object.summary.as_deref(),
2822            Some("Architecture overview")
2823        );
2824        assert_eq!(links[1].id, 10002);
2825        assert!(links[1].global_id.is_none());
2826        assert!(links[1].object.summary.is_none());
2827    }
2828
2829    #[tokio::test]
2830    async fn get_remote_links_returns_empty_when_no_links() {
2831        let server = MockServer::start().await;
2832
2833        Mock::given(method("GET"))
2834            .and(path("/rest/api/3/issue/TEST-1/remotelink"))
2835            .respond_with(ResponseTemplate::new(200).set_body_json(json!([])))
2836            .mount(&server)
2837            .await;
2838
2839        let client = cloud_client(&server);
2840        let links = client.get_remote_links("TEST-1").await.expect("parse");
2841        assert!(links.is_empty());
2842    }
2843
2844    #[tokio::test]
2845    async fn get_project_components_parses_id_and_name() {
2846        let server = MockServer::start().await;
2847
2848        Mock::given(method("GET"))
2849            .and(path("/rest/api/3/project/PROJ/components"))
2850            .and(header("authorization", cloud_auth().as_str()))
2851            .respond_with(ResponseTemplate::new(200).set_body_json(json!([
2852                {
2853                    "id": "10100",
2854                    "name": "Backend",
2855                    "description": "Server-side code",
2856                    "self": "https://jira.example.com/rest/api/3/component/10100"
2857                },
2858                {
2859                    "id": "10101",
2860                    "name": "Frontend"
2861                }
2862            ])))
2863            .mount(&server)
2864            .await;
2865
2866        let client = cloud_client(&server);
2867        let components = client
2868            .get_project_components("PROJ")
2869            .await
2870            .expect("components should parse");
2871
2872        assert_eq!(components.len(), 2);
2873        assert_eq!(components[0].id, "10100");
2874        assert_eq!(components[0].name, "Backend");
2875        assert_eq!(
2876            components[0].description.as_deref(),
2877            Some("Server-side code")
2878        );
2879        assert!(components[0].self_url.is_some());
2880        assert_eq!(components[1].name, "Frontend");
2881        assert!(components[1].description.is_none());
2882    }
2883
2884    #[tokio::test]
2885    async fn get_transitions_parses_id_name_and_preserves_extra_fields() {
2886        let server = MockServer::start().await;
2887
2888        Mock::given(method("GET"))
2889            .and(path("/rest/api/3/issue/TEST-1/transitions"))
2890            .and(header("authorization", cloud_auth().as_str()))
2891            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2892                "expand": "transitions",
2893                "transitions": [
2894                    {
2895                        "id": "21",
2896                        "name": "In Progress",
2897                        "hasScreen": false,
2898                        "isGlobal": false,
2899                        "isInitial": false,
2900                        "isAvailable": true,
2901                        "to": {
2902                            "id": "3",
2903                            "name": "In Progress",
2904                            "statusCategory": { "key": "indeterminate" }
2905                        }
2906                    },
2907                    {
2908                        "id": "31",
2909                        "name": "Done",
2910                        "to": { "id": "10001", "name": "Done" }
2911                    }
2912                ]
2913            })))
2914            .mount(&server)
2915            .await;
2916
2917        let client = cloud_client(&server);
2918        let transitions = client
2919            .get_transitions("TEST-1")
2920            .await
2921            .expect("transitions should parse");
2922
2923        assert_eq!(transitions.len(), 2);
2924        assert_eq!(transitions[0].id, "21");
2925        assert_eq!(transitions[0].name, "In Progress");
2926        assert_eq!(
2927            transitions[0].to.as_ref().and_then(|t| t.name.as_deref()),
2928            Some("In Progress")
2929        );
2930
2931        // Extra Jira fields (hasScreen, isGlobal, ...) must round-trip via #[serde(flatten)]
2932        // so MCP consumers re-serializing the struct keep the full payload shape.
2933        let reserialized = serde_json::to_value(&transitions[0]).expect("serialize");
2934        assert_eq!(reserialized["hasScreen"], json!(false));
2935        assert_eq!(reserialized["isAvailable"], json!(true));
2936
2937        assert_eq!(transitions[1].id, "31");
2938        assert!(transitions[1].to.is_some());
2939    }
2940
2941    #[tokio::test]
2942    async fn get_transitions_returns_empty_when_no_transitions_available() {
2943        let server = MockServer::start().await;
2944
2945        Mock::given(method("GET"))
2946            .and(path("/rest/api/3/issue/TEST-1/transitions"))
2947            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2948                "transitions": []
2949            })))
2950            .mount(&server)
2951            .await;
2952
2953        let client = cloud_client(&server);
2954        let transitions = client.get_transitions("TEST-1").await.expect("parse");
2955        assert!(transitions.is_empty());
2956    }
2957}