diff --git a/Cargo.lock b/Cargo.lock index 00d1aa4..38dbb8f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -148,6 +148,7 @@ name = "checkgit" version = "0.1.0" dependencies = [ "checkgit_core", + "chrono", "clap", "colored", "dirs", @@ -168,6 +169,16 @@ dependencies = [ "tokio", ] +[[package]] +name = "chrono" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "158b0bd7d75cbb6bf9c25967a48a2e9f77da95876b858eadfabaa99cd069de6e" +dependencies = [ + "num", + "time", +] + [[package]] name = "clap" version = "4.5.60" @@ -530,7 +541,7 @@ checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "libc", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", ] [[package]] @@ -943,7 +954,7 @@ checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" dependencies = [ "libc", "log", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.48.0", ] @@ -954,7 +965,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.61.2", ] @@ -975,6 +986,37 @@ dependencies = [ "tempfile", ] +[[package]] +name = "num" +version = "0.1.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9bdb1fb680e609c2e0930c1866cafdd0be7e7c7a1ecf92aec71ed8d99d3e133" +dependencies = [ + "num-integer", + "num-iter", + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1572,6 +1614,17 @@ dependencies = [ "weezl", ] +[[package]] +name = "time" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b797afad3f312d1c66a56d11d0316f916356d11bd158fbc6ca6389ff6bf805a" +dependencies = [ + "libc", + "wasi 0.10.0+wasi-snapshot-preview1", + "winapi", +] + [[package]] name = "tinystr" version = "0.8.2" @@ -1770,6 +1823,12 @@ dependencies = [ "try-lock", ] +[[package]] +name = "wasi" +version = "0.10.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" diff --git a/checkgit/Cargo.toml b/checkgit/Cargo.toml index 2078b8f..54e8822 100644 --- a/checkgit/Cargo.toml +++ b/checkgit/Cargo.toml @@ -11,4 +11,5 @@ dirs = "6.0.0" serde = { version = "1.0.228", features = ["derive"] } tokio = { version = "1", features = ["full"] } toml = "0.9.8" -viuer = "0.7" \ No newline at end of file +viuer = "0.7" +chrono = "0.3" \ No newline at end of file diff --git a/checkgit/src/main.rs b/checkgit/src/main.rs index da3c422..4476056 100644 --- a/checkgit/src/main.rs +++ b/checkgit/src/main.rs @@ -31,7 +31,7 @@ async fn main() { } match get_user_profile(&username, token).await { - Ok(profile) => render(profile), + Ok(profile) => render(&profile), Err(e) => eprintln!("Error: {}", e), } } \ No newline at end of file diff --git a/checkgit/src/render.rs b/checkgit/src/render.rs index 526b9c9..87a2fdf 100644 --- a/checkgit/src/render.rs +++ b/checkgit/src/render.rs @@ -1,11 +1,12 @@ use crate::helper::{clear_screen, move_cursor_right, move_cursor_up}; +use chrono::{Datelike, Duration, UTC}; use colored::Colorize; use viuer::{Config, print}; -pub fn render(profile: checkgit_core::UserProfile) { +pub fn render(profile: &checkgit_core::UserProfile) { clear_screen(); - let avatar_width: u32 = 25; + let avatar_width: u32 = 35; print( &profile.avatar_image, @@ -95,6 +96,42 @@ pub fn render(profile: checkgit_core::UserProfile) { move_cursor_right(col); println!(); + move_cursor_right(col); + println!("{}", "Contribution Stats".bold().truecolor(230, 237, 243)); + move_cursor_right(col); + println!("{}", "─".repeat(34).truecolor(48, 54, 61)); + + move_cursor_right(col); + println!( + "{} {}", + "Commits:".truecolor(125, 133, 144), + profile.stats.commits + ); + + move_cursor_right(col); + println!( + "{} {}", + "PRs:".truecolor(125, 133, 144), + profile.stats.pull_requests + ); + + move_cursor_right(col); + println!( + "{} {}", + "Reviews:".truecolor(125, 133, 144), + profile.stats.reviews + ); + + move_cursor_right(col); + println!( + "{} {}", + "Issues:".truecolor(125, 133, 144), + profile.stats.issues + ); + + move_cursor_right(col); + println!(); + move_cursor_right(col); println!("{}", "Popular repositories".bold().truecolor(230, 237, 243)); move_cursor_right(col); @@ -108,24 +145,17 @@ pub fn render(profile: checkgit_core::UserProfile) { println!("{}", stars.to_string().truecolor(125, 133, 144)); } - let profile_lines_printed: u16 = - 10 + profile.top_repos.len() as u16 + if profile.bio.is_some() { 1 } else { 0 }; - - let remaining = (avatar_height_rows as u16).saturating_sub(profile_lines_printed); - for _ in 0..remaining { - println!(); - } - println!(); - render_heatmap(profile.contribution_matrix); + render_heatmap(&profile.contribution_matrix); } -pub fn render_heatmap(matrix: Vec>) { +pub fn render_heatmap(matrix: &[Vec]) { if matrix.is_empty() { return; } - let weeks = matrix.iter().map(|r| r.len()).max().unwrap_or(0); + let weeks = matrix[0].len(); + let total: u32 = matrix.iter().flatten().sum(); println!( @@ -153,25 +183,18 @@ pub fn render_heatmap(matrix: Vec>) { } } - let months = [ - "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", "Jan", "Feb", "Mar", - ]; - - let mut month_positions = vec![None; weeks]; - - for (i, m) in months.iter().enumerate() { - let pos = (i * weeks) / 12; - if pos < weeks { - month_positions[pos] = Some(*m); - } - } + let start = UTC::now() - Duration::weeks(52); print!(" "); for w in 0..weeks { - if let Some(m) = month_positions[w] { - print!("{}", m.truecolor(125, 133, 144)); - for _ in 0..(3 - m.len()) { + let date = start + Duration::weeks(w as i64); + + if date.day() <= 7 { + let label = date.format("%b").to_string(); + print!("{}", label.truecolor(125, 133, 144)); + + for _ in 0..(3 - label.len()) { print!(" "); } } else { @@ -204,7 +227,6 @@ pub fn render_heatmap(matrix: Vec>) { println!(); } - println!(); print!("{}", "Less ".truecolor(125, 133, 144)); @@ -217,9 +239,7 @@ pub fn render_heatmap(matrix: Vec>) { println!("{}", "More".truecolor(125, 133, 144)); - let mut longest = 0; - let mut current = 0; let mut running = 0; for week in 0..weeks { @@ -239,7 +259,9 @@ pub fn render_heatmap(matrix: Vec>) { } } - for week in (0..weeks).rev() { + let mut current = 0; + + 'outer: for week in (0..weeks).rev() { for day in (0..7).rev() { let v = matrix .get(day) @@ -249,13 +271,10 @@ pub fn render_heatmap(matrix: Vec>) { if v > 0 { current += 1; - } else { - break; + } else if current > 0 { + break 'outer; } } - if current == 0 { - break; - } } println!(); diff --git a/checkgit_core/src/github.rs b/checkgit_core/src/github.rs index bef3cca..ff07c22 100644 --- a/checkgit_core/src/github.rs +++ b/checkgit_core/src/github.rs @@ -1,7 +1,11 @@ use reqwest::{Client, StatusCode}; use serde::Deserialize; -use crate::{error::CheckGitError, models::GraphQLResponse}; +use crate::{ + error::CheckGitError, + models::{ContributionStats, GraphQLResponse}, +}; + use image::{DynamicImage, imageops::FilterType}; #[derive(Debug, Deserialize)] @@ -29,11 +33,18 @@ pub struct GithubClient { impl GithubClient { pub fn new(token: Option) -> Result { let client = Client::builder().user_agent("checkgit").build()?; + Ok(Self { client, token }) } async fn send_request(&self, url: &str) -> Result { - let response = self.client.get(url).send().await?; + let mut req = self.client.get(url); + + if let Some(token) = &self.token { + req = req.bearer_auth(token); + } + + let response = req.send().await?; self.handle_status(response).await } @@ -55,7 +66,9 @@ impl GithubClient { pub async fn fetch_user(&self, username: &str) -> Result { let url = format!("https://api.github.com/users/{}", username); + let response = self.send_request(&url).await?; + Ok(response.json::().await?) } @@ -67,7 +80,9 @@ impl GithubClient { "https://api.github.com/users/{}/repos?per_page=100&sort=stars&direction=desc", username ); + let response = self.send_request(&url).await?; + Ok(response.json::>().await?) } @@ -88,6 +103,7 @@ impl GithubClient { .map_err(|e| CheckGitError::ImageError(e.to_string()))?; let size = img.width().min(img.height()); + let cropped = img.crop_imm( (img.width() - size) / 2, (img.height() - size) / 2, @@ -105,24 +121,32 @@ impl GithubClient { pub async fn fetch_contributions( &self, username: &str, - ) -> Result>, CheckGitError> { + ) -> Result<(Vec>, ContributionStats), CheckGitError> { let token = self.token.as_ref().ok_or(CheckGitError::Unauthorized)?; let query = r#" - query($login: String!) { - user(login: $login) { - contributionsCollection { - contributionCalendar { - weeks { - contributionDays { - contributionCount - } - } + query($login: String!) { + user(login: $login) { + contributionsCollection { + + totalCommitContributions + totalIssueContributions + totalPullRequestContributions + totalPullRequestReviewContributions + totalRepositoriesWithContributedCommits + + contributionCalendar { + weeks { + contributionDays { + contributionCount } } } + } - "#; + } + } + "#; let body = serde_json::json!({ "query": query, @@ -138,33 +162,40 @@ impl GithubClient { .await?; let response = self.handle_status(response).await?; - let text = response.text().await?; - let parsed: GraphQLResponse = - serde_json::from_str(&text).map_err(|_| CheckGitError::InvalidResponse)?; + let parsed: GraphQLResponse = response + .json() + .await + .map_err(|_| CheckGitError::InvalidResponse)?; - let weeks = parsed + let collection = parsed .data - .unwrap() + .ok_or(CheckGitError::InvalidResponse)? .user - .contributions_collection - .contribution_calendar - .weeks; + .contributions_collection; + + let weeks = collection.contribution_calendar.weeks; let mut matrix: Vec> = vec![Vec::new(); 7]; for week in weeks { - for (i, day) in week.contribution_days.into_iter().enumerate() { - if i < 7 { - matrix[i].push(day.contribution_count); - } + for (day_index, day) in week.contribution_days.iter().enumerate() { + matrix[day_index].push(day.contribution_count); } } - Ok(matrix) + let stats = ContributionStats { + commits: collection.total_commit_contributions, + pull_requests: collection.total_pull_request_contributions, + reviews: collection.total_pull_request_review_contributions, + issues: collection.total_issue_contributions, + repos_contributed: collection.total_repositories_with_contributed_commits, + }; + + Ok((matrix, stats)) } } pub fn calculate_total_stars(repos: &[GithubRepoResponse]) -> u32 { repos.iter().map(|r| r.stargazers_count).sum() -} \ No newline at end of file +} diff --git a/checkgit_core/src/lib.rs b/checkgit_core/src/lib.rs index b321544..fab9312 100644 --- a/checkgit_core/src/lib.rs +++ b/checkgit_core/src/lib.rs @@ -4,30 +4,33 @@ mod models; use error::*; use github::*; +use crate::models::ContributionStats; #[derive(Debug)] pub struct UserProfile { pub username: String, pub display_name: Option, pub bio: Option, - pub avatar_image: image::DynamicImage, + pub avatar_image: image::DynamicImage, pub followers: u32, pub following: u32, pub repo_count: u32, pub total_stars: u32, pub top_repos: Vec<(String, u32)>, pub contribution_matrix: Vec>, + pub stats: ContributionStats, } pub async fn get_user_profile( username: &str, token: Option, ) -> Result { + let github = GithubClient::new(token)?; let user = github.fetch_user(username).await?; - let (repos, contributions, avatar_image) = tokio::try_join!( + let (repos, (contributions, stats), avatar_image) = tokio::try_join!( github.fetch_repos(username), github.fetch_contributions(username), github.fetch_avatar_image(&user.avatar_url), @@ -55,5 +58,6 @@ pub async fn get_user_profile( total_stars, top_repos, contribution_matrix: contributions, + stats, }) } \ No newline at end of file diff --git a/checkgit_core/src/models.rs b/checkgit_core/src/models.rs index 9661510..bff02a3 100644 --- a/checkgit_core/src/models.rs +++ b/checkgit_core/src/models.rs @@ -2,23 +2,29 @@ use serde::Deserialize; #[derive(Debug, Deserialize)] pub struct GraphQLResponse { - pub data: Option, + pub data: Option, } #[derive(Debug, Deserialize)] -pub struct GraphQLUser { - pub user: ContributionsCollection, +pub struct UserData { + pub user: User, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct ContributionsCollection { - pub contributions_collection: ContributionCalendarWrapper, +pub struct User { + pub contributions_collection: ContributionsCollection, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct ContributionCalendarWrapper { +pub struct ContributionsCollection { + pub total_commit_contributions: u32, + pub total_issue_contributions: u32, + pub total_pull_request_contributions: u32, + pub total_pull_request_review_contributions: u32, + pub total_repositories_with_contributed_commits: u32, + pub contribution_calendar: ContributionCalendar, } @@ -37,4 +43,13 @@ pub struct Week { #[serde(rename_all = "camelCase")] pub struct ContributionDay { pub contribution_count: u32, +} + +#[derive(Debug)] +pub struct ContributionStats { + pub commits: u32, + pub pull_requests: u32, + pub reviews: u32, + pub issues: u32, + pub repos_contributed: u32, } \ No newline at end of file