use std::io::ErrorKind;
use std::process::Command;
use chrono::{Datelike, Local, NaiveDate};
use serde::Deserialize;
use crate::calendar::{Calendar, Day};
const QUERY: &str = "\
query($login: String!, $from: DateTime!, $to: DateTime!) {
user(login: $login) {
login
contributionsCollection(from: $from, to: $to) {
contributionYears
contributionCalendar {
totalContributions
weeks { contributionDays { date contributionCount contributionLevel } }
}
}
}
}";
const NO_GH: &str = "`gh` (GitHub CLI) was not found on PATH. \
Install it from https://cli.github.com, then run `gh auth login`.";
pub fn fetch(login: &str, year: i32) -> Result<Calendar, String> {
let from = format!("{year}-01-01T00:00:00Z");
let to = format!("{year}-12-31T23:59:59Z");
let body = run_query(&[("login", login), ("from", &from), ("to", &to)])?;
parse(year, &body, Some(Local::now().date_naive()))
}
pub fn from_file(path: &str) -> Result<Calendar, String> {
let body =
std::fs::read_to_string(path).map_err(|error| format!("could not read {path}: {error}"))?;
parse(Local::now().year(), &body, None)
}
pub fn whoami() -> Option<String> {
let out = Command::new("gh")
.args(["api", "user", "--jq", ".login"])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let login = String::from_utf8_lossy(&out.stdout).trim().to_string();
(!login.is_empty()).then_some(login)
}
fn run_query(vars: &[(&str, &str)]) -> Result<String, String> {
let mut cmd = Command::new("gh");
cmd.args(["api", "graphql", "-f", &format!("query={QUERY}")]);
for (key, value) in vars {
cmd.arg("-f").arg(format!("{key}={value}"));
}
let out = cmd.output().map_err(|e| match e.kind() {
ErrorKind::NotFound => NO_GH.to_string(),
_ => format!("could not run gh: {e}"),
})?;
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
if stdout.trim().is_empty() {
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
return Err(if stderr.is_empty() {
format!("gh exited with {} and no output", out.status)
} else {
crate::printable(&stderr)
});
}
Ok(stdout)
}
fn parse(fallback_year: i32, body: &str, now: Option<NaiveDate>) -> Result<Calendar, String> {
let resp: Response =
serde_json::from_str(body).map_err(|e| format!("unexpected response from gh: {e}"))?;
if let Some(message) = resp.errors.into_iter().flatten().next().map(|e| e.message) {
return Err(crate::printable(&message));
}
let user = resp
.data
.and_then(|d| d.user)
.ok_or_else(|| "GitHub returned no user for that login".to_string())?;
let calendar = &user.contributions.calendar;
let mut days = Vec::with_capacity(371);
for week in &calendar.weeks {
for raw in &week.days {
let date = NaiveDate::parse_from_str(&raw.date, "%Y-%m-%d")
.map_err(|e| format!("bad date {:?}: {e}", raw.date))?;
days.push(Day {
date,
count: raw.count,
level: level_of(&raw.level),
future: now.is_some_and(|today| date > today),
});
}
}
days.sort_unstable_by_key(|d| d.date);
if let (Some(first), Some(last)) = (days.first(), days.last()) {
let span = (last.date - first.date).num_days();
if span > 400 {
return Err(format!(
"that calendar spans {span} days from {} to {}; a year is at most 366",
first.date, last.date
));
}
}
let year = days.first().map_or(fallback_year, |day| day.date.year());
Ok(Calendar::build(
crate::printable(&user.login),
year,
calendar.total,
user.contributions.years,
days,
))
}
fn level_of(level: &str) -> u8 {
match level {
"FIRST_QUARTILE" => 1,
"SECOND_QUARTILE" => 2,
"THIRD_QUARTILE" => 3,
"FOURTH_QUARTILE" => 4,
_ => 0,
}
}
#[derive(Deserialize)]
struct Response {
data: Option<Data>,
errors: Option<Vec<GraphQlError>>,
}
#[derive(Deserialize)]
struct Data {
user: Option<User>,
}
#[derive(Deserialize)]
struct User {
login: String,
#[serde(rename = "contributionsCollection")]
contributions: Contributions,
}
#[derive(Deserialize)]
struct Contributions {
#[serde(rename = "contributionYears")]
years: Vec<i32>,
#[serde(rename = "contributionCalendar")]
calendar: CalendarData,
}
#[derive(Deserialize)]
struct CalendarData {
#[serde(rename = "totalContributions")]
total: u32,
weeks: Vec<RawWeek>,
}
#[derive(Deserialize)]
struct RawWeek {
#[serde(rename = "contributionDays")]
days: Vec<RawDay>,
}
#[derive(Deserialize)]
struct RawDay {
date: String,
#[serde(rename = "contributionCount")]
count: u32,
#[serde(rename = "contributionLevel")]
level: String,
}
#[derive(Deserialize)]
struct GraphQlError {
message: String,
}