1use crate::error::{Error, Result};
4use crate::interner::StringInterner;
5use crate::types::{ChangeType, ChangedFile, DiffResult};
6use serde::Deserialize;
7
8#[derive(Debug, Deserialize)]
10struct GitHubFile {
11 filename: String,
13 status: String,
15 previous_filename: Option<String>,
17}
18
19#[derive(Debug, Deserialize)]
21struct GitHubCompareResponse {
22 total_commits: u32,
23 files: Vec<GitHubFile>,
24 #[serde(default)]
26 files_truncated: bool,
27}
28
29pub struct GitHubApiClient {
31 client: reqwest::Client,
32 base_url: String,
33 token: Option<String>,
34}
35
36impl std::fmt::Debug for GitHubApiClient {
37 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38 f.debug_struct("GitHubApiClient")
39 .field("base_url", &self.base_url)
40 .field("token", &self.token.as_ref().map(|_| "<redacted>"))
41 .finish_non_exhaustive()
42 }
43}
44
45impl GitHubApiClient {
46 pub fn new(base_url: String, token: Option<String>) -> Self {
48 let client = reqwest::Client::builder()
49 .user_agent("lechange/0.1.0")
50 .build()
51 .unwrap_or_else(|_| reqwest::Client::new());
52
53 Self {
54 client,
55 base_url,
56 token,
57 }
58 }
59
60 pub fn from_env() -> Result<Self> {
62 let base_url = std::env::var("GITHUB_API_URL")
63 .unwrap_or_else(|_| "https://api.github.com".to_string());
64
65 let token = std::env::var("GITHUB_TOKEN").ok();
66
67 Ok(Self::new(base_url, token))
68 }
69
70 pub async fn fetch_changed_files(
72 &self,
73 owner: &str,
74 repo: &str,
75 pr_number: u32,
76 interner: &StringInterner,
77 ) -> Result<DiffResult> {
78 let url = format!(
79 "{}/repos/{}/{}/pulls/{}/files",
80 self.base_url, owner, repo, pr_number
81 );
82
83 let mut all_files = Vec::new();
84 let mut page = 1;
85
86 loop {
88 let mut request = self
89 .client
90 .get(&url)
91 .query(&[("page", page.to_string()), ("per_page", "100".to_string())]);
92
93 if let Some(ref token) = self.token {
95 request = request.header("Authorization", format!("Bearer {}", token));
96 }
97
98 let response = request
99 .send()
100 .await
101 .map_err(|e| Error::Runtime(format!("GitHub API request failed: {}", e)))?;
102
103 if !response.status().is_success() {
104 return Err(Error::Runtime(format!(
105 "GitHub API returned error: {}",
106 response.status()
107 )));
108 }
109
110 let files: Vec<GitHubFile> = response.json().await.map_err(|e| {
111 Error::Runtime(format!("Failed to parse GitHub API response: {}", e))
112 })?;
113
114 if files.is_empty() {
115 break;
116 }
117
118 all_files.extend(files);
119 page += 1;
120
121 if page > 1000 {
123 return Err(Error::Runtime(
124 "Too many pages in GitHub API response".to_string(),
125 ));
126 }
127 }
128
129 let mut result = DiffResult::default();
131
132 for file in all_files {
133 let change_type = match file.status.as_str() {
134 "added" => ChangeType::Added,
135 "removed" => ChangeType::Deleted,
136 "modified" => ChangeType::Modified,
137 "renamed" => ChangeType::Renamed,
138 "copied" => ChangeType::Copied,
139 "changed" => ChangeType::TypeChanged,
140 _ => ChangeType::Unknown,
141 };
142
143 let previous_path = file.previous_filename.as_ref().map(|p| interner.intern(p));
144
145 result.files.push(ChangedFile {
146 path: interner.intern(&file.filename),
147 change_type,
148 previous_path,
149 is_symlink: false, submodule_depth: 0,
151 origin: crate::types::FileOrigin {
152 in_current_changes: true,
153 in_previous_failure: false,
154 in_previous_success: false,
155 },
156 });
157 }
158
159 Ok(result)
160 }
161
162 pub async fn compare_refs(
167 &self,
168 owner: &str,
169 repo: &str,
170 base: &str,
171 head: &str,
172 interner: &StringInterner,
173 ) -> Result<DiffResult> {
174 let url = format!(
175 "{}/repos/{}/{}/compare/{}...{}",
176 self.base_url, owner, repo, base, head
177 );
178
179 let mut all_files = Vec::new();
180 let mut page = 1;
181
182 loop {
183 let mut request = self
184 .client
185 .get(&url)
186 .query(&[("page", page.to_string()), ("per_page", "100".to_string())]);
187
188 if let Some(ref token) = self.token {
189 request = request.header("Authorization", format!("Bearer {}", token));
190 }
191
192 let response = request
193 .send()
194 .await
195 .map_err(|e| Error::Runtime(format!("GitHub API compare request failed: {}", e)))?;
196
197 if !response.status().is_success() {
198 return Err(Error::Runtime(format!(
199 "GitHub API compare returned error: {}",
200 response.status()
201 )));
202 }
203
204 let compare: GitHubCompareResponse = response.json().await.map_err(|e| {
205 Error::Runtime(format!("Failed to parse GitHub compare response: {}", e))
206 })?;
207
208 all_files.extend(compare.files);
209
210 if all_files.len() >= compare.total_commits as usize * 100 || compare.files_truncated {
212 break; }
214
215 if all_files.len() < 100 * page as usize {
216 break; }
218
219 page += 1;
220
221 if page > 100 {
222 break; }
224 }
225
226 let mut result = DiffResult::default();
228
229 for file in all_files {
230 let change_type = match file.status.as_str() {
231 "added" => ChangeType::Added,
232 "removed" => ChangeType::Deleted,
233 "modified" => ChangeType::Modified,
234 "renamed" => ChangeType::Renamed,
235 "copied" => ChangeType::Copied,
236 "changed" => ChangeType::TypeChanged,
237 _ => ChangeType::Unknown,
238 };
239
240 let previous_path = file.previous_filename.as_ref().map(|p| interner.intern(p));
241
242 result.files.push(ChangedFile {
243 path: interner.intern(&file.filename),
244 change_type,
245 previous_path,
246 is_symlink: false,
247 submodule_depth: 0,
248 origin: crate::types::FileOrigin {
249 in_current_changes: true,
250 in_previous_failure: false,
251 in_previous_success: false,
252 },
253 });
254 }
255
256 Ok(result)
257 }
258
259 pub fn extract_pr_info_from_env() -> Result<(String, String, u32)> {
261 let repository = std::env::var("GITHUB_REPOSITORY")
262 .map_err(|_| Error::Config("GITHUB_REPOSITORY not set".to_string()))?;
263 let github_ref = std::env::var("GITHUB_REF")
264 .map_err(|_| Error::Config("GITHUB_REF not set".to_string()))?;
265 Self::parse_pr_info(&repository, &github_ref)
266 }
267
268 fn parse_pr_info(repository: &str, github_ref: &str) -> Result<(String, String, u32)> {
270 let parts: Vec<&str> = repository.split('/').collect();
271 if parts.len() != 2 {
272 return Err(Error::Config(format!(
273 "Invalid GITHUB_REPOSITORY format: {}",
274 repository
275 )));
276 }
277
278 let owner = parts[0].to_string();
279 let repo = parts[1].to_string();
280
281 let pr_number = if github_ref.starts_with("refs/pull/") {
282 let pr_parts: Vec<&str> = github_ref.split('/').collect();
283 if pr_parts.len() >= 3 {
284 pr_parts[2].parse::<u32>().map_err(|_| {
285 Error::Config(format!("Invalid PR number in GITHUB_REF: {}", github_ref))
286 })?
287 } else {
288 return Err(Error::Config(format!(
289 "Invalid GITHUB_REF format: {}",
290 github_ref
291 )));
292 }
293 } else {
294 return Err(Error::Config("Not a pull request event".to_string()));
295 };
296
297 Ok((owner, repo, pr_number))
298 }
299}
300
301#[cfg(test)]
302mod tests {
303 use super::*;
304
305 #[test]
306 fn test_github_client_creation() {
307 let client = GitHubApiClient::new("https://api.github.com".to_string(), None);
308 assert_eq!(client.base_url, "https://api.github.com");
309 assert!(client.token.is_none());
310 }
311
312 #[test]
313 fn test_github_client_with_token() {
314 let client = GitHubApiClient::new(
315 "https://api.github.com".to_string(),
316 Some("test_token".to_string()),
317 );
318 assert_eq!(client.token, Some("test_token".to_string()));
319 }
320
321 #[test]
322 fn test_parse_pr_info_invalid_format() {
323 let result = GitHubApiClient::parse_pr_info("invalid", "refs/pull/123/merge");
324 assert!(result.is_err());
325 }
326
327 #[test]
328 fn test_parse_pr_info_happy_path() {
329 let result = GitHubApiClient::parse_pr_info("owner/repo", "refs/pull/42/merge");
330 assert!(result.is_ok());
331 let (owner, repo, pr_number) = result.unwrap();
332 assert_eq!(owner, "owner");
333 assert_eq!(repo, "repo");
334 assert_eq!(pr_number, 42);
335 }
336
337 #[test]
338 fn test_parse_pr_info_not_a_pr() {
339 let result = GitHubApiClient::parse_pr_info("owner/repo", "refs/heads/main");
340 assert!(result.is_err());
341 }
342
343 #[test]
344 fn test_github_client_debug_redacts_token() {
345 let client = GitHubApiClient::new(
346 "https://api.github.com".to_string(),
347 Some("ghp_SuperSecretToken12345".to_string()),
348 );
349 let debug_output = format!("{:?}", client);
350 assert!(
351 !debug_output.contains("ghp_SuperSecretToken12345"),
352 "Debug output must not contain the actual token: {}",
353 debug_output
354 );
355 assert!(
356 debug_output.contains("<redacted>"),
357 "Debug output should show <redacted>: {}",
358 debug_output
359 );
360 }
361
362 #[test]
363 fn test_github_client_debug_no_token() {
364 let client = GitHubApiClient::new("https://api.github.com".to_string(), None);
365 let debug_output = format!("{:?}", client);
366 assert!(
367 !debug_output.contains("<redacted>"),
368 "Should not show <redacted> when no token is set"
369 );
370 assert!(debug_output.contains("token: None"));
371 }
372}