use crate::error::{Error, Result};
use crate::interner::StringInterner;
use crate::types::{ChangeType, ChangedFile, DiffResult};
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct GitHubFile {
filename: String,
status: String,
previous_filename: Option<String>,
}
#[derive(Debug, Deserialize)]
struct GitHubCompareResponse {
total_commits: u32,
files: Vec<GitHubFile>,
#[serde(default)]
files_truncated: bool,
}
pub struct GitHubApiClient {
client: reqwest::Client,
base_url: String,
token: Option<String>,
}
impl std::fmt::Debug for GitHubApiClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GitHubApiClient")
.field("base_url", &self.base_url)
.field("token", &self.token.as_ref().map(|_| "<redacted>"))
.finish_non_exhaustive()
}
}
impl GitHubApiClient {
pub fn new(base_url: String, token: Option<String>) -> Self {
let client = reqwest::Client::builder()
.user_agent("lechange/0.1.0")
.build()
.unwrap_or_else(|_| reqwest::Client::new());
Self {
client,
base_url,
token,
}
}
pub fn from_env() -> Result<Self> {
let base_url = std::env::var("GITHUB_API_URL")
.unwrap_or_else(|_| "https://api.github.com".to_string());
let token = std::env::var("GITHUB_TOKEN").ok();
Ok(Self::new(base_url, token))
}
pub async fn fetch_changed_files(
&self,
owner: &str,
repo: &str,
pr_number: u32,
interner: &StringInterner,
) -> Result<DiffResult> {
let url = format!(
"{}/repos/{}/{}/pulls/{}/files",
self.base_url, owner, repo, pr_number
);
let mut all_files = Vec::new();
let mut page = 1;
loop {
let mut request = self
.client
.get(&url)
.query(&[("page", page.to_string()), ("per_page", "100".to_string())]);
if let Some(ref token) = self.token {
request = request.header("Authorization", format!("Bearer {}", token));
}
let response = request
.send()
.await
.map_err(|e| Error::Runtime(format!("GitHub API request failed: {}", e)))?;
if !response.status().is_success() {
return Err(Error::Runtime(format!(
"GitHub API returned error: {}",
response.status()
)));
}
let files: Vec<GitHubFile> = response.json().await.map_err(|e| {
Error::Runtime(format!("Failed to parse GitHub API response: {}", e))
})?;
if files.is_empty() {
break;
}
all_files.extend(files);
page += 1;
if page > 1000 {
return Err(Error::Runtime(
"Too many pages in GitHub API response".to_string(),
));
}
}
let mut result = DiffResult::default();
for file in all_files {
let change_type = match file.status.as_str() {
"added" => ChangeType::Added,
"removed" => ChangeType::Deleted,
"modified" => ChangeType::Modified,
"renamed" => ChangeType::Renamed,
"copied" => ChangeType::Copied,
"changed" => ChangeType::TypeChanged,
_ => ChangeType::Unknown,
};
let previous_path = file.previous_filename.as_ref().map(|p| interner.intern(p));
result.files.push(ChangedFile {
path: interner.intern(&file.filename),
change_type,
previous_path,
is_symlink: false, submodule_depth: 0,
origin: crate::types::FileOrigin {
in_current_changes: true,
in_previous_failure: false,
in_previous_success: false,
},
});
}
Ok(result)
}
pub async fn compare_refs(
&self,
owner: &str,
repo: &str,
base: &str,
head: &str,
interner: &StringInterner,
) -> Result<DiffResult> {
let url = format!(
"{}/repos/{}/{}/compare/{}...{}",
self.base_url, owner, repo, base, head
);
let mut all_files = Vec::new();
let mut page = 1;
loop {
let mut request = self
.client
.get(&url)
.query(&[("page", page.to_string()), ("per_page", "100".to_string())]);
if let Some(ref token) = self.token {
request = request.header("Authorization", format!("Bearer {}", token));
}
let response = request
.send()
.await
.map_err(|e| Error::Runtime(format!("GitHub API compare request failed: {}", e)))?;
if !response.status().is_success() {
return Err(Error::Runtime(format!(
"GitHub API compare returned error: {}",
response.status()
)));
}
let compare: GitHubCompareResponse = response.json().await.map_err(|e| {
Error::Runtime(format!("Failed to parse GitHub compare response: {}", e))
})?;
all_files.extend(compare.files);
if all_files.len() >= compare.total_commits as usize * 100 || compare.files_truncated {
break; }
if all_files.len() < 100 * page as usize {
break; }
page += 1;
if page > 100 {
break; }
}
let mut result = DiffResult::default();
for file in all_files {
let change_type = match file.status.as_str() {
"added" => ChangeType::Added,
"removed" => ChangeType::Deleted,
"modified" => ChangeType::Modified,
"renamed" => ChangeType::Renamed,
"copied" => ChangeType::Copied,
"changed" => ChangeType::TypeChanged,
_ => ChangeType::Unknown,
};
let previous_path = file.previous_filename.as_ref().map(|p| interner.intern(p));
result.files.push(ChangedFile {
path: interner.intern(&file.filename),
change_type,
previous_path,
is_symlink: false,
submodule_depth: 0,
origin: crate::types::FileOrigin {
in_current_changes: true,
in_previous_failure: false,
in_previous_success: false,
},
});
}
Ok(result)
}
pub fn extract_pr_info_from_env() -> Result<(String, String, u32)> {
let repository = std::env::var("GITHUB_REPOSITORY")
.map_err(|_| Error::Config("GITHUB_REPOSITORY not set".to_string()))?;
let github_ref = std::env::var("GITHUB_REF")
.map_err(|_| Error::Config("GITHUB_REF not set".to_string()))?;
Self::parse_pr_info(&repository, &github_ref)
}
fn parse_pr_info(repository: &str, github_ref: &str) -> Result<(String, String, u32)> {
let parts: Vec<&str> = repository.split('/').collect();
if parts.len() != 2 {
return Err(Error::Config(format!(
"Invalid GITHUB_REPOSITORY format: {}",
repository
)));
}
let owner = parts[0].to_string();
let repo = parts[1].to_string();
let pr_number = if github_ref.starts_with("refs/pull/") {
let pr_parts: Vec<&str> = github_ref.split('/').collect();
if pr_parts.len() >= 3 {
pr_parts[2].parse::<u32>().map_err(|_| {
Error::Config(format!("Invalid PR number in GITHUB_REF: {}", github_ref))
})?
} else {
return Err(Error::Config(format!(
"Invalid GITHUB_REF format: {}",
github_ref
)));
}
} else {
return Err(Error::Config("Not a pull request event".to_string()));
};
Ok((owner, repo, pr_number))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_github_client_creation() {
let client = GitHubApiClient::new("https://api.github.com".to_string(), None);
assert_eq!(client.base_url, "https://api.github.com");
assert!(client.token.is_none());
}
#[test]
fn test_github_client_with_token() {
let client = GitHubApiClient::new(
"https://api.github.com".to_string(),
Some("test_token".to_string()),
);
assert_eq!(client.token, Some("test_token".to_string()));
}
#[test]
fn test_parse_pr_info_invalid_format() {
let result = GitHubApiClient::parse_pr_info("invalid", "refs/pull/123/merge");
assert!(result.is_err());
}
#[test]
fn test_parse_pr_info_happy_path() {
let result = GitHubApiClient::parse_pr_info("owner/repo", "refs/pull/42/merge");
assert!(result.is_ok());
let (owner, repo, pr_number) = result.unwrap();
assert_eq!(owner, "owner");
assert_eq!(repo, "repo");
assert_eq!(pr_number, 42);
}
#[test]
fn test_parse_pr_info_not_a_pr() {
let result = GitHubApiClient::parse_pr_info("owner/repo", "refs/heads/main");
assert!(result.is_err());
}
#[test]
fn test_github_client_debug_redacts_token() {
let client = GitHubApiClient::new(
"https://api.github.com".to_string(),
Some("ghp_SuperSecretToken12345".to_string()),
);
let debug_output = format!("{:?}", client);
assert!(
!debug_output.contains("ghp_SuperSecretToken12345"),
"Debug output must not contain the actual token: {}",
debug_output
);
assert!(
debug_output.contains("<redacted>"),
"Debug output should show <redacted>: {}",
debug_output
);
}
#[test]
fn test_github_client_debug_no_token() {
let client = GitHubApiClient::new("https://api.github.com".to_string(), None);
let debug_output = format!("{:?}", client);
assert!(
!debug_output.contains("<redacted>"),
"Should not show <redacted> when no token is set"
);
assert!(debug_output.contains("token: None"));
}
}