use std::collections::BTreeMap;
use std::error::Error;
use std::fmt;
use std::io::ErrorKind;
use std::process::Command;
use chrono::{DateTime, Days, Months, NaiveDate};
use serde_json::Value;
use crate::history::{
AccountSnapshot, ConflictingRepository, ContributionDay, ContributionHistory,
ContributionHistoryError, LanguageColor, RepositoryObservation, RepositoryVisibility,
calculate_language_shares, summarize_contributions,
};
pub trait GraphqlClient {
fn run_query(&self, query: &str, variables: &[(&str, &str)]) -> Result<Value, CollectionError>;
}
pub struct GhCli;
impl GraphqlClient for GhCli {
fn run_query(&self, query: &str, variables: &[(&str, &str)]) -> Result<Value, CollectionError> {
let mut gh_command = Command::new("gh");
gh_command
.args(["api", "graphql", "-f"])
.arg(format!("query={query}"));
for (variable_name, variable_value) in variables {
gh_command
.arg("-f")
.arg(format!("{variable_name}={variable_value}"));
}
let gh_output = gh_command.output().map_err(|spawn_error| {
if spawn_error.kind() == ErrorKind::NotFound {
CollectionError::GhNotInstalled
} else {
CollectionError::GhFailed(spawn_error.to_string())
}
})?;
if !gh_output.status.success() {
return Err(CollectionError::GhFailed(
String::from_utf8_lossy(&gh_output.stderr).trim().to_owned(),
));
}
let response: Value = serde_json::from_slice(&gh_output.stdout).map_err(|parse_error| {
CollectionError::UnexpectedResponse(format!("gh output is not JSON: {parse_error}"))
})?;
Ok(read_field(&response, "data")?.clone())
}
}
const ACCOUNT_QUERY: &str =
"query($login: String!) { user(login: $login) { databaseId createdAt } }";
const REPOSITORY_FIELDS: &str = "nodes { databaseId visibility isFork primaryLanguage { name color } } pageInfo { hasNextPage endCursor }";
const CALENDAR_QUERY: &str = "query($login: String!, $from: DateTime!, $to: DateTime!) { \
user(login: $login) { contributionsCollection(from: $from, to: $to) { \
contributionCalendar { weeks { contributionDays { date contributionCount } } } } } }";
pub fn collect_account_snapshot(
github: &impl GraphqlClient,
login: &str,
today: NaiveDate,
) -> Result<AccountSnapshot, CollectionError> {
let account_data = github.run_query(ACCOUNT_QUERY, &[("login", login)])?;
let user = account_data
.get("user")
.filter(|user| !user.is_null())
.ok_or_else(|| CollectionError::AccountNotFound(login.to_owned()))?;
let account_id = read_u64(user, "databaseId")?;
let created_on = DateTime::parse_from_rfc3339(read_str(user, "createdAt")?)
.map_err(|parse_error| {
CollectionError::UnexpectedResponse(format!(
"createdAt is not a timestamp: {parse_error}"
))
})?
.date_naive();
let mut language_colors = BTreeMap::new();
let mut repositories = collect_repositories(
github,
login,
&mut language_colors,
"repositories",
"repositories(first: 100, after: $cursor, ownerAffiliations: [OWNER], privacy: PUBLIC)",
)?;
repositories.extend(collect_repositories(
github,
login,
&mut language_colors,
"repositoriesContributedTo",
"repositoriesContributedTo(first: 100, after: $cursor, privacy: PUBLIC, \
includeUserRepositories: false, \
contributionTypes: [COMMIT, ISSUE, PULL_REQUEST, PULL_REQUEST_REVIEW, REPOSITORY])",
)?);
let account_snapshot = AccountSnapshot {
account_id,
repositories,
contribution_history: Some(collect_contribution_history(
github, login, created_on, today,
)?),
language_colors: Some(language_colors),
};
summarize_contributions(&account_snapshot).map_err(CollectionError::InvalidHistory)?;
calculate_language_shares(&account_snapshot).map_err(CollectionError::ConflictingRepository)?;
Ok(account_snapshot)
}
fn collect_repositories(
github: &impl GraphqlClient,
login: &str,
language_colors: &mut BTreeMap<String, Option<LanguageColor>>,
connection_name: &str,
connection_call: &str,
) -> Result<Vec<RepositoryObservation>, CollectionError> {
let query = format!(
"query($login: String!, $cursor: String) {{ user(login: $login) {{ {connection_call} {{ {REPOSITORY_FIELDS} }} }} }}"
);
let mut repositories = Vec::new();
let mut page_cursor: Option<String> = None;
loop {
let mut variables = vec![("login", login)];
if let Some(page_cursor) = &page_cursor {
variables.push(("cursor", page_cursor.as_str()));
}
let page_data = github.run_query(&query, &variables)?;
let connection = read_field(read_field(&page_data, "user")?, connection_name)?;
for repository_node in read_array(connection, "nodes")? {
repositories.push(read_repository(repository_node, language_colors)?);
}
let page_info = read_field(connection, "pageInfo")?;
if !read_bool(page_info, "hasNextPage")? {
return Ok(repositories);
}
let next_cursor = read_str(page_info, "endCursor")?.to_owned();
if page_cursor.as_deref() == Some(next_cursor.as_str()) {
return Err(CollectionError::UnexpectedResponse(format!(
"{connection_name} repeated page cursor {next_cursor}"
)));
}
page_cursor = Some(next_cursor);
}
}
fn read_repository(
repository_node: &Value,
language_colors: &mut BTreeMap<String, Option<LanguageColor>>,
) -> Result<RepositoryObservation, CollectionError> {
let visibility = match read_str(repository_node, "visibility")? {
"PUBLIC" => RepositoryVisibility::Public,
"PRIVATE" => RepositoryVisibility::Private,
"INTERNAL" => RepositoryVisibility::Internal,
other => {
return Err(CollectionError::UnexpectedResponse(format!(
"unknown repository visibility {other}"
)));
}
};
let primary_language = match read_field(repository_node, "primaryLanguage")? {
Value::Null => None,
language => {
let language_name = read_str(language, "name")?.to_owned();
let language_color = match read_field(language, "color")? {
Value::Null => None,
color => Some(
LanguageColor::try_from(color.as_str().ok_or_else(|| {
CollectionError::UnexpectedResponse(format!(
"{language_name} color is not text"
))
})?)
.map_err(CollectionError::UnexpectedResponse)?,
),
};
if let Some(recorded_color) =
language_colors.insert(language_name.clone(), language_color)
&& recorded_color != language_color
{
return Err(CollectionError::ConflictingLanguageColor(language_name));
}
Some(language_name)
}
};
Ok(RepositoryObservation {
repository_id: read_u64(repository_node, "databaseId")?,
visibility,
is_fork: read_bool(repository_node, "isFork")?,
primary_language,
})
}
fn collect_contribution_history(
github: &impl GraphqlClient,
login: &str,
created_on: NaiveDate,
today: NaiveDate,
) -> Result<ContributionHistory, CollectionError> {
let last_complete_day = today
.pred_opt()
.expect("calendar windows stay within supported dates");
if created_on > last_complete_day {
return Ok(ContributionHistory {
observed_on: last_complete_day,
days: Vec::new(),
});
}
let mut daily_counts = BTreeMap::new();
let mut window_start = created_on;
while window_start <= last_complete_day {
let window_end = window_start
.checked_add_months(Months::new(12))
.and_then(|next_year| next_year.pred_opt())
.expect("calendar windows stay within supported dates")
.min(last_complete_day);
daily_counts.extend(collect_calendar_window(
github,
login,
window_start,
window_end,
)?);
window_start = window_end
.checked_add_days(Days::new(1))
.expect("calendar windows stay within supported dates");
}
let first_reported_on = daily_counts.keys().next().copied();
let observed_on = match (first_reported_on, daily_counts.keys().next_back()) {
(Some(first_date), Some(&last_date)) if first_date == created_on => last_date,
_ => {
return Err(CollectionError::IncompleteCalendar {
created_on,
first_reported_on,
});
}
};
Ok(ContributionHistory {
observed_on,
days: daily_counts
.into_iter()
.map(|(date, contribution_count)| ContributionDay {
date,
contribution_count,
})
.collect(),
})
}
fn collect_calendar_window(
github: &impl GraphqlClient,
login: &str,
window_start: NaiveDate,
window_end: NaiveDate,
) -> Result<BTreeMap<NaiveDate, u32>, CollectionError> {
let mut window_daily_counts = BTreeMap::new();
let mut request_end = window_end;
loop {
let request_daily_counts = request_calendar_days(github, login, window_start, request_end)?;
let Some(&earliest_reported_on) = request_daily_counts.keys().next() else {
return Ok(window_daily_counts);
};
window_daily_counts.extend(request_daily_counts);
if earliest_reported_on == window_start {
return Ok(window_daily_counts);
}
request_end = earliest_reported_on
.pred_opt()
.expect("calendar windows stay within supported dates");
}
}
fn request_calendar_days(
github: &impl GraphqlClient,
login: &str,
request_start: NaiveDate,
request_end: NaiveDate,
) -> Result<BTreeMap<NaiveDate, u32>, CollectionError> {
let from = format!("{request_start}T00:00:00Z");
let to = format!("{request_end}T23:59:59Z");
let calendar_data = github.run_query(
CALENDAR_QUERY,
&[("login", login), ("from", &from), ("to", &to)],
)?;
let calendar = read_field(
read_field(
read_field(&calendar_data, "user")?,
"contributionsCollection",
)?,
"contributionCalendar",
)?;
let mut daily_counts = BTreeMap::new();
for calendar_week in read_array(calendar, "weeks")? {
for calendar_day in read_array(calendar_week, "contributionDays")? {
let contribution_date =
NaiveDate::parse_from_str(read_str(calendar_day, "date")?, "%Y-%m-%d").map_err(
|parse_error| {
CollectionError::UnexpectedResponse(format!(
"calendar date is invalid: {parse_error}"
))
},
)?;
if contribution_date < request_start || contribution_date > request_end {
continue;
}
let contribution_count = u32::try_from(read_u64(calendar_day, "contributionCount")?)
.map_err(|_| {
CollectionError::UnexpectedResponse(format!(
"contribution count for {contribution_date} is out of range"
))
})?;
if let Some(previous_count) = daily_counts.insert(contribution_date, contribution_count)
&& previous_count != contribution_count
{
return Err(CollectionError::InvalidHistory(
ContributionHistoryError::ConflictingDate(contribution_date),
));
}
}
}
Ok(daily_counts)
}
fn read_field<'a>(object: &'a Value, field_name: &str) -> Result<&'a Value, CollectionError> {
object.get(field_name).ok_or_else(|| {
CollectionError::UnexpectedResponse(format!("response is missing {field_name}"))
})
}
fn read_array<'a>(object: &'a Value, field_name: &str) -> Result<&'a Vec<Value>, CollectionError> {
read_field(object, field_name)?
.as_array()
.ok_or_else(|| CollectionError::UnexpectedResponse(format!("{field_name} is not a list")))
}
fn read_str<'a>(object: &'a Value, field_name: &str) -> Result<&'a str, CollectionError> {
read_field(object, field_name)?
.as_str()
.ok_or_else(|| CollectionError::UnexpectedResponse(format!("{field_name} is not text")))
}
fn read_u64(object: &Value, field_name: &str) -> Result<u64, CollectionError> {
read_field(object, field_name)?.as_u64().ok_or_else(|| {
CollectionError::UnexpectedResponse(format!("{field_name} is not a whole number"))
})
}
fn read_bool(object: &Value, field_name: &str) -> Result<bool, CollectionError> {
read_field(object, field_name)?.as_bool().ok_or_else(|| {
CollectionError::UnexpectedResponse(format!("{field_name} is not true or false"))
})
}
#[derive(Debug, Eq, PartialEq)]
pub enum CollectionError {
GhNotInstalled,
GhFailed(String),
AccountNotFound(String),
UnexpectedResponse(String),
IncompleteCalendar {
created_on: NaiveDate,
first_reported_on: Option<NaiveDate>,
},
InvalidHistory(ContributionHistoryError),
ConflictingRepository(ConflictingRepository),
ConflictingLanguageColor(String),
}
impl fmt::Display for CollectionError {
fn fmt(&self, output_formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::GhNotInstalled => write!(
output_formatter,
"gh is not installed or not on PATH; install the GitHub CLI from https://cli.github.com"
),
Self::GhFailed(gh_message) => write!(
output_formatter,
"gh api failed: {gh_message}; check `gh auth status`, or set GH_TOKEN in CI"
),
Self::AccountNotFound(login) => {
write!(output_formatter, "GitHub has no account named {login}")
}
Self::UnexpectedResponse(detail) => write!(
output_formatter,
"unexpected GitHub response: {detail}; try again or report the response"
),
Self::IncompleteCalendar {
created_on,
first_reported_on: Some(first_reported_on),
} => write!(
output_formatter,
"GitHub's contribution calendar starts on {first_reported_on}, but the account was created on {created_on}; try again"
),
Self::IncompleteCalendar {
created_on,
first_reported_on: None,
} => write!(
output_formatter,
"GitHub reported no contribution days since the account was created on {created_on}; try again"
),
Self::InvalidHistory(history_error) => {
write!(
output_formatter,
"collected history is invalid: {history_error}"
)
}
Self::ConflictingRepository(repository_error) => {
write!(output_formatter, "{repository_error}")
}
Self::ConflictingLanguageColor(language_name) => write!(
output_formatter,
"GitHub reported two colors for {language_name}; try again"
),
}
}
}
impl Error for CollectionError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
Self::InvalidHistory(history_error) => Some(history_error),
Self::ConflictingRepository(repository_error) => Some(repository_error),
_ => None,
}
}
}