1use std::collections::BTreeMap;
5use std::error::Error;
6use std::fmt;
7use std::io::ErrorKind;
8use std::process::Command;
9
10use chrono::{DateTime, Days, Months, NaiveDate};
11use serde_json::Value;
12
13use crate::history::{
14 AccountSnapshot, ConflictingRepository, ContributionDay, ContributionHistory,
15 ContributionHistoryError, LanguageColor, RepositoryObservation, RepositoryVisibility,
16 calculate_language_shares, summarize_contributions,
17};
18
19pub trait GraphqlClient {
21 fn run_query(&self, query: &str, variables: &[(&str, &str)]) -> Result<Value, CollectionError>;
24}
25
26pub struct GhCli;
29
30impl GraphqlClient for GhCli {
31 fn run_query(&self, query: &str, variables: &[(&str, &str)]) -> Result<Value, CollectionError> {
32 let mut gh_command = Command::new("gh");
33 gh_command
34 .args(["api", "graphql", "-f"])
35 .arg(format!("query={query}"));
36 for (variable_name, variable_value) in variables {
37 gh_command
39 .arg("-f")
40 .arg(format!("{variable_name}={variable_value}"));
41 }
42 let gh_output = gh_command.output().map_err(|spawn_error| {
43 if spawn_error.kind() == ErrorKind::NotFound {
44 CollectionError::GhNotInstalled
45 } else {
46 CollectionError::GhFailed(spawn_error.to_string())
47 }
48 })?;
49 if !gh_output.status.success() {
50 return Err(CollectionError::GhFailed(
51 String::from_utf8_lossy(&gh_output.stderr).trim().to_owned(),
52 ));
53 }
54 let response: Value = serde_json::from_slice(&gh_output.stdout).map_err(|parse_error| {
55 CollectionError::UnexpectedResponse(format!("gh output is not JSON: {parse_error}"))
56 })?;
57 Ok(read_field(&response, "data")?.clone())
58 }
59}
60
61const ACCOUNT_QUERY: &str =
62 "query($login: String!) { user(login: $login) { databaseId createdAt } }";
63const REPOSITORY_FIELDS: &str = "nodes { databaseId visibility isFork primaryLanguage { name color } } pageInfo { hasNextPage endCursor }";
64const CALENDAR_QUERY: &str = "query($login: String!, $from: DateTime!, $to: DateTime!) { \
65 user(login: $login) { contributionsCollection(from: $from, to: $to) { \
66 contributionCalendar { weeks { contributionDays { date contributionCount } } } } } }";
67
68pub fn collect_account_snapshot(
82 github: &impl GraphqlClient,
83 login: &str,
84 today: NaiveDate,
85) -> Result<AccountSnapshot, CollectionError> {
86 let account_data = github.run_query(ACCOUNT_QUERY, &[("login", login)])?;
87 let user = account_data
88 .get("user")
89 .filter(|user| !user.is_null())
90 .ok_or_else(|| CollectionError::AccountNotFound(login.to_owned()))?;
91 let account_id = read_u64(user, "databaseId")?;
92 let created_on = DateTime::parse_from_rfc3339(read_str(user, "createdAt")?)
93 .map_err(|parse_error| {
94 CollectionError::UnexpectedResponse(format!(
95 "createdAt is not a timestamp: {parse_error}"
96 ))
97 })?
98 .date_naive();
99 let mut language_colors = BTreeMap::new();
100 let mut repositories = collect_repositories(
101 github,
102 login,
103 &mut language_colors,
104 "repositories",
105 "repositories(first: 100, after: $cursor, ownerAffiliations: [OWNER], privacy: PUBLIC)",
106 )?;
107 repositories.extend(collect_repositories(
108 github,
109 login,
110 &mut language_colors,
111 "repositoriesContributedTo",
112 "repositoriesContributedTo(first: 100, after: $cursor, privacy: PUBLIC, \
113 includeUserRepositories: false, \
114 contributionTypes: [COMMIT, ISSUE, PULL_REQUEST, PULL_REQUEST_REVIEW, REPOSITORY])",
115 )?);
116 let account_snapshot = AccountSnapshot {
117 account_id,
118 repositories,
119 contribution_history: Some(collect_contribution_history(
120 github, login, created_on, today,
121 )?),
122 language_colors: Some(language_colors),
123 };
124 summarize_contributions(&account_snapshot).map_err(CollectionError::InvalidHistory)?;
125 calculate_language_shares(&account_snapshot).map_err(CollectionError::ConflictingRepository)?;
126 Ok(account_snapshot)
127}
128
129fn collect_repositories(
132 github: &impl GraphqlClient,
133 login: &str,
134 language_colors: &mut BTreeMap<String, Option<LanguageColor>>,
135 connection_name: &str,
136 connection_call: &str,
137) -> Result<Vec<RepositoryObservation>, CollectionError> {
138 let query = format!(
139 "query($login: String!, $cursor: String) {{ user(login: $login) {{ {connection_call} {{ {REPOSITORY_FIELDS} }} }} }}"
140 );
141 let mut repositories = Vec::new();
142 let mut page_cursor: Option<String> = None;
143 loop {
144 let mut variables = vec![("login", login)];
145 if let Some(page_cursor) = &page_cursor {
146 variables.push(("cursor", page_cursor.as_str()));
147 }
148 let page_data = github.run_query(&query, &variables)?;
149 let connection = read_field(read_field(&page_data, "user")?, connection_name)?;
150 for repository_node in read_array(connection, "nodes")? {
151 repositories.push(read_repository(repository_node, language_colors)?);
152 }
153 let page_info = read_field(connection, "pageInfo")?;
154 if !read_bool(page_info, "hasNextPage")? {
155 return Ok(repositories);
156 }
157 let next_cursor = read_str(page_info, "endCursor")?.to_owned();
158 if page_cursor.as_deref() == Some(next_cursor.as_str()) {
159 return Err(CollectionError::UnexpectedResponse(format!(
160 "{connection_name} repeated page cursor {next_cursor}"
161 )));
162 }
163 page_cursor = Some(next_cursor);
164 }
165}
166
167fn read_repository(
168 repository_node: &Value,
169 language_colors: &mut BTreeMap<String, Option<LanguageColor>>,
170) -> Result<RepositoryObservation, CollectionError> {
171 let visibility = match read_str(repository_node, "visibility")? {
172 "PUBLIC" => RepositoryVisibility::Public,
173 "PRIVATE" => RepositoryVisibility::Private,
174 "INTERNAL" => RepositoryVisibility::Internal,
175 other => {
176 return Err(CollectionError::UnexpectedResponse(format!(
177 "unknown repository visibility {other}"
178 )));
179 }
180 };
181 let primary_language = match read_field(repository_node, "primaryLanguage")? {
182 Value::Null => None,
183 language => {
184 let language_name = read_str(language, "name")?.to_owned();
185 let language_color = match read_field(language, "color")? {
186 Value::Null => None,
187 color => Some(
188 LanguageColor::try_from(color.as_str().ok_or_else(|| {
189 CollectionError::UnexpectedResponse(format!(
190 "{language_name} color is not text"
191 ))
192 })?)
193 .map_err(CollectionError::UnexpectedResponse)?,
194 ),
195 };
196 if let Some(recorded_color) =
197 language_colors.insert(language_name.clone(), language_color)
198 && recorded_color != language_color
199 {
200 return Err(CollectionError::ConflictingLanguageColor(language_name));
201 }
202 Some(language_name)
203 }
204 };
205 Ok(RepositoryObservation {
206 repository_id: read_u64(repository_node, "databaseId")?,
207 visibility,
208 is_fork: read_bool(repository_node, "isFork")?,
209 primary_language,
210 })
211}
212
213fn collect_contribution_history(
216 github: &impl GraphqlClient,
217 login: &str,
218 created_on: NaiveDate,
219 today: NaiveDate,
220) -> Result<ContributionHistory, CollectionError> {
221 let last_complete_day = today
222 .pred_opt()
223 .expect("calendar windows stay within supported dates");
224 if created_on > last_complete_day {
225 return Ok(ContributionHistory {
226 observed_on: last_complete_day,
227 days: Vec::new(),
228 });
229 }
230 let mut daily_counts = BTreeMap::new();
231 let mut window_start = created_on;
232 while window_start <= last_complete_day {
233 let window_end = window_start
234 .checked_add_months(Months::new(12))
235 .and_then(|next_year| next_year.pred_opt())
236 .expect("calendar windows stay within supported dates")
237 .min(last_complete_day);
238 let from = format!("{window_start}T00:00:00Z");
239 let to = format!("{window_end}T23:59:59Z");
240 let calendar_data = github.run_query(
241 CALENDAR_QUERY,
242 &[("login", login), ("from", &from), ("to", &to)],
243 )?;
244 let calendar = read_field(
245 read_field(
246 read_field(&calendar_data, "user")?,
247 "contributionsCollection",
248 )?,
249 "contributionCalendar",
250 )?;
251 for calendar_week in read_array(calendar, "weeks")? {
252 for calendar_day in read_array(calendar_week, "contributionDays")? {
253 let contribution_date =
254 NaiveDate::parse_from_str(read_str(calendar_day, "date")?, "%Y-%m-%d")
255 .map_err(|parse_error| {
256 CollectionError::UnexpectedResponse(format!(
257 "calendar date is invalid: {parse_error}"
258 ))
259 })?;
260 if contribution_date < window_start || contribution_date > window_end {
261 continue;
262 }
263 let contribution_count =
264 u32::try_from(read_u64(calendar_day, "contributionCount")?).map_err(|_| {
265 CollectionError::UnexpectedResponse(format!(
266 "contribution count for {contribution_date} is out of range"
267 ))
268 })?;
269 if let Some(previous_count) =
270 daily_counts.insert(contribution_date, contribution_count)
271 && previous_count != contribution_count
272 {
273 return Err(CollectionError::InvalidHistory(
274 ContributionHistoryError::ConflictingDate(contribution_date),
275 ));
276 }
277 }
278 }
279 window_start = window_end
280 .checked_add_days(Days::new(1))
281 .expect("calendar windows stay within supported dates");
282 }
283 let first_reported_on = daily_counts.keys().next().copied();
284 let observed_on = match (first_reported_on, daily_counts.keys().next_back()) {
285 (Some(first_date), Some(&last_date)) if first_date == created_on => last_date,
286 _ => {
287 return Err(CollectionError::IncompleteCalendar {
288 created_on,
289 first_reported_on,
290 });
291 }
292 };
293 Ok(ContributionHistory {
294 observed_on,
295 days: daily_counts
296 .into_iter()
297 .map(|(date, contribution_count)| ContributionDay {
298 date,
299 contribution_count,
300 })
301 .collect(),
302 })
303}
304
305fn read_field<'a>(object: &'a Value, field_name: &str) -> Result<&'a Value, CollectionError> {
306 object.get(field_name).ok_or_else(|| {
307 CollectionError::UnexpectedResponse(format!("response is missing {field_name}"))
308 })
309}
310
311fn read_array<'a>(object: &'a Value, field_name: &str) -> Result<&'a Vec<Value>, CollectionError> {
312 read_field(object, field_name)?
313 .as_array()
314 .ok_or_else(|| CollectionError::UnexpectedResponse(format!("{field_name} is not a list")))
315}
316
317fn read_str<'a>(object: &'a Value, field_name: &str) -> Result<&'a str, CollectionError> {
318 read_field(object, field_name)?
319 .as_str()
320 .ok_or_else(|| CollectionError::UnexpectedResponse(format!("{field_name} is not text")))
321}
322
323fn read_u64(object: &Value, field_name: &str) -> Result<u64, CollectionError> {
324 read_field(object, field_name)?.as_u64().ok_or_else(|| {
325 CollectionError::UnexpectedResponse(format!("{field_name} is not a whole number"))
326 })
327}
328
329fn read_bool(object: &Value, field_name: &str) -> Result<bool, CollectionError> {
330 read_field(object, field_name)?.as_bool().ok_or_else(|| {
331 CollectionError::UnexpectedResponse(format!("{field_name} is not true or false"))
332 })
333}
334
335#[derive(Debug, Eq, PartialEq)]
337pub enum CollectionError {
338 GhNotInstalled,
340 GhFailed(String),
342 AccountNotFound(String),
344 UnexpectedResponse(String),
346 IncompleteCalendar {
348 created_on: NaiveDate,
350 first_reported_on: Option<NaiveDate>,
352 },
353 InvalidHistory(ContributionHistoryError),
355 ConflictingRepository(ConflictingRepository),
357 ConflictingLanguageColor(String),
359}
360
361impl fmt::Display for CollectionError {
362 fn fmt(&self, output_formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
363 match self {
364 Self::GhNotInstalled => write!(
365 output_formatter,
366 "gh is not installed or not on PATH; install the GitHub CLI from https://cli.github.com"
367 ),
368 Self::GhFailed(gh_message) => write!(
369 output_formatter,
370 "gh api failed: {gh_message}; check `gh auth status`, or set GH_TOKEN in CI"
371 ),
372 Self::AccountNotFound(login) => {
373 write!(output_formatter, "GitHub has no account named {login}")
374 }
375 Self::UnexpectedResponse(detail) => write!(
376 output_formatter,
377 "unexpected GitHub response: {detail}; try again or report the response"
378 ),
379 Self::IncompleteCalendar {
380 created_on,
381 first_reported_on: Some(first_reported_on),
382 } => write!(
383 output_formatter,
384 "GitHub's contribution calendar starts on {first_reported_on}, but the account was created on {created_on}; try again"
385 ),
386 Self::IncompleteCalendar {
387 created_on,
388 first_reported_on: None,
389 } => write!(
390 output_formatter,
391 "GitHub reported no contribution days since the account was created on {created_on}; try again"
392 ),
393 Self::InvalidHistory(history_error) => {
394 write!(
395 output_formatter,
396 "collected history is invalid: {history_error}"
397 )
398 }
399 Self::ConflictingRepository(repository_error) => {
400 write!(output_formatter, "{repository_error}")
401 }
402 Self::ConflictingLanguageColor(language_name) => write!(
403 output_formatter,
404 "GitHub reported two colors for {language_name}; try again"
405 ),
406 }
407 }
408}
409
410impl Error for CollectionError {
411 fn source(&self) -> Option<&(dyn Error + 'static)> {
412 match self {
413 Self::InvalidHistory(history_error) => Some(history_error),
414 Self::ConflictingRepository(repository_error) => Some(repository_error),
415 _ => None,
416 }
417 }
418}