Skip to main content

lechange_core/http/
client.rs

1//! GitHub REST API client for pull request file fetching
2
3use crate::error::{Error, Result};
4use crate::interner::StringInterner;
5use crate::types::{ChangeType, ChangedFile, DiffResult};
6use serde::Deserialize;
7
8/// GitHub API response for a changed file
9#[derive(Debug, Deserialize)]
10struct GitHubFile {
11    /// File path
12    filename: String,
13    /// Change status (added, removed, modified, renamed, copied, changed)
14    status: String,
15    /// Previous filename (for renamed files)
16    previous_filename: Option<String>,
17}
18
19/// GitHub API response for comparing two refs
20#[derive(Debug, Deserialize)]
21struct GitHubCompareResponse {
22    total_commits: u32,
23    files: Vec<GitHubFile>,
24    /// Whether the file list was truncated (too many files)
25    #[serde(default)]
26    files_truncated: bool,
27}
28
29/// GitHub API client for fetching PR files
30pub 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    /// Create a new GitHub API client
47    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    /// Create from environment variables
61    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    /// Fetch changed files for a pull request
71    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        // Paginate through all results (GitHub returns max 100 per page)
87        loop {
88            let mut request = self
89                .client
90                .get(&url)
91                .query(&[("page", page.to_string()), ("per_page", "100".to_string())]);
92
93            // Add authorization header if token is present
94            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            // Safety limit to prevent infinite loops
122            if page > 1000 {
123                return Err(Error::Runtime(
124                    "Too many pages in GitHub API response".to_string(),
125                ));
126            }
127        }
128
129        // Convert GitHub files to DiffResult
130        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, // GitHub API doesn't provide symlink info
150                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    /// Compare two refs and get changed files
163    ///
164    /// Endpoint: GET /repos/{owner}/{repo}/compare/{base}...{head}
165    /// Useful for non-PR contexts where git diff is not available.
166    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            // GitHub compare API paginates via Link header for large diffs
211            if all_files.len() >= compare.total_commits as usize * 100 || compare.files_truncated {
212                break; // We've gotten what we can
213            }
214
215            if all_files.len() < 100 * page as usize {
216                break; // Less than a full page, we're done
217            }
218
219            page += 1;
220
221            if page > 100 {
222                break; // Safety limit
223            }
224        }
225
226        // Convert to DiffResult
227        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    /// Extract owner, repo, and PR number from GitHub environment
260    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    /// Parse owner, repo, and PR number from repository and ref strings
269    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}