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 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 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 let search_username = self.get_search_username(username);
100
101 let pull_requests = serde_json::json!({
103 "awaiting_review": self.search_items(&format!("is:pr state:open review-requested:{}", search_username), 10),
104 "open": self.search_items(&format!("is:pr state:open author:{}", search_username), 10),
105 "mentions": self.search_items(&format!("is:pr state:open mentions:{}", search_username), 10),
106 });
107
108 let issues = serde_json::json!({
109 "assigned": self.search_items(&format!("is:issue state:open assignee:{}", search_username), 10),
110 "created": self.search_items(&format!("is:issue state:open author:{}", search_username), 10),
111 "mentions": self.search_items(&format!("is:issue state:open mentions:{}", search_username), 10),
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 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 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 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 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 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 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 }
285 }
286 username.to_string()
287 }
288
289 fn search_items(&self, query: &str, per_page: usize) -> i64 {
290 let search_type = if query.contains("is:pr") {
292 "prs"
293 } else {
294 "issues"
295 };
296
297 let cleaned_query = query.replace("is:pr ", "").replace("is:issue ", "");
299
300 let flags = self.parse_search_query(&cleaned_query);
302
303 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 0;
322 }
323 };
324
325 let stdout = match String::from_utf8(output.stdout) {
326 Ok(s) => s,
327 Err(_) => {
328 return 0;
329 }
330 };
331
332 let data: Vec<Value> = match serde_json::from_str(&stdout) {
333 Ok(d) => d,
334 Err(_) => {
335 return 0;
336 }
337 };
338
339 data.len() as i64
340 }
341
342 fn parse_search_query(&self, query: &str) -> Vec<String> {
343 let mut flags = Vec::new();
345 let parts: Vec<&str> = query.split_whitespace().collect();
346
347 for part in parts {
348 if let Some((key, value)) = part.split_once(':') {
349 match key {
350 "assignee" => {
351 flags.push("--assignee".to_string());
352 flags.push(value.to_string());
353 }
354 "author" => {
355 flags.push("--author".to_string());
356 flags.push(value.to_string());
357 }
358 "mentions" => {
359 flags.push("--mentions".to_string());
360 flags.push(value.to_string());
361 }
362 "review-requested" => {
363 flags.push("--review-requested".to_string());
364 flags.push(value.to_string());
365 }
366 "state" => {
367 flags.push("--state".to_string());
368 flags.push(value.to_string());
369 }
370 "is" => {
371 }
374 _ => {
375 flags.push(part.to_string());
377 }
378 }
379 } else {
380 flags.push(part.to_string());
382 }
383 }
384
385 flags
386 }
387}