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;
5
6use super::types::*;
7use super::ApiError;
8
9pub struct JiraClient {
10    http: reqwest::Client,
11    base_url: String,
12    host: String,
13}
14
15impl JiraClient {
16    pub fn new(host: &str, email: &str, token: &str) -> Result<Self, ApiError> {
17        // Determine the scheme. An explicit `http://` prefix is preserved as-is
18        // (useful for local testing); everything else defaults to HTTPS.
19        let (scheme, domain) = if host.starts_with("http://") {
20            ("http", host.trim_start_matches("http://").trim_end_matches('/'))
21        } else {
22            ("https", host.trim_start_matches("https://").trim_end_matches('/'))
23        };
24
25        if domain.is_empty() {
26            return Err(ApiError::Other("Host cannot be empty".into()));
27        }
28
29        let credentials = BASE64.encode(format!("{email}:{token}"));
30        let auth_value = format!("Basic {credentials}");
31
32        let mut headers = HeaderMap::new();
33        headers.insert(
34            AUTHORIZATION,
35            HeaderValue::from_str(&auth_value).map_err(|e| ApiError::Other(e.to_string()))?,
36        );
37
38        let http = reqwest::Client::builder()
39            .default_headers(headers)
40            .timeout(std::time::Duration::from_secs(30))
41            .build()
42            .map_err(ApiError::Http)?;
43
44        let base_url = format!("{scheme}://{domain}/rest/api/3");
45
46        Ok(Self {
47            http,
48            base_url,
49            host: domain.to_string(),
50        })
51    }
52
53    pub fn host(&self) -> &str {
54        &self.host
55    }
56
57    fn map_status(status: u16, body: String) -> ApiError {
58        // Truncate body to avoid leaking large/sensitive API responses
59        let message = truncate_error_body(&body);
60        match status {
61            401 | 403 => ApiError::Auth(message),
62            404 => ApiError::NotFound(message),
63            429 => ApiError::RateLimit,
64            _ => ApiError::Api { status, message },
65        }
66    }
67
68    async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T, ApiError> {
69        let url = format!("{}/{path}", self.base_url);
70        let resp = self.http.get(&url).send().await?;
71        let status = resp.status();
72        if !status.is_success() {
73            let body = resp.text().await.unwrap_or_default();
74            return Err(Self::map_status(status.as_u16(), body));
75        }
76        resp.json::<T>().await.map_err(ApiError::Http)
77    }
78
79    async fn post<T: DeserializeOwned>(
80        &self,
81        path: &str,
82        body: &serde_json::Value,
83    ) -> Result<T, ApiError> {
84        let url = format!("{}/{path}", self.base_url);
85        let resp = self.http.post(&url).json(body).send().await?;
86        let status = resp.status();
87        if !status.is_success() {
88            let body_text = resp.text().await.unwrap_or_default();
89            return Err(Self::map_status(status.as_u16(), body_text));
90        }
91        resp.json::<T>().await.map_err(ApiError::Http)
92    }
93
94    async fn post_empty_response(
95        &self,
96        path: &str,
97        body: &serde_json::Value,
98    ) -> Result<(), ApiError> {
99        let url = format!("{}/{path}", self.base_url);
100        let resp = self.http.post(&url).json(body).send().await?;
101        let status = resp.status();
102        if !status.is_success() {
103            let body_text = resp.text().await.unwrap_or_default();
104            return Err(Self::map_status(status.as_u16(), body_text));
105        }
106        Ok(())
107    }
108
109    async fn put_empty_response(
110        &self,
111        path: &str,
112        body: &serde_json::Value,
113    ) -> Result<(), ApiError> {
114        let url = format!("{}/{path}", self.base_url);
115        let resp = self.http.put(&url).json(body).send().await?;
116        let status = resp.status();
117        if !status.is_success() {
118            let body_text = resp.text().await.unwrap_or_default();
119            return Err(Self::map_status(status.as_u16(), body_text));
120        }
121        Ok(())
122    }
123
124    // ── Issues ────────────────────────────────────────────────────────────────
125
126    /// Search issues using JQL.
127    pub async fn search(
128        &self,
129        jql: &str,
130        max_results: usize,
131        start_at: usize,
132    ) -> Result<SearchResponse, ApiError> {
133        let fields = "summary,status,assignee,priority,issuetype,created,updated";
134        let path = format!(
135            "search?jql={}&maxResults={max_results}&startAt={start_at}&fields={fields}",
136            percent_encode(jql)
137        );
138        self.get(&path).await
139    }
140
141    /// Fetch a single issue by key (e.g. `PROJ-123`), including all comments.
142    ///
143    /// Jira embeds only the first page of comments in the issue response. When
144    /// the embedded page is incomplete, additional requests are made to fetch
145    /// the remaining comments.
146    pub async fn get_issue(&self, key: &str) -> Result<Issue, ApiError> {
147        validate_issue_key(key)?;
148        let fields =
149            "summary,status,assignee,reporter,priority,issuetype,description,labels,created,updated,comment";
150        let path = format!("issue/{key}?fields={fields}");
151        let mut issue: Issue = self.get(&path).await?;
152
153        // Fetch remaining comment pages if the embedded page is incomplete
154        if let Some(ref mut comment_list) = issue.fields.comment
155            && comment_list.total > comment_list.comments.len()
156        {
157            let mut start_at = comment_list.comments.len();
158            while comment_list.comments.len() < comment_list.total {
159                let page: CommentList = self
160                    .get(&format!(
161                        "issue/{key}/comment?startAt={start_at}&maxResults=100"
162                    ))
163                    .await?;
164                if page.comments.is_empty() {
165                    break;
166                }
167                start_at += page.comments.len();
168                comment_list.comments.extend(page.comments);
169            }
170        }
171
172        Ok(issue)
173    }
174
175    /// Create a new issue.
176    #[allow(clippy::too_many_arguments)]
177    pub async fn create_issue(
178        &self,
179        project_key: &str,
180        issue_type: &str,
181        summary: &str,
182        description: Option<&str>,
183        priority: Option<&str>,
184        labels: Option<&[&str]>,
185        assignee: Option<&str>,
186    ) -> Result<CreateIssueResponse, ApiError> {
187        let mut fields = serde_json::json!({
188            "project": { "key": project_key },
189            "issuetype": { "name": issue_type },
190            "summary": summary,
191        });
192
193        if let Some(desc) = description {
194            fields["description"] = text_to_adf(desc);
195        }
196        if let Some(p) = priority {
197            fields["priority"] = serde_json::json!({ "name": p });
198        }
199        if let Some(lbls) = labels
200            && !lbls.is_empty()
201        {
202            fields["labels"] = serde_json::json!(lbls);
203        }
204        if let Some(account_id) = assignee {
205            fields["assignee"] = serde_json::json!({ "accountId": account_id });
206        }
207
208        self.post("issue", &serde_json::json!({ "fields": fields }))
209            .await
210    }
211
212    /// Add a comment to an issue.
213    pub async fn add_comment(&self, key: &str, body: &str) -> Result<Comment, ApiError> {
214        validate_issue_key(key)?;
215        let payload = serde_json::json!({ "body": text_to_adf(body) });
216        self.post(&format!("issue/{key}/comment"), &payload).await
217    }
218
219    /// List available transitions for an issue.
220    pub async fn get_transitions(&self, key: &str) -> Result<Vec<Transition>, ApiError> {
221        validate_issue_key(key)?;
222        let resp: TransitionsResponse = self.get(&format!("issue/{key}/transitions")).await?;
223        Ok(resp.transitions)
224    }
225
226    /// Execute a transition by transition ID.
227    pub async fn do_transition(&self, key: &str, transition_id: &str) -> Result<(), ApiError> {
228        validate_issue_key(key)?;
229        let payload = serde_json::json!({ "transition": { "id": transition_id } });
230        self.post_empty_response(&format!("issue/{key}/transitions"), &payload)
231            .await
232    }
233
234    /// Assign an issue to a user by account ID, or unassign with `None`.
235    pub async fn assign_issue(
236        &self,
237        key: &str,
238        account_id: Option<&str>,
239    ) -> Result<(), ApiError> {
240        validate_issue_key(key)?;
241        let payload = serde_json::json!({
242            "accountId": account_id
243        });
244        self.put_empty_response(&format!("issue/{key}/assignee"), &payload)
245            .await
246    }
247
248    /// Get the currently authenticated user.
249    pub async fn get_myself(&self) -> Result<Myself, ApiError> {
250        self.get("myself").await
251    }
252
253    /// Update issue fields (summary, description, priority).
254    pub async fn update_issue(
255        &self,
256        key: &str,
257        summary: Option<&str>,
258        description: Option<&str>,
259        priority: Option<&str>,
260    ) -> Result<(), ApiError> {
261        validate_issue_key(key)?;
262        let mut fields = serde_json::Map::new();
263        if let Some(s) = summary {
264            fields.insert("summary".into(), serde_json::Value::String(s.into()));
265        }
266        if let Some(d) = description {
267            fields.insert("description".into(), text_to_adf(d));
268        }
269        if let Some(p) = priority {
270            fields.insert(
271                "priority".into(),
272                serde_json::json!({ "name": p }),
273            );
274        }
275        if fields.is_empty() {
276            return Err(ApiError::InvalidInput(
277                "At least one field (--summary, --description, --priority) is required".into(),
278            ));
279        }
280        self.put_empty_response(
281            &format!("issue/{key}"),
282            &serde_json::json!({ "fields": fields }),
283        )
284        .await
285    }
286
287    // ── Projects ──────────────────────────────────────────────────────────────
288
289    /// List all accessible projects, fetching all pages from the paginated endpoint.
290    pub async fn list_projects(&self) -> Result<Vec<Project>, ApiError> {
291        let mut all: Vec<Project> = Vec::new();
292        let mut start_at: usize = 0;
293        const PAGE: usize = 50;
294
295        loop {
296            let path = format!(
297                "project/search?startAt={start_at}&maxResults={PAGE}&orderBy=key"
298            );
299            let page: ProjectSearchResponse = self.get(&path).await?;
300            let is_last = page.is_last || page.values.len() < PAGE;
301            all.extend(page.values);
302            if is_last {
303                break;
304            }
305            start_at += PAGE;
306        }
307
308        Ok(all)
309    }
310
311    /// Fetch a single project by key.
312    pub async fn get_project(&self, key: &str) -> Result<Project, ApiError> {
313        self.get(&format!("project/{key}")).await
314    }
315}
316
317/// Validate that a key matches the `[A-Z][A-Z0-9]*-[0-9]+` format
318/// before using it in a URL path.
319///
320/// Jira project keys start with an uppercase letter and may contain further
321/// uppercase letters or digits (e.g. `ABC2-123` is valid).
322fn validate_issue_key(key: &str) -> Result<(), ApiError> {
323    let mut parts = key.splitn(2, '-');
324    let project = parts.next().unwrap_or("");
325    let number = parts.next().unwrap_or("");
326
327    let valid = !project.is_empty()
328        && !number.is_empty()
329        && project.chars().next().is_some_and(|c| c.is_ascii_uppercase())
330        && project
331            .chars()
332            .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit())
333        && number.chars().all(|c| c.is_ascii_digit());
334
335    if valid {
336        Ok(())
337    } else {
338        Err(ApiError::InvalidInput(format!(
339            "Invalid issue key '{key}'. Expected format: PROJECT-123"
340        )))
341    }
342}
343
344/// Percent-encode a string for use in a URL query parameter.
345///
346/// Uses `%20` for spaces (not `+`) per standard URL encoding.
347fn percent_encode(s: &str) -> String {
348    let mut encoded = String::with_capacity(s.len() * 2);
349    for byte in s.bytes() {
350        match byte {
351            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
352                encoded.push(byte as char)
353            }
354            b => encoded.push_str(&format!("%{b:02X}")),
355        }
356    }
357    encoded
358}
359
360/// Truncate an API error body to avoid leaking large or sensitive responses.
361fn truncate_error_body(body: &str) -> String {
362    const MAX: usize = 200;
363    if body.chars().count() <= MAX {
364        body.to_string()
365    } else {
366        let truncated: String = body.chars().take(MAX).collect();
367        format!("{truncated}… (truncated)")
368    }
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374
375    #[test]
376    fn percent_encode_spaces_use_percent_20() {
377        assert_eq!(percent_encode("project = FOO"), "project%20%3D%20FOO");
378    }
379
380    #[test]
381    fn percent_encode_complex_jql() {
382        let jql = r#"project = "MY PROJECT""#;
383        let encoded = percent_encode(jql);
384        assert!(encoded.contains("project"));
385        assert!(!encoded.contains('"'));
386        assert!(!encoded.contains(' '));
387    }
388
389    #[test]
390    fn validate_issue_key_valid() {
391        assert!(validate_issue_key("PROJ-123").is_ok());
392        assert!(validate_issue_key("ABC-1").is_ok());
393        assert!(validate_issue_key("MYPROJECT-9999").is_ok());
394        // Digits are allowed in the project key after the initial letter
395        assert!(validate_issue_key("ABC2-123").is_ok());
396        assert!(validate_issue_key("P1-1").is_ok());
397    }
398
399    #[test]
400    fn validate_issue_key_invalid() {
401        assert!(validate_issue_key("proj-123").is_err()); // lowercase
402        assert!(validate_issue_key("PROJ123").is_err());  // no dash
403        assert!(validate_issue_key("PROJ-abc").is_err()); // non-numeric suffix
404        assert!(validate_issue_key("../etc/passwd").is_err());
405        assert!(validate_issue_key("").is_err());
406        assert!(validate_issue_key("1PROJ-123").is_err()); // starts with digit
407    }
408
409    #[test]
410    fn truncate_error_body_short() {
411        let body = "short error";
412        assert_eq!(truncate_error_body(body), body);
413    }
414
415    #[test]
416    fn truncate_error_body_long() {
417        let body = "x".repeat(300);
418        let result = truncate_error_body(&body);
419        assert!(result.len() < body.len());
420        assert!(result.ends_with("(truncated)"));
421    }
422}