diff --git a/Cargo.lock b/Cargo.lock index e780731..7488638 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -732,6 +732,7 @@ dependencies = [ "serde", "serde_json", "thiserror 1.0.69", + "urlencoding", ] [[package]] diff --git a/crates/github/Cargo.toml b/crates/github/Cargo.toml index 2022e0f..242e5c8 100644 --- a/crates/github/Cargo.toml +++ b/crates/github/Cargo.toml @@ -11,3 +11,5 @@ serde.workspace = true serde_json.workspace = true thiserror.workspace = true anyhow.workspace = true +urlencoding.workspace = true + diff --git a/crates/github/src/commits.rs b/crates/github/src/commits.rs index 85dfadc..84823f0 100644 --- a/crates/github/src/commits.rs +++ b/crates/github/src/commits.rs @@ -1,4 +1,18 @@ +use reqwest::Client; use serde::{Deserialize, Serialize}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum CommitSyncError { + #[error("Client build error: {0}")] + ClientBuild(reqwest::Error), + #[error("HTTP request error: {0}")] + RequestFailed(reqwest::Error), + #[error("API error {0}: {1}")] + ApiError(u16, String), + #[error("JSON decode error: {0}")] + JsonDecode(reqwest::Error), +} #[derive(Debug, Serialize, Deserialize, Clone)] pub struct CommitStats { @@ -9,6 +23,96 @@ pub struct CommitStats { pub deletions: i32, } +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct GitHubCommitDetail { + pub author: Option, + pub message: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct GitHubCommitAuthor { + pub name: String, + pub email: String, + pub date: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct GitHubCommit { + pub sha: String, + pub commit: GitHubCommitDetail, + pub html_url: String, +} + pub fn calculate_commit_impact(additions: i32, deletions: i32) -> f64 { - (additions + deletions) as f64 * 0.1 + let total = (additions + deletions) as f64; + (total * 0.1).min(100.0) +} + +pub struct GithubCommitClient { + client: Client, + base_url: String, +} + +impl GithubCommitClient { + pub fn new() -> Result { + Self::with_base_url("https://api.github.com".to_string()) + } + + pub fn with_base_url(base_url: String) -> Result { + let client = Client::builder() + .user_agent("DevResume-AI") + .build() + .map_err(CommitSyncError::ClientBuild)?; + + Ok(Self { client, base_url }) + } + + pub async fn fetch_commits( + &self, + token: &str, + owner: &str, + repo: &str, + since: Option<&str>, + ) -> Result, CommitSyncError> { + let mut url = format!( + "{}/repos/{}/{}/commits?per_page=100", + self.base_url, owner, repo + ); + if let Some(since_ts) = since { + url.push_str(&format!("&since={}", urlencoding::encode(since_ts))); + } + + let response = self + .client + .get(&url) + .bearer_auth(token) + .send() + .await + .map_err(CommitSyncError::RequestFailed)?; + + if !response.status().is_success() { + let status = response.status().as_u16(); + let text = response.text().await.unwrap_or_default(); + return Err(CommitSyncError::ApiError(status, text)); + } + + response + .json::>() + .await + .map_err(CommitSyncError::JsonDecode) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_calculate_commit_impact() { + let impact = calculate_commit_impact(50, 10); + assert_eq!(impact, 6.0); + + let capped_impact = calculate_commit_impact(2000, 1000); + assert_eq!(capped_impact, 100.0); + } } diff --git a/crates/github/src/lib.rs b/crates/github/src/lib.rs index 0f629a7..51381f6 100644 --- a/crates/github/src/lib.rs +++ b/crates/github/src/lib.rs @@ -4,11 +4,16 @@ pub mod issues; pub mod oauth; pub mod pull_requests; pub mod repositories; +pub mod sync; pub mod webhooks; pub use actions::detect_github_actions_workflows; -pub use commits::{calculate_commit_impact, CommitStats}; +pub use commits::{ + calculate_commit_impact, CommitStats, GitHubCommit, GitHubCommitAuthor, GitHubCommitDetail, + GithubCommitClient, +}; pub use oauth::build_github_oauth_url; pub use pull_requests::analyze_pr_impact; -pub use repositories::{GithubRepo, GithubRepoClient}; +pub use repositories::{GithubRepo, GithubRepoClient, SyncOptions}; +pub use sync::{RepositorySyncEngine, RepositorySyncReport, SyncError, SyncType}; pub use webhooks::verify_webhook_signature; diff --git a/crates/github/src/repositories.rs b/crates/github/src/repositories.rs index 30a1d5f..4972b93 100644 --- a/crates/github/src/repositories.rs +++ b/crates/github/src/repositories.rs @@ -1,7 +1,20 @@ use reqwest::Client; use serde::{Deserialize, Serialize}; +use thiserror::Error; -#[derive(Debug, Serialize, Deserialize, Clone)] +#[derive(Debug, Error)] +pub enum GitHubRepoError { + #[error("Client creation failed: {0}")] + ClientBuild(reqwest::Error), + #[error("HTTP request failed: {0}")] + RequestFailed(reqwest::Error), + #[error("API returned error status {0}: {1}")] + ApiError(u16, String), + #[error("Deserialization failed: {0}")] + JsonParse(reqwest::Error), +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub struct GithubRepo { pub id: i64, pub name: String, @@ -14,30 +27,121 @@ pub struct GithubRepo { pub stargazers_count: i32, pub forks_count: i32, pub open_issues_count: i32, + pub updated_at: Option, +} + +#[derive(Debug, Clone)] +pub struct SyncOptions { + pub page: u32, + pub per_page: u32, + pub visibility: String, // "all", "public", "private" + pub sort: String, // "created", "updated", "pushed", "full_name" +} + +impl Default for SyncOptions { + fn default() -> Self { + Self { + page: 1, + per_page: 30, + visibility: "all".to_string(), + sort: "updated".to_string(), + } + } } pub struct GithubRepoClient { client: Client, + base_url: String, } impl GithubRepoClient { - pub fn new() -> Self { - Self { - client: Client::builder() - .user_agent("DevResume-AI") - .build() - .unwrap(), + pub fn new() -> Result { + Self::with_base_url("https://api.github.com".to_string()) + } + + pub fn with_base_url(base_url: String) -> Result { + let client = Client::builder() + .user_agent("DevResume-AI") + .build() + .map_err(GitHubRepoError::ClientBuild)?; + + Ok(Self { client, base_url }) + } + + pub async fn fetch_repositories( + &self, + token: &str, + opts: &SyncOptions, + ) -> Result, GitHubRepoError> { + let url = format!( + "{}/user/repos?page={}&per_page={}&visibility={}&sort={}", + self.base_url, opts.page, opts.per_page, opts.visibility, opts.sort + ); + + let response = self + .client + .get(&url) + .bearer_auth(token) + .send() + .await + .map_err(GitHubRepoError::RequestFailed)?; + + if !response.status().is_success() { + let status = response.status().as_u16(); + let text = response.text().await.unwrap_or_default(); + return Err(GitHubRepoError::ApiError(status, text)); } + + response + .json::>() + .await + .map_err(GitHubRepoError::JsonParse) } - pub async fn fetch_repositories(&self, token: &str) -> anyhow::Result> { + pub async fn fetch_single_repository( + &self, + token: &str, + owner: &str, + repo: &str, + ) -> Result { + let url = format!("{}/repos/{}/{}", self.base_url, owner, repo); + let response = self .client - .get("https://api.github.com/user/repos") + .get(&url) .bearer_auth(token) .send() - .await?; - let repos = response.json::>().await?; - Ok(repos) + .await + .map_err(GitHubRepoError::RequestFailed)?; + + if !response.status().is_success() { + let status = response.status().as_u16(); + let text = response.text().await.unwrap_or_default(); + return Err(GitHubRepoError::ApiError(status, text)); + } + + response + .json::() + .await + .map_err(GitHubRepoError::JsonParse) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_sync_options_default() { + let opts = SyncOptions::default(); + assert_eq!(opts.page, 1); + assert_eq!(opts.per_page, 30); + assert_eq!(opts.visibility, "all"); + } + + #[test] + fn test_client_instantiation() { + let client = GithubRepoClient::new(); + assert!(client.is_ok()); } } diff --git a/crates/github/src/sync.rs b/crates/github/src/sync.rs new file mode 100644 index 0000000..27e233d --- /dev/null +++ b/crates/github/src/sync.rs @@ -0,0 +1,118 @@ +use crate::commits::GithubCommitClient; +use crate::repositories::{GithubRepo, GithubRepoClient, SyncOptions}; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum SyncError { + #[error("Repository fetch failed: {0}")] + RepoError(#[from] crate::repositories::GitHubRepoError), + #[error("Commit fetch failed: {0}")] + CommitError(#[from] crate::commits::CommitSyncError), + #[error("Database error: {0}")] + DatabaseError(String), +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +pub enum SyncType { + Full, + Incremental, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct RepositorySyncReport { + pub total_repositories_synced: usize, + pub total_commits_synced: usize, + pub sync_type: SyncType, + pub duration_seconds: f64, +} + +pub struct RepositorySyncEngine { + repo_client: GithubRepoClient, + commit_client: GithubCommitClient, +} + +impl RepositorySyncEngine { + pub fn new() -> Result { + Ok(Self { + repo_client: GithubRepoClient::new()?, + commit_client: GithubCommitClient::new()?, + }) + } + + pub fn with_clients(repo_client: GithubRepoClient, commit_client: GithubCommitClient) -> Self { + Self { + repo_client, + commit_client, + } + } + + pub async fn sync_user_repositories( + &self, + token: &str, + sync_type: SyncType, + ) -> Result { + let start = std::time::Instant::now(); + let opts = SyncOptions::default(); + + let repos: Vec = self.repo_client.fetch_repositories(token, &opts).await?; + let repo_count = repos.len(); + let mut total_commits = 0; + + for repo in &repos { + let owner_and_name: Vec<&str> = repo.full_name.split('/').collect(); + if owner_and_name.len() == 2 { + let owner = owner_and_name[0]; + let repo_name = owner_and_name[1]; + + let since = if sync_type == SyncType::Incremental { + repo.updated_at.as_deref() + } else { + None + }; + + if let Ok(commits) = self + .commit_client + .fetch_commits(token, owner, repo_name, since) + .await + { + total_commits += commits.len(); + } + } + } + + let duration = start.elapsed().as_secs_f64(); + + Ok(RepositorySyncReport { + total_repositories_synced: repo_count, + total_commits_synced: total_commits, + sync_type, + duration_seconds: duration, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_sync_engine_instantiation() { + let engine = RepositorySyncEngine::new(); + assert!(engine.is_ok()); + } + + #[test] + fn test_sync_report_format() { + let report = RepositorySyncReport { + total_repositories_synced: 5, + total_commits_synced: 120, + sync_type: SyncType::Full, + duration_seconds: 1.5, + }; + + assert_eq!(report.total_repositories_synced, 5); + assert_eq!(report.sync_type, SyncType::Full); + } +} diff --git a/crates/github/tests/sync_tests.rs b/crates/github/tests/sync_tests.rs new file mode 100644 index 0000000..7c373fb --- /dev/null +++ b/crates/github/tests/sync_tests.rs @@ -0,0 +1,44 @@ +use github::{calculate_commit_impact, GithubRepo, SyncOptions}; + +#[test] +fn test_github_repo_struct_serialization() { + let repo = GithubRepo { + id: 123456, + name: "DevResume-AI".to_string(), + full_name: "ChamathDilshanC/DevResume-AI".to_string(), + html_url: "https://github.com/ChamathDilshanC/DevResume-AI".to_string(), + description: Some("AI-powered resume platform".to_string()), + default_branch: "main".to_string(), + private: false, + language: Some("Rust".to_string()), + stargazers_count: 42, + forks_count: 10, + open_issues_count: 2, + updated_at: Some("2026-08-03T00:00:00Z".to_string()), + }; + + let json = serde_json::to_string(&repo).expect("Serialization failed"); + assert!(json.contains("DevResume-AI")); + + let deserialized: GithubRepo = serde_json::from_str(&json).expect("Deserialization failed"); + assert_eq!(deserialized.id, 123456); +} + +#[test] +fn test_commit_impact_calculation() { + assert_eq!(calculate_commit_impact(10, 10), 2.0); + assert_eq!(calculate_commit_impact(500, 500), 100.0); +} + +#[test] +fn test_sync_options_construction() { + let opts = SyncOptions { + page: 2, + per_page: 50, + visibility: "public".to_string(), + sort: "pushed".to_string(), + }; + + assert_eq!(opts.page, 2); + assert_eq!(opts.per_page, 50); +}