gh_trophy/github/
activity.rs

1use chrono::{Datelike, NaiveDate, Weekday};
2use serde::{Deserialize, Serialize, Serializer};
3use std::collections::HashMap;
4
5type UserName<'a> = &'a str;
6
7type DateRange = (NaiveDate, NaiveDate);
8
9#[derive(Debug, Hash, Eq, PartialEq)]
10pub struct YearWeek {
11    pub year: usize,
12    pub week: usize,
13}
14
15impl Serialize for YearWeek {
16    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
17    where
18        S: Serializer,
19    {
20        serializer.serialize_str(&format!("{}-W{:02}", self.year, self.week))
21    }
22}
23
24/// Data structure representing the GitHub user activity
25#[derive(Serialize, Debug)]
26pub struct Activity {
27    /// Date range of the represented activity period
28    pub date_range: DateRange,
29    /// Contributions in the activity date range, it is a map from
30    /// year to weeks.
31    /// Weeks are represented as a map from the day of the way (e.g: 0 -> Monday)
32    /// to the number of contributions on that day.
33    pub contributions: HashMap<YearWeek, HashMap<Weekday, u32>>,
34}
35
36impl Activity {
37    pub fn number_of_weeks(&self) -> usize {
38        ((self.date_range.1 - self.date_range.0).num_days() as f32 / 7.0).ceil() as usize
39    }
40
41    /// Obtain a simplified representation of the activity data.
42    /// This is a 2D matrix where rows are weeks and columns days of
43    /// the week with Monday at index 0.
44    pub fn as_matrix(&self) -> Vec<Vec<u32>> {
45        let mut matrix: Vec<Vec<u32>> = vec![vec![0; 7]; self.number_of_weeks()];
46        for date in self.date_range.0.iter_weeks().take(self.number_of_weeks()) {
47            let year_week = get_year_week(date);
48            if let Some(week_entry) = self.contributions.get(&year_week) {
49                for (weekday, count) in week_entry {
50                    let week_index = year_week.week - 1;
51                    let day_index = weekday.num_days_from_monday() as usize;
52                    matrix[week_index][day_index] = count.clone();
53                }
54            }
55        }
56        return matrix;
57    }
58}
59
60// GraphQL request and response structures
61#[derive(Serialize, Debug)]
62struct GraphQLRequest {
63    query: String,
64    variables: GraphQLVariables,
65}
66
67#[derive(Serialize, Debug)]
68struct GraphQLVariables {
69    username: String,
70    from: String,
71    to: String,
72}
73
74#[derive(Deserialize, Debug)]
75struct GraphQLResponse {
76    data: GraphQLData,
77}
78
79#[derive(Deserialize, Debug)]
80struct GraphQLData {
81    user: User,
82}
83
84#[derive(Deserialize, Debug)]
85struct User {
86    #[serde(rename = "contributionsCollection")]
87    contributions_collection: ContributionsCollection,
88}
89
90#[derive(Deserialize, Debug)]
91struct ContributionsCollection {
92    #[serde(rename = "contributionCalendar")]
93    contribution_calendar: ContributionCalendar,
94}
95
96#[derive(Deserialize, Debug)]
97struct ContributionCalendar {
98    #[allow(dead_code)]
99    #[serde(rename = "totalContributions")]
100    total_contributions: u32,
101    weeks: Vec<Week>,
102}
103
104#[derive(Deserialize, Debug)]
105struct Week {
106    #[serde(rename = "contributionDays")]
107    contribution_days: Vec<ContributionDay>,
108}
109
110#[derive(Deserialize, Debug)]
111struct ContributionDay {
112    date: String,
113    #[serde(rename = "contributionCount")]
114    contribution_count: u32,
115}
116
117fn get_year_week(date: NaiveDate) -> YearWeek {
118    let iso_week = date.iso_week();
119    YearWeek {
120        year: iso_week.year() as usize,
121        week: iso_week.week() as usize,
122    }
123}
124
125/// Function using GitHub GraphQL API to download target user
126/// activity on the specified date range.
127/// if `maybe_token`is not `None`, it will be used as application
128/// authentication token. This is required to obtain private repositories
129/// contributions.
130pub async fn get_activity(
131    user: UserName<'_>,
132    date_range: DateRange,
133    maybe_token: Option<String>,
134) -> Result<Activity, Box<dyn std::error::Error>> {
135    let number_of_weeks = ((date_range.1 - date_range.0).num_days() as f32 / 7.0).ceil() as usize;
136
137    let mut contributions: HashMap<YearWeek, HashMap<Weekday, u32>> = HashMap::new();
138
139    // Initialize the activity weeks
140    for date in date_range.0.iter_weeks().take(number_of_weeks) {
141        let year_week = get_year_week(date);
142        contributions.insert(year_week, HashMap::new());
143    }
144
145    let client = reqwest::Client::new();
146
147    // Format dates for GraphQL query (ISO 8601 format)
148    let from = format!("{}T00:00:00Z", date_range.0);
149    let to = format!("{}T23:59:59Z", date_range.1);
150
151    // GraphQL query to fetch contribution calendar
152    let graphql_query = r#"
153        query($username: String!, $from: DateTime!, $to: DateTime!) {
154            user(login: $username) {
155                contributionsCollection(from: $from, to: $to) {
156                    contributionCalendar {
157                        totalContributions
158                        weeks {
159                            contributionDays {
160                                date
161                                contributionCount
162                            }
163                        }
164                    }
165                }
166            }
167        }
168    "#;
169
170    let request_body = GraphQLRequest {
171        query: graphql_query.to_string(),
172        variables: GraphQLVariables {
173            username: user.to_string(),
174            from,
175            to,
176        },
177    };
178
179    // Build the request
180    let mut request = client
181        .post("https://api.github.com/graphql")
182        .header("User-Agent", "gh-trophy")
183        .json(&request_body);
184
185    // Add authentication if token is provided
186    if let Some(token) = maybe_token {
187        request = request.bearer_auth(token);
188    }
189
190    // Make the request
191    let response = request.send().await?;
192
193    // Check for errors
194    let status = response.status();
195    let response_text = response.text().await?;
196
197    if !status.is_success() {
198        return Err(format!("GitHub API error {}: {}", status, response_text).into());
199    }
200
201    // Parse the GraphQL response
202    let graphql_response: GraphQLResponse = serde_json::from_str(&response_text)?;
203
204    // Process the contribution calendar data
205    for week in graphql_response
206        .data
207        .user
208        .contributions_collection
209        .contribution_calendar
210        .weeks
211    {
212        for day in week.contribution_days {
213            if day.contribution_count > 0 {
214                // Parse the date
215                if let Ok(date) = NaiveDate::parse_from_str(&day.date, "%Y-%m-%d") {
216                    if date >= date_range.0 && date <= date_range.1 {
217                        let year_week = get_year_week(date);
218                        let weekday = date.weekday();
219
220                        let entry = contributions.entry(year_week).or_insert_with(HashMap::new);
221                        *entry.entry(weekday).or_insert(0) += day.contribution_count;
222                    }
223                }
224            }
225        }
226    }
227
228    Ok(Activity {
229        date_range,
230        contributions,
231    })
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237    use chrono::NaiveDate;
238
239    #[test]
240    fn test_year_week_serialization() {
241        let year_week = YearWeek {
242            year: 2024,
243            week: 5,
244        };
245        let serialized = serde_json::to_string(&year_week).unwrap();
246        assert_eq!(serialized, "\"2024-W05\"");
247    }
248
249    #[test]
250    fn test_year_week_serialization_single_digit() {
251        let year_week = YearWeek {
252            year: 2023,
253            week: 1,
254        };
255        let serialized = serde_json::to_string(&year_week).unwrap();
256        assert_eq!(serialized, "\"2023-W01\"");
257    }
258
259    #[test]
260    fn test_year_week_serialization_double_digit() {
261        let year_week = YearWeek {
262            year: 2024,
263            week: 52,
264        };
265        let serialized = serde_json::to_string(&year_week).unwrap();
266        assert_eq!(serialized, "\"2024-W52\"");
267    }
268
269    #[test]
270    fn test_get_year_week() {
271        let date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
272        let year_week = get_year_week(date);
273        assert_eq!(year_week.year, 2024);
274        assert_eq!(year_week.week, 3);
275    }
276
277    #[test]
278    fn test_get_year_week_start_of_year() {
279        let date = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
280        let year_week = get_year_week(date);
281        assert_eq!(year_week.year, 2024);
282        assert_eq!(year_week.week, 1);
283    }
284
285    #[test]
286    fn test_get_year_week_end_of_year() {
287        let date = NaiveDate::from_ymd_opt(2024, 12, 30).unwrap();
288        let year_week = get_year_week(date);
289        assert_eq!(year_week.year, 2025);
290        assert_eq!(year_week.week, 1);
291    }
292
293    #[test]
294    fn test_activity_number_of_weeks_exact() {
295        let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
296        let end = NaiveDate::from_ymd_opt(2024, 1, 28).unwrap();
297        let activity = Activity {
298            date_range: (start, end),
299            contributions: HashMap::new(),
300        };
301        assert_eq!(activity.number_of_weeks(), 4);
302    }
303
304    #[test]
305    fn test_activity_number_of_weeks_partial() {
306        let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
307        let end = NaiveDate::from_ymd_opt(2024, 1, 30).unwrap();
308        let activity = Activity {
309            date_range: (start, end),
310            contributions: HashMap::new(),
311        };
312        // 29 days / 7 = 4.14, should ceil to 5
313        assert_eq!(activity.number_of_weeks(), 5);
314    }
315
316    #[test]
317    fn test_activity_number_of_weeks_one_week() {
318        let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
319        let end = NaiveDate::from_ymd_opt(2024, 1, 7).unwrap();
320        let activity = Activity {
321            date_range: (start, end),
322            contributions: HashMap::new(),
323        };
324        assert_eq!(activity.number_of_weeks(), 1);
325    }
326
327    #[test]
328    fn test_activity_as_matrix_empty() {
329        let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
330        let end = NaiveDate::from_ymd_opt(2024, 1, 14).unwrap();
331        let activity = Activity {
332            date_range: (start, end),
333            contributions: HashMap::new(),
334        };
335        let matrix = activity.as_matrix();
336        assert_eq!(matrix.len(), 2); // 2 weeks
337        assert_eq!(matrix[0].len(), 7); // 7 days
338        assert_eq!(matrix[1].len(), 7); // 7 days
339        // All values should be 0
340        for row in matrix {
341            for value in row {
342                assert_eq!(value, 0);
343            }
344        }
345    }
346
347    #[test]
348    fn test_activity_as_matrix_with_contributions() {
349        let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
350        let end = NaiveDate::from_ymd_opt(2024, 1, 14).unwrap();
351
352        let mut contributions = HashMap::new();
353
354        // Add contributions for week 1 of 2024
355        let year_week = YearWeek {
356            year: 2024,
357            week: 1,
358        };
359        let mut week_contributions = HashMap::new();
360        week_contributions.insert(Weekday::Mon, 5);
361        week_contributions.insert(Weekday::Wed, 3);
362        contributions.insert(year_week, week_contributions);
363
364        let activity = Activity {
365            date_range: (start, end),
366            contributions,
367        };
368
369        let matrix = activity.as_matrix();
370        assert_eq!(matrix.len(), 2); // 2 weeks
371
372        // Check Monday (index 0) has 5 contributions
373        assert_eq!(matrix[0][0], 5);
374        // Check Wednesday (index 2) has 3 contributions
375        assert_eq!(matrix[0][2], 3);
376        // Check other days are 0
377        assert_eq!(matrix[0][1], 0); // Tuesday
378        assert_eq!(matrix[0][3], 0); // Thursday
379    }
380
381    #[test]
382    fn test_activity_as_matrix_multiple_weeks() {
383        let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
384        let end = NaiveDate::from_ymd_opt(2024, 1, 21).unwrap();
385
386        let mut contributions = HashMap::new();
387
388        // Week 1
389        let mut week1 = HashMap::new();
390        week1.insert(Weekday::Mon, 10);
391        contributions.insert(
392            YearWeek {
393                year: 2024,
394                week: 1,
395            },
396            week1,
397        );
398
399        // Week 2
400        let mut week2 = HashMap::new();
401        week2.insert(Weekday::Fri, 7);
402        contributions.insert(
403            YearWeek {
404                year: 2024,
405                week: 2,
406            },
407            week2,
408        );
409
410        let activity = Activity {
411            date_range: (start, end),
412            contributions,
413        };
414
415        let matrix = activity.as_matrix();
416        assert_eq!(matrix.len(), 3); // 3 weeks
417
418        // Week 1, Monday
419        assert_eq!(matrix[0][0], 10);
420        // Week 2, Friday (index 4)
421        assert_eq!(matrix[1][4], 7);
422    }
423}