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