ricecoder_github/managers/
github_manager.rs1use crate::errors::{GitHubError, Result};
4use serde::{Deserialize, Serialize};
5use std::sync::Arc;
6use std::time::Duration;
7use tokio::sync::RwLock;
8use tracing::{debug, error, info, warn};
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct GitHubConfig {
13 pub token: String,
15 pub owner: String,
17 pub repo: String,
19 pub base_branch: String,
21 #[serde(default = "default_timeout")]
23 pub timeout_secs: u64,
24 #[serde(default = "default_max_retries")]
26 pub max_retries: u32,
27 #[serde(default = "default_retry_backoff")]
29 pub retry_backoff_ms: u64,
30}
31
32fn default_timeout() -> u64 {
33 30
34}
35
36fn default_max_retries() -> u32 {
37 3
38}
39
40fn default_retry_backoff() -> u64 {
41 100
42}
43
44impl GitHubConfig {
45 pub fn new(token: impl Into<String>, owner: impl Into<String>, repo: impl Into<String>) -> Self {
47 Self {
48 token: token.into(),
49 owner: owner.into(),
50 repo: repo.into(),
51 base_branch: "main".to_string(),
52 timeout_secs: default_timeout(),
53 max_retries: default_max_retries(),
54 retry_backoff_ms: default_retry_backoff(),
55 }
56 }
57
58 pub fn validate(&self) -> Result<()> {
60 if self.token.is_empty() {
61 return Err(GitHubError::config_error("GitHub token is required"));
62 }
63 if self.owner.is_empty() {
64 return Err(GitHubError::config_error("Repository owner is required"));
65 }
66 if self.repo.is_empty() {
67 return Err(GitHubError::config_error("Repository name is required"));
68 }
69 if self.timeout_secs == 0 {
70 return Err(GitHubError::config_error("Timeout must be greater than 0"));
71 }
72 Ok(())
73 }
74}
75
76#[derive(Debug, Clone)]
78pub struct RateLimit {
79 pub remaining: u32,
81 pub limit: u32,
83 pub reset_at: u64,
85}
86
87impl RateLimit {
88 pub fn is_exceeded(&self) -> bool {
90 self.remaining == 0
91 }
92
93 pub fn time_until_reset(&self) -> u64 {
95 let now = std::time::SystemTime::now()
96 .duration_since(std::time::UNIX_EPOCH)
97 .unwrap_or_default()
98 .as_secs();
99 self.reset_at.saturating_sub(now)
100 }
101}
102
103pub struct GitHubManager {
105 config: Arc<RwLock<GitHubConfig>>,
107 rate_limit: Arc<RwLock<Option<RateLimit>>>,
109 client: Arc<RwLock<Option<octocrab::Octocrab>>>,
111}
112
113impl GitHubManager {
114 pub fn new(config: GitHubConfig) -> Result<Self> {
116 config.validate()?;
117 info!(
118 owner = %config.owner,
119 repo = %config.repo,
120 "Creating GitHub manager"
121 );
122 Ok(Self {
123 config: Arc::new(RwLock::new(config)),
124 rate_limit: Arc::new(RwLock::new(None)),
125 client: Arc::new(RwLock::new(None)),
126 })
127 }
128
129 pub async fn initialize(&self) -> Result<()> {
131 let config = self.config.read().await;
132 debug!("Initializing GitHub client");
133
134 let client = octocrab::OctocrabBuilder::new()
136 .personal_token(config.token.clone())
137 .build()
138 .map_err(|e| GitHubError::auth_error(format!("Failed to create GitHub client: {}", e)))?;
139
140 client
142 .current()
143 .user()
144 .await
145 .map_err(|e| GitHubError::auth_error(format!("Authentication failed: {}", e)))?;
146
147 info!("GitHub client initialized successfully");
148
149 let mut client_lock = self.client.write().await;
150 *client_lock = Some(client);
151
152 Ok(())
153 }
154
155 pub async fn get_client(&self) -> Result<Arc<octocrab::Octocrab>> {
157 let client_lock = self.client.read().await;
158 if let Some(client) = client_lock.as_ref() {
159 Ok(Arc::new(client.clone()))
160 } else {
161 Err(GitHubError::auth_error(
162 "GitHub client not initialized. Call initialize() first.",
163 ))
164 }
165 }
166
167 pub async fn get_config(&self) -> GitHubConfig {
169 self.config.read().await.clone()
170 }
171
172 pub async fn update_config(&self, config: GitHubConfig) -> Result<()> {
174 config.validate()?;
175 let mut config_lock = self.config.write().await;
176 *config_lock = config;
177 info!("GitHub configuration updated");
178 Ok(())
179 }
180
181 pub async fn get_rate_limit(&self) -> Option<RateLimit> {
183 self.rate_limit.read().await.clone()
184 }
185
186 pub async fn update_rate_limit(&self, rate_limit: RateLimit) {
188 let mut limit_lock = self.rate_limit.write().await;
189 *limit_lock = Some(rate_limit);
190 }
191
192 pub async fn is_rate_limited(&self) -> bool {
194 if let Some(limit) = self.rate_limit.read().await.as_ref() {
195 limit.is_exceeded()
196 } else {
197 false
198 }
199 }
200
201 pub async fn wait_for_rate_limit_reset(&self) -> Result<()> {
203 if let Some(limit) = self.rate_limit.read().await.as_ref() {
204 let wait_time = limit.time_until_reset();
205 if wait_time > 0 {
206 warn!(
207 wait_seconds = wait_time,
208 "Rate limited, waiting for reset"
209 );
210 tokio::time::sleep(Duration::from_secs(wait_time + 1)).await;
211 }
212 }
213 Ok(())
214 }
215
216 pub async fn with_retry<F, T>(&self, mut operation: F) -> Result<T>
218 where
219 F: FnMut() -> futures::future::BoxFuture<'static, Result<T>>,
220 {
221 let config = self.config.read().await;
222 let max_retries = config.max_retries;
223 let retry_backoff = config.retry_backoff_ms;
224 drop(config);
225
226 let mut attempt = 0;
227 loop {
228 match operation().await {
229 Ok(result) => {
230 if attempt > 0 {
231 debug!(attempts = attempt, "Operation succeeded after retries");
232 }
233 return Ok(result);
234 }
235 Err(e) => {
236 attempt += 1;
237
238 let is_retryable = matches!(
240 e,
241 GitHubError::NetworkError(_)
242 | GitHubError::Timeout
243 | GitHubError::RateLimitExceeded
244 );
245
246 if !is_retryable || attempt >= max_retries {
247 error!(
248 attempt,
249 max_retries,
250 error = %e,
251 "Operation failed permanently"
252 );
253 return Err(e);
254 }
255
256 let wait_ms = retry_backoff * 2_u64.pow(attempt - 1);
257 warn!(
258 attempt,
259 wait_ms,
260 error = %e,
261 "Operation failed, retrying"
262 );
263 tokio::time::sleep(Duration::from_millis(wait_ms)).await;
264 }
265 }
266 }
267 }
268
269 pub async fn get_repository(&self) -> Result<String> {
271 let config = self.config.read().await;
272 Ok(format!("{}/{}", config.owner, config.repo))
273 }
274
275 pub async fn health_check(&self) -> Result<()> {
277 debug!("Performing health check");
278
279 let client = self.get_client().await?;
280 let config = self.config.read().await;
281
282 client
284 .repos(&config.owner, &config.repo)
285 .get()
286 .await
287 .map_err(|e| GitHubError::api_error(format!("Health check failed: {}", e)))?;
288
289 info!("Health check passed");
290 Ok(())
291 }
292}
293
294#[cfg(test)]
295mod tests {
296 use super::*;
297
298 #[test]
299 fn test_github_config_creation() {
300 let config = GitHubConfig::new("token123", "owner", "repo");
301 assert_eq!(config.token, "token123");
302 assert_eq!(config.owner, "owner");
303 assert_eq!(config.repo, "repo");
304 assert_eq!(config.base_branch, "main");
305 }
306
307 #[test]
308 fn test_github_config_validation_success() {
309 let config = GitHubConfig::new("token123", "owner", "repo");
310 assert!(config.validate().is_ok());
311 }
312
313 #[test]
314 fn test_github_config_validation_empty_token() {
315 let config = GitHubConfig {
316 token: String::new(),
317 owner: "owner".to_string(),
318 repo: "repo".to_string(),
319 base_branch: "main".to_string(),
320 timeout_secs: 30,
321 max_retries: 3,
322 retry_backoff_ms: 100,
323 };
324 assert!(config.validate().is_err());
325 }
326
327 #[test]
328 fn test_github_config_validation_empty_owner() {
329 let config = GitHubConfig {
330 token: "token123".to_string(),
331 owner: String::new(),
332 repo: "repo".to_string(),
333 base_branch: "main".to_string(),
334 timeout_secs: 30,
335 max_retries: 3,
336 retry_backoff_ms: 100,
337 };
338 assert!(config.validate().is_err());
339 }
340
341 #[test]
342 fn test_github_config_validation_empty_repo() {
343 let config = GitHubConfig {
344 token: "token123".to_string(),
345 owner: "owner".to_string(),
346 repo: String::new(),
347 base_branch: "main".to_string(),
348 timeout_secs: 30,
349 max_retries: 3,
350 retry_backoff_ms: 100,
351 };
352 assert!(config.validate().is_err());
353 }
354
355 #[test]
356 fn test_rate_limit_is_exceeded() {
357 let limit = RateLimit {
358 remaining: 0,
359 limit: 60,
360 reset_at: 0,
361 };
362 assert!(limit.is_exceeded());
363 }
364
365 #[test]
366 fn test_rate_limit_not_exceeded() {
367 let limit = RateLimit {
368 remaining: 10,
369 limit: 60,
370 reset_at: 0,
371 };
372 assert!(!limit.is_exceeded());
373 }
374
375 #[tokio::test]
376 async fn test_github_manager_creation() {
377 let config = GitHubConfig::new("token123", "owner", "repo");
378 let manager = GitHubManager::new(config);
379 assert!(manager.is_ok());
380 }
381
382 #[tokio::test]
383 async fn test_github_manager_invalid_config() {
384 let config = GitHubConfig {
385 token: String::new(),
386 owner: "owner".to_string(),
387 repo: "repo".to_string(),
388 base_branch: "main".to_string(),
389 timeout_secs: 30,
390 max_retries: 3,
391 retry_backoff_ms: 100,
392 };
393 let manager = GitHubManager::new(config);
394 assert!(manager.is_err());
395 }
396
397 #[tokio::test]
398 async fn test_get_repository() {
399 let config = GitHubConfig::new("token123", "owner", "repo");
400 let manager = GitHubManager::new(config).unwrap();
401 let repo = manager.get_repository().await.unwrap();
402 assert_eq!(repo, "owner/repo");
403 }
404
405 #[tokio::test]
406 async fn test_update_config() {
407 let config = GitHubConfig::new("token123", "owner", "repo");
408 let manager = GitHubManager::new(config).unwrap();
409
410 let new_config = GitHubConfig::new("token456", "owner2", "repo2");
411 assert!(manager.update_config(new_config).await.is_ok());
412
413 let repo = manager.get_repository().await.unwrap();
414 assert_eq!(repo, "owner2/repo2");
415 }
416
417 #[tokio::test]
418 async fn test_rate_limit_tracking() {
419 let config = GitHubConfig::new("token123", "owner", "repo");
420 let manager = GitHubManager::new(config).unwrap();
421
422 assert!(manager.get_rate_limit().await.is_none());
423
424 let limit = RateLimit {
425 remaining: 10,
426 limit: 60,
427 reset_at: 0,
428 };
429 manager.update_rate_limit(limit).await;
430
431 assert!(manager.get_rate_limit().await.is_some());
432 assert!(!manager.is_rate_limited().await);
433 }
434}