Skip to main content

github_star_counter/
lib.rs

1#[macro_use]
2extern crate lazy_static;
3pub use crate::request::BasicAuth;
4use bytesize::ByteSize;
5use futures::future::join_all as join_all_futures;
6use futures::{FutureExt, TryFutureExt};
7use itertools::Itertools;
8use log::{error, info};
9use std::fmt::Write;
10use std::fs;
11use std::path::PathBuf;
12use std::{future::Future, sync::atomic::Ordering, time::Instant};
13use tera::{Context, Tera};
14
15mod api;
16mod request;
17
18pub use crate::api::*;
19
20pub type Error = Box<dyn std::error::Error>;
21
22fn filter_repos(repos: &Vec<Repo>, user_login: &str, is_user: bool) -> Vec<usize> {
23    let compare_username_matches = |want: bool, user: String| {
24        move |r: &Repo| {
25            if r.owner.login.eq(&user) == want {
26                Some(r.stargazers_count)
27            } else {
28                None
29            }
30        }
31    };
32
33    repos
34        .iter()
35        .filter_map(compare_username_matches(is_user, user_login.to_owned()))
36        .collect()
37}
38
39pub async fn count_stars(
40    username: &str,
41    no_orgs: bool,
42    auth: Option<BasicAuth>,
43    page_size: usize,
44) -> Result<Response, Error> {
45    let fetch_repos_for_user = |user| {
46        fetch_repos(user, page_size, |user, page_number| {
47            let repos_paged_url = format!(
48                "users/{}/repos?per_page={}&page={}",
49                user.login,
50                page_size,
51                page_number + 1
52            );
53            request::json_log_failure(repos_paged_url, auth.clone())
54        })
55        .map_err(|e| {
56            error!("Could not fetch repositories: {}", e);
57            e
58        })
59    };
60    let flatten_into_vec = |vec: Vec<_>| vec.into_iter().flatten().flatten().collect::<Vec<_>>();
61
62    let user_url = format!("users/{}", username);
63    let user: User = request::json(user_url.clone(), auth.clone()).await?;
64    let orgs_url = format!("{}/orgs", user_url);
65    let mut user_repos_futures = vec![fetch_repos_for_user(user.clone()).boxed_local()];
66
67    if !no_orgs {
68        let auth = auth.clone();
69        let orgs_repos_future = async move {
70            let orgs: Vec<RepoOwner> = request::json_log_failure(orgs_url, auth.clone())
71                .await
72                .unwrap_or_else(|_| Vec::new());
73
74            let repos_of_orgs = flatten_into_vec(
75                join_all_futures(orgs.into_iter().map(|user| {
76                    request::json_log_failure::<User>(format!("users/{}", user.login), auth.clone())
77                        .and_then(fetch_repos_for_user)
78                }))
79                .await,
80            );
81            Ok(repos_of_orgs)
82        }
83            .boxed_local();
84        user_repos_futures.push(orgs_repos_future);
85    };
86
87    let start = Instant::now();
88    let repos = flatten_into_vec(join_all_futures(user_repos_futures).await);
89
90    let elapsed = start.elapsed();
91    let duration_in_network_requests = request::TOTAL_DURATION.lock().unwrap().as_secs_f32();
92    info!(
93        "Total bytes received in body: {}",
94        ByteSize(request::TOTAL_BYTES_RECEIVED_IN_BODY.load(Ordering::Relaxed))
95    );
96    info!(
97        "Total time spent in network requests: {:.2}s",
98        duration_in_network_requests
99    );
100    info!(
101        "Wallclock time for future processing: {:.2}s",
102        elapsed.as_secs_f32()
103    );
104    info!(
105        "Speedup due to networking concurrency: {:.2}x",
106        duration_in_network_requests / elapsed.as_secs_f32()
107    );
108
109    Ok(Response { user, repos })
110}
111
112async fn fetch_repos<F>(
113    user: User, // TODO: can this also be &User?
114    page_size: usize,
115    mut fetch_page: impl FnMut(User, usize) -> F, // TODO would want 'async impl' for -> F; and &User instead of User!
116) -> Result<Vec<Repo>, Error>
117where
118    F: Future<Output = Result<Vec<Repo>, Error>>,
119{
120    if page_size == 0 {
121        return Err("PageSize must be greater than 0".into());
122    }
123    let page_count = user.public_repos / page_size;
124    let page_futures = (0..=page_count).map(|page_number| fetch_page(user.clone(), page_number));
125    let results = join_all_futures(page_futures).await;
126    let pages_with_results: Vec<Vec<Repo>> = results
127        .into_iter()
128        .collect::<Result<Vec<Vec<_>>, Error>>()?
129        .into_iter()
130        .collect();
131
132    sanity_check(page_size, &pages_with_results);
133    Ok(pages_with_results.into_iter().concat())
134}
135
136#[cfg(test)]
137fn sanity_check(_page_size: usize, _pages_with_results: &Vec<Vec<Repo>>) {}
138
139#[cfg(not(test))]
140fn sanity_check(page_size: usize, pages_with_results: &Vec<Vec<Repo>>) {
141    if pages_with_results.len() > 0 {
142        if let Some(v) = pages_with_results
143            .iter()
144            .take(
145                pages_with_results
146                    .len()
147                    .checked_sub(1)
148                    .expect("more than one page"),
149            )
150            .filter(|v| v.len() != page_size)
151            .next()
152        {
153            panic!(
154                "Asked for {} repos per page, but got only {} in a page which wasn't the last one. --page-size should probably be {}",
155                page_size,
156                v.len(),
157                v.len()
158            );
159        }
160    }
161}
162
163fn get_stats(repos: &Vec<Repo>, login: &str) -> RepoStats {
164    let total: usize = repos.iter().map(|r| r.stargazers_count).sum();
165    let total_by_user_only = filter_repos(&repos, login, true);
166    let total_by_orgs_only = filter_repos(&repos, login, false);
167
168    RepoStats {
169        total,
170        total_by_user_only,
171        total_by_orgs_only,
172    }
173}
174
175pub fn render_output(
176    template: Option<PathBuf>,
177    mut repos: Vec<Repo>,
178    login: String,
179    repo_limit: usize,
180    stargazer_threshold: usize,
181) -> Result<String, Error> {
182    let stats = get_stats(&repos, &login);
183
184    repos.sort_by(|a, b| b.stargazers_count.cmp(&a.stargazers_count));
185    let mut repos: Vec<_> = repos
186        .into_iter()
187        .filter(|r| r.stargazers_count >= stargazer_threshold)
188        .take(repo_limit)
189        .collect();
190
191    if !stats.total_by_orgs_only.is_empty() {
192        for mut repo in repos.iter_mut() {
193            repo.name = format!("{}/{}", repo.owner.login, repo.name);
194        }
195    }
196
197    match template {
198        Some(template) => template_output(repos, stats, login, template),
199        None => default_output(repos, stats, login),
200    }
201}
202
203pub fn template_output(
204    repos: Vec<Repo>,
205    stats: RepoStats,
206    login: String,
207    template: PathBuf,
208) -> Result<String, Error> {
209    let mut context = Context::new();
210    context.insert("repos", &repos);
211    context.insert("total", &stats.total);
212    context.insert("total_by_user_only", &stats.total_by_user_only);
213    context.insert("total_by_orgs_only", &stats.total_by_orgs_only);
214    context.insert("login", &login);
215
216    let template: String = fs::read_to_string(template)?;
217    let rendered = Tera::one_off(&template, &context, true)?;
218    Ok(rendered)
219}
220
221pub fn default_output(repos: Vec<Repo>, stats: RepoStats, login: String) -> Result<String, Error> {
222    let mut out = String::new();
223    writeln!(out, "Total: {}", stats.total)?;
224    if !stats.total_by_user_only.is_empty() && !stats.total_by_orgs_only.is_empty() {
225        writeln!(
226            out,
227            "Total for {}: {}",
228            login,
229            stats.total_by_user_only.iter().sum::<usize>()
230        )?;
231    }
232    if !stats.total_by_orgs_only.is_empty() {
233        writeln!(
234            out,
235            "Total for orgs: {}",
236            stats.total_by_orgs_only.iter().sum::<usize>()
237        )?;
238    }
239
240    if repos.len() > 0 {
241        writeln!(out)?;
242    }
243
244    let max_width = repos.iter().map(|r| r.name.len()).max().unwrap_or(0);
245    for repo in repos {
246        writeln!(
247            out,
248            "{:width$}   ★  {}",
249            repo.name,
250            repo.stargazers_count,
251            width = max_width
252        )?;
253    }
254    Ok(out)
255}
256
257#[cfg(test)]
258mod tests;