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