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 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 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 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 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 #[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 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 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 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 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 pub async fn get_myself(&self) -> Result<Myself, ApiError> {
286 self.get("myself").await
287 }
288
289 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 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 pub async fn get_project(&self, key: &str) -> Result<Project, ApiError> {
354 self.get(&format!("project/{key}")).await
355 }
356}
357
358fn 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
388fn 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
404fn 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 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()); assert!(validate_issue_key("PROJ123").is_err()); assert!(validate_issue_key("PROJ-abc").is_err()); assert!(validate_issue_key("../etc/passwd").is_err());
513 assert!(validate_issue_key("").is_err());
514 assert!(validate_issue_key("1PROJ-123").is_err()); }
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}