1use std::time::Duration;
2
3use reqwest::{
4 header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE},
5 Client, Response, StatusCode,
6};
7use serde_json::{json, Value};
8use tracing::{debug, warn};
9
10use crate::{
11 adf::markdown_to_adf,
12 config::JiraConfig,
13 error::{JiraError, Result},
14 model::{
15 attachment::Attachment,
16 field::Field,
17 issue::{
18 CreateIssueRequest, CreateIssueRequestV2, Issue, RawIssue, RawSearchResponse,
19 SearchResult, UpdateIssueRequest,
20 },
21 worklog::Worklog,
22 },
23};
24
25const PLATFORM_BASE: &str = "/rest/api/3";
26const AGILE_BASE: &str = "/rest/agile/1.0";
27const MAX_RETRIES: u32 = 3;
28
29pub struct JiraClient {
30 http: Client,
31 config: JiraConfig,
32}
33
34impl JiraClient {
35 pub fn new(config: JiraConfig) -> Self {
36 let http = Client::builder()
37 .timeout(Duration::from_secs(config.timeout_secs))
38 .build()
39 .expect("Failed to build HTTP client");
40
41 Self { http, config }
42 }
43
44 pub fn base_url(&self) -> &str {
45 &self.config.base_url
46 }
47
48 fn platform_url(&self, path: &str) -> String {
49 format!(
50 "{}{}{}",
51 self.config.base_url.trim_end_matches('/'),
52 PLATFORM_BASE,
53 path
54 )
55 }
56
57 #[allow(dead_code)]
58 fn agile_url(&self, path: &str) -> String {
59 format!(
60 "{}{}{}",
61 self.config.base_url.trim_end_matches('/'),
62 AGILE_BASE,
63 path
64 )
65 }
66
67 fn auth_headers(&self) -> Result<HeaderMap> {
68 let token = self.config.token.as_deref().ok_or_else(|| {
69 JiraError::Auth("No token configured. Run `jira auth login` first.".into())
70 })?;
71
72 let credentials = base64_encode(&format!("{}:{}", self.config.email, token));
73 let auth_value = format!("Basic {credentials}");
74
75 let mut headers = HeaderMap::new();
76 headers.insert(
77 AUTHORIZATION,
78 HeaderValue::from_str(&auth_value)
79 .map_err(|e| JiraError::Auth(format!("Invalid auth header: {e}")))?,
80 );
81 headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
82
83 Ok(headers)
84 }
85
86 fn auth_headers_no_content_type(&self) -> Result<HeaderMap> {
88 let token = self.config.token.as_deref().ok_or_else(|| {
89 JiraError::Auth("No token configured. Run `jira auth login` first.".into())
90 })?;
91
92 let credentials = base64_encode(&format!("{}:{}", self.config.email, token));
93 let auth_value = format!("Basic {credentials}");
94
95 let mut headers = HeaderMap::new();
96 headers.insert(
97 AUTHORIZATION,
98 HeaderValue::from_str(&auth_value)
99 .map_err(|e| JiraError::Auth(format!("Invalid auth header: {e}")))?,
100 );
101 Ok(headers)
102 }
103
104 pub async fn get_myself(&self) -> Result<String> {
106 let headers = self.auth_headers()?;
107 let url = self.platform_url("/myself");
108
109 let http = &self.http;
110 let user: serde_json::Value = self
111 .request(|| http.get(&url).headers(headers.clone()))
112 .await?;
113
114 user.get("accountId")
115 .and_then(|v| v.as_str())
116 .map(|s| s.to_string())
117 .ok_or_else(|| JiraError::Api {
118 status: 0,
119 message: "Could not get accountId from /myself".into(),
120 })
121 }
122
123 async fn resolve_assignee_account_id(&self, s: &str) -> Result<String> {
129 if s == "me" {
130 return self.get_myself().await;
131 }
132 if !s.contains('@') {
133 return Ok(s.to_string());
134 }
135 let users = self.search_users(s).await?;
137 users
138 .iter()
139 .find(|u| {
140 u.get("emailAddress")
141 .and_then(|v| v.as_str())
142 .map(|e| e.eq_ignore_ascii_case(s))
143 .unwrap_or(false)
144 })
145 .or_else(|| users.first())
146 .and_then(|u| u.get("accountId"))
147 .and_then(|v| v.as_str())
148 .map(|s| s.to_string())
149 .ok_or_else(|| JiraError::Api {
150 status: 0,
151 message: format!("User not found: {s}"),
152 })
153 }
154
155 async fn request<T>(&self, builder_fn: impl Fn() -> reqwest::RequestBuilder) -> Result<T>
157 where
158 T: serde::de::DeserializeOwned,
159 {
160 let mut attempt = 0u32;
161 loop {
162 attempt += 1;
163 let req = builder_fn();
164 let response = req.send().await?;
165
166 if response.status() == StatusCode::TOO_MANY_REQUESTS {
167 let retry_after = response
168 .headers()
169 .get("Retry-After")
170 .and_then(|v| v.to_str().ok())
171 .and_then(|v| v.parse::<u64>().ok())
172 .unwrap_or(60);
173
174 warn!("Rate limited. Retrying after {}s", retry_after);
175
176 if attempt >= MAX_RETRIES {
177 return Err(JiraError::RateLimit { retry_after });
178 }
179
180 tokio::time::sleep(Duration::from_secs(retry_after)).await;
181 continue;
182 }
183
184 return handle_response(response).await;
185 }
186 }
187
188 async fn request_no_body(
190 &self,
191 builder_fn: impl Fn() -> reqwest::RequestBuilder,
192 ) -> Result<()> {
193 let mut attempt = 0u32;
194 loop {
195 attempt += 1;
196 let req = builder_fn();
197 let response = req.send().await?;
198
199 if response.status() == StatusCode::TOO_MANY_REQUESTS {
200 let retry_after = response
201 .headers()
202 .get("Retry-After")
203 .and_then(|v| v.to_str().ok())
204 .and_then(|v| v.parse::<u64>().ok())
205 .unwrap_or(60);
206
207 warn!("Rate limited. Retrying after {}s", retry_after);
208
209 if attempt >= MAX_RETRIES {
210 return Err(JiraError::RateLimit { retry_after });
211 }
212
213 tokio::time::sleep(Duration::from_secs(retry_after)).await;
214 continue;
215 }
216
217 let status = response.status();
218 if status.is_success() {
219 return Ok(());
220 }
221
222 let body = response.text().await.unwrap_or_default();
223 if status == StatusCode::NOT_FOUND {
224 return Err(JiraError::NotFound(body));
225 }
226 return Err(JiraError::Api {
227 status: status.as_u16(),
228 message: body,
229 });
230 }
231 }
232
233 async fn request_multipart<T>(
235 &self,
236 builder_fn: impl Fn() -> reqwest::RequestBuilder,
237 ) -> Result<T>
238 where
239 T: serde::de::DeserializeOwned,
240 {
241 let mut attempt = 0u32;
242 loop {
243 attempt += 1;
244 let req = builder_fn();
245 let response = req.send().await?;
246
247 if response.status() == StatusCode::TOO_MANY_REQUESTS {
248 let retry_after = response
249 .headers()
250 .get("Retry-After")
251 .and_then(|v| v.to_str().ok())
252 .and_then(|v| v.parse::<u64>().ok())
253 .unwrap_or(60);
254
255 warn!("Rate limited. Retrying after {}s", retry_after);
256
257 if attempt >= MAX_RETRIES {
258 return Err(JiraError::RateLimit { retry_after });
259 }
260
261 tokio::time::sleep(Duration::from_secs(retry_after)).await;
262 continue;
263 }
264
265 return handle_response(response).await;
266 }
267 }
268
269 pub async fn search_issues(
271 &self,
272 jql: &str,
273 next_page_token: Option<&str>,
274 max_results: Option<u32>,
275 ) -> Result<SearchResult> {
276 let headers = self.auth_headers()?;
277 let url = self.platform_url("/search/jql");
278
279 let mut body = json!({
280 "jql": jql,
281 "maxResults": max_results.unwrap_or(50),
282 "fields": ["summary", "status", "assignee", "reporter", "priority",
283 "issuetype", "project", "created", "updated", "description"]
284 });
285
286 if let Some(token) = next_page_token {
287 body["nextPageToken"] = json!(token);
288 }
289
290 debug!("Searching JQL: {}", jql);
291
292 let http = &self.http;
293 let raw: RawSearchResponse = self
294 .request(|| http.post(&url).headers(headers.clone()).json(&body))
295 .await?;
296
297 Ok(SearchResult {
298 issues: raw.issues.into_iter().map(|r| r.into_issue()).collect(),
299 next_page_token: raw.next_page_token,
300 total: raw.total,
301 })
302 }
303
304 pub async fn get_issue(&self, key: &str) -> Result<Issue> {
306 let headers = self.auth_headers()?;
307 let url = self.platform_url(&format!("/issue/{key}"));
308
309 let http = &self.http;
310 let raw: RawIssue = self
311 .request(|| http.get(&url).headers(headers.clone()))
312 .await?;
313
314 Ok(raw.into_issue())
315 }
316
317 pub async fn create_issue(&self, req: CreateIssueRequest) -> Result<Issue> {
319 let headers = self.auth_headers()?;
320 let url = self.platform_url("/issue");
321
322 let description_adf = req.description.as_deref().map(markdown_to_adf);
323
324 let mut fields = json!({
325 "project": { "key": req.project_key },
326 "summary": req.summary,
327 "issuetype": { "name": req.issue_type }
328 });
329
330 if let Some(adf) = description_adf {
331 fields["description"] = adf;
332 }
333
334 if let Some(assignee) = &req.assignee {
335 let account_id = self.resolve_assignee_account_id(assignee).await?;
336 fields["assignee"] = json!({ "accountId": account_id });
337 }
338
339 if let Some(priority) = &req.priority {
340 fields["priority"] = json!({ "name": priority });
341 }
342
343 let body = json!({ "fields": fields });
344
345 #[derive(serde::Deserialize)]
346 struct CreateResponse {
347 key: String,
348 }
349
350 let http = &self.http;
351 let resp: CreateResponse = self
352 .request(|| http.post(&url).headers(headers.clone()).json(&body))
353 .await?;
354
355 self.get_issue(&resp.key).await
357 }
358
359 pub async fn update_issue(&self, key: &str, req: UpdateIssueRequest) -> Result<()> {
361 let headers = self.auth_headers()?;
362 let url = self.platform_url(&format!("/issue/{key}"));
363
364 let mut fields = json!({});
365
366 if let Some(summary) = &req.summary {
367 fields["summary"] = json!(summary);
368 }
369
370 if let Some(description) = &req.description {
371 fields["description"] = markdown_to_adf(description);
372 }
373
374 if let Some(assignee) = &req.assignee {
375 let account_id = self.resolve_assignee_account_id(assignee).await?;
376 fields["assignee"] = json!({ "accountId": account_id });
377 }
378
379 if let Some(priority) = &req.priority {
380 fields["priority"] = json!({ "name": priority });
381 }
382
383 let body = json!({ "fields": fields });
384
385 let http = &self.http;
386 self.request_no_body(|| http.put(&url).headers(headers.clone()).json(&body))
387 .await
388 }
389
390 pub async fn delete_issue(&self, key: &str) -> Result<()> {
392 let headers = self.auth_headers()?;
393 let url = self.platform_url(&format!("/issue/{key}"));
394
395 let http = &self.http;
396 self.request_no_body(|| http.delete(&url).headers(headers.clone()))
397 .await
398 }
399
400 pub async fn get_project_fields(&self, project_key: &str) -> Result<Vec<Field>> {
402 let headers = self.auth_headers()?;
403 let url = self.platform_url(&format!("/issue/createmeta/{project_key}/issuetypes"));
404
405 #[derive(serde::Deserialize)]
406 struct IssueTypeMeta {
407 #[serde(rename = "issueTypes")]
408 issue_types: Vec<IssueTypeDetail>,
409 }
410
411 #[derive(serde::Deserialize)]
412 struct IssueTypeDetail {
413 fields: Option<std::collections::HashMap<String, FieldMeta>>,
414 }
415
416 #[derive(serde::Deserialize)]
417 struct FieldMeta {
418 name: String,
419 required: bool,
420 schema: Option<Value>,
421 }
422
423 let http = &self.http;
424 let meta: IssueTypeMeta = self
425 .request(|| http.get(&url).headers(headers.clone()))
426 .await?;
427
428 let mut fields: Vec<Field> = Vec::new();
429 let mut seen = std::collections::HashSet::new();
430
431 for it in meta.issue_types {
432 if let Some(field_map) = it.fields {
433 for (id, meta) in field_map {
434 if seen.insert(id.clone()) {
435 let field_type = meta
436 .schema
437 .as_ref()
438 .and_then(|s| s.get("type"))
439 .and_then(|v| v.as_str())
440 .unwrap_or("unknown")
441 .to_string();
442
443 fields.push(Field {
444 id,
445 name: meta.name,
446 field_type,
447 required: meta.required,
448 schema: meta.schema,
449 allowed_values: None,
450 });
451 }
452 }
453 }
454 }
455
456 Ok(fields)
457 }
458
459 pub async fn get_server_info(&self) -> Result<Value> {
461 let headers = self.auth_headers()?;
462 let url = self.platform_url("/serverInfo");
463
464 let http = &self.http;
465 self.request(|| http.get(&url).headers(headers.clone()))
466 .await
467 }
468
469 pub async fn transition_issue(&self, key: &str, transition_id: &str) -> Result<()> {
471 let headers = self.auth_headers()?;
472 let url = self.platform_url(&format!("/issue/{key}/transitions"));
473
474 let body = json!({
475 "transition": { "id": transition_id }
476 });
477
478 let http = &self.http;
479 self.request_no_body(|| http.post(&url).headers(headers.clone()).json(&body))
480 .await
481 }
482
483 pub async fn get_transitions(&self, key: &str) -> Result<Vec<Value>> {
485 let headers = self.auth_headers()?;
486 let url = self.platform_url(&format!("/issue/{key}/transitions"));
487
488 #[derive(serde::Deserialize)]
489 struct TransitionsResponse {
490 transitions: Vec<Value>,
491 }
492
493 let http = &self.http;
494 let resp: TransitionsResponse = self
495 .request(|| http.get(&url).headers(headers.clone()))
496 .await?;
497
498 Ok(resp.transitions)
499 }
500
501 pub async fn get_issue_types(&self, project_key: &str) -> Result<Vec<IssueType>> {
503 let headers = self.auth_headers()?;
504 let url = self.platform_url(&format!("/issue/createmeta/{project_key}/issuetypes"));
505
506 #[derive(serde::Deserialize)]
507 struct MetaResponse {
508 #[serde(rename = "issueTypes")]
509 issue_types: Vec<IssueType>,
510 }
511
512 let http = &self.http;
513 let resp: MetaResponse = self
514 .request(|| http.get(&url).headers(headers.clone()))
515 .await?;
516
517 Ok(resp.issue_types)
518 }
519
520 pub async fn get_fields_for_issue_type(
522 &self,
523 project_key: &str,
524 issue_type_id: &str,
525 ) -> Result<Vec<Field>> {
526 let headers = self.auth_headers()?;
527 let url = self.platform_url(&format!(
528 "/issue/createmeta/{project_key}/issuetypes/{issue_type_id}"
529 ));
530
531 #[derive(serde::Deserialize)]
532 struct FieldMetaResponse {
533 fields: std::collections::HashMap<String, FieldMeta>,
534 }
535
536 #[derive(serde::Deserialize)]
537 struct FieldMeta {
538 name: String,
539 required: bool,
540 schema: Option<Value>,
541 #[serde(rename = "allowedValues")]
542 allowed_values: Option<Vec<Value>>,
543 }
544
545 let http = &self.http;
546 let resp: FieldMetaResponse = self
547 .request(|| http.get(&url).headers(headers.clone()))
548 .await?;
549
550 let fields = resp
551 .fields
552 .into_iter()
553 .map(|(id, meta)| {
554 let field_type = meta
555 .schema
556 .as_ref()
557 .and_then(|s| s.get("type"))
558 .and_then(|v| v.as_str())
559 .unwrap_or("unknown")
560 .to_string();
561
562 Field {
563 id,
564 name: meta.name,
565 field_type,
566 required: meta.required,
567 schema: meta.schema,
568 allowed_values: meta.allowed_values,
569 }
570 })
571 .collect();
572
573 Ok(fields)
574 }
575
576 pub async fn search_users(&self, query: &str) -> Result<Vec<Value>> {
578 let headers = self.auth_headers()?;
579 let url = self.platform_url("/user/search");
580
581 let http = &self.http;
582 let users: Vec<Value> = self
583 .request(|| {
584 http.get(&url)
585 .headers(headers.clone())
586 .query(&[("query", query), ("maxResults", "20")])
587 })
588 .await?;
589
590 Ok(users)
591 }
592
593 pub async fn upload_attachment(
595 &self,
596 issue_key: &str,
597 file_path: &std::path::Path,
598 ) -> Result<Vec<Attachment>> {
599 use reqwest::{header::HeaderValue, multipart};
600
601 let headers = self.auth_headers_no_content_type()?;
602 let url = self.platform_url(&format!("/issue/{issue_key}/attachments"));
603
604 let file_name = file_path
605 .file_name()
606 .and_then(|n| n.to_str())
607 .unwrap_or("attachment")
608 .to_string();
609
610 let bytes = std::fs::read(file_path)?;
611
612 let mime = mime_guess::from_path(file_path)
613 .first_or_octet_stream()
614 .to_string();
615
616 let http = &self.http;
617 let raw_attachments: Vec<Value> = self
618 .request_multipart(|| {
619 let part = multipart::Part::bytes(bytes.clone())
620 .file_name(file_name.clone())
621 .mime_str(&mime)
622 .expect("invalid mime type");
623 let form = multipart::Form::new().part("file", part);
624
625 let mut req_headers = headers.clone();
626 req_headers.insert("X-Atlassian-Token", HeaderValue::from_static("no-check"));
627
628 http.post(&url).headers(req_headers).multipart(form)
629 })
630 .await?;
631
632 Ok(raw_attachments
633 .iter()
634 .filter_map(Attachment::from_value)
635 .collect())
636 }
637
638 pub async fn create_issue_v2(&self, req: CreateIssueRequestV2) -> Result<Issue> {
640 let headers = self.auth_headers()?;
641 let url = self.platform_url("/issue");
642
643 let description_adf = req.description.as_deref().map(markdown_to_adf);
644
645 let mut fields = json!({
646 "project": { "key": req.project_key },
647 "summary": req.summary,
648 "issuetype": { "name": req.issue_type }
649 });
650
651 if let Some(adf) = description_adf {
652 fields["description"] = adf;
653 }
654 if let Some(assignee) = &req.assignee {
655 let account_id = self.resolve_assignee_account_id(assignee).await?;
656 fields["assignee"] = json!({ "accountId": account_id });
657 }
658 if let Some(priority) = &req.priority {
659 fields["priority"] = json!({ "name": priority });
660 }
661
662 for (field_id, value) in &req.custom_fields {
663 fields[field_id] = value.to_api_json();
664 }
665
666 let body = json!({ "fields": fields });
667
668 #[derive(serde::Deserialize)]
669 struct CreateResponse {
670 key: String,
671 }
672
673 let http = &self.http;
674 let resp: CreateResponse = self
675 .request(|| http.post(&url).headers(headers.clone()).json(&body))
676 .await?;
677
678 self.get_issue(&resp.key).await
679 }
680
681 pub async fn get_worklogs(&self, issue_key: &str) -> Result<Vec<Worklog>> {
685 let headers = self.auth_headers()?;
686 let url = self.platform_url(&format!("/issue/{issue_key}/worklog"));
687
688 #[derive(serde::Deserialize)]
689 struct WorklogResponse {
690 worklogs: Vec<Value>,
691 }
692
693 let http = &self.http;
694 let resp: WorklogResponse = self
695 .request(|| http.get(&url).headers(headers.clone()))
696 .await?;
697
698 Ok(resp
699 .worklogs
700 .iter()
701 .filter_map(|v| Worklog::from_value(v, issue_key))
702 .collect())
703 }
704
705 pub async fn add_worklog(
709 &self,
710 issue_key: &str,
711 time_spent: &str,
712 comment: Option<&str>,
713 started: Option<&str>,
714 ) -> Result<Worklog> {
715 let headers = self.auth_headers()?;
716 let url = self.platform_url(&format!("/issue/{issue_key}/worklog"));
717
718 let started_str = started
720 .map(|s| s.to_string())
721 .unwrap_or_else(current_jira_timestamp);
722
723 let mut body = json!({
724 "timeSpent": time_spent,
725 "started": started_str,
726 });
727
728 if let Some(c) = comment {
729 body["comment"] = markdown_to_adf(c);
730 }
731
732 let http = &self.http;
733 let raw: Value = self
734 .request(|| http.post(&url).headers(headers.clone()).json(&body))
735 .await?;
736
737 Worklog::from_value(&raw, issue_key).ok_or_else(|| JiraError::Api {
738 status: 0,
739 message: "Failed to parse worklog".into(),
740 })
741 }
742
743 pub async fn delete_worklog(&self, issue_key: &str, worklog_id: &str) -> Result<()> {
745 let headers = self.auth_headers()?;
746 let url = self.platform_url(&format!("/issue/{issue_key}/worklog/{worklog_id}"));
747
748 let http = &self.http;
749 self.request_no_body(|| http.delete(&url).headers(headers.clone()))
750 .await
751 }
752
753 pub async fn get_all_issues(&self, jql: &str) -> Result<Vec<Issue>> {
758 let mut all_issues = Vec::new();
759 let mut next_page_token: Option<String> = None;
760 let mut iterations = 0u32;
761 const MAX_ITERATIONS: u32 = 500;
762
763 loop {
764 iterations += 1;
765 if iterations > MAX_ITERATIONS {
766 break;
767 }
768
769 let result = self
770 .search_issues(jql, next_page_token.as_deref(), Some(100))
771 .await?;
772
773 all_issues.extend(result.issues);
774
775 match result.next_page_token {
776 Some(token) => next_page_token = Some(token),
777 None => break,
778 }
779 }
780
781 Ok(all_issues)
782 }
783
784 pub async fn archive_issues(&self, issue_keys: &[String]) -> Result<()> {
786 if issue_keys.is_empty() {
787 return Ok(());
788 }
789 let headers = self.auth_headers()?;
790 let url = self.platform_url("/issue/archive");
791
792 for chunk in issue_keys.chunks(1000) {
794 let body = json!({ "issueIdsOrKeys": chunk });
795 let http = &self.http;
796 let _: Value = self
798 .request(|| http.put(&url).headers(headers.clone()).json(&body))
799 .await?;
800 }
801
802 Ok(())
803 }
804
805 pub async fn raw_request(
811 &self,
812 method: &str,
813 path: &str,
814 body: Option<Value>,
815 ) -> Result<Option<Value>> {
816 let headers = self.auth_headers()?;
817 let url = format!("{}{}", self.config.base_url.trim_end_matches('/'), path);
818
819 let http = &self.http;
820 let mut attempt = 0u32;
821 loop {
822 attempt += 1;
823 let req = match method.to_uppercase().as_str() {
824 "GET" => http.get(&url),
825 "POST" => http.post(&url),
826 "PUT" => http.put(&url),
827 "DELETE" => http.delete(&url),
828 "PATCH" => http.patch(&url),
829 _ => http.get(&url),
830 };
831 let req = req.headers(headers.clone());
832 let req = if let Some(b) = &body {
833 req.json(b)
834 } else {
835 req
836 };
837
838 let response = req.send().await?;
839
840 if response.status() == StatusCode::TOO_MANY_REQUESTS {
841 let retry_after = response
842 .headers()
843 .get("Retry-After")
844 .and_then(|v| v.to_str().ok())
845 .and_then(|v| v.parse::<u64>().ok())
846 .unwrap_or(60);
847 warn!("Rate limited. Retrying after {}s", retry_after);
848 if attempt >= MAX_RETRIES {
849 return Err(JiraError::RateLimit { retry_after });
850 }
851 tokio::time::sleep(Duration::from_secs(retry_after)).await;
852 continue;
853 }
854
855 let status = response.status();
856
857 if status == StatusCode::NO_CONTENT {
859 return Ok(None);
860 }
861
862 if status.is_success() {
863 let value: Value = response.json().await?;
864 return Ok(Some(value));
865 }
866
867 let body_text = response.text().await.unwrap_or_default();
868 return Err(match status {
869 StatusCode::NOT_FOUND => JiraError::NotFound(body_text),
870 StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => {
871 JiraError::Auth(format!("HTTP {status}: {body_text}"))
872 }
873 _ => JiraError::Api {
874 status: status.as_u16(),
875 message: body_text,
876 },
877 });
878 }
879 }
880
881 pub async fn is_premium(&self) -> bool {
885 match self.get_server_info().await {
886 Ok(info) => {
887 let license = info
888 .get("deploymentType")
889 .and_then(|v| v.as_str())
890 .unwrap_or("");
891 let _ = license;
893 let headers = match self.auth_headers() {
895 Ok(h) => h,
896 Err(_) => return false,
897 };
898 let url = self.platform_url("/plans/plan");
899 let http = &self.http;
900 matches!(
901 http.get(&url).headers(headers).send().await,
902 Ok(r) if r.status().is_success()
903 )
904 }
905 Err(_) => false,
906 }
907 }
908
909 pub async fn get_plans(&self) -> Result<Vec<Value>> {
911 let headers = self.auth_headers()?;
912 let url = self.platform_url("/plans/plan");
913
914 #[derive(serde::Deserialize)]
915 struct PlansResponse {
916 values: Vec<Value>,
917 }
918
919 let http = &self.http;
920 let resp: PlansResponse = self
921 .request(|| http.get(&url).headers(headers.clone()))
922 .await?;
923
924 Ok(resp.values)
925 }
926}
927
928#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
930pub struct IssueType {
931 pub id: String,
932 pub name: String,
933}
934
935async fn handle_response<T>(response: Response) -> Result<T>
936where
937 T: serde::de::DeserializeOwned,
938{
939 let status = response.status();
940
941 if status.is_success() {
942 if status == StatusCode::NO_CONTENT || status == StatusCode::RESET_CONTENT {
945 return serde_json::from_value(serde_json::Value::Null).map_err(|_| JiraError::Api {
946 status: status.as_u16(),
947 message: "Unexpected empty response body".into(),
948 });
949 }
950 let value: T = response.json().await?;
951 return Ok(value);
952 }
953
954 let body = response.text().await.unwrap_or_default();
955
956 match status {
957 StatusCode::NOT_FOUND => Err(JiraError::NotFound(body)),
958 StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => {
959 Err(JiraError::Auth(format!("HTTP {status}: {body}")))
960 }
961 _ => Err(JiraError::Api {
962 status: status.as_u16(),
963 message: body,
964 }),
965 }
966}
967
968fn current_jira_timestamp() -> String {
970 use std::time::{SystemTime, UNIX_EPOCH};
971 let secs = SystemTime::now()
972 .duration_since(UNIX_EPOCH)
973 .map(|d| d.as_secs())
974 .unwrap_or(0);
975
976 let s = secs % 60;
978 let m = (secs / 60) % 60;
979 let h = (secs / 3600) % 24;
980 let days = secs / 86400;
982 let year_approx = 1970 + days / 365;
985 let day_of_year = days % 365;
986 let month = (day_of_year / 30) + 1;
987 let day = (day_of_year % 30) + 1;
988 format!(
989 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.000+0000",
990 year_approx,
991 month.min(12),
992 day.min(28),
993 h,
994 m,
995 s
996 )
997}
998
999fn base64_encode(input: &str) -> String {
1000 use std::fmt::Write;
1001 const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
1002 let bytes = input.as_bytes();
1003 let mut result = String::new();
1004 let mut i = 0;
1005 while i < bytes.len() {
1006 let b0 = bytes[i] as u32;
1007 let b1 = if i + 1 < bytes.len() {
1008 bytes[i + 1] as u32
1009 } else {
1010 0
1011 };
1012 let b2 = if i + 2 < bytes.len() {
1013 bytes[i + 2] as u32
1014 } else {
1015 0
1016 };
1017
1018 let _ = write!(result, "{}", CHARS[((b0 >> 2) & 0x3F) as usize] as char);
1019 let _ = write!(
1020 result,
1021 "{}",
1022 CHARS[(((b0 & 0x3) << 4) | ((b1 >> 4) & 0xF)) as usize] as char
1023 );
1024 if i + 1 < bytes.len() {
1025 let _ = write!(
1026 result,
1027 "{}",
1028 CHARS[(((b1 & 0xF) << 2) | ((b2 >> 6) & 0x3)) as usize] as char
1029 );
1030 } else {
1031 result.push('=');
1032 }
1033 if i + 2 < bytes.len() {
1034 let _ = write!(result, "{}", CHARS[(b2 & 0x3F) as usize] as char);
1035 } else {
1036 result.push('=');
1037 }
1038 i += 3;
1039 }
1040 result
1041}