Skip to main content

jira_cli/api/
client.rs

1use base64::Engine;
2use base64::engine::general_purpose::STANDARD as BASE64;
3use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderValue};
4use serde::de::DeserializeOwned;
5use std::collections::BTreeMap;
6use std::path::PathBuf;
7
8use super::ApiError;
9use super::AuthType;
10use super::types::*;
11
12mod hierarchy;
13mod metadata;
14mod sprints;
15
16pub struct JiraClient {
17    http: reqwest::Client,
18    base_url: String,
19    agile_base_url: String,
20    site_url: String,
21    host: String,
22    api_version: u8,
23    read_only: bool,
24    /// Whether issue reads fill in `Issue::epic`; see `enable_epic_lookup`.
25    epic_lookup: std::sync::atomic::AtomicBool,
26    /// Data Center Epic Link field IDs, resolved at most once per client.
27    epic_link_fields: tokio::sync::OnceCell<Vec<String>>,
28}
29
30const SEARCH_FIELDS: [&str; 8] = [
31    "summary",
32    "status",
33    "assignee",
34    "priority",
35    "issuetype",
36    "parent",
37    "created",
38    "updated",
39];
40const ISSUE_DETAIL_FIELDS: [&str; 16] = [
41    "summary",
42    "status",
43    "assignee",
44    "reporter",
45    "priority",
46    "issuetype",
47    "parent",
48    "description",
49    "labels",
50    "components",
51    "fixVersions",
52    "versions",
53    "created",
54    "updated",
55    "comment",
56    "issuelinks",
57];
58const SEARCH_GET_JQL_LIMIT: usize = 1500;
59
60/// Max issues per page the Jira Cloud `/search/jql` endpoint will return when
61/// any non-ID fields are requested. The server silently caps larger values,
62/// so we paginate internally to fulfil larger caller-requested limits.
63const SEARCH_JQL_MAX_PAGE: usize = 100;
64
65/// Page size used when walking the cursor forward to simulate an offset on
66/// Jira Cloud. Requests only `id` to stay cheap (allows up to 5000/page).
67const SEARCH_JQL_SKIP_PAGE: usize = 1000;
68
69/// Build a JSON array of name/ID options for components and versions.
70fn named_option_array(items: &[&str]) -> serde_json::Value {
71    serde_json::Value::Array(
72        items
73            .iter()
74            .map(|name| metadata::unresolved_option(name))
75            .collect(),
76    )
77}
78
79impl JiraClient {
80    pub fn new(
81        host: &str,
82        email: &str,
83        token: &str,
84        auth_type: AuthType,
85        api_version: u8,
86    ) -> Result<Self, ApiError> {
87        Self::new_with_cloud(host, email, token, auth_type, api_version, None, "classic")
88    }
89
90    pub fn new_with_cloud(
91        host: &str,
92        email: &str,
93        token: &str,
94        auth_type: AuthType,
95        api_version: u8,
96        cloud_id: Option<&str>,
97        token_kind: &str,
98    ) -> Result<Self, ApiError> {
99        // Determine the scheme. An explicit `http://` prefix is preserved as-is
100        // (useful for local testing); everything else defaults to HTTPS.
101        let (scheme, domain) = if host.starts_with("http://") {
102            (
103                "http",
104                host.trim_start_matches("http://").trim_end_matches('/'),
105            )
106        } else {
107            (
108                "https",
109                host.trim_start_matches("https://").trim_end_matches('/'),
110            )
111        };
112
113        if domain.is_empty() {
114            return Err(ApiError::Other("Host cannot be empty".into()));
115        }
116
117        let auth_value = match auth_type {
118            AuthType::Basic => {
119                let credentials = BASE64.encode(format!("{email}:{token}"));
120                format!("Basic {credentials}")
121            }
122            AuthType::Pat => format!("Bearer {token}"),
123        };
124
125        let mut headers = HeaderMap::new();
126        headers.insert(
127            AUTHORIZATION,
128            HeaderValue::from_str(&auth_value).map_err(|e| ApiError::Other(e.to_string()))?,
129        );
130
131        let http = reqwest::Client::builder()
132            .default_headers(headers)
133            .timeout(std::time::Duration::from_secs(30))
134            .build()
135            .map_err(ApiError::Http)?;
136
137        let site_url = format!("{scheme}://{domain}");
138        let api_origin = if token_kind == "scoped" {
139            let cloud_id = cloud_id
140                .filter(|value| !value.trim().is_empty())
141                .ok_or_else(|| {
142                    ApiError::InvalidInput("scoped token requires a Jira Cloud ID".into())
143                })?;
144            format!("https://api.atlassian.com/ex/jira/{cloud_id}")
145        } else {
146            site_url.clone()
147        };
148        let base_url = format!("{api_origin}/rest/api/{api_version}");
149        let agile_base_url = format!("{api_origin}/rest/agile/1.0");
150
151        Ok(Self {
152            http,
153            base_url,
154            agile_base_url,
155            site_url,
156            host: domain.to_string(),
157            api_version,
158            read_only: false,
159            epic_lookup: std::sync::atomic::AtomicBool::new(false),
160            epic_link_fields: tokio::sync::OnceCell::new(),
161        })
162    }
163
164    /// Enforce the CLI's read-only policy at every HTTP write boundary.
165    pub fn with_read_only(mut self, read_only: bool) -> Self {
166        self.read_only = read_only;
167        self
168    }
169
170    fn ensure_writable(&self) -> Result<(), ApiError> {
171        if self.read_only {
172            Err(ApiError::InvalidInput(
173                "read-only mode is enabled; Jira writes are blocked".into(),
174            ))
175        } else {
176            Ok(())
177        }
178    }
179
180    pub fn host(&self) -> &str {
181        &self.host
182    }
183
184    pub fn api_version(&self) -> u8 {
185        self.api_version
186    }
187
188    pub fn browse_base_url(&self) -> &str {
189        &self.site_url
190    }
191
192    pub fn browse_url(&self, issue_key: &str) -> String {
193        format!("{}/browse/{issue_key}", self.browse_base_url())
194    }
195
196    fn map_status(status: u16, body: String) -> ApiError {
197        let message = summarize_error_body(status, &body);
198        match status {
199            401 | 403 => ApiError::Auth(message),
200            404 => ApiError::NotFound(message),
201            409 => ApiError::Conflict(message),
202            429 => ApiError::RateLimit,
203            _ => ApiError::Api { status, message },
204        }
205    }
206
207    async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T, ApiError> {
208        let url = format!("{}/{path}", self.base_url);
209        self.send(self.http.get(&url))
210            .await?
211            .json()
212            .await
213            .map_err(ApiError::Http)
214    }
215
216    async fn agile_get<T: DeserializeOwned>(&self, path: &str) -> Result<T, ApiError> {
217        let url = format!("{}/{path}", self.agile_base_url);
218        self.send(self.http.get(&url))
219            .await?
220            .json()
221            .await
222            .map_err(ApiError::Http)
223    }
224
225    async fn post<T: DeserializeOwned>(
226        &self,
227        path: &str,
228        body: &serde_json::Value,
229    ) -> Result<T, ApiError> {
230        let url = format!("{}/{path}", self.base_url);
231        self.send(self.http.post(&url).json(body))
232            .await?
233            .json()
234            .await
235            .map_err(ApiError::Http)
236    }
237
238    async fn post_empty_response(
239        &self,
240        path: &str,
241        body: &serde_json::Value,
242    ) -> Result<(), ApiError> {
243        let url = format!("{}/{path}", self.base_url);
244        self.send(self.http.post(&url).json(body)).await?;
245        Ok(())
246    }
247
248    async fn put_empty_response(
249        &self,
250        path: &str,
251        body: &serde_json::Value,
252    ) -> Result<(), ApiError> {
253        let url = format!("{}/{path}", self.base_url);
254        self.send(self.http.put(&url).json(body)).await?;
255        Ok(())
256    }
257
258    /// All requests pass through this boundary. Only the explicitly enumerated
259    /// search POSTs are reads; other non-GET/HEAD requests require write access.
260    async fn send(&self, builder: reqwest::RequestBuilder) -> Result<reqwest::Response, ApiError> {
261        let request = builder.build()?;
262        let method = request.method();
263        let read_post = method == reqwest::Method::POST
264            && ["search", "search/jql"].iter().any(|path| {
265                // Compare canonical URLs: configured hosts may include an
266                // uppercase name or a default port that reqwest normalizes.
267                reqwest::Url::parse(&format!("{}/{path}", self.base_url))
268                    .is_ok_and(|allowed| &allowed == request.url())
269            });
270        if !matches!(*method, reqwest::Method::GET | reqwest::Method::HEAD) && !read_post {
271            self.ensure_writable()?;
272        }
273        let resp = self.http.execute(request).await?;
274        let status = resp.status();
275        if !status.is_success() {
276            let body = resp.text().await.unwrap_or_default();
277            return Err(Self::map_status(status.as_u16(), body));
278        }
279        Ok(resp)
280    }
281
282    // ── Issues ────────────────────────────────────────────────────────────────
283
284    /// Search issues using JQL.
285    ///
286    /// On API v2 (Jira Data Center / Server) this uses the classic
287    /// `/rest/api/2/search` endpoint with offset-based pagination.
288    ///
289    /// On API v3 (Jira Cloud) this uses the replacement
290    /// `/rest/api/3/search/jql` endpoint - the original `/search` was retired
291    /// on 2025-10-31 and returns 410 Gone. The new endpoint only supports
292    /// cursor-based pagination and does not return an exact total, so we
293    /// simulate the `start_at` offset by walking the cursor forward.
294    pub async fn search(
295        &self,
296        jql: &str,
297        max_results: usize,
298        start_at: usize,
299    ) -> Result<SearchResponse, ApiError> {
300        if self.api_version >= 3 {
301            self.search_jql_v3(jql, max_results, start_at).await
302        } else {
303            self.search_v2(jql, max_results, start_at).await
304        }
305    }
306
307    async fn search_v2(
308        &self,
309        jql: &str,
310        max_results: usize,
311        start_at: usize,
312    ) -> Result<SearchResponse, ApiError> {
313        let field_list = self.issue_fields(&SEARCH_FIELDS).await?;
314        let fields = field_list.join(",");
315        let encoded_jql = percent_encode(jql);
316        // Every counter is optional because a response that omits one must stay
317        // distinguishable from one reporting zero. Defaulting `total` to 0 made
318        // `is_last` below true for any page, silently ending `--all` after the
319        // first one while the JSON reported no results next to the results.
320        #[derive(serde::Deserialize)]
321        struct RawV2 {
322            issues: Vec<Issue>,
323            total: Option<usize>,
324            #[serde(rename = "startAt")]
325            start_at: Option<usize>,
326            #[serde(rename = "maxResults")]
327            max_results: Option<usize>,
328        }
329        let mut raw: RawV2 = if encoded_jql.len() <= SEARCH_GET_JQL_LIMIT {
330            let path = format!(
331                "search?jql={encoded_jql}&maxResults={max_results}&startAt={start_at}&fields={fields}"
332            );
333            self.get(&path).await?
334        } else {
335            self.post(
336                "search",
337                &serde_json::json!({
338                    "jql": jql,
339                    "maxResults": max_results,
340                    "startAt": start_at,
341                    "fields": field_list,
342                }),
343            )
344            .await?
345        };
346        self.fill_epics(&mut raw.issues).await?;
347        // An absent offset or page size is reported as what was asked for, which
348        // is true of the request even when the server does not echo it back.
349        let echoed_start_at = raw.start_at.unwrap_or(start_at);
350        let is_last = match raw.total {
351            Some(total) => echoed_start_at + raw.issues.len() >= total,
352            // Without a total, a page shorter than the one requested is the only
353            // reliable end-of-results signal.
354            None => raw.issues.len() < max_results,
355        };
356        Ok(SearchResponse {
357            issues: raw.issues,
358            total: raw.total,
359            start_at: echoed_start_at,
360            max_results: raw.max_results.unwrap_or(max_results),
361            is_last,
362        })
363    }
364
365    /// Fetch a single page from the Jira Cloud `/search/jql` endpoint with
366    /// the full field list populated on each issue.
367    ///
368    /// Always uses POST: it handles long JQL without URL-length limits and
369    /// accepts `fields` as a JSON array (GET requires repeated query params).
370    async fn search_jql_page(
371        &self,
372        jql: &str,
373        page_size: usize,
374        next_token: Option<&str>,
375    ) -> Result<SearchJqlPage, ApiError> {
376        let mut body = serde_json::json!({
377            "jql": jql,
378            "maxResults": page_size,
379            "fields": SEARCH_FIELDS,
380        });
381        if let Some(t) = next_token {
382            body["nextPageToken"] = serde_json::Value::String(t.to_string());
383        }
384        self.post("search/jql", &body).await
385    }
386
387    /// Fetch a `/search/jql` page requesting only the `id` field.
388    ///
389    /// Used to cheaply walk the cursor forward when simulating an offset.
390    /// Issues in the response lack a `fields` sub-object, so they are
391    /// deserialized as raw JSON values rather than full `Issue`s.
392    async fn search_jql_skip_page(
393        &self,
394        jql: &str,
395        page_size: usize,
396        next_token: Option<&str>,
397    ) -> Result<SearchJqlSkipPage, ApiError> {
398        let mut body = serde_json::json!({
399            "jql": jql,
400            "maxResults": page_size,
401            "fields": ["id"],
402        });
403        if let Some(t) = next_token {
404            body["nextPageToken"] = serde_json::Value::String(t.to_string());
405        }
406        self.post("search/jql", &body).await
407    }
408
409    async fn search_jql_v3(
410        &self,
411        jql: &str,
412        max_results: usize,
413        start_at: usize,
414    ) -> Result<SearchResponse, ApiError> {
415        // Walk the cursor forward to simulate `start_at`. The `/search/jql`
416        // endpoint only supports sequential cursor pagination, so arbitrary
417        // offsets require fetching and discarding earlier pages. Request
418        // `id`-only to keep skip-pages cheap.
419        let mut next_token: Option<String> = None;
420        let mut skipped = 0usize;
421        while skipped < start_at {
422            let want = (start_at - skipped).min(SEARCH_JQL_SKIP_PAGE);
423            let page = self
424                .search_jql_skip_page(jql, want, next_token.as_deref())
425                .await?;
426            let got = page.issues.len();
427            skipped += got;
428            if got == 0 || page.is_last {
429                // Offset is past the end of the result set.
430                return Ok(SearchResponse {
431                    issues: Vec::new(),
432                    total: None,
433                    start_at,
434                    max_results: 0,
435                    is_last: true,
436                });
437            }
438            next_token = page.next_page_token;
439            if next_token.is_none() {
440                // Server reported more pages but returned no cursor; treat as end
441                // rather than silently restarting from page 0 on the next iteration.
442                return Ok(SearchResponse {
443                    issues: Vec::new(),
444                    total: None,
445                    start_at,
446                    max_results: 0,
447                    is_last: true,
448                });
449            }
450        }
451
452        // Collect up to `max_results` issues, paging internally to honour
453        // the server's per-page cap when fields are requested.
454        let mut collected: Vec<Issue> = Vec::new();
455        let mut is_last = false;
456        while collected.len() < max_results {
457            let remaining = max_results - collected.len();
458            let want = remaining.min(SEARCH_JQL_MAX_PAGE);
459            let page = self
460                .search_jql_page(jql, want, next_token.as_deref())
461                .await?;
462            let got = page.issues.len();
463            collected.extend(page.issues);
464            if page.is_last || got == 0 {
465                is_last = true;
466                break;
467            }
468            next_token = page.next_page_token;
469            if next_token.is_none() {
470                is_last = true;
471                break;
472            }
473        }
474
475        self.fill_epics(&mut collected).await?;
476        let returned = collected.len();
477        Ok(SearchResponse {
478            issues: collected,
479            // Cloud `/search/jql` does not return an exact total.
480            total: None,
481            start_at,
482            max_results: returned,
483            is_last,
484        })
485    }
486
487    /// Fetch a single issue by key (e.g. `PROJ-123`), including all comments.
488    ///
489    /// Jira embeds only the first page of comments in the issue response. When
490    /// the embedded page is incomplete, additional requests are made to fetch
491    /// the remaining comments.
492    pub async fn get_issue(&self, key: &str) -> Result<Issue, ApiError> {
493        validate_issue_key(key)?;
494        let fields = self.issue_fields(&ISSUE_DETAIL_FIELDS).await?.join(",");
495        let path = format!("issue/{key}?fields={fields}");
496        let mut issue: Issue = self.get(&path).await?;
497        self.fill_epics(std::slice::from_mut(&mut issue)).await?;
498
499        // Fetch remaining comment pages if the embedded page is incomplete
500        if let Some(ref mut comment_list) = issue.fields.comment
501            && comment_list.total > comment_list.comments.len()
502        {
503            let mut start_at = comment_list.comments.len();
504            while comment_list.comments.len() < comment_list.total {
505                let page: CommentList = self
506                    .get(&format!(
507                        "issue/{key}/comment?startAt={start_at}&maxResults=100"
508                    ))
509                    .await?;
510                if page.comments.is_empty() {
511                    break;
512                }
513                start_at += page.comments.len();
514                comment_list.comments.extend(page.comments);
515            }
516        }
517
518        Ok(issue)
519    }
520
521    /// Create a new issue using the same prepared fields as a preview.
522    pub async fn create_issue(
523        &self,
524        draft: &IssueDraft<'_>,
525        custom_fields: &[(String, serde_json::Value)],
526    ) -> Result<CreateIssueResponse, ApiError> {
527        let (fields, meta) = self.prepare_create_payload(draft, custom_fields).await?;
528        self.post("issue", &serde_json::json!({ "fields": fields }))
529            .await
530            .map_err(|err| metadata::create_error(err, meta.as_ref(), draft))
531    }
532
533    /// Resolve and validate a create request without sending a write.
534    pub async fn preview_create_issue(
535        &self,
536        draft: &IssueDraft<'_>,
537        custom_fields: &[(String, serde_json::Value)],
538    ) -> Result<serde_json::Value, ApiError> {
539        let (fields, meta) = self.prepare_create_payload(draft, custom_fields).await?;
540        if meta.is_none() {
541            let project = fields["project"]["key"]
542                .as_str()
543                .or_else(|| fields["project"]["id"].as_str())
544                .expect("validated project");
545            self.get_project(project).await?;
546        }
547        Ok(write_preview(
548            fields,
549            Some(meta.is_some()),
550            meta.as_ref().is_some_and(|m| m.fields.is_some()),
551        ))
552    }
553
554    async fn prepare_create_payload(
555        &self,
556        draft: &IssueDraft<'_>,
557        custom_fields: &[(String, serde_json::Value)],
558    ) -> Result<(serde_json::Value, Option<metadata::CreateMetadata>), ApiError> {
559        let mut fields = serde_json::json!({
560            "project": { "key": draft.project_key },
561            "issuetype": metadata::unresolved_option(draft.issue_type),
562            "summary": draft.summary,
563        });
564
565        if let Some(desc) = draft.description {
566            fields["description"] = self.make_body(desc);
567        }
568        if let Some(p) = draft.priority {
569            fields["priority"] = metadata::unresolved_option(p);
570        }
571        if let Some(lbls) = draft.labels
572            && !lbls.is_empty()
573        {
574            fields["labels"] = serde_json::json!(lbls);
575        }
576        if let Some(comps) = draft.components
577            && !comps.is_empty()
578        {
579            fields["components"] = named_option_array(comps);
580        }
581        if let Some(fvs) = draft.fix_versions
582            && !fvs.is_empty()
583        {
584            fields["fixVersions"] = named_option_array(fvs);
585        }
586        if let Some(assignee) = draft.assignee {
587            fields["assignee"] = assignee
588                .map(|id| self.assignee_payload(id))
589                .unwrap_or(serde_json::Value::Null);
590        }
591        for (key, value) in custom_fields {
592            fields[key] = value.clone();
593        }
594        let meta = self
595            .prepare_create(&mut fields, draft, custom_fields)
596            .await?;
597        Ok((fields, meta))
598    }
599
600    /// Log work on an issue.
601    ///
602    /// `time_spent` uses Jira duration format (e.g. `2h 30m`, `1d`, `30m`).
603    /// `started` is an ISO-8601 datetime string; when `None` the server uses now.
604    pub async fn log_work(
605        &self,
606        key: &str,
607        time_spent: &str,
608        comment: Option<&str>,
609        started: Option<&str>,
610    ) -> Result<WorklogEntry, ApiError> {
611        validate_issue_key(key)?;
612        let mut payload = serde_json::json!({ "timeSpent": time_spent });
613        if let Some(c) = comment {
614            payload["comment"] = self.make_body(c);
615        }
616        if let Some(s) = started {
617            payload["started"] = serde_json::Value::String(s.to_string());
618        }
619        self.post(&format!("issue/{key}/worklog"), &payload).await
620    }
621
622    /// Add a comment to an issue.
623    pub async fn add_comment(&self, key: &str, body: &str) -> Result<Comment, ApiError> {
624        validate_issue_key(key)?;
625        let payload = serde_json::json!({ "body": self.make_body(body) });
626        self.post(&format!("issue/{key}/comment"), &payload).await
627    }
628
629    /// List available transitions for an issue.
630    pub async fn get_transitions(&self, key: &str) -> Result<Vec<Transition>, ApiError> {
631        validate_issue_key(key)?;
632        let resp: TransitionsResponse = self.get(&format!("issue/{key}/transitions")).await?;
633        Ok(resp.transitions)
634    }
635
636    /// Execute a transition by transition ID.
637    pub async fn do_transition(&self, key: &str, transition_id: &str) -> Result<(), ApiError> {
638        validate_issue_key(key)?;
639        let payload = serde_json::json!({ "transition": { "id": transition_id } });
640        self.post_empty_response(&format!("issue/{key}/transitions"), &payload)
641            .await
642    }
643
644    /// Assign an issue to a user, or unassign with `None`.
645    ///
646    /// API v3 (Jira Cloud) identifies users by `accountId`.
647    /// API v2 (Jira Data Center / Server) identifies users by `name` (username).
648    pub async fn assign_issue(&self, key: &str, account_id: Option<&str>) -> Result<(), ApiError> {
649        validate_issue_key(key)?;
650        let payload = match account_id {
651            Some(id) => self.assignee_payload(id),
652            None => {
653                if self.api_version >= 3 {
654                    serde_json::json!({ "accountId": null })
655                } else {
656                    serde_json::json!({ "name": null })
657                }
658            }
659        };
660        self.put_empty_response(&format!("issue/{key}/assignee"), &payload)
661            .await
662    }
663
664    /// Build the assignee payload for the current API version.
665    ///
666    /// API v3 uses `accountId`; API v2 uses `name` (username).
667    fn assignee_payload(&self, id: &str) -> serde_json::Value {
668        if self.api_version >= 3 {
669            serde_json::json!({ "accountId": id })
670        } else {
671            serde_json::json!({ "name": id })
672        }
673    }
674
675    /// Get the currently authenticated user.
676    pub async fn get_myself(&self) -> Result<Myself, ApiError> {
677        self.get("myself").await
678    }
679
680    /// Update issue fields.
681    ///
682    /// All fields in `update` are optional. `components`, `fix_versions`, and `labels`
683    /// are three-state: `None` leaves the field untouched, `Some(&[])` clears it,
684    /// `Some(&[..])` replaces it. `assignee` is also three-state:
685    /// `None` = untouched, `Some(None)` = unassign, `Some(Some(id))` = set.
686    pub async fn update_issue(
687        &self,
688        key: &str,
689        update: &IssueUpdate<'_>,
690        custom_fields: &[(String, serde_json::Value)],
691    ) -> Result<(), ApiError> {
692        let (fields, meta) = self
693            .prepare_update_payload(key, update, custom_fields)
694            .await?;
695        self.put_empty_response(
696            &format!("issue/{key}"),
697            &serde_json::json!({"fields": fields}),
698        )
699        .await
700        .map_err(|err| {
701            // A refused type change is explained as such, not as an epic
702            // linking problem.
703            let type_refused = update.issue_type.is_some()
704                && matches!(&err, ApiError::Api { status: 400, message } if message.contains("issuetype"));
705            if !type_refused {
706                return metadata::write_error(err, meta.as_ref(), update.priority, update.epic);
707            }
708            match metadata::write_error(err, meta.as_ref(), update.priority, None) {
709                ApiError::Api { status, mut message } => {
710                    message.push_str("; Jira only changes an issue type in place when both types share a workflow and field configuration, otherwise use More > Move in the Jira web UI");
711                    ApiError::Api { status, message }
712                }
713                err => err,
714            }
715        })
716    }
717
718    /// Resolve and validate an update request without sending a write.
719    pub async fn preview_update_issue(
720        &self,
721        key: &str,
722        update: &IssueUpdate<'_>,
723        custom_fields: &[(String, serde_json::Value)],
724    ) -> Result<serde_json::Value, ApiError> {
725        let (fields, meta) = self
726            .prepare_update_payload(key, update, custom_fields)
727            .await?;
728        if meta.is_none() {
729            self.issue_type_for_link(key).await?;
730        }
731        Ok(write_preview(fields, None, meta.is_some()))
732    }
733
734    async fn prepare_update_payload(
735        &self,
736        key: &str,
737        update: &IssueUpdate<'_>,
738        custom_fields: &[(String, serde_json::Value)],
739    ) -> Result<(serde_json::Value, Option<metadata::Fields>), ApiError> {
740        validate_issue_key(key)?;
741        let mut fields = serde_json::Map::new();
742        if let Some(s) = update.summary {
743            fields.insert("summary".into(), serde_json::Value::String(s.into()));
744        }
745        if let Some(d) = update.description {
746            fields.insert("description".into(), self.make_body(d));
747        }
748        if let Some(p) = update.priority {
749            fields.insert("priority".into(), metadata::unresolved_option(p));
750        }
751        if let Some(comps) = update.components {
752            fields.insert("components".into(), named_option_array(comps));
753        }
754        if let Some(fvs) = update.fix_versions {
755            fields.insert("fixVersions".into(), named_option_array(fvs));
756        }
757        if let Some(lbls) = update.labels {
758            fields.insert("labels".into(), serde_json::json!(lbls));
759        }
760        if let Some(assignee_choice) = update.assignee {
761            let payload = match assignee_choice {
762                None => serde_json::Value::Null,
763                Some(id) => self.assignee_payload(id),
764            };
765            fields.insert("assignee".into(), payload);
766        }
767        for (k, value) in custom_fields {
768            fields.insert(k.clone(), value.clone());
769        }
770        if fields.is_empty()
771            && update.issue_type.is_none()
772            && update.epic.is_none()
773            && !update.clear_epic
774        {
775            return Err(ApiError::InvalidInput(
776                "At least one field (--summary, --description, --priority, --type, --epic, --clear-epic, --components, --fix-versions, --labels, --assignee, or --field) is required"
777                    .into(),
778            ));
779        }
780        let mut fields = serde_json::Value::Object(fields);
781        let meta = self
782            .prepare_update(key, &mut fields, update, custom_fields)
783            .await?;
784        Ok((fields, meta))
785    }
786
787    /// Build the appropriate body value for a description or comment field.
788    ///
789    /// API v3 (Jira Cloud) requires Atlassian Document Format (ADF). API v2
790    /// (Jira Data Center / Server) accepts plain strings.
791    fn make_body(&self, text: &str) -> serde_json::Value {
792        if self.api_version >= 3 {
793            text_to_adf(text)
794        } else {
795            serde_json::Value::String(text.to_string())
796        }
797    }
798
799    // ── Attachments ───────────────────────────────────────────────────────────
800
801    /// List the attachments on an issue.
802    pub async fn list_attachments(&self, key: &str) -> Result<Vec<Attachment>, ApiError> {
803        validate_issue_key(key)?;
804        #[derive(serde::Deserialize)]
805        struct Wrapper {
806            fields: AttachmentField,
807        }
808        #[derive(serde::Deserialize)]
809        struct AttachmentField {
810            #[serde(default)]
811            attachment: Vec<Attachment>,
812        }
813        let w: Wrapper = self.get(&format!("issue/{key}?fields=attachment")).await?;
814        Ok(w.fields.attachment)
815    }
816
817    /// Upload one or more local files to an issue.
818    ///
819    /// Jira rejects this endpoint unless the `X-Atlassian-Token: no-check`
820    /// header is present (XSRF protection) and every file is sent under the
821    /// multipart field name `file`.
822    pub async fn upload_attachments(
823        &self,
824        key: &str,
825        paths: &[PathBuf],
826    ) -> Result<Vec<Attachment>, ApiError> {
827        self.ensure_writable()?;
828        validate_issue_key(key)?;
829
830        let mut form = reqwest::multipart::Form::new();
831        for path in paths {
832            let file_name = path
833                .file_name()
834                .and_then(|name| name.to_str())
835                .ok_or_else(|| {
836                    ApiError::InvalidInput(format!(
837                        "'{}' does not name a file to upload",
838                        path.display()
839                    ))
840                })?
841                .to_string();
842            let content_type = mime_guess::from_path(&file_name).first_or_octet_stream();
843            let data = std::fs::read(path)
844                .map_err(|e| ApiError::Other(format!("cannot read {}: {e}", path.display())))?;
845            let part = reqwest::multipart::Part::bytes(data)
846                .file_name(file_name)
847                .mime_str(content_type.as_ref())
848                .map_err(|e| ApiError::Other(e.to_string()))?;
849            form = form.part("file", part);
850        }
851
852        let url = format!("{}/issue/{key}/attachments", self.base_url);
853        let request = self
854            .http
855            .post(&url)
856            .header("X-Atlassian-Token", "no-check")
857            .multipart(form);
858        let resp = self.send(request).await?;
859        resp.json::<Vec<Attachment>>().await.map_err(ApiError::Http)
860    }
861
862    /// Fetch the metadata of a single attachment.
863    pub async fn get_attachment(&self, id: &str) -> Result<Attachment, ApiError> {
864        validate_attachment_id(id)?;
865        self.get(&format!("attachment/{id}")).await
866    }
867
868    /// Download the binary content of an attachment.
869    ///
870    /// Jira answers with a redirect to the storage backend; reqwest follows it
871    /// and drops the `Authorization` header when the target is another origin.
872    pub async fn download_attachment(&self, id: &str) -> Result<Vec<u8>, ApiError> {
873        validate_attachment_id(id)?;
874        let url = format!("{}/attachment/content/{id}", self.base_url);
875        let resp = self.send(self.http.get(&url)).await?;
876        let bytes = resp.bytes().await.map_err(ApiError::Http)?;
877        Ok(bytes.to_vec())
878    }
879
880    /// Delete an attachment by its ID.
881    pub async fn delete_attachment(&self, id: &str) -> Result<(), ApiError> {
882        self.ensure_writable()?;
883        validate_attachment_id(id)?;
884        let url = format!("{}/attachment/{id}", self.base_url);
885        self.send(self.http.delete(&url)).await?;
886        Ok(())
887    }
888
889    // ── Users ─────────────────────────────────────────────────────────────────
890
891    /// Search for users matching a query string.
892    ///
893    /// API v2: uses `username` parameter. API v3: uses `query` parameter.
894    pub async fn search_users(&self, query: &str) -> Result<Vec<User>, ApiError> {
895        let encoded = percent_encode(query);
896        let param = if self.api_version >= 3 {
897            "query"
898        } else {
899            "username"
900        };
901        let path = format!("user/search?{param}={encoded}&maxResults=50");
902        self.get::<Vec<User>>(&path).await
903    }
904
905    // ── Issue links ───────────────────────────────────────────────────────────
906
907    /// List available issue link types.
908    pub async fn get_link_types(&self) -> Result<Vec<IssueLinkType>, ApiError> {
909        #[derive(serde::Deserialize)]
910        struct Wrapper {
911            #[serde(rename = "issueLinkTypes")]
912            types: Vec<IssueLinkType>,
913        }
914        let w: Wrapper = self.get("issueLinkType").await?;
915        Ok(w.types)
916    }
917
918    /// Link two issues.
919    ///
920    /// `link_type` is the name of the link type (e.g. "Blocks", "Duplicate").
921    /// The direction follows the link type's `outward` description:
922    /// `from_key` outward-links to `to_key`.
923    pub async fn link_issues(
924        &self,
925        from_key: &str,
926        to_key: &str,
927        link_type: &str,
928    ) -> Result<(), ApiError> {
929        self.ensure_writable()?;
930        validate_issue_key(from_key)?;
931        validate_issue_key(to_key)?;
932        let payload = serde_json::json!({
933            "type": { "name": link_type },
934            "inwardIssue": { "key": from_key },
935            "outwardIssue": { "key": to_key },
936        });
937        let url = format!("{}/issueLink", self.base_url);
938        self.send(self.http.post(&url).json(&payload)).await?;
939        Ok(())
940    }
941
942    /// Remove an issue link by its ID.
943    pub async fn unlink_issues(&self, link_id: &str) -> Result<(), ApiError> {
944        self.ensure_writable()?;
945        let url = format!("{}/issueLink/{link_id}", self.base_url);
946        self.send(self.http.delete(&url)).await?;
947        Ok(())
948    }
949
950    // ── Boards & Sprints ──────────────────────────────────────────────────────
951
952    /// List all boards, fetching all pages.
953    pub async fn list_boards(&self) -> Result<Vec<Board>, ApiError> {
954        self.list_boards_for_project(None).await
955    }
956
957    pub async fn list_boards_for_project(
958        &self,
959        project: Option<&str>,
960    ) -> Result<Vec<Board>, ApiError> {
961        let mut all = Vec::new();
962        let mut start_at = 0usize;
963        const PAGE: usize = 50;
964        loop {
965            let project_param = project
966                .map(|p| format!("&projectKeyOrId={}", percent_encode(p)))
967                .unwrap_or_default();
968            let path = format!("board?startAt={start_at}&maxResults={PAGE}{project_param}");
969            let page: BoardSearchResponse = self.agile_get(&path).await?;
970            let received = page.values.len();
971            all.extend(page.values);
972            if page.is_last || received == 0 {
973                break;
974            }
975            start_at += received;
976        }
977        Ok(all)
978    }
979
980    /// Fetch one board without scanning unrelated boards.
981    pub async fn get_board(&self, id: u64) -> Result<Board, ApiError> {
982        self.agile_get(&format!("board/{id}")).await
983    }
984
985    /// Discovery may include nonstandard board types. Skip only Jira's explicit
986    /// unsupported-sprints response, never authentication or unrelated errors.
987    pub(crate) async fn list_sprints_for_discovery(
988        &self,
989        board_id: u64,
990        state: Option<&str>,
991    ) -> Result<Option<Vec<Sprint>>, ApiError> {
992        match self.list_sprints(board_id, state).await {
993            Ok(sprints) => Ok(Some(sprints)),
994            Err(ApiError::Api {
995                status: 400,
996                ref message,
997            }) if message
998                .to_ascii_lowercase()
999                .contains("does not support sprints") =>
1000            {
1001                Ok(None)
1002            }
1003            Err(error) => Err(error),
1004        }
1005    }
1006
1007    /// List sprints for a board, optionally filtered by state.
1008    ///
1009    /// `state` can be "active", "closed", "future", or `None` for all.
1010    pub async fn list_sprints(
1011        &self,
1012        board_id: u64,
1013        state: Option<&str>,
1014    ) -> Result<Vec<Sprint>, ApiError> {
1015        let mut all = Vec::new();
1016        let mut start_at = 0usize;
1017        const PAGE: usize = 50;
1018        loop {
1019            let state_param = state
1020                .map(|s| format!("&state={}", percent_encode(s)))
1021                .unwrap_or_default();
1022            let path = format!(
1023                "board/{board_id}/sprint?startAt={start_at}&maxResults={PAGE}{state_param}"
1024            );
1025            let page: SprintSearchResponse = self.agile_get(&path).await?;
1026            let received = page.values.len();
1027            all.extend(page.values);
1028            if page.is_last || received == 0 {
1029                break;
1030            }
1031            start_at += received;
1032        }
1033        Ok(all)
1034    }
1035
1036    // ── Projects ──────────────────────────────────────────────────────────────
1037
1038    /// List all accessible projects.
1039    ///
1040    /// API v3 (Jira Cloud) uses the paginated `project/search` endpoint.
1041    /// API v2 (Jira Data Center / Server) uses the simpler `project` endpoint
1042    /// that returns all results in a single flat array.
1043    pub async fn list_projects(&self) -> Result<Vec<Project>, ApiError> {
1044        if self.api_version < 3 {
1045            return self.get::<Vec<Project>>("project").await;
1046        }
1047
1048        let mut all: Vec<Project> = Vec::new();
1049        let mut start_at: usize = 0;
1050        const PAGE: usize = 50;
1051
1052        loop {
1053            let path = format!("project/search?startAt={start_at}&maxResults={PAGE}&orderBy=key");
1054            let page: ProjectSearchResponse = self.get(&path).await?;
1055            let page_start = page.start_at;
1056            let received = page.values.len();
1057            let total = page.total;
1058            all.extend(page.values);
1059
1060            if page.is_last || all.len() >= total {
1061                break;
1062            }
1063
1064            if received == 0 {
1065                return Err(ApiError::Other(
1066                    "Project pagination returned an empty non-terminal page".into(),
1067                ));
1068            }
1069
1070            start_at = page_start.saturating_add(received);
1071        }
1072
1073        Ok(all)
1074    }
1075
1076    /// Fetch a single project by key.
1077    pub async fn get_project(&self, key: &str) -> Result<Project, ApiError> {
1078        self.get(&format!("project/{}", metadata::encode_segment(key)))
1079            .await
1080    }
1081
1082    /// List all components for a project.
1083    ///
1084    /// Returns a flat array on both Jira Cloud (API v3) and DC/Server (API v2)
1085    /// - the `project/{key}/components` endpoint is not paginated.
1086    pub async fn list_components(&self, project_key: &str) -> Result<Vec<Component>, ApiError> {
1087        self.get::<Vec<Component>>(&format!("project/{project_key}/components"))
1088            .await
1089    }
1090
1091    /// List all versions for a project.
1092    ///
1093    /// Returns a flat array on both Jira Cloud (API v3) and DC/Server (API v2)
1094    /// - the `project/{key}/versions` endpoint is not paginated.
1095    pub async fn list_versions(&self, project_key: &str) -> Result<Vec<Version>, ApiError> {
1096        self.get::<Vec<Version>>(&format!("project/{project_key}/versions"))
1097            .await
1098    }
1099
1100    // ── Fields ────────────────────────────────────────────────────────────────
1101
1102    /// List all available fields (system and custom).
1103    pub async fn list_fields(&self) -> Result<Vec<Field>, ApiError> {
1104        self.get::<Vec<Field>>("field").await
1105    }
1106
1107    /// Move an issue to a sprint.
1108    ///
1109    /// Uses the Agile REST API which is version-independent.
1110    pub async fn move_issue_to_sprint(
1111        &self,
1112        issue_key: &str,
1113        sprint_id: u64,
1114    ) -> Result<(), ApiError> {
1115        self.ensure_writable()?;
1116        validate_issue_key(issue_key)?;
1117        let url = format!("{}/sprint/{sprint_id}/issue", self.agile_base_url);
1118        let payload = serde_json::json!({ "issues": [issue_key] });
1119        self.send(self.http.post(&url).json(&payload)).await?;
1120        Ok(())
1121    }
1122
1123    /// Fetch a single sprint by numeric ID.
1124    pub async fn get_sprint(&self, sprint_id: u64) -> Result<Sprint, ApiError> {
1125        self.agile_get::<Sprint>(&format!("sprint/{sprint_id}"))
1126            .await
1127    }
1128
1129    /// Resolve a sprint specifier to its numeric ID.
1130    ///
1131    /// See [`resolve_sprint`] for accepted specifier formats.
1132    pub async fn resolve_sprint_id(&self, specifier: &str) -> Result<u64, ApiError> {
1133        if let Ok(id) = specifier.parse::<u64>() {
1134            return Ok(id);
1135        }
1136        self.resolve_sprint(specifier).await.map(|s| s.id)
1137    }
1138}
1139
1140/// Validate that a key matches the `[A-Z][A-Z0-9]*-[0-9]+` format
1141/// before using it in a URL path.
1142///
1143/// Jira project keys start with an uppercase letter and may contain further
1144/// uppercase letters or digits (e.g. `ABC2-123` is valid).
1145fn validate_issue_key(key: &str) -> Result<(), ApiError> {
1146    let mut parts = key.splitn(2, '-');
1147    let project = parts.next().unwrap_or("");
1148    let number = parts.next().unwrap_or("");
1149
1150    let valid = !project.is_empty()
1151        && !number.is_empty()
1152        && project
1153            .chars()
1154            .next()
1155            .is_some_and(|c| c.is_ascii_uppercase())
1156        && project
1157            .chars()
1158            .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit())
1159        && number.chars().all(|c| c.is_ascii_digit());
1160
1161    if valid {
1162        Ok(())
1163    } else {
1164        Err(ApiError::InvalidInput(format!(
1165            "Invalid issue key '{key}'. Expected format: PROJECT-123"
1166        )))
1167    }
1168}
1169
1170/// Validate that an attachment ID is numeric before using it in a URL path.
1171fn validate_attachment_id(id: &str) -> Result<(), ApiError> {
1172    if !id.is_empty() && id.chars().all(|c| c.is_ascii_digit()) {
1173        Ok(())
1174    } else {
1175        Err(ApiError::InvalidInput(format!(
1176            "Invalid attachment ID '{id}'. Expected a number."
1177        )))
1178    }
1179}
1180
1181/// Percent-encode a string for use in a URL query parameter.
1182///
1183/// Uses `%20` for spaces (not `+`) per standard URL encoding.
1184fn percent_encode(s: &str) -> String {
1185    let mut encoded = String::with_capacity(s.len() * 2);
1186    for byte in s.bytes() {
1187        match byte {
1188            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1189                encoded.push(byte as char)
1190            }
1191            b => encoded.push_str(&format!("%{b:02X}")),
1192        }
1193    }
1194    encoded
1195}
1196
1197/// Truncate an API error body when explicitly debugging HTTP failures.
1198fn truncate_error_body(body: &str) -> String {
1199    const MAX: usize = 200;
1200    if body.chars().count() <= MAX {
1201        body.to_string()
1202    } else {
1203        let truncated: String = body.chars().take(MAX).collect();
1204        format!("{truncated}… (truncated)")
1205    }
1206}
1207
1208fn summarize_error_body(status: u16, body: &str) -> String {
1209    if should_include_raw_error_body() && !body.trim().is_empty() {
1210        return truncate_error_body(body);
1211    }
1212
1213    if let Some(message) = summarize_json_error_body(body) {
1214        return message;
1215    }
1216
1217    default_status_message(status)
1218}
1219
1220fn summarize_json_error_body(body: &str) -> Option<String> {
1221    let parsed: JiraErrorPayload = serde_json::from_str(body).ok()?;
1222    let mut parts = Vec::new();
1223
1224    if !parsed.error_messages.is_empty() {
1225        parts.push(format_error_messages(&parsed.error_messages));
1226    }
1227
1228    if !parsed.errors.is_empty() {
1229        let fields = parsed.errors.keys().take(5).cloned().collect::<Vec<_>>();
1230        parts.push(format!(
1231            "validation errors for fields: {}",
1232            fields.join(", ")
1233        ));
1234    }
1235
1236    if parts.is_empty() {
1237        None
1238    } else {
1239        Some(parts.join("; "))
1240    }
1241}
1242
1243/// Maximum number of Jira `errorMessages` entries to surface inline before
1244/// collapsing the remainder into a `(+N more)` suffix.
1245const MAX_ERROR_MESSAGES_SHOWN: usize = 3;
1246
1247/// Maximum character length of each individual message, so a single
1248/// pathological Jira response cannot dominate the user-visible error line.
1249const MAX_ERROR_MESSAGE_LEN: usize = 240;
1250
1251fn format_error_messages(messages: &[String]) -> String {
1252    let shown: Vec<String> = messages
1253        .iter()
1254        .take(MAX_ERROR_MESSAGES_SHOWN)
1255        .map(|m| truncate_message(m.trim()))
1256        .collect();
1257    let joined = shown.join(" | ");
1258    let remaining = messages.len().saturating_sub(MAX_ERROR_MESSAGES_SHOWN);
1259    if remaining > 0 {
1260        format!("{joined} (+{remaining} more)")
1261    } else {
1262        joined
1263    }
1264}
1265
1266fn truncate_message(msg: &str) -> String {
1267    if msg.chars().count() <= MAX_ERROR_MESSAGE_LEN {
1268        msg.to_string()
1269    } else {
1270        let truncated: String = msg.chars().take(MAX_ERROR_MESSAGE_LEN).collect();
1271        format!("{truncated}…")
1272    }
1273}
1274
1275fn default_status_message(status: u16) -> String {
1276    match status {
1277        401 | 403 => "request unauthorized".into(),
1278        404 => "resource not found".into(),
1279        409 => "conflicts with the current state of the resource".into(),
1280        429 => "rate limited by Jira".into(),
1281        400..=499 => format!("request failed with status {status}"),
1282        _ => format!("Jira request failed with status {status}"),
1283    }
1284}
1285
1286fn should_include_raw_error_body() -> bool {
1287    std::env::var("JIRA_DEBUG_HTTP").is_ok_and(|value| crate::config::is_truthy(&value))
1288}
1289
1290#[derive(Debug, serde::Deserialize)]
1291#[serde(rename_all = "camelCase")]
1292struct JiraErrorPayload {
1293    #[serde(default)]
1294    error_messages: Vec<String>,
1295    #[serde(default)]
1296    errors: BTreeMap<String, String>,
1297}
1298
1299fn write_preview(
1300    fields: serde_json::Value,
1301    issue_types: Option<bool>,
1302    field_metadata: bool,
1303) -> serde_json::Value {
1304    let mut warnings = vec![
1305        "Jira may apply additional workflow, permission, or plugin validators when the write is submitted.",
1306    ];
1307    if issue_types == Some(false) {
1308        warnings.push("Issue type metadata is unavailable; the issue type could not be normalized or validated.");
1309    }
1310    if !field_metadata {
1311        warnings.push("Field metadata is unavailable; required fields and allowed values could not be fully validated.");
1312    }
1313    serde_json::json!({"fields":fields, "metadata":{"issueTypes":issue_types, "fields":field_metadata}, "warnings":warnings})
1314}
1315
1316#[cfg(test)]
1317mod tests {
1318    use super::*;
1319
1320    #[test]
1321    fn percent_encode_spaces_use_percent_20() {
1322        assert_eq!(percent_encode("project = FOO"), "project%20%3D%20FOO");
1323    }
1324
1325    #[test]
1326    fn percent_encode_complex_jql() {
1327        let jql = r#"project = "MY PROJECT""#;
1328        let encoded = percent_encode(jql);
1329        assert!(encoded.contains("project"));
1330        assert!(!encoded.contains('"'));
1331        assert!(!encoded.contains(' '));
1332    }
1333
1334    #[test]
1335    fn validate_issue_key_valid() {
1336        assert!(validate_issue_key("PROJ-123").is_ok());
1337        assert!(validate_issue_key("ABC-1").is_ok());
1338        assert!(validate_issue_key("MYPROJECT-9999").is_ok());
1339        // Digits are allowed in the project key after the initial letter
1340        assert!(validate_issue_key("ABC2-123").is_ok());
1341        assert!(validate_issue_key("P1-1").is_ok());
1342    }
1343
1344    #[test]
1345    fn validate_issue_key_invalid() {
1346        assert!(validate_issue_key("proj-123").is_err()); // lowercase
1347        assert!(validate_issue_key("PROJ123").is_err()); // no dash
1348        assert!(validate_issue_key("PROJ-abc").is_err()); // non-numeric suffix
1349        assert!(validate_issue_key("../etc/passwd").is_err());
1350        assert!(validate_issue_key("").is_err());
1351        assert!(validate_issue_key("1PROJ-123").is_err()); // starts with digit
1352    }
1353
1354    #[test]
1355    fn truncate_error_body_short() {
1356        let body = "short error";
1357        assert_eq!(truncate_error_body(body), body);
1358    }
1359
1360    #[test]
1361    fn truncate_error_body_long() {
1362        let body = "x".repeat(300);
1363        let result = truncate_error_body(&body);
1364        assert!(result.len() < body.len());
1365        assert!(result.ends_with("(truncated)"));
1366    }
1367
1368    #[test]
1369    fn summarize_json_error_body_surfaces_messages_and_redacts_field_values() {
1370        let body = serde_json::json!({
1371            "errorMessages": ["JQL validation failed"],
1372            "errors": {
1373                "summary": "Summary must not contain secret project name",
1374                "description": "Description cannot include api token"
1375            }
1376        })
1377        .to_string();
1378
1379        let message = summarize_error_body(400, &body);
1380        // errorMessages are server-provided strings, safe to surface in full.
1381        assert!(message.contains("JQL validation failed"));
1382        // `errors` keys (field names) are safe; their values may echo user
1383        // input and must stay redacted.
1384        assert!(message.contains("summary"));
1385        assert!(message.contains("description"));
1386        assert!(!message.contains("secret project name"));
1387        assert!(!message.contains("api token"));
1388    }
1389
1390    #[test]
1391    fn summarize_json_error_body_reports_retired_api() {
1392        // Real payload shape returned by Atlassian after CHANGE-2046.
1393        let body = serde_json::json!({
1394            "errorMessages": [
1395                "The requested API has been removed. Please migrate to the /rest/api/3/search/jql API."
1396            ],
1397            "errors": {}
1398        })
1399        .to_string();
1400
1401        let message = summarize_error_body(410, &body);
1402        assert!(message.contains("The requested API has been removed"));
1403        assert!(message.contains("/rest/api/3/search/jql"));
1404    }
1405
1406    #[test]
1407    fn summarize_json_error_body_joins_multiple_messages() {
1408        let body = serde_json::json!({
1409            "errorMessages": ["first problem", "second problem"],
1410            "errors": {}
1411        })
1412        .to_string();
1413
1414        let message = summarize_error_body(400, &body);
1415        assert!(message.contains("first problem"));
1416        assert!(message.contains("second problem"));
1417        assert!(message.contains(" | "));
1418    }
1419
1420    #[test]
1421    fn summarize_json_error_body_collapses_overflow_messages() {
1422        let body = serde_json::json!({
1423            "errorMessages": ["a", "b", "c", "d", "e"],
1424            "errors": {}
1425        })
1426        .to_string();
1427
1428        let message = summarize_error_body(400, &body);
1429        assert!(message.contains("(+2 more)"));
1430    }
1431
1432    #[test]
1433    fn summarize_json_error_body_truncates_oversized_message() {
1434        let huge = "x".repeat(1000);
1435        let body = serde_json::json!({
1436            "errorMessages": [huge],
1437            "errors": {}
1438        })
1439        .to_string();
1440
1441        let message = summarize_error_body(400, &body);
1442        assert!(message.chars().count() < 500);
1443        assert!(message.contains('…'));
1444    }
1445
1446    #[test]
1447    fn browse_url_preserves_explicit_http_hosts() {
1448        let client = JiraClient::new(
1449            "http://localhost:8080",
1450            "me@example.com",
1451            "token",
1452            AuthType::Basic,
1453            3,
1454        )
1455        .unwrap();
1456        assert_eq!(
1457            client.browse_url("PROJ-1"),
1458            "http://localhost:8080/browse/PROJ-1"
1459        );
1460    }
1461
1462    #[test]
1463    fn new_with_pat_auth_does_not_require_email() {
1464        let client = JiraClient::new(
1465            "https://jira.example.com",
1466            "",
1467            "my-pat-token",
1468            AuthType::Pat,
1469            3,
1470        );
1471        assert!(client.is_ok());
1472    }
1473
1474    #[test]
1475    fn new_with_api_v2_uses_v2_base_url() {
1476        let client = JiraClient::new(
1477            "https://jira.example.com",
1478            "me@example.com",
1479            "token",
1480            AuthType::Basic,
1481            2,
1482        )
1483        .unwrap();
1484        assert_eq!(client.api_version(), 2);
1485    }
1486
1487    #[test]
1488    fn scoped_cloud_token_uses_gateway_but_keeps_site_links() {
1489        let client = JiraClient::new_with_cloud(
1490            "acme.atlassian.net",
1491            "me@example.com",
1492            "token",
1493            AuthType::Basic,
1494            3,
1495            Some("cloud-123"),
1496            "scoped",
1497        )
1498        .unwrap();
1499
1500        assert_eq!(
1501            client.base_url,
1502            "https://api.atlassian.com/ex/jira/cloud-123/rest/api/3"
1503        );
1504        assert_eq!(
1505            client.agile_base_url,
1506            "https://api.atlassian.com/ex/jira/cloud-123/rest/agile/1.0"
1507        );
1508        assert_eq!(
1509            client.browse_url("PROJ-1"),
1510            "https://acme.atlassian.net/browse/PROJ-1"
1511        );
1512    }
1513
1514    #[test]
1515    fn scoped_cloud_token_requires_cloud_id() {
1516        let error = JiraClient::new_with_cloud(
1517            "acme.atlassian.net",
1518            "me@example.com",
1519            "token",
1520            AuthType::Basic,
1521            3,
1522            None,
1523            "scoped",
1524        )
1525        .err()
1526        .expect("scoped credentials without a Cloud ID must fail");
1527        assert!(error.to_string().contains("Cloud ID"));
1528    }
1529}