llm_optimizer_integrations/jira/
client.rs1use 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#[derive(Clone)]
19pub struct JiraClient {
20 client: reqwest::Client,
22 auth: AuthManager,
24 rate_limiter: Arc<RateLimiter<governor::state::direct::NotKeyed, governor::state::InMemoryState, governor::clock::DefaultClock>>,
26}
27
28impl JiraClient {
29 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 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 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 self.get_issue(issue_key).await
96 }
97
98 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 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 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 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 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 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 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 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 async fn execute_request<T: serde::de::DeserializeOwned>(
316 &self,
317 request_builder: reqwest::RequestBuilder,
318 ) -> Result<T> {
319 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 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 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 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 if response.status().is_success() {
361 return response
362 .json::<T>()
363 .await
364 .context("Failed to parse response JSON");
365 }
366
367 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 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 async fn execute_request_no_response(
398 &self,
399 request_builder: reqwest::RequestBuilder,
400 ) -> Result<()> {
401 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}