gitfetch_rs/fetcher/
github.rs

1use super::Fetcher;
2use anyhow::Result;
3use async_trait::async_trait;
4use serde_json::Value;
5use std::process::Command;
6
7pub struct GitHubFetcher {
8  _client: reqwest::Client,
9}
10
11impl GitHubFetcher {
12  pub fn new() -> Result<Self> {
13    Self::check_gh_cli()?;
14
15    Ok(Self {
16      _client: reqwest::Client::new(),
17    })
18  }
19
20  fn check_gh_cli() -> Result<()> {
21    let output = Command::new("gh").args(&["auth", "status"]).output();
22
23    match output {
24      Ok(out) if out.status.success() => Ok(()),
25      Ok(_) => Err(anyhow::anyhow!(
26        "GitHub CLI not authenticated. Run: gh auth login"
27      )),
28      Err(_) => Err(anyhow::anyhow!("GitHub CLI not installed")),
29    }
30  }
31
32  fn gh_api(&self, endpoint: &str) -> Result<Value> {
33    let output = Command::new("gh").args(&["api", endpoint]).output()?;
34
35    if !output.status.success() {
36      let stderr = String::from_utf8_lossy(&output.stderr);
37      return Err(anyhow::anyhow!("gh api failed: {}", stderr));
38    }
39
40    let stdout = String::from_utf8(output.stdout)?;
41    let data: Value = serde_json::from_str(&stdout)?;
42    Ok(data)
43  }
44
45  fn gh_graphql(&self, query: &str) -> Result<Value> {
46    let output = Command::new("gh")
47      .args(&["api", "graphql", "-f", &format!("query={}", query)])
48      .output()?;
49
50    if !output.status.success() {
51      return Ok(serde_json::json!({}));
52    }
53
54    let stdout = String::from_utf8(output.stdout)?;
55    let data: Value = serde_json::from_str(&stdout)?;
56    Ok(data)
57  }
58}
59
60#[async_trait]
61impl Fetcher for GitHubFetcher {
62  async fn get_authenticated_user(&self) -> Result<String> {
63    let data = self.gh_api("/user")?;
64    data["login"]
65      .as_str()
66      .map(String::from)
67      .ok_or_else(|| anyhow::anyhow!("Could not get authenticated user"))
68  }
69
70  async fn fetch_user_data(&self, username: &str) -> Result<Value> {
71    self.gh_api(&format!("/users/{}", username))
72  }
73
74  async fn fetch_user_stats(&self, username: &str, _user_data: Option<&Value>) -> Result<Value> {
75    // Fetch all public repositories (matching Python behavior)
76    let repos = self.fetch_repos(username)?;
77
78    let total_stars: i64 = repos
79      .iter()
80      .filter_map(|r| r["stargazers_count"].as_i64())
81      .sum();
82    let total_forks: i64 = repos.iter().filter_map(|r| r["forks_count"].as_i64()).sum();
83
84    // Calculate language statistics
85    let languages = self.calculate_language_stats(&repos);
86
87    let contrib_graph = match self.fetch_contribution_graph(username) {
88      Ok(graph) => graph,
89      Err(e) => {
90        eprintln!("Warning: Failed to fetch contribution graph: {}", e);
91        serde_json::json!([])
92      }
93    };
94
95    let (current_streak, longest_streak, total_contributions) =
96      self.calculate_contribution_stats(&contrib_graph);
97
98    // Get search username (@me for authenticated user, otherwise username)
99    let search_username = self.get_search_username(username);
100
101    // Fetch PR and issue statistics
102    let pull_requests = serde_json::json!({
103        "awaiting_review": self.search_items(&format!("is:pr state:open review-requested:{}", search_username), 5),
104        "open": self.search_items(&format!("is:pr state:open author:{}", search_username), 5),
105        "mentions": self.search_items(&format!("is:pr state:open mentions:{}", search_username), 5),
106    });
107
108    let issues = serde_json::json!({
109        "assigned": self.search_items(&format!("is:issue state:open assignee:{}", search_username), 5),
110        "created": self.search_items(&format!("is:issue state:open author:{}", search_username), 5),
111        "mentions": self.search_items(&format!("is:issue state:open mentions:{}", search_username), 5),
112    });
113
114    Ok(serde_json::json!({
115        "total_stars": total_stars,
116        "total_forks": total_forks,
117        "total_repos": repos.len(),
118        "contribution_graph": contrib_graph,
119        "current_streak": current_streak,
120        "longest_streak": longest_streak,
121        "total_contributions": total_contributions,
122        "languages": languages,
123        "pull_requests": pull_requests,
124        "issues": issues,
125    }))
126  }
127}
128
129impl GitHubFetcher {
130  fn fetch_contribution_graph(&self, username: &str) -> Result<Value> {
131    // GraphQL query for contribution calendar (matching Python behavior)
132    // Always use user(login: "...") - does NOT include private contributions
133    let query = format!(
134      r#"{{
135        user(login: "{}") {{
136          contributionsCollection {{
137            contributionCalendar {{
138              weeks {{
139                contributionDays {{
140                  contributionCount
141                  date
142                }}
143              }}
144            }}
145          }}
146        }}
147      }}"#,
148      username
149    );
150
151    let data = self.gh_graphql(&query)?;
152    let path = &data["data"]["user"]["contributionsCollection"]["contributionCalendar"]["weeks"];
153
154    Ok(path.clone())
155  }
156
157  fn calculate_contribution_stats(&self, graph: &Value) -> (u32, u32, u32) {
158    let weeks = graph.as_array();
159    if weeks.is_none() {
160      return (0, 0, 0);
161    }
162
163    let mut all_contributions: Vec<u32> = weeks
164      .unwrap()
165      .iter()
166      .flat_map(|w| w["contributionDays"].as_array())
167      .flatten()
168      .filter_map(|d| d["contributionCount"].as_u64())
169      .map(|c| c as u32)
170      .collect();
171
172    all_contributions.reverse();
173
174    let total: u32 = all_contributions.iter().sum();
175
176    let mut current_streak = 0;
177    for &count in &all_contributions {
178      if count > 0 {
179        current_streak += 1;
180      } else {
181        break;
182      }
183    }
184
185    let mut longest_streak = 0;
186    let mut temp_streak = 0;
187    for &count in &all_contributions {
188      if count > 0 {
189        temp_streak += 1;
190        longest_streak = longest_streak.max(temp_streak);
191      } else {
192        temp_streak = 0;
193      }
194    }
195
196    (current_streak, longest_streak, total)
197  }
198
199  fn calculate_language_stats(&self, repos: &[Value]) -> Value {
200    use std::collections::HashMap;
201
202    // First pass: collect language occurrences
203    let mut language_counts: HashMap<String, i32> = HashMap::new();
204
205    for repo in repos {
206      if let Some(language) = repo["language"].as_str() {
207        if !language.is_empty() {
208          let normalized = language.to_lowercase();
209          *language_counts.entry(normalized).or_insert(0) += 1;
210        }
211      }
212    }
213
214    // Calculate percentages
215    let total: i32 = language_counts.values().sum();
216    if total == 0 {
217      return serde_json::json!({});
218    }
219
220    let mut language_percentages: HashMap<String, f64> = HashMap::new();
221    for (lang, count) in language_counts {
222      let percentage = (count as f64 / total as f64) * 100.0;
223      // Capitalize first letter
224      let display_name = lang
225        .chars()
226        .enumerate()
227        .map(|(i, c)| {
228          if i == 0 {
229            c.to_uppercase().to_string()
230          } else {
231            c.to_string()
232          }
233        })
234        .collect::<String>();
235      language_percentages.insert(display_name, percentage);
236    }
237
238    serde_json::to_value(language_percentages).unwrap_or_else(|_| serde_json::json!({}))
239  }
240
241  fn fetch_repos(&self, username: &str) -> Result<Vec<Value>> {
242    // Always fetch public repositories only (matching Python gitfetch behavior)
243    // This uses /users/{username}/repos which only returns public repos
244    let mut repos = Vec::new();
245    let mut page = 1;
246    let per_page = 100;
247
248    loop {
249      let endpoint = format!(
250        "/users/{}/repos?page={}&per_page={}&type=owner&sort=updated",
251        username, page, per_page
252      );
253      let data = self.gh_api(&endpoint)?;
254
255      let data_array = match data.as_array() {
256        Some(arr) if !arr.is_empty() => arr,
257        _ => break,
258      };
259
260      repos.extend(data_array.clone());
261      page += 1;
262
263      if data_array.len() < per_page {
264        break;
265      }
266    }
267
268    Ok(repos)
269  }
270
271  fn get_search_username(&self, username: &str) -> String {
272    // Get the username to use for search queries
273    // Uses @me for the authenticated user, otherwise the provided username
274    match self.gh_api("/user") {
275      Ok(auth_user) => {
276        if let Some(login) = auth_user["login"].as_str() {
277          if login == username {
278            return "@me".to_string();
279          }
280        }
281      }
282      Err(_) => {
283        // If we can't determine auth user, use provided username
284      }
285    }
286    username.to_string()
287  }
288
289  fn search_items(&self, query: &str, per_page: usize) -> Value {
290    // Search issues and PRs using GitHub CLI search command
291    let search_type = if query.contains("is:pr") {
292      "prs"
293    } else {
294      "issues"
295    };
296
297    // Remove is:pr/issue from query as it's implied by search type
298    let cleaned_query = query.replace("is:pr ", "").replace("is:issue ", "");
299
300    // Parse query string and convert to command-line flags
301    let flags = self.parse_search_query(&cleaned_query);
302
303    // Build command
304    let mut cmd = Command::new("gh");
305    cmd.arg("search").arg(search_type);
306
307    for flag in flags {
308      cmd.arg(flag);
309    }
310
311    cmd.args(&[
312      "--limit",
313      &per_page.to_string(),
314      "--json",
315      "number,title,repository,url,state",
316    ]);
317
318    let output = match cmd.output() {
319      Ok(out) if out.status.success() => out,
320      _ => {
321        return serde_json::json!({"total_count": 0, "items": []});
322      }
323    };
324
325    let stdout = match String::from_utf8(output.stdout) {
326      Ok(s) => s,
327      Err(_) => {
328        return serde_json::json!({"total_count": 0, "items": []});
329      }
330    };
331
332    let data: Vec<Value> = match serde_json::from_str(&stdout) {
333      Ok(d) => d,
334      Err(_) => {
335        return serde_json::json!({"total_count": 0, "items": []});
336      }
337    };
338
339    // Extract relevant fields from items
340    let items: Vec<Value> = data
341      .iter()
342      .take(per_page)
343      .map(|item| {
344        let repo_info = item.get("repository").and_then(|r| r.as_object());
345        let repo_name = repo_info
346          .and_then(|r| r.get("nameWithOwner"))
347          .or_else(|| repo_info.and_then(|r| r.get("name")))
348          .and_then(|n| n.as_str())
349          .unwrap_or("");
350
351        serde_json::json!({
352          "title": item.get("title").and_then(|t| t.as_str()).unwrap_or(""),
353          "repo": repo_name,
354          "url": item.get("url").and_then(|u| u.as_str()).unwrap_or(""),
355          "number": item.get("number").and_then(|n| n.as_i64()).unwrap_or(0),
356        })
357      })
358      .collect();
359
360    serde_json::json!({
361      "total_count": items.len(),
362      "items": items
363    })
364  }
365
366  fn parse_search_query(&self, query: &str) -> Vec<String> {
367    // Parse search query string into command-line flags
368    let mut flags = Vec::new();
369    let parts: Vec<&str> = query.split_whitespace().collect();
370
371    for part in parts {
372      if let Some((key, value)) = part.split_once(':') {
373        match key {
374          "assignee" => {
375            flags.push("--assignee".to_string());
376            flags.push(value.to_string());
377          }
378          "author" => {
379            flags.push("--author".to_string());
380            flags.push(value.to_string());
381          }
382          "mentions" => {
383            flags.push("--mentions".to_string());
384            flags.push(value.to_string());
385          }
386          "review-requested" => {
387            flags.push("--review-requested".to_string());
388            flags.push(value.to_string());
389          }
390          "state" => {
391            flags.push("--state".to_string());
392            flags.push(value.to_string());
393          }
394          "is" => {
395            // is:pr and is:issue are handled by search type
396            // Skip this
397          }
398          _ => {
399            // For other qualifiers, add as search term
400            flags.push(part.to_string());
401          }
402        }
403      } else {
404        // Add as general search term
405        flags.push(part.to_string());
406      }
407    }
408
409    flags
410  }
411}