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