ricecoder_github/managers/
github_manager.rs

1//! GitHub Manager - Central coordinator for GitHub operations
2
3use 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/// GitHub configuration
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct GitHubConfig {
13    /// GitHub API token
14    pub token: String,
15    /// Repository owner
16    pub owner: String,
17    /// Repository name
18    pub repo: String,
19    /// Base branch (default: main)
20    pub base_branch: String,
21    /// API timeout in seconds
22    #[serde(default = "default_timeout")]
23    pub timeout_secs: u64,
24    /// Max retries for transient errors
25    #[serde(default = "default_max_retries")]
26    pub max_retries: u32,
27    /// Retry backoff in milliseconds
28    #[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    /// Create a new GitHub configuration
46    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    /// Validate the configuration
59    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/// Rate limit information
77#[derive(Debug, Clone)]
78pub struct RateLimit {
79    /// Remaining requests
80    pub remaining: u32,
81    /// Total limit
82    pub limit: u32,
83    /// Reset time (Unix timestamp)
84    pub reset_at: u64,
85}
86
87impl RateLimit {
88    /// Check if rate limit is exceeded
89    pub fn is_exceeded(&self) -> bool {
90        self.remaining == 0
91    }
92
93    /// Get time until reset in seconds
94    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
103/// GitHub Manager - Central coordinator for all GitHub operations
104pub struct GitHubManager {
105    /// Configuration
106    config: Arc<RwLock<GitHubConfig>>,
107    /// Rate limit information
108    rate_limit: Arc<RwLock<Option<RateLimit>>>,
109    /// Octocrab client
110    client: Arc<RwLock<Option<octocrab::Octocrab>>>,
111}
112
113impl GitHubManager {
114    /// Create a new GitHub manager
115    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    /// Initialize the GitHub client
130    pub async fn initialize(&self) -> Result<()> {
131        let config = self.config.read().await;
132        debug!("Initializing GitHub client");
133
134        // Create octocrab client
135        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        // Test authentication by getting the current user
141        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    /// Get the GitHub client
156    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    /// Get current configuration
168    pub async fn get_config(&self) -> GitHubConfig {
169        self.config.read().await.clone()
170    }
171
172    /// Update configuration
173    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    /// Get rate limit information
182    pub async fn get_rate_limit(&self) -> Option<RateLimit> {
183        self.rate_limit.read().await.clone()
184    }
185
186    /// Update rate limit information
187    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    /// Check if rate limited
193    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    /// Wait for rate limit reset
202    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    /// Perform operation with retry logic
217    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                    // Check if error is retryable
239                    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    /// Get repository information
270    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    /// Health check
276    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        // Try to get repository info
283        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}