irongit

The dotfiles manager.

feat: use github rpc for stars if GITHUB_TOKEN is available

huncholanehuncholaneauthored
parent 661db9bcommit 14cfedc9e8d61c84a32d7e06f467eae8ec1753d5Browse files

1 file changed, +161 -4

+161-4src/main.rs
@@ -3,14 +3,20 @@ use clap::{Args, Parser, Subcommand, ValueEnum};
33 use clap_complete::{generate, shells};
44 use std::collections::HashMap;
55 use comfy_table::{Table, presets::UTF8_BORDERS_ONLY, modifiers::UTF8_ROUND_CORNERS};
6+use std::env;
7+use std::sync::{Arc, atomic::{AtomicBool, Ordering}};
8+use std::thread;
9+use std::time::Duration;
610 use std::fs;
711 use std::io;
12+use std::io::Write;
813 use std::path::Path;
914 use std::process::Command;
1015
1116 const DOTMAN_DIR: &str = "/usr/local/share/dotman";
1217 const DEFAULT_FLEX_URL: &str =
1318 "https://raw.githubusercontent.com/huncholane/dotman/refs/heads/main/flex.yml";
19+const GH_TOKEN_HELP_URL: &str = "https://github.com/settings/personal-access-tokens";
1420
1521 #[derive(Parser)]
1622 #[command(name = "dotman", about = "Manage dotfile repos and links", version)]
@@ -389,13 +395,41 @@ fn cmd_flex(args: FlexArgs) -> Result<()> {
389395 }
390396 }
391397
392- // Collect stars (best-effort)
398+ // Collect stars efficiently (GraphQL when token present; REST fallback otherwise)
399+ let token = env::var("GITHUB_TOKEN").ok();
400+ let mut warn_graphql_failed = false;
401+ // Show a spinner during star fetching
402+ let spinner_stop = start_spinner("Downloading stars from github..");
403+
393404 let mut detailed: Vec<(String, String, u64)> = Vec::with_capacity(items.len());
394- for (ty, link) in items {
395- let stars = github_stars(&link).unwrap_or(0);
396- detailed.push((ty, link, stars));
405+ if let Some(ref t) = token {
406+ let links_only: Vec<String> = items.iter().map(|(_, l)| l.clone()).collect();
407+ match github_stars_batch(&links_only, Some(t.as_str())) {
408+ Ok(stars_map) => {
409+ for (ty, link) in items {
410+ let stars = *stars_map.get(&link).unwrap_or(&0);
411+ detailed.push((ty, link, stars));
412+ }
413+ }
414+ Err(_) => {
415+ warn_graphql_failed = true;
416+ for (ty, link) in items {
417+ let stars = github_stars(&link).unwrap_or(0);
418+ detailed.push((ty, link, stars));
419+ }
420+ }
421+ }
422+ } else {
423+ for (ty, link) in items {
424+ let stars = github_stars(&link).unwrap_or(0);
425+ detailed.push((ty, link, stars));
426+ }
397427 }
398428
429+ spinner_stop.store(true, Ordering::SeqCst);
430+ // Leave the last line in place; print a newline to cleanly end spinner
431+ eprintln!("");
432+
399433 // Sort by stars desc
400434 detailed.sort_by(|a, b| b.2.cmp(&a.2));
401435
@@ -412,10 +446,40 @@ fn cmd_flex(args: FlexArgs) -> Result<()> {
412446 }
413447
414448 println!("{}", table);
449+ if token.is_none() {
450+ println!(
451+ "\x1b[33mTo improve flex performance, please set your GITHUB_TOKEN environment variable.\nObtain token at {GH_TOKEN_HELP_URL}\x1b[0m"
452+ );
453+ }
454+ if warn_graphql_failed {
455+ println!(
456+ "\x1b[33mGITHUB_TOKEN detected but GitHub GraphQL failed; falling back to REST.\nObtain token at {GH_TOKEN_HELP_URL}\x1b[0m"
457+ );
458+ }
415459
416460 Ok(())
417461 }
418462
463+fn start_spinner(message: &str) -> Arc<AtomicBool> {
464+ let stop = Arc::new(AtomicBool::new(false));
465+ let stop_clone = Arc::clone(&stop);
466+ let msg = message.to_string();
467+ thread::spawn(move || {
468+ let frames = ["-", "\\", "|", "/"]; // simple spinner
469+ let mut i = 0usize;
470+ // Print initial line
471+ eprint!("{} {}\r", frames[i % frames.len()], msg);
472+ let _ = std::io::stderr().flush();
473+ while !stop_clone.load(Ordering::SeqCst) {
474+ i = (i + 1) % frames.len();
475+ eprint!("{} {}\r", frames[i], msg);
476+ let _ = std::io::stderr().flush();
477+ thread::sleep(Duration::from_millis(120));
478+ }
479+ });
480+ stop
481+}
482+
419483 fn fetch_text(url: &str) -> Result<String> {
420484 let client = reqwest::blocking::Client::builder()
421485 .user_agent("dotman/0.1")
@@ -496,3 +560,96 @@ fn github_stars(link: &str) -> Result<u64> {
496560 .unwrap_or(0);
497561 Ok(stars)
498562 }
563+
564+fn parse_github_owner_repo(link: &str) -> Option<(String, String)> {
565+ let lower = link.to_lowercase();
566+ if !lower.contains("github.com") {
567+ return None;
568+ }
569+ if let Ok(parsed) = url::Url::parse(link) {
570+ if parsed.domain().unwrap_or("") != "github.com" {
571+ return None;
572+ }
573+ let mut segs = parsed.path_segments()?;
574+ let owner = segs.next()?.to_string();
575+ if let Some(mut repo) = segs.next() {
576+ if let Some(stripped) = repo.strip_suffix('.').or_else(|| repo.strip_suffix(".git")) {
577+ repo = stripped;
578+ }
579+ return Some((owner, repo.to_string()));
580+ } else {
581+ return Some((owner.clone(), owner));
582+ }
583+ } else if let Some(rest) = lower.strip_prefix("git@github.com:") {
584+ let parts: Vec<&str> = rest.split('/').collect();
585+ if parts.len() >= 2 {
586+ let mut repo = parts[1].to_string();
587+ if let Some(stripped) = repo.strip_suffix('.').or_else(|| repo.strip_suffix(".git")) {
588+ repo = stripped.to_string();
589+ }
590+ return Some((parts[0].to_string(), repo));
591+ } else if parts.len() == 1 {
592+ let owner = parts[0].to_string();
593+ return Some((owner.clone(), owner));
594+ }
595+ }
596+ None
597+}
598+
599+fn github_stars_batch(links: &[String], token: Option<&str>) -> Result<HashMap<String, u64>> {
600+ let mut entries: Vec<(String, (String, String))> = Vec::new();
601+ for l in links {
602+ if let Some((o, r)) = parse_github_owner_repo(l) {
603+ entries.push((l.clone(), (o, r)));
604+ }
605+ }
606+ if entries.is_empty() {
607+ return Ok(HashMap::new());
608+ }
609+
610+ let client = reqwest::blocking::Client::builder()
611+ .user_agent("dotman/0.1")
612+ .build()
613+ .context("building http client")?;
614+
615+ let mut out: HashMap<String, u64> = HashMap::new();
616+
617+ for chunk in entries.chunks(50) {
618+ let mut q = String::from("query { ");
619+ for (i, (_link, (owner, repo))) in chunk.iter().enumerate() {
620+ let alias = format!("r{}", i);
621+ let owner_esc = owner.replace('"', "\\\"");
622+ let repo_esc = repo.replace('"', "\\\"");
623+ q.push_str(&format!(
624+ "{}: repository(owner:\"{}\", name:\"{}\") {{ stargazerCount }} ",
625+ alias, owner_esc, repo_esc
626+ ));
627+ }
628+ q.push('}');
629+
630+ let mut req = client
631+ .post("https://api.github.com/graphql")
632+ .json(&serde_json::json!({"query": q}));
633+ if let Some(t) = token {
634+ req = req.header("Authorization", format!("Bearer {}", t));
635+ }
636+ let resp = req.send().context("graphql request failed")?;
637+ if !resp.status().is_success() {
638+ return Err(anyhow::anyhow!("graphql status {}", resp.status()));
639+ }
640+ let v: serde_json::Value = resp.json().context("parse graphql json")?;
641+ if let Some(data) = v.get("data").and_then(|d| d.as_object()) {
642+ for (i, (link, _)) in chunk.iter().enumerate() {
643+ let alias = format!("r{}", i);
644+ let count = data
645+ .get(&alias)
646+ .and_then(|obj| obj.get("stargazerCount"))
647+ .and_then(|n| n.as_u64())
648+ .unwrap_or(0);
649+ out.insert(link.clone(), count);
650+ }
651+ }
652+ }
653+
654+ Ok(out)
655+}