Skip to main content

llm_optimizer_integrations/jira/
client.rs

1//! Jira REST API client
2//!
3//! Production-ready client for Jira Cloud and Server APIs with comprehensive
4//! error handling, rate limiting, and retry logic.
5
6use super::auth::AuthManager;
7use super::types::*;
8use anyhow::{anyhow, Context, Result};
9use governor::{Quota, RateLimiter};
10use reqwest::StatusCode;
11use std::num::NonZeroU32;
12use std::sync::Arc;
13use std::time::Duration;
14use tokio::time::sleep;
15use tracing::{debug, error, info, warn};
16
17/// Jira API client
18#[derive(Clone)]
19pub struct JiraClient {
20    /// HTTP client
21    client: reqwest::Client,
22    /// Authentication manager
23    auth: AuthManager,
24    /// Rate limiter
25    rate_limiter: Arc<RateLimiter<governor::state::direct::NotKeyed, governor::state::InMemoryState, governor::clock::DefaultClock>>,
26}
27
28impl JiraClient {
29    /// Create a new Jira client
30    ///
31    /// # Arguments
32    ///
33    /// * `config` - Jira configuration
34    ///
35    /// # Returns
36    ///
37    /// Returns a new JiraClient instance
38    pub async fn new(config: JiraConfig) -> Result<Self> {
39        let auth = AuthManager::new(config.clone());
40        let timeout = Duration::from_secs(config.timeout_secs);
41
42        let client = reqwest::Client::builder()
43            .timeout(timeout)
44            .user_agent("llm-auto-optimizer/1.0")
45            .build()
46            .context("Failed to create HTTP client")?;
47
48        // Setup rate limiter based on config
49        let rate_limit = NonZeroU32::new(config.rate_limit_per_minute)
50            .ok_or_else(|| anyhow!("Rate limit must be greater than 0"))?;
51        let quota = Quota::per_minute(rate_limit);
52        let rate_limiter = Arc::new(RateLimiter::direct(quota));
53
54        info!(
55            "Initialized Jira client for: {}",
56            config.base_url
57        );
58
59        Ok(Self {
60            client,
61            auth,
62            rate_limiter,
63        })
64    }
65
66    /// Create a new issue
67    ///
68    /// # Arguments
69    ///
70    /// * `request` - Issue creation request
71    ///
72    /// # Returns
73    ///
74    /// Returns the created issue
75    pub async fn create_issue(&self, request: CreateIssueRequest) -> Result<Issue> {
76        let url = format!("{}/rest/api/3/issue", self.auth.get_base_url().await);
77
78        debug!("Creating issue: {}", request.fields.summary);
79
80        let response: serde_json::Value = self
81            .execute_request(
82                self.client
83                    .post(&url)
84                    .json(&request),
85            )
86            .await?;
87
88        let issue_key = response["key"]
89            .as_str()
90            .ok_or_else(|| anyhow!("No issue key in response"))?;
91
92        info!("Created issue: {}", issue_key);
93
94        // Fetch the full issue details
95        self.get_issue(issue_key).await
96    }
97
98    /// Get an issue by key
99    ///
100    /// # Arguments
101    ///
102    /// * `issue_key` - Issue key (e.g., "PROJ-123")
103    ///
104    /// # Returns
105    ///
106    /// Returns the issue details
107    pub async fn get_issue(&self, issue_key: &str) -> Result<Issue> {
108        let url = format!(
109            "{}/rest/api/3/issue/{}",
110            self.auth.get_base_url().await,
111            issue_key
112        );
113
114        debug!("Fetching issue: {}", issue_key);
115
116        let issue: Issue = self
117            .execute_request(self.client.get(&url))
118            .await?;
119
120        Ok(issue)
121    }
122
123    /// Update an issue
124    ///
125    /// # Arguments
126    ///
127    /// * `issue_key` - Issue key to update
128    /// * `request` - Update request
129    ///
130    /// # Returns
131    ///
132    /// Returns Ok(()) on success
133    pub async fn update_issue(
134        &self,
135        issue_key: &str,
136        request: UpdateIssueRequest,
137    ) -> Result<()> {
138        let url = format!(
139            "{}/rest/api/3/issue/{}",
140            self.auth.get_base_url().await,
141            issue_key
142        );
143
144        debug!("Updating issue: {}", issue_key);
145
146        self.execute_request_no_response(
147            self.client
148                .put(&url)
149                .json(&request),
150        )
151        .await?;
152
153        info!("Updated issue: {}", issue_key);
154        Ok(())
155    }
156
157    /// Delete an issue
158    ///
159    /// # Arguments
160    ///
161    /// * `issue_key` - Issue key to delete
162    ///
163    /// # Returns
164    ///
165    /// Returns Ok(()) on success
166    pub async fn delete_issue(&self, issue_key: &str) -> Result<()> {
167        let url = format!(
168            "{}/rest/api/3/issue/{}",
169            self.auth.get_base_url().await,
170            issue_key
171        );
172
173        debug!("Deleting issue: {}", issue_key);
174
175        self.execute_request_no_response(self.client.delete(&url))
176            .await?;
177
178        info!("Deleted issue: {}", issue_key);
179        Ok(())
180    }
181
182    /// Search issues using JQL
183    ///
184    /// # Arguments
185    ///
186    /// * `request` - JQL search request
187    ///
188    /// # Returns
189    ///
190    /// Returns search results
191    pub async fn search_issues(&self, request: JqlSearchRequest) -> Result<JqlSearchResponse> {
192        let url = format!("{}/rest/api/3/search", self.auth.get_base_url().await);
193
194        debug!("Searching issues with JQL: {}", request.jql);
195
196        let response: JqlSearchResponse = self
197            .execute_request(
198                self.client
199                    .post(&url)
200                    .json(&request),
201            )
202            .await?;
203
204        info!(
205            "Found {} issues (showing {}-{})",
206            response.total,
207            response.start_at,
208            response.start_at + response.issues.len() as u32
209        );
210
211        Ok(response)
212    }
213
214    /// Get all projects
215    ///
216    /// # Returns
217    ///
218    /// Returns a list of projects
219    pub async fn get_projects(&self) -> Result<Vec<Project>> {
220        let url = format!("{}/rest/api/3/project", self.auth.get_base_url().await);
221
222        debug!("Fetching all projects");
223
224        let projects: Vec<Project> = self
225            .execute_request(self.client.get(&url))
226            .await?;
227
228        info!("Found {} projects", projects.len());
229        Ok(projects)
230    }
231
232    /// Get a project by key
233    ///
234    /// # Arguments
235    ///
236    /// * `project_key` - Project key
237    ///
238    /// # Returns
239    ///
240    /// Returns project details
241    pub async fn get_project(&self, project_key: &str) -> Result<Project> {
242        let url = format!(
243            "{}/rest/api/3/project/{}",
244            self.auth.get_base_url().await,
245            project_key
246        );
247
248        debug!("Fetching project: {}", project_key);
249
250        let project: Project = self
251            .execute_request(self.client.get(&url))
252            .await?;
253
254        Ok(project)
255    }
256
257    /// Get all boards (Agile API)
258    ///
259    /// # Returns
260    ///
261    /// Returns a list of boards
262    pub async fn get_boards(&self) -> Result<Vec<Board>> {
263        let url = format!(
264            "{}/rest/agile/1.0/board",
265            self.auth.get_base_url().await
266        );
267
268        debug!("Fetching all boards");
269
270        #[derive(serde::Deserialize)]
271        struct BoardsResponse {
272            values: Vec<Board>,
273        }
274
275        let response: BoardsResponse = self
276            .execute_request(self.client.get(&url))
277            .await?;
278
279        info!("Found {} boards", response.values.len());
280        Ok(response.values)
281    }
282
283    /// Get sprints for a board
284    ///
285    /// # Arguments
286    ///
287    /// * `board_id` - Board ID
288    ///
289    /// # Returns
290    ///
291    /// Returns a list of sprints
292    pub async fn get_board_sprints(&self, board_id: u64) -> Result<Vec<Sprint>> {
293        let url = format!(
294            "{}/rest/agile/1.0/board/{}/sprint",
295            self.auth.get_base_url().await,
296            board_id
297        );
298
299        debug!("Fetching sprints for board: {}", board_id);
300
301        #[derive(serde::Deserialize)]
302        struct SprintsResponse {
303            values: Vec<Sprint>,
304        }
305
306        let response: SprintsResponse = self
307            .execute_request(self.client.get(&url))
308            .await?;
309
310        info!("Found {} sprints for board {}", response.values.len(), board_id);
311        Ok(response.values)
312    }
313
314    /// Execute an HTTP request with retry logic and error handling
315    async fn execute_request<T: serde::de::DeserializeOwned>(
316        &self,
317        request_builder: reqwest::RequestBuilder,
318    ) -> Result<T> {
319        // Wait for rate limiter
320        self.rate_limiter.until_ready().await;
321
322        let max_retries = self.auth.get_max_retries().await;
323        let mut last_error = None;
324
325        for attempt in 0..=max_retries {
326            if attempt > 0 {
327                let delay = Duration::from_secs(2u64.pow(attempt));
328                debug!("Retrying after {:?} (attempt {})", delay, attempt);
329                sleep(delay).await;
330            }
331
332            // Clone the request builder by rebuilding it
333            let headers = self.auth.get_auth_headers().await?;
334            let request = request_builder
335                .try_clone()
336                .ok_or_else(|| anyhow!("Failed to clone request"))?
337                .headers(headers);
338
339            match request.send().await {
340                Ok(response) => {
341                    let status = response.status();
342
343                    // Handle rate limiting
344                    if status == StatusCode::TOO_MANY_REQUESTS {
345                        warn!("Rate limited by Jira API");
346                        last_error = Some(anyhow!("Rate limited"));
347                        continue;
348                    }
349
350                    // Handle auth errors - try token refresh
351                    if status == StatusCode::UNAUTHORIZED {
352                        debug!("Unauthorized - attempting token refresh");
353                        if self.auth.refresh_token_if_needed(&self.client).await? {
354                            last_error = Some(anyhow!("Token expired, refreshed"));
355                            continue;
356                        }
357                    }
358
359                    // Handle success
360                    if response.status().is_success() {
361                        return response
362                            .json::<T>()
363                            .await
364                            .context("Failed to parse response JSON");
365                    }
366
367                    // Handle error responses
368                    let error_text = response.text().await.unwrap_or_default();
369                    let error_msg = if let Ok(err) = serde_json::from_str::<ErrorResponse>(&error_text) {
370                        format!(
371                            "Jira API error: {}",
372                            err.error_messages.join(", ")
373                        )
374                    } else {
375                        format!("Jira API error ({}): {}", status, error_text)
376                    };
377
378                    error!("{}", error_msg);
379                    last_error = Some(anyhow!(error_msg));
380
381                    // Don't retry on client errors (except auth/rate limit)
382                    if status.is_client_error() {
383                        break;
384                    }
385                }
386                Err(e) => {
387                    warn!("Request failed: {}", e);
388                    last_error = Some(anyhow!(e));
389                }
390            }
391        }
392
393        Err(last_error.unwrap_or_else(|| anyhow!("Request failed after retries")))
394    }
395
396    /// Execute a request that doesn't return a body
397    async fn execute_request_no_response(
398        &self,
399        request_builder: reqwest::RequestBuilder,
400    ) -> Result<()> {
401        // Wait for rate limiter
402        self.rate_limiter.until_ready().await;
403
404        let max_retries = self.auth.get_max_retries().await;
405        let mut last_error = None;
406
407        for attempt in 0..=max_retries {
408            if attempt > 0 {
409                let delay = Duration::from_secs(2u64.pow(attempt));
410                debug!("Retrying after {:?} (attempt {})", delay, attempt);
411                sleep(delay).await;
412            }
413
414            let headers = self.auth.get_auth_headers().await?;
415            let request = request_builder
416                .try_clone()
417                .ok_or_else(|| anyhow!("Failed to clone request"))?
418                .headers(headers);
419
420            match request.send().await {
421                Ok(response) => {
422                    let status = response.status();
423
424                    if status == StatusCode::TOO_MANY_REQUESTS {
425                        warn!("Rate limited by Jira API");
426                        last_error = Some(anyhow!("Rate limited"));
427                        continue;
428                    }
429
430                    if status == StatusCode::UNAUTHORIZED {
431                        debug!("Unauthorized - attempting token refresh");
432                        if self.auth.refresh_token_if_needed(&self.client).await? {
433                            last_error = Some(anyhow!("Token expired, refreshed"));
434                            continue;
435                        }
436                    }
437
438                    if response.status().is_success() {
439                        return Ok(());
440                    }
441
442                    let error_text = response.text().await.unwrap_or_default();
443                    error!("Jira API error ({}): {}", status, error_text);
444                    last_error = Some(anyhow!("Jira API error: {}", error_text));
445
446                    if status.is_client_error() {
447                        break;
448                    }
449                }
450                Err(e) => {
451                    warn!("Request failed: {}", e);
452                    last_error = Some(anyhow!(e));
453                }
454            }
455        }
456
457        Err(last_error.unwrap_or_else(|| anyhow!("Request failed after retries")))
458    }
459}
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464
465    fn test_config() -> JiraConfig {
466        JiraConfig {
467            base_url: "https://test.atlassian.net".to_string(),
468            auth: JiraAuth::Basic {
469                email: "test@example.com".to_string(),
470                api_token: "test-token".to_string(),
471            },
472            timeout_secs: 30,
473            max_retries: 3,
474            rate_limit_per_minute: 100,
475        }
476    }
477
478    #[tokio::test]
479    async fn test_client_creation() {
480        let config = test_config();
481        let client = JiraClient::new(config).await;
482        assert!(client.is_ok());
483    }
484
485    #[tokio::test]
486    async fn test_invalid_rate_limit() {
487        let mut config = test_config();
488        config.rate_limit_per_minute = 0;
489        let client = JiraClient::new(config).await;
490        assert!(client.is_err());
491    }
492}