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