use crate::Contributor; use crate::parse::PrRef; use serde::Deserialize; /// Abstraction over the GitHub API for testability. pub trait GitHubClient { /// Returns the login of the PR author. fn get_pr_author(&self, pr: &PrRef) -> Result; /// Returns contributors extracted from commits on the PR. fn get_commit_contributors(&self, pr: &PrRef) -> Result, String>; /// Returns logins of users who left issue comments on the PR. fn get_issue_commenters(&self, pr: &PrRef) -> Result, String>; /// Returns logins of users who left review comments on the PR. fn get_review_commenters(&self, pr: &PrRef) -> Result, String>; /// Resolves a GitHub login to a Contributor (name + email). fn get_user(&self, login: &str) -> Result; } // --- GitHub API response types (only what we need) --- #[derive(Deserialize)] struct PrResponse { user: GitHubUser, } #[derive(Deserialize)] struct GitHubUser { login: String, } #[derive(Deserialize)] struct CommitResponse { commit: CommitData, author: Option, } #[derive(Deserialize)] struct CommitData { author: CommitAuthor, } #[derive(Deserialize)] struct CommitAuthor { name: String, email: String, } #[derive(Deserialize)] struct CommentResponse { user: GitHubUser, } #[derive(Deserialize)] struct UserResponse { login: String, name: Option, email: Option, id: u64, } // --- Real implementation using ureq --- pub struct UreqGitHubClient { token: Option, agent: ureq::Agent, } impl UreqGitHubClient { pub fn new(token: Option) -> Self { Self { token, agent: ureq::Agent::new_with_defaults(), } } fn get_json(&self, url: &str) -> Result { let mut req = self.agent.get(url).header("User-Agent", "autore-cli"); if let Some(ref token) = self.token { req = req.header("Authorization", &format!("Bearer {token}")); } req = req.header("Accept", "application/vnd.github+json"); let mut response = req.call().map_err(map_ureq_error)?; let body = response .body_mut() .read_to_string() .map_err(|e| e.to_string())?; serde_json::from_str(&body).map_err(|e| format!("failed to parse response: {e}")) } /// Fetches all pages for a paginated GitHub API endpoint. fn get_all_pages(&self, url: &str) -> Result, String> { let mut all_items = Vec::new(); let mut next_url = Some(format!("{url}?per_page=100")); while let Some(current_url) = next_url { let mut req = self .agent .get(¤t_url) .header("User-Agent", "autore-cli"); if let Some(ref token) = self.token { req = req.header("Authorization", &format!("Bearer {token}")); } req = req.header("Accept", "application/vnd.github+json"); let mut response = req.call().map_err(map_ureq_error)?; // Parse Link header for next page next_url = response .headers() .get("link") .and_then(|v| v.to_str().ok()) .and_then(parse_next_link); let body = response .body_mut() .read_to_string() .map_err(|e| e.to_string())?; let items: Vec = serde_json::from_str(&body) .map_err(|e| format!("failed to parse response: {e}"))?; all_items.extend(items); } Ok(all_items) } } impl GitHubClient for UreqGitHubClient { fn get_pr_author(&self, pr: &PrRef) -> Result { let url = format!( "https://api.github.com/repos/{}/{}/pulls/{}", pr.owner, pr.repo, pr.number ); let pr_resp: PrResponse = self.get_json(&url)?; Ok(pr_resp.user.login) } fn get_commit_contributors(&self, pr: &PrRef) -> Result, String> { let url = format!( "https://api.github.com/repos/{}/{}/pulls/{}/commits", pr.owner, pr.repo, pr.number ); let commits: Vec = self.get_all_pages(&url)?; let contributors = commits .into_iter() .map(|c| Contributor { login: c.author.map(|a| a.login), name: c.commit.author.name, email: c.commit.author.email, }) .collect(); Ok(contributors) } fn get_issue_commenters(&self, pr: &PrRef) -> Result, String> { let url = format!( "https://api.github.com/repos/{}/{}/issues/{}/comments", pr.owner, pr.repo, pr.number ); let comments: Vec = self.get_all_pages(&url)?; Ok(comments.into_iter().map(|c| c.user.login).collect()) } fn get_review_commenters(&self, pr: &PrRef) -> Result, String> { let url = format!( "https://api.github.com/repos/{}/{}/pulls/{}/comments", pr.owner, pr.repo, pr.number ); let comments: Vec = self.get_all_pages(&url)?; Ok(comments.into_iter().map(|c| c.user.login).collect()) } fn get_user(&self, login: &str) -> Result { let url = format!("https://api.github.com/users/{login}"); let user: UserResponse = self.get_json(&url)?; let name = user.name.unwrap_or_else(|| user.login.clone()); let email = user .email .unwrap_or_else(|| format!("{}+{}@users.noreply.github.com", user.id, user.login)); Ok(Contributor { login: Some(user.login), name, email, }) } } fn map_ureq_error(error: ureq::Error) -> String { match error { ureq::Error::StatusCode(404) => "PR not found".to_string(), ureq::Error::StatusCode(401) => "authentication failed".to_string(), ureq::Error::StatusCode(403) => { "forbidden: you may be rate limited, use --token to authenticate".to_string() } ureq::Error::StatusCode(code) => format!("GitHub API returned status {code}"), other => format!("request failed: {other}"), } } /// Parses the GitHub `Link` header to find the `rel="next"` URL. fn parse_next_link(header: &str) -> Option { for part in header.split(',') { let part = part.trim(); if part.ends_with("rel=\"next\"") { // Extract URL between < and > if let Some(start) = part.find('<') { if let Some(end) = part.find('>') { return Some(part[start + 1..end].to_string()); } } } } None } #[cfg(test)] mod tests { use super::*; #[test] fn parse_link_header_with_next() { let header = r#"; rel="next", ; rel="last""#; let next = parse_next_link(header); assert_eq!( next, Some( "https://api.github.com/repos/org/repo/pulls/1/commits?page=2&per_page=100" .to_string() ) ); } #[test] fn parse_link_header_without_next() { let header = r#"; rel="prev", ; rel="last""#; let next = parse_next_link(header); assert_eq!(next, None); } #[test] fn parse_link_header_empty() { assert_eq!(parse_next_link(""), None); } }