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 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 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 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 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 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 #[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 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 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 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 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 pub async fn get_myself(&self) -> Result<Myself, ApiError> {
250 self.get("myself").await
251 }
252
253 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 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 pub async fn get_project(&self, key: &str) -> Result<Project, ApiError> {
313 self.get(&format!("project/{key}")).await
314 }
315}
316
317fn 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
344fn 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
360fn 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 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()); assert!(validate_issue_key("PROJ123").is_err()); assert!(validate_issue_key("PROJ-abc").is_err()); assert!(validate_issue_key("../etc/passwd").is_err());
405 assert!(validate_issue_key("").is_err());
406 assert!(validate_issue_key("1PROJ-123").is_err()); }
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}