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