Skip to main content

jira_core/
client.rs

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    /// Auth headers without Content-Type — required for multipart uploads.
87    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    /// Get the current authenticated user's accountId.
105    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    /// Resolve an assignee string to a Jira accountId.
124    ///
125    /// - `"me"` → current user's accountId via /myself
126    /// - contains `@` → search by email, return first match's accountId
127    /// - anything else → treated as a raw accountId and returned as-is
128    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        // Resolve email → accountId via user search
136        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    /// Core request method with rate-limit retry logic.
156    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    /// Core request method for responses with no body (204 No Content).
189    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    /// Multipart request with rate-limit retry (for attachment uploads).
234    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    /// Search issues using JQL with cursor-based pagination.
270    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    /// Fetch a single issue by key.
305    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    /// Create a new issue.
318    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        // Fetch the full issue after creation
356        self.get_issue(&resp.key).await
357    }
358
359    /// Update an existing issue.
360    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    /// Delete an issue.
391    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    /// Get fields available for a project (runtime field resolution — no hardcoding).
401    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    /// Get server info (used to detect Jira tier).
460    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    /// Transition an issue to a new status.
470    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    /// Get available transitions for an issue.
484    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    /// Get available issue types for a project (id + name).
502    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    /// Get fields for a specific issue type within a project (with allowed values).
521    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    /// Search Jira users by query string (for User field autocomplete).
577    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    /// Upload a file as an attachment to an issue.
594    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    /// Create a new issue with dynamic custom fields.
639    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    // ── Worklog ──────────────────────────────────────────────────────────────
682
683    /// List all worklogs for an issue.
684    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    /// Add a worklog entry to an issue.
706    /// `time_spent` uses Jira format: "2h 30m", "1d", "45m"
707    /// `started` is optional ISO 8601 timestamp; defaults to now if None.
708    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        // Jira requires started in "2006-01-02T15:04:05.000+0000" format
719        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    /// Delete a worklog entry.
744    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    // ── Bulk ops ─────────────────────────────────────────────────────────────
754
755    /// Fetch ALL issues matching a JQL query using cursor-based pagination.
756    /// Respects the Atlassian safeguard: max 500 pages.
757    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    /// Archive a batch of issues by key. Jira accepts up to 1000 per request.
785    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        // Batch in chunks of 1000
793        for chunk in issue_keys.chunks(1000) {
794            let body = json!({ "issueIdsOrKeys": chunk });
795            let http = &self.http;
796            // Archive returns 200 with a body — use request() not request_no_body()
797            let _: Value = self
798                .request(|| http.put(&url).headers(headers.clone()).json(&body))
799                .await?;
800        }
801
802        Ok(())
803    }
804
805    // ── Raw API passthrough ───────────────────────────────────────────────────
806
807    /// Execute an arbitrary Jira REST API call and return the raw JSON response.
808    /// Returns `None` for 204 No Content responses (success with no body).
809    /// `path` should start with `/rest/...`
810    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            // 204 No Content — success with empty body
858            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    // ── Plans API (Jira Premium) ──────────────────────────────────────────────
882
883    /// Check if this Jira instance is Premium tier.
884    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                // "Cloud" with advanced features, or check licenseInfo
892                let _ = license;
893                // Simplest heuristic: try to call the plans endpoint
894                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    /// List Jira Plans (requires Jira Premium / Advanced Roadmaps).
910    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/// Issue type metadata (id + name) returned by createmeta.
929#[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        // 204/205: no body — callers expecting a body should use request_no_body().
943        // Defensive: try to deserialize from null (works for Value and Option<T>).
944        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
968/// Returns current UTC time in Jira worklog format: "2006-01-02T15:04:05.000+0000"
969fn 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    // Manual conversion: secs since epoch → date/time components
977    let s = secs % 60;
978    let m = (secs / 60) % 60;
979    let h = (secs / 3600) % 24;
980    // Days since epoch
981    let days = secs / 86400;
982    // Simplified: use a rough date calculation
983    // For worklog "started", accuracy to the day is sufficient
984    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}