Skip to main content

jira_core/
client.rs

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