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