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::types::*;
9
10pub struct JiraClient {
11    http: reqwest::Client,
12    base_url: String,
13    site_url: String,
14    host: String,
15}
16
17const SEARCH_FIELDS: [&str; 7] = [
18    "summary",
19    "status",
20    "assignee",
21    "priority",
22    "issuetype",
23    "created",
24    "updated",
25];
26const SEARCH_GET_JQL_LIMIT: usize = 1500;
27
28impl JiraClient {
29    pub fn new(host: &str, email: &str, token: &str) -> Result<Self, ApiError> {
30        // Determine the scheme. An explicit `http://` prefix is preserved as-is
31        // (useful for local testing); everything else defaults to HTTPS.
32        let (scheme, domain) = if host.starts_with("http://") {
33            (
34                "http",
35                host.trim_start_matches("http://").trim_end_matches('/'),
36            )
37        } else {
38            (
39                "https",
40                host.trim_start_matches("https://").trim_end_matches('/'),
41            )
42        };
43
44        if domain.is_empty() {
45            return Err(ApiError::Other("Host cannot be empty".into()));
46        }
47
48        let credentials = BASE64.encode(format!("{email}:{token}"));
49        let auth_value = format!("Basic {credentials}");
50
51        let mut headers = HeaderMap::new();
52        headers.insert(
53            AUTHORIZATION,
54            HeaderValue::from_str(&auth_value).map_err(|e| ApiError::Other(e.to_string()))?,
55        );
56
57        let http = reqwest::Client::builder()
58            .default_headers(headers)
59            .timeout(std::time::Duration::from_secs(30))
60            .build()
61            .map_err(ApiError::Http)?;
62
63        let site_url = format!("{scheme}://{domain}");
64        let base_url = format!("{site_url}/rest/api/3");
65
66        Ok(Self {
67            http,
68            base_url,
69            site_url,
70            host: domain.to_string(),
71        })
72    }
73
74    pub fn host(&self) -> &str {
75        &self.host
76    }
77
78    pub fn browse_base_url(&self) -> &str {
79        &self.site_url
80    }
81
82    pub fn browse_url(&self, issue_key: &str) -> String {
83        format!("{}/browse/{issue_key}", self.browse_base_url())
84    }
85
86    fn map_status(status: u16, body: String) -> ApiError {
87        let message = summarize_error_body(status, &body);
88        match status {
89            401 | 403 => ApiError::Auth(message),
90            404 => ApiError::NotFound(message),
91            429 => ApiError::RateLimit,
92            _ => ApiError::Api { status, message },
93        }
94    }
95
96    async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T, ApiError> {
97        let url = format!("{}/{path}", self.base_url);
98        let resp = self.http.get(&url).send().await?;
99        let status = resp.status();
100        if !status.is_success() {
101            let body = resp.text().await.unwrap_or_default();
102            return Err(Self::map_status(status.as_u16(), body));
103        }
104        resp.json::<T>().await.map_err(ApiError::Http)
105    }
106
107    async fn post<T: DeserializeOwned>(
108        &self,
109        path: &str,
110        body: &serde_json::Value,
111    ) -> Result<T, ApiError> {
112        let url = format!("{}/{path}", self.base_url);
113        let resp = self.http.post(&url).json(body).send().await?;
114        let status = resp.status();
115        if !status.is_success() {
116            let body_text = resp.text().await.unwrap_or_default();
117            return Err(Self::map_status(status.as_u16(), body_text));
118        }
119        resp.json::<T>().await.map_err(ApiError::Http)
120    }
121
122    async fn post_empty_response(
123        &self,
124        path: &str,
125        body: &serde_json::Value,
126    ) -> Result<(), ApiError> {
127        let url = format!("{}/{path}", self.base_url);
128        let resp = self.http.post(&url).json(body).send().await?;
129        let status = resp.status();
130        if !status.is_success() {
131            let body_text = resp.text().await.unwrap_or_default();
132            return Err(Self::map_status(status.as_u16(), body_text));
133        }
134        Ok(())
135    }
136
137    async fn put_empty_response(
138        &self,
139        path: &str,
140        body: &serde_json::Value,
141    ) -> Result<(), ApiError> {
142        let url = format!("{}/{path}", self.base_url);
143        let resp = self.http.put(&url).json(body).send().await?;
144        let status = resp.status();
145        if !status.is_success() {
146            let body_text = resp.text().await.unwrap_or_default();
147            return Err(Self::map_status(status.as_u16(), body_text));
148        }
149        Ok(())
150    }
151
152    // ── Issues ────────────────────────────────────────────────────────────────
153
154    /// Search issues using JQL.
155    pub async fn search(
156        &self,
157        jql: &str,
158        max_results: usize,
159        start_at: usize,
160    ) -> Result<SearchResponse, ApiError> {
161        let fields = SEARCH_FIELDS.join(",");
162        let encoded_jql = percent_encode(jql);
163        if encoded_jql.len() <= SEARCH_GET_JQL_LIMIT {
164            let path = format!(
165                "search?jql={encoded_jql}&maxResults={max_results}&startAt={start_at}&fields={fields}"
166            );
167            self.get(&path).await
168        } else {
169            self.post(
170                "search",
171                &serde_json::json!({
172                    "jql": jql,
173                    "maxResults": max_results,
174                    "startAt": start_at,
175                    "fields": SEARCH_FIELDS,
176                }),
177            )
178            .await
179        }
180    }
181
182    /// Fetch a single issue by key (e.g. `PROJ-123`), including all comments.
183    ///
184    /// Jira embeds only the first page of comments in the issue response. When
185    /// the embedded page is incomplete, additional requests are made to fetch
186    /// the remaining comments.
187    pub async fn get_issue(&self, key: &str) -> Result<Issue, ApiError> {
188        validate_issue_key(key)?;
189        let fields = "summary,status,assignee,reporter,priority,issuetype,description,labels,created,updated,comment";
190        let path = format!("issue/{key}?fields={fields}");
191        let mut issue: Issue = self.get(&path).await?;
192
193        // Fetch remaining comment pages if the embedded page is incomplete
194        if let Some(ref mut comment_list) = issue.fields.comment
195            && comment_list.total > comment_list.comments.len()
196        {
197            let mut start_at = comment_list.comments.len();
198            while comment_list.comments.len() < comment_list.total {
199                let page: CommentList = self
200                    .get(&format!(
201                        "issue/{key}/comment?startAt={start_at}&maxResults=100"
202                    ))
203                    .await?;
204                if page.comments.is_empty() {
205                    break;
206                }
207                start_at += page.comments.len();
208                comment_list.comments.extend(page.comments);
209            }
210        }
211
212        Ok(issue)
213    }
214
215    /// Create a new issue.
216    #[allow(clippy::too_many_arguments)]
217    pub async fn create_issue(
218        &self,
219        project_key: &str,
220        issue_type: &str,
221        summary: &str,
222        description: Option<&str>,
223        priority: Option<&str>,
224        labels: Option<&[&str]>,
225        assignee: Option<&str>,
226    ) -> Result<CreateIssueResponse, ApiError> {
227        let mut fields = serde_json::json!({
228            "project": { "key": project_key },
229            "issuetype": { "name": issue_type },
230            "summary": summary,
231        });
232
233        if let Some(desc) = description {
234            fields["description"] = text_to_adf(desc);
235        }
236        if let Some(p) = priority {
237            fields["priority"] = serde_json::json!({ "name": p });
238        }
239        if let Some(lbls) = labels
240            && !lbls.is_empty()
241        {
242            fields["labels"] = serde_json::json!(lbls);
243        }
244        if let Some(account_id) = assignee {
245            fields["assignee"] = serde_json::json!({ "accountId": account_id });
246        }
247
248        self.post("issue", &serde_json::json!({ "fields": fields }))
249            .await
250    }
251
252    /// Add a comment to an issue.
253    pub async fn add_comment(&self, key: &str, body: &str) -> Result<Comment, ApiError> {
254        validate_issue_key(key)?;
255        let payload = serde_json::json!({ "body": text_to_adf(body) });
256        self.post(&format!("issue/{key}/comment"), &payload).await
257    }
258
259    /// List available transitions for an issue.
260    pub async fn get_transitions(&self, key: &str) -> Result<Vec<Transition>, ApiError> {
261        validate_issue_key(key)?;
262        let resp: TransitionsResponse = self.get(&format!("issue/{key}/transitions")).await?;
263        Ok(resp.transitions)
264    }
265
266    /// Execute a transition by transition ID.
267    pub async fn do_transition(&self, key: &str, transition_id: &str) -> Result<(), ApiError> {
268        validate_issue_key(key)?;
269        let payload = serde_json::json!({ "transition": { "id": transition_id } });
270        self.post_empty_response(&format!("issue/{key}/transitions"), &payload)
271            .await
272    }
273
274    /// Assign an issue to a user by account ID, or unassign with `None`.
275    pub async fn assign_issue(&self, key: &str, account_id: Option<&str>) -> Result<(), ApiError> {
276        validate_issue_key(key)?;
277        let payload = serde_json::json!({
278            "accountId": account_id
279        });
280        self.put_empty_response(&format!("issue/{key}/assignee"), &payload)
281            .await
282    }
283
284    /// Get the currently authenticated user.
285    pub async fn get_myself(&self) -> Result<Myself, ApiError> {
286        self.get("myself").await
287    }
288
289    /// Update issue fields (summary, description, priority).
290    pub async fn update_issue(
291        &self,
292        key: &str,
293        summary: Option<&str>,
294        description: Option<&str>,
295        priority: Option<&str>,
296    ) -> Result<(), ApiError> {
297        validate_issue_key(key)?;
298        let mut fields = serde_json::Map::new();
299        if let Some(s) = summary {
300            fields.insert("summary".into(), serde_json::Value::String(s.into()));
301        }
302        if let Some(d) = description {
303            fields.insert("description".into(), text_to_adf(d));
304        }
305        if let Some(p) = priority {
306            fields.insert("priority".into(), serde_json::json!({ "name": p }));
307        }
308        if fields.is_empty() {
309            return Err(ApiError::InvalidInput(
310                "At least one field (--summary, --description, --priority) is required".into(),
311            ));
312        }
313        self.put_empty_response(
314            &format!("issue/{key}"),
315            &serde_json::json!({ "fields": fields }),
316        )
317        .await
318    }
319
320    // ── Projects ──────────────────────────────────────────────────────────────
321
322    /// List all accessible projects, fetching all pages from the paginated endpoint.
323    pub async fn list_projects(&self) -> Result<Vec<Project>, ApiError> {
324        let mut all: Vec<Project> = Vec::new();
325        let mut start_at: usize = 0;
326        const PAGE: usize = 50;
327
328        loop {
329            let path = format!("project/search?startAt={start_at}&maxResults={PAGE}&orderBy=key");
330            let page: ProjectSearchResponse = self.get(&path).await?;
331            let page_start = page.start_at;
332            let received = page.values.len();
333            let total = page.total;
334            all.extend(page.values);
335
336            if page.is_last || all.len() >= total {
337                break;
338            }
339
340            if received == 0 {
341                return Err(ApiError::Other(
342                    "Project pagination returned an empty non-terminal page".into(),
343                ));
344            }
345
346            start_at = page_start.saturating_add(received);
347        }
348
349        Ok(all)
350    }
351
352    /// Fetch a single project by key.
353    pub async fn get_project(&self, key: &str) -> Result<Project, ApiError> {
354        self.get(&format!("project/{key}")).await
355    }
356}
357
358/// Validate that a key matches the `[A-Z][A-Z0-9]*-[0-9]+` format
359/// before using it in a URL path.
360///
361/// Jira project keys start with an uppercase letter and may contain further
362/// uppercase letters or digits (e.g. `ABC2-123` is valid).
363fn validate_issue_key(key: &str) -> Result<(), ApiError> {
364    let mut parts = key.splitn(2, '-');
365    let project = parts.next().unwrap_or("");
366    let number = parts.next().unwrap_or("");
367
368    let valid = !project.is_empty()
369        && !number.is_empty()
370        && project
371            .chars()
372            .next()
373            .is_some_and(|c| c.is_ascii_uppercase())
374        && project
375            .chars()
376            .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit())
377        && number.chars().all(|c| c.is_ascii_digit());
378
379    if valid {
380        Ok(())
381    } else {
382        Err(ApiError::InvalidInput(format!(
383            "Invalid issue key '{key}'. Expected format: PROJECT-123"
384        )))
385    }
386}
387
388/// Percent-encode a string for use in a URL query parameter.
389///
390/// Uses `%20` for spaces (not `+`) per standard URL encoding.
391fn percent_encode(s: &str) -> String {
392    let mut encoded = String::with_capacity(s.len() * 2);
393    for byte in s.bytes() {
394        match byte {
395            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
396                encoded.push(byte as char)
397            }
398            b => encoded.push_str(&format!("%{b:02X}")),
399        }
400    }
401    encoded
402}
403
404/// Truncate an API error body when explicitly debugging HTTP failures.
405fn truncate_error_body(body: &str) -> String {
406    const MAX: usize = 200;
407    if body.chars().count() <= MAX {
408        body.to_string()
409    } else {
410        let truncated: String = body.chars().take(MAX).collect();
411        format!("{truncated}… (truncated)")
412    }
413}
414
415fn summarize_error_body(status: u16, body: &str) -> String {
416    if should_include_raw_error_body() && !body.trim().is_empty() {
417        return truncate_error_body(body);
418    }
419
420    if let Some(message) = summarize_json_error_body(body) {
421        return message;
422    }
423
424    default_status_message(status)
425}
426
427fn summarize_json_error_body(body: &str) -> Option<String> {
428    let parsed: JiraErrorPayload = serde_json::from_str(body).ok()?;
429    let mut parts = Vec::new();
430
431    if !parsed.error_messages.is_empty() {
432        parts.push(format!(
433            "{} Jira error message(s) returned",
434            parsed.error_messages.len()
435        ));
436    }
437
438    if !parsed.errors.is_empty() {
439        let fields = parsed.errors.keys().take(5).cloned().collect::<Vec<_>>();
440        parts.push(format!(
441            "validation errors for fields: {}",
442            fields.join(", ")
443        ));
444    }
445
446    if parts.is_empty() {
447        None
448    } else {
449        Some(parts.join("; "))
450    }
451}
452
453fn default_status_message(status: u16) -> String {
454    match status {
455        401 | 403 => "request unauthorized".into(),
456        404 => "resource not found".into(),
457        429 => "rate limited by Jira".into(),
458        400..=499 => format!("request failed with status {status}"),
459        _ => format!("Jira request failed with status {status}"),
460    }
461}
462
463fn should_include_raw_error_body() -> bool {
464    matches!(
465        std::env::var("JIRA_DEBUG_HTTP").ok().as_deref(),
466        Some("1" | "true" | "TRUE" | "yes" | "YES")
467    )
468}
469
470#[derive(Debug, serde::Deserialize)]
471#[serde(rename_all = "camelCase")]
472struct JiraErrorPayload {
473    #[serde(default)]
474    error_messages: Vec<String>,
475    #[serde(default)]
476    errors: BTreeMap<String, String>,
477}
478
479#[cfg(test)]
480mod tests {
481    use super::*;
482
483    #[test]
484    fn percent_encode_spaces_use_percent_20() {
485        assert_eq!(percent_encode("project = FOO"), "project%20%3D%20FOO");
486    }
487
488    #[test]
489    fn percent_encode_complex_jql() {
490        let jql = r#"project = "MY PROJECT""#;
491        let encoded = percent_encode(jql);
492        assert!(encoded.contains("project"));
493        assert!(!encoded.contains('"'));
494        assert!(!encoded.contains(' '));
495    }
496
497    #[test]
498    fn validate_issue_key_valid() {
499        assert!(validate_issue_key("PROJ-123").is_ok());
500        assert!(validate_issue_key("ABC-1").is_ok());
501        assert!(validate_issue_key("MYPROJECT-9999").is_ok());
502        // Digits are allowed in the project key after the initial letter
503        assert!(validate_issue_key("ABC2-123").is_ok());
504        assert!(validate_issue_key("P1-1").is_ok());
505    }
506
507    #[test]
508    fn validate_issue_key_invalid() {
509        assert!(validate_issue_key("proj-123").is_err()); // lowercase
510        assert!(validate_issue_key("PROJ123").is_err()); // no dash
511        assert!(validate_issue_key("PROJ-abc").is_err()); // non-numeric suffix
512        assert!(validate_issue_key("../etc/passwd").is_err());
513        assert!(validate_issue_key("").is_err());
514        assert!(validate_issue_key("1PROJ-123").is_err()); // starts with digit
515    }
516
517    #[test]
518    fn truncate_error_body_short() {
519        let body = "short error";
520        assert_eq!(truncate_error_body(body), body);
521    }
522
523    #[test]
524    fn truncate_error_body_long() {
525        let body = "x".repeat(300);
526        let result = truncate_error_body(&body);
527        assert!(result.len() < body.len());
528        assert!(result.ends_with("(truncated)"));
529    }
530
531    #[test]
532    fn summarize_json_error_body_redacts_values() {
533        let body = serde_json::json!({
534            "errorMessages": ["JQL validation failed"],
535            "errors": {
536                "summary": "Summary must not contain secret project name",
537                "description": "Description cannot include api token"
538            }
539        })
540        .to_string();
541
542        let message = summarize_error_body(400, &body);
543        assert!(message.contains("1 Jira error message(s) returned"));
544        assert!(message.contains("summary"));
545        assert!(message.contains("description"));
546        assert!(!message.contains("secret project name"));
547        assert!(!message.contains("api token"));
548    }
549
550    #[test]
551    fn browse_url_preserves_explicit_http_hosts() {
552        let client = JiraClient::new("http://localhost:8080", "me@example.com", "token").unwrap();
553        assert_eq!(
554            client.browse_url("PROJ-1"),
555            "http://localhost:8080/browse/PROJ-1"
556        );
557    }
558}