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    // gh CLI 검증을 실제 사용 시점으로 지연 (Python과 동일)
14    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    // Fetch all public repositories (matching Python behavior)
63    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    // Calculate language statistics
72    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    // Get search username (@me for authenticated user, otherwise username)
86    let search_username = self.get_search_username(username);
87
88    // Fetch PR and issue statistics
89    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    // GraphQL query for contribution calendar (matching Python behavior)
119    // Always use user(login: "...") - does NOT include private contributions
120    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    // First pass: collect language occurrences
190    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    // Calculate percentages
202    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      // Capitalize first letter
211      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    // Always fetch public repositories only (matching Python gitfetch behavior)
230    // This uses /users/{username}/repos which only returns public repos
231    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    // Get the username to use for search queries
260    // Uses @me for the authenticated user, otherwise the provided username
261    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        // If we can't determine auth user, use provided username
271      }
272    }
273    username.to_string()
274  }
275
276  fn search_items(&self, query: &str, per_page: usize) -> Value {
277    // Search issues and PRs using GitHub CLI search command
278    let search_type = if query.contains("is:pr") {
279      "prs"
280    } else {
281      "issues"
282    };
283
284    // Remove is:pr/issue from query as it's implied by search type
285    let cleaned_query = query.replace("is:pr ", "").replace("is:issue ", "");
286
287    // Parse query string and convert to command-line flags
288    let flags = self.parse_search_query(&cleaned_query);
289
290    // Build command
291    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    // Extract relevant fields from items
327    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    // Parse search query string into command-line flags
355    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            // is:pr and is:issue are handled by search type
383            // Skip this
384          }
385          _ => {
386            // For other qualifiers, add as search term
387            flags.push(part.to_string());
388          }
389        }
390      } else {
391        // Add as general search term
392        flags.push(part.to_string());
393      }
394    }
395
396    flags
397  }
398}