use anyhow::{Context, Result};
use chrono::{DateTime, FixedOffset, Utc};
use serde::Serialize;
use crate::api::http;
use crate::api::rest;
use crate::config::LanguageConfig;
use crate::models::{
ContributionCalendar, ContributionDay, ContributionWeek, PinnedRepo, UserStats,
};
use super::models::{
ContributionsCollectionNode, RepoResponse, RepositoriesNode, UserNode, UserResponse,
};
use super::retry::send_with_retry;
include!(concat!(env!("OUT_DIR"), "/queries.rs"));
#[derive(Clone)]
pub struct GraphQLClient {
client: reqwest::Client,
pinned_repos: Vec<(String, String)>, timezone_offset: FixedOffset, language_config: LanguageConfig,
}
const GRAPHQL_ENDPOINT: &str = "https://api.github.com/graphql";
const GRAPHQL_REPOS_PAGE_SIZE: usize = 100;
#[derive(Debug, Serialize)]
struct GraphQLRequest<'a> {
query: &'a str,
variables: serde_json::Value,
}
impl GraphQLClient {
pub fn new(
token: String,
pinned_repos_str: Option<String>,
timezone_str: Option<String>,
) -> Self {
let client = http::build_github_client(&token)
.unwrap_or_else(|e| panic!("failed to build HTTP client: {e:#}"));
let pinned_repos = pinned_repos_str
.unwrap_or_default()
.split(',')
.filter_map(|s| {
let s = s.trim();
if s.is_empty() {
return None;
}
let parts: Vec<&str> = s.splitn(2, '/').collect();
if parts.len() == 2 {
Some((parts[0].to_string(), parts[1].to_string()))
} else {
eprintln!("warning: invalid pinned repo format: {s} (expected owner/repo)");
None
}
})
.collect();
let timezone_offset = parse_timezone_offset(timezone_str.as_deref());
Self {
client,
pinned_repos,
timezone_offset,
language_config: LanguageConfig::default(),
}
}
pub fn with_language_config(mut self, language_config: LanguageConfig) -> Self {
self.language_config = language_config;
self
}
pub async fn fetch_stats(&self, username: &str) -> Result<UserStats> {
let now = Utc::now();
let one_year_ago = now - chrono::Duration::days(365);
eprintln!("fetching user profile...");
let user = self
.fetch_user_with_repos(username, &one_year_ago, &now)
.await?;
let (total_stars, total_forks) = self.compute_repo_totals(&user.repositories.nodes);
let (age_years, age_days) = self.compute_account_age(now, user.created_at);
let calendar = self.build_calendar(&user.contributions_collection);
let streaks = calendar.compute_streaks();
eprintln!("fetching pinned repos ({})...", self.pinned_repos.len());
let pinned_repos = self.fetch_pinned_repos(&one_year_ago, &user.id).await;
eprintln!(
"fetching commit sample (limit {})...",
self.language_config.commits_limit
);
let commit_sample = match rest::fetch_commit_sample(
&self.client,
&user.login,
self.language_config.commits_limit,
)
.await
{
Ok(commits) => Some(commits),
Err(e) => {
eprintln!("warning: failed to fetch commit sample: {e:#}");
None
}
};
eprintln!("computing time distribution...");
let time_distribution = commit_sample
.as_ref()
.map(|commits| rest::compute_time_distribution(commits, self.timezone_offset));
let commit_count = commit_sample.as_ref().map(|c| c.len()).unwrap_or(0);
eprintln!("computing language usage ({commit_count} commits)...");
let language_stats = if let Some(ref commits) = commit_sample {
match rest::compute_language_usage(&self.client, commits, &self.language_config).await {
Ok(result) if result.1 > 0 => Some(result),
Ok(_) => None,
Err(e) => {
eprintln!("warning: failed to compute language usage: {e:#}");
None
}
}
} else {
None
};
eprintln!("done.");
let (first_issue, first_pr, first_repo) =
self.first_contributions(&user.contributions_collection);
Ok(UserStats {
name: user.name,
username: user.login,
bio: user.bio,
company: user.company,
location: user.location,
website_url: user.website_url,
twitter_username: user.twitter_username,
avatar_url: user.avatar_url,
organizations: Some(user.organizations.total_count),
repos: user.repositories.total_count,
stars: total_stars,
forks: total_forks,
followers: user.followers.total_count,
commits: user.contributions_collection.total_commit_contributions,
prs: user
.contributions_collection
.total_pull_request_contributions,
issues: user.contributions_collection.total_issue_contributions,
total_repository_contributions: Some(
user.contributions_collection.total_repository_contributions,
),
restricted_contributions: Some(
user.contributions_collection.restricted_contributions_count,
),
contribution_years: Some(user.contributions_collection.contribution_years),
first_issue_contribution: first_issue,
first_pull_request_contribution: first_pr,
first_repository_contribution: first_repo,
account_age_years: age_years,
account_age_days: age_days,
contribution_calendar: Some(calendar),
streaks: Some(streaks),
pinned_repos,
time_distribution,
language_usage: language_stats.as_ref().map(|v| v.0.clone()),
language_total_changes: language_stats.as_ref().map(|v| v.1),
language_sampled_commits: language_stats.as_ref().map(|v| v.2),
})
}
fn compute_repo_totals(&self, repos: &[super::models::RepoStatsNode]) -> (u64, u64) {
let stars = repos.iter().map(|r| r.stargazer_count).sum();
let forks = repos.iter().map(|r| r.fork_count).sum();
(stars, forks)
}
fn compute_account_age(&self, now: DateTime<Utc>, created_at: DateTime<Utc>) -> (u64, u64) {
let age = now.signed_duration_since(created_at);
let age_days = age.num_days().max(0) as u64;
let age_years = age_days / 365;
(age_years, age_days)
}
fn build_calendar(&self, collection: &ContributionsCollectionNode) -> ContributionCalendar {
ContributionCalendar {
total_contributions: collection.contribution_calendar.total_contributions,
weeks: collection
.contribution_calendar
.weeks
.iter()
.map(|w| ContributionWeek {
days: w
.contribution_days
.iter()
.map(|d| ContributionDay {
date: d.date.clone(),
contribution_count: d.contribution_count,
level: level_from_string(&d.contribution_level),
})
.collect(),
})
.collect(),
}
}
fn first_contributions(
&self,
collection: &ContributionsCollectionNode,
) -> (Option<String>, Option<String>, Option<String>) {
let first_issue = collection
.first_issue_contribution
.as_ref()
.map(|c| c.occurred_at.format("%Y-%m-%d").to_string());
let first_pr = collection
.first_pull_request_contribution
.as_ref()
.map(|c| c.occurred_at.format("%Y-%m-%d").to_string());
let first_repo = collection
.first_repository_contribution
.as_ref()
.map(|c| c.occurred_at.format("%Y-%m-%d").to_string());
(first_issue, first_pr, first_repo)
}
async fn fetch_user_with_repos(
&self,
username: &str,
from: &DateTime<Utc>,
to: &DateTime<Utc>,
) -> Result<UserNode> {
let mut repo_after: Option<String> = None;
let mut combined: Option<UserNode> = None;
loop {
let page = self
.fetch_user_page(username, from, to, repo_after.as_deref())
.await?;
if let Some(ref mut user) = combined {
merge_repository_page(&mut user.repositories, page.repositories);
} else {
combined = Some(page);
}
let page_info = combined
.as_ref()
.map(|u| u.repositories.page_info.clone())
.unwrap();
if !page_info.has_next_page {
break;
}
repo_after = page_info.end_cursor;
if repo_after.is_none() {
break;
}
}
combined.context("User not found")
}
async fn fetch_user_page(
&self,
username: &str,
from: &DateTime<Utc>,
to: &DateTime<Utc>,
repo_after: Option<&str>,
) -> Result<UserNode> {
let variables = serde_json::json!({
"login": username,
"from": from.to_rfc3339(),
"to": to.to_rfc3339(),
"repoAfter": repo_after,
"repoFirst": GRAPHQL_REPOS_PAGE_SIZE,
});
let request = GraphQLRequest {
query: USER_QUERY,
variables,
};
let response = send_with_retry(
|| self.client.post(GRAPHQL_ENDPOINT).json(&request),
"GraphQL user request",
)
.await?;
let gql_response: UserResponse = response
.json()
.await
.context("Failed to parse user GraphQL response")?;
if let Some(errors) = gql_response.errors {
let msgs: Vec<_> = errors.iter().map(|e| e.message.as_str()).collect();
anyhow::bail!("GraphQL errors: {}", msgs.join(", "));
}
gql_response
.data
.and_then(|d| d.user)
.context("User not found")
}
async fn fetch_pinned_repos(
&self,
since: &DateTime<Utc>,
author_id: &str,
) -> Option<Vec<PinnedRepo>> {
if self.pinned_repos.is_empty() {
return None;
}
let mut join_set = tokio::task::JoinSet::new();
for (index, (owner, name)) in self.pinned_repos.iter().enumerate() {
let client = self.clone();
let owner = owner.clone();
let name = name.clone();
let since = *since;
let author_id = author_id.to_string();
join_set.spawn(async move {
client
.fetch_repo(&owner, &name, &since, &author_id)
.await
.map(|repo| (index, repo))
});
}
let mut repos = Vec::new();
while let Some(result) = join_set.join_next().await {
match result {
Ok(Ok((index, repo))) => repos.push((index, repo)),
Ok(Err(e)) => eprintln!("warning: failed to fetch pinned repo: {e:#}"),
Err(e) => eprintln!("warning: failed to join pinned repo task: {e:#}"),
}
}
if repos.is_empty() {
None
} else {
repos.sort_by_key(|(index, _)| *index);
Some(repos.into_iter().map(|(_, repo)| repo).collect())
}
}
async fn fetch_repo(
&self,
owner: &str,
name: &str,
since: &DateTime<Utc>,
author_id: &str,
) -> Result<PinnedRepo> {
let variables = serde_json::json!({
"owner": owner,
"name": name,
"since": since.to_rfc3339(),
"authorId": author_id,
});
let request = GraphQLRequest {
query: REPO_QUERY,
variables,
};
let response = send_with_retry(
|| self.client.post(GRAPHQL_ENDPOINT).json(&request),
"GraphQL repo request",
)
.await?;
let gql_response: RepoResponse = response
.json()
.await
.context("Failed to parse repo GraphQL response")?;
if let Some(errors) = gql_response.errors {
let msgs: Vec<_> = errors.iter().map(|e| e.message.as_str()).collect();
anyhow::bail!("GraphQL errors: {}", msgs.join(", "));
}
let repo = gql_response
.data
.and_then(|d| d.repository)
.context("Repository not found")?;
let (additions, deletions, commits, last_date) =
if let Some(ref branch) = repo.default_branch_ref {
let history = branch.target.history.as_ref();
let adds: u64 = history
.map(|h| h.nodes.iter().map(|c| c.additions).sum())
.unwrap_or(0);
let dels: u64 = history
.map(|h| h.nodes.iter().map(|c| c.deletions).sum())
.unwrap_or(0);
let count = history.map(|h| h.total_count).unwrap_or(0);
let date = history
.and_then(|h| h.nodes.first())
.map(|c| c.committed_date.format("%Y-%m-%d").to_string());
(adds, dels, count, date)
} else {
(0, 0, 0, None)
};
let topics: Vec<String> = repo
.repository_topics
.nodes
.iter()
.map(|n| n.topic.name.clone())
.collect();
let topics = if topics.is_empty() {
None
} else {
Some(topics)
};
Ok(PinnedRepo {
name: repo.name,
description: repo.description,
stars: repo.stargazer_count,
forks: repo.fork_count,
watchers: repo.watchers.total_count,
issues: repo.issues.total_count,
pull_requests: repo.pull_requests.total_count,
releases: repo.releases.total_count,
license: repo.license_info.and_then(|l| l.spdx_id),
topics,
language: repo.primary_language.as_ref().map(|l| l.name.clone()),
language_color: repo.primary_language.and_then(|l| l.color),
is_archived: repo.is_archived,
is_fork: repo.is_fork,
is_template: repo.is_template,
disk_usage: repo.disk_usage,
default_branch: repo.default_branch_ref.as_ref().map(|b| b.name.clone()),
recent_additions: additions,
recent_deletions: deletions,
recent_commits: commits,
last_commit_date: last_date,
})
}
}
fn parse_timezone_offset(input: Option<&str>) -> FixedOffset {
input
.and_then(|s| s.parse::<FixedOffset>().ok())
.unwrap_or_else(|| FixedOffset::east_opt(0).unwrap())
}
fn merge_repository_page(target: &mut RepositoriesNode, page: RepositoriesNode) {
target.nodes.extend(page.nodes);
target.total_count = page.total_count;
target.page_info = page.page_info;
}
fn level_from_string(level: &str) -> u8 {
match level {
"NONE" => 0,
"FIRST_QUARTILE" => 1,
"SECOND_QUARTILE" => 2,
"THIRD_QUARTILE" => 3,
"FOURTH_QUARTILE" => 4,
_ => 0,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_timezone_offset_defaults_to_utc() {
let offset = parse_timezone_offset(Some("invalid"));
assert_eq!(offset.local_minus_utc(), 0);
}
#[test]
fn parse_timezone_offset_parses_valid() {
let offset = parse_timezone_offset(Some("+08:00"));
assert_eq!(offset.local_minus_utc(), 8 * 3600);
}
#[test]
fn merge_repository_page_appends_nodes() {
let mut target = RepositoriesNode {
total_count: 1,
nodes: vec![crate::api::graphql::models::RepoStatsNode {
stargazer_count: 1,
fork_count: 2,
}],
page_info: crate::api::graphql::models::PageInfoNode {
has_next_page: true,
end_cursor: Some("cursor".to_string()),
},
};
let page = RepositoriesNode {
total_count: 2,
nodes: vec![crate::api::graphql::models::RepoStatsNode {
stargazer_count: 3,
fork_count: 4,
}],
page_info: crate::api::graphql::models::PageInfoNode {
has_next_page: false,
end_cursor: None,
},
};
merge_repository_page(&mut target, page);
assert_eq!(target.total_count, 2);
assert_eq!(target.nodes.len(), 2);
assert!(!target.page_info.has_next_page);
}
}