Something went wrong. Try again.
Get co-authors
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261use 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<String, String>;
/// Returns contributors extracted from commits on the PR. fn get_commit_contributors(&self, pr: &PrRef) -> Result<Vec<Contributor>, String>;
/// Returns logins of users who left issue comments on the PR. fn get_issue_commenters(&self, pr: &PrRef) -> Result<Vec<String>, String>;
/// Returns logins of users who left review comments on the PR. fn get_review_commenters(&self, pr: &PrRef) -> Result<Vec<String>, String>;
/// Resolves a GitHub login to a Contributor (name + email). fn get_user(&self, login: &str) -> Result<Contributor, String>;}
// --- 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<GitHubUser>,}
#[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<String>, email: Option<String>, id: u64,}
// --- Real implementation using ureq ---
pub struct UreqGitHubClient { token: Option<String>, agent: ureq::Agent,}
impl UreqGitHubClient { pub fn new(token: Option<String>) -> Self { Self { token, agent: ureq::Agent::new_with_defaults(), } }
fn get_json<T: serde::de::DeserializeOwned>(&self, url: &str) -> Result<T, String> { 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<T: serde::de::DeserializeOwned>(&self, url: &str) -> Result<Vec<T>, 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<T> = 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<String, String> { 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<Vec<Contributor>, String> { let url = format!( "https://api.github.com/repos/{}/{}/pulls/{}/commits", pr.owner, pr.repo, pr.number ); let commits: Vec<CommitResponse> = 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<Vec<String>, String> { let url = format!( "https://api.github.com/repos/{}/{}/issues/{}/comments", pr.owner, pr.repo, pr.number ); let comments: Vec<CommentResponse> = self.get_all_pages(&url)?; Ok(comments.into_iter().map(|c| c.user.login).collect()) }
fn get_review_commenters(&self, pr: &PrRef) -> Result<Vec<String>, String> { let url = format!( "https://api.github.com/repos/{}/{}/pulls/{}/comments", pr.owner, pr.repo, pr.number ); let comments: Vec<CommentResponse> = self.get_all_pages(&url)?; Ok(comments.into_iter().map(|c| c.user.login).collect()) }
fn get_user(&self, login: &str) -> Result<Contributor, String> { 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<String> { 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#"<https://api.github.com/repos/org/repo/pulls/1/commits?page=2&per_page=100>; rel="next", <https://api.github.com/repos/org/repo/pulls/1/commits?page=3&per_page=100>; 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#"<https://api.github.com/repos/org/repo/pulls/1/commits?page=1&per_page=100>; rel="prev", <https://api.github.com/repos/org/repo/pulls/1/commits?page=3&per_page=100>; rel="last""#; let next = parse_next_link(header); assert_eq!(next, None); }
#[test] fn parse_link_header_empty() { assert_eq!(parse_next_link(""), None); }}