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(
83 github: &impl GraphqlClient,
84 login: &str,
85 today: NaiveDate,
86) -> Result<AccountSnapshot, CollectionError> {
87 let account_data = github.run_query(ACCOUNT_QUERY, &[("login", login)])?;
88 let user = account_data
89 .get("user")
90 .filter(|user| !user.is_null())
91 .ok_or_else(|| CollectionError::AccountNotFound(login.to_owned()))?;
92 let account_id = read_u64(user, "databaseId")?;
93 let created_on = DateTime::parse_from_rfc3339(read_str(user, "createdAt")?)
94 .map_err(|parse_error| {
95 CollectionError::UnexpectedResponse(format!(
96 "createdAt is not a timestamp: {parse_error}"
97 ))
98 })?
99 .date_naive();
100 let mut language_colors = BTreeMap::new();
101 let mut repositories = collect_repositories(
102 github,
103 login,
104 &mut language_colors,
105 "repositories",
106 "repositories(first: 100, after: $cursor, ownerAffiliations: [OWNER], privacy: PUBLIC)",
107 )?;
108 repositories.extend(collect_repositories(
109 github,
110 login,
111 &mut language_colors,
112 "repositoriesContributedTo",
113 "repositoriesContributedTo(first: 100, after: $cursor, privacy: PUBLIC, \
114 includeUserRepositories: false, \
115 contributionTypes: [COMMIT, ISSUE, PULL_REQUEST, PULL_REQUEST_REVIEW, REPOSITORY])",
116 )?);
117 let account_snapshot = AccountSnapshot {
118 account_id,
119 repositories,
120 contribution_history: Some(collect_contribution_history(
121 github, login, created_on, today,
122 )?),
123 language_colors: Some(language_colors),
124 };
125 summarize_contributions(&account_snapshot).map_err(CollectionError::InvalidHistory)?;
126 calculate_language_shares(&account_snapshot).map_err(CollectionError::ConflictingRepository)?;
127 Ok(account_snapshot)
128}
129
130fn collect_repositories(
133 github: &impl GraphqlClient,
134 login: &str,
135 language_colors: &mut BTreeMap<String, Option<LanguageColor>>,
136 connection_name: &str,
137 connection_call: &str,
138) -> Result<Vec<RepositoryObservation>, CollectionError> {
139 let query = format!(
140 "query($login: String!, $cursor: String) {{ user(login: $login) {{ {connection_call} {{ {REPOSITORY_FIELDS} }} }} }}"
141 );
142 let mut repositories = Vec::new();
143 let mut page_cursor: Option<String> = None;
144 loop {
145 let mut variables = vec![("login", login)];
146 if let Some(page_cursor) = &page_cursor {
147 variables.push(("cursor", page_cursor.as_str()));
148 }
149 let page_data = github.run_query(&query, &variables)?;
150 let connection = read_field(read_field(&page_data, "user")?, connection_name)?;
151 for repository_node in read_array(connection, "nodes")? {
152 repositories.push(read_repository(repository_node, language_colors)?);
153 }
154 let page_info = read_field(connection, "pageInfo")?;
155 if !read_bool(page_info, "hasNextPage")? {
156 return Ok(repositories);
157 }
158 let next_cursor = read_str(page_info, "endCursor")?.to_owned();
159 if page_cursor.as_deref() == Some(next_cursor.as_str()) {
160 return Err(CollectionError::UnexpectedResponse(format!(
161 "{connection_name} repeated page cursor {next_cursor}"
162 )));
163 }
164 page_cursor = Some(next_cursor);
165 }
166}
167
168fn read_repository(
169 repository_node: &Value,
170 language_colors: &mut BTreeMap<String, Option<LanguageColor>>,
171) -> Result<RepositoryObservation, CollectionError> {
172 let visibility = match read_str(repository_node, "visibility")? {
173 "PUBLIC" => RepositoryVisibility::Public,
174 "PRIVATE" => RepositoryVisibility::Private,
175 "INTERNAL" => RepositoryVisibility::Internal,
176 other => {
177 return Err(CollectionError::UnexpectedResponse(format!(
178 "unknown repository visibility {other}"
179 )));
180 }
181 };
182 let primary_language = match read_field(repository_node, "primaryLanguage")? {
183 Value::Null => None,
184 language => {
185 let language_name = read_str(language, "name")?.to_owned();
186 let language_color = match read_field(language, "color")? {
187 Value::Null => None,
188 color => Some(
189 LanguageColor::try_from(color.as_str().ok_or_else(|| {
190 CollectionError::UnexpectedResponse(format!(
191 "{language_name} color is not text"
192 ))
193 })?)
194 .map_err(CollectionError::UnexpectedResponse)?,
195 ),
196 };
197 if let Some(recorded_color) =
198 language_colors.insert(language_name.clone(), language_color)
199 && recorded_color != language_color
200 {
201 return Err(CollectionError::ConflictingLanguageColor(language_name));
202 }
203 Some(language_name)
204 }
205 };
206 Ok(RepositoryObservation {
207 repository_id: read_u64(repository_node, "databaseId")?,
208 visibility,
209 is_fork: read_bool(repository_node, "isFork")?,
210 primary_language,
211 })
212}
213
214fn collect_contribution_history(
217 github: &impl GraphqlClient,
218 login: &str,
219 created_on: NaiveDate,
220 today: NaiveDate,
221) -> Result<ContributionHistory, CollectionError> {
222 let last_complete_day = today
223 .pred_opt()
224 .expect("calendar windows stay within supported dates");
225 if created_on > last_complete_day {
226 return Ok(ContributionHistory {
227 observed_on: last_complete_day,
228 days: Vec::new(),
229 });
230 }
231 let mut daily_counts = BTreeMap::new();
232 let mut window_start = created_on;
233 while window_start <= last_complete_day {
234 let window_end = window_start
235 .checked_add_months(Months::new(12))
236 .and_then(|next_year| next_year.pred_opt())
237 .expect("calendar windows stay within supported dates")
238 .min(last_complete_day);
239 daily_counts.extend(collect_calendar_window(
240 github,
241 login,
242 window_start,
243 window_end,
244 )?);
245 window_start = window_end
246 .checked_add_days(Days::new(1))
247 .expect("calendar windows stay within supported dates");
248 }
249 let first_reported_on = daily_counts.keys().next().copied();
250 let observed_on = match (first_reported_on, daily_counts.keys().next_back()) {
251 (Some(first_date), Some(&last_date)) if first_date == created_on => last_date,
252 _ => {
253 return Err(CollectionError::IncompleteCalendar {
254 created_on,
255 first_reported_on,
256 });
257 }
258 };
259 Ok(ContributionHistory {
260 observed_on,
261 days: daily_counts
262 .into_iter()
263 .map(|(date, contribution_count)| ContributionDay {
264 date,
265 contribution_count,
266 })
267 .collect(),
268 })
269}
270
271fn collect_calendar_window(
277 github: &impl GraphqlClient,
278 login: &str,
279 window_start: NaiveDate,
280 window_end: NaiveDate,
281) -> Result<BTreeMap<NaiveDate, u32>, CollectionError> {
282 let mut window_daily_counts = BTreeMap::new();
283 let mut request_end = window_end;
284 loop {
285 let request_daily_counts = request_calendar_days(github, login, window_start, request_end)?;
286 let Some(&earliest_reported_on) = request_daily_counts.keys().next() else {
287 return Ok(window_daily_counts);
288 };
289 window_daily_counts.extend(request_daily_counts);
290 if earliest_reported_on == window_start {
291 return Ok(window_daily_counts);
292 }
293 request_end = earliest_reported_on
294 .pred_opt()
295 .expect("calendar windows stay within supported dates");
296 }
297}
298
299fn request_calendar_days(
303 github: &impl GraphqlClient,
304 login: &str,
305 request_start: NaiveDate,
306 request_end: NaiveDate,
307) -> Result<BTreeMap<NaiveDate, u32>, CollectionError> {
308 let from = format!("{request_start}T00:00:00Z");
309 let to = format!("{request_end}T23:59:59Z");
310 let calendar_data = github.run_query(
311 CALENDAR_QUERY,
312 &[("login", login), ("from", &from), ("to", &to)],
313 )?;
314 let calendar = read_field(
315 read_field(
316 read_field(&calendar_data, "user")?,
317 "contributionsCollection",
318 )?,
319 "contributionCalendar",
320 )?;
321 let mut daily_counts = BTreeMap::new();
322 for calendar_week in read_array(calendar, "weeks")? {
323 for calendar_day in read_array(calendar_week, "contributionDays")? {
324 let contribution_date =
325 NaiveDate::parse_from_str(read_str(calendar_day, "date")?, "%Y-%m-%d").map_err(
326 |parse_error| {
327 CollectionError::UnexpectedResponse(format!(
328 "calendar date is invalid: {parse_error}"
329 ))
330 },
331 )?;
332 if contribution_date < request_start || contribution_date > request_end {
333 continue;
334 }
335 let contribution_count = u32::try_from(read_u64(calendar_day, "contributionCount")?)
336 .map_err(|_| {
337 CollectionError::UnexpectedResponse(format!(
338 "contribution count for {contribution_date} is out of range"
339 ))
340 })?;
341 if let Some(previous_count) = daily_counts.insert(contribution_date, contribution_count)
342 && previous_count != contribution_count
343 {
344 return Err(CollectionError::InvalidHistory(
345 ContributionHistoryError::ConflictingDate(contribution_date),
346 ));
347 }
348 }
349 }
350 Ok(daily_counts)
351}
352
353fn read_field<'a>(object: &'a Value, field_name: &str) -> Result<&'a Value, CollectionError> {
354 object.get(field_name).ok_or_else(|| {
355 CollectionError::UnexpectedResponse(format!("response is missing {field_name}"))
356 })
357}
358
359fn read_array<'a>(object: &'a Value, field_name: &str) -> Result<&'a Vec<Value>, CollectionError> {
360 read_field(object, field_name)?
361 .as_array()
362 .ok_or_else(|| CollectionError::UnexpectedResponse(format!("{field_name} is not a list")))
363}
364
365fn read_str<'a>(object: &'a Value, field_name: &str) -> Result<&'a str, CollectionError> {
366 read_field(object, field_name)?
367 .as_str()
368 .ok_or_else(|| CollectionError::UnexpectedResponse(format!("{field_name} is not text")))
369}
370
371fn read_u64(object: &Value, field_name: &str) -> Result<u64, CollectionError> {
372 read_field(object, field_name)?.as_u64().ok_or_else(|| {
373 CollectionError::UnexpectedResponse(format!("{field_name} is not a whole number"))
374 })
375}
376
377fn read_bool(object: &Value, field_name: &str) -> Result<bool, CollectionError> {
378 read_field(object, field_name)?.as_bool().ok_or_else(|| {
379 CollectionError::UnexpectedResponse(format!("{field_name} is not true or false"))
380 })
381}
382
383#[derive(Debug, Eq, PartialEq)]
385pub enum CollectionError {
386 GhNotInstalled,
388 GhFailed(String),
390 AccountNotFound(String),
392 UnexpectedResponse(String),
394 IncompleteCalendar {
396 created_on: NaiveDate,
398 first_reported_on: Option<NaiveDate>,
400 },
401 InvalidHistory(ContributionHistoryError),
403 ConflictingRepository(ConflictingRepository),
405 ConflictingLanguageColor(String),
407}
408
409impl fmt::Display for CollectionError {
410 fn fmt(&self, output_formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
411 match self {
412 Self::GhNotInstalled => write!(
413 output_formatter,
414 "gh is not installed or not on PATH; install the GitHub CLI from https://cli.github.com"
415 ),
416 Self::GhFailed(gh_message) => write!(
417 output_formatter,
418 "gh api failed: {gh_message}; check `gh auth status`, or set GH_TOKEN in CI"
419 ),
420 Self::AccountNotFound(login) => {
421 write!(output_formatter, "GitHub has no account named {login}")
422 }
423 Self::UnexpectedResponse(detail) => write!(
424 output_formatter,
425 "unexpected GitHub response: {detail}; try again or report the response"
426 ),
427 Self::IncompleteCalendar {
428 created_on,
429 first_reported_on: Some(first_reported_on),
430 } => write!(
431 output_formatter,
432 "GitHub's contribution calendar starts on {first_reported_on}, but the account was created on {created_on}; try again"
433 ),
434 Self::IncompleteCalendar {
435 created_on,
436 first_reported_on: None,
437 } => write!(
438 output_formatter,
439 "GitHub reported no contribution days since the account was created on {created_on}; try again"
440 ),
441 Self::InvalidHistory(history_error) => {
442 write!(
443 output_formatter,
444 "collected history is invalid: {history_error}"
445 )
446 }
447 Self::ConflictingRepository(repository_error) => {
448 write!(output_formatter, "{repository_error}")
449 }
450 Self::ConflictingLanguageColor(language_name) => write!(
451 output_formatter,
452 "GitHub reported two colors for {language_name}; try again"
453 ),
454 }
455 }
456}
457
458impl Error for CollectionError {
459 fn source(&self) -> Option<&(dyn Error + 'static)> {
460 match self {
461 Self::InvalidHistory(history_error) => Some(history_error),
462 Self::ConflictingRepository(repository_error) => Some(repository_error),
463 _ => None,
464 }
465 }
466}