use rusqlite::params;
use tracing::{debug, warn};
use async_trait::async_trait;
use crate::collect::errors::Result;
use crate::collect::github::repo_resolver::{build_http_client, parse_slug};
use crate::collect::github::retry::retry_get;
use crate::collect::github::types::{ApiPull, GitHubIssue, GitHubPrCommit, GitHubReview};
use crate::collect::pr_provider::PrProvider;
use crate::core::config::GithubConfig;
use crate::core::db::Database;
use crate::core::models::{PrState, PullRequest};
pub(crate) const GITHUB_API_BASE: &str = "https://api.github.com";
pub(crate) const PAGE_SIZE: u32 = 100;
pub(crate) const USER_AGENT_VALUE: &str = "trusty-git-analytics/0.1";
pub struct GitHubClient {
pub(crate) client: reqwest::Client,
pub(crate) token: Option<String>,
pub(crate) owner: String,
pub(crate) repo: String,
pub(crate) repos: Vec<(String, String)>,
}
pub(crate) fn commit_shas_for_pull(p: &ApiPull) -> Result<String> {
match (&p.merge_commit_sha, p.merged_at.is_some()) {
(Some(s), true) => Ok(serde_json::to_string(&vec![s.clone()])?),
_ => Ok("[]".to_string()),
}
}
impl GitHubClient {
pub fn new(config: &GithubConfig) -> Result<Self> {
use crate::collect::errors::CollectError;
let repo_slug = config
.repo
.as_ref()
.ok_or_else(|| CollectError::Config("github.repo is required (owner/name)".into()))?;
let (owner, repo) = parse_slug(repo_slug)?;
let http = build_http_client(config)?;
Ok(Self {
client: http,
token: config.token.clone(),
owner: owner.clone(),
repo: repo.clone(),
repos: vec![(owner, repo)],
})
}
pub fn new_for_prs(config: &GithubConfig, repos: Vec<(String, String)>) -> Result<Self> {
use crate::collect::errors::CollectError;
if repos.is_empty() {
return Err(CollectError::Config(
"GitHubClient::new_for_prs requires at least one (owner, repo)".into(),
));
}
let (primary_owner, primary_repo) = repos[0].clone();
let http = build_http_client(config)?;
Ok(Self {
client: http,
token: config.token.clone(),
owner: primary_owner,
repo: primary_repo,
repos,
})
}
pub fn new_for_reviews(config: &GithubConfig) -> Result<Self> {
let http = build_http_client(config)?;
Ok(Self {
client: http,
token: config.token.clone(),
owner: String::new(),
repo: String::new(),
repos: Vec::new(),
})
}
pub async fn fetch_pull_requests(&self) -> Result<Vec<PullRequest>> {
let mut out: Vec<PullRequest> = Vec::new();
for (owner, repo) in &self.repos {
match self.fetch_pull_requests_for_repo(owner, repo).await {
Ok(mut prs) => out.append(&mut prs),
Err(e) => {
warn!(
owner = %owner,
repo = %repo,
error = %e,
"GitHub PR fetch failed for repo; continuing with remaining repos"
);
}
}
}
Ok(out)
}
async fn fetch_pull_requests_for_repo(
&self,
owner: &str,
repo: &str,
) -> Result<Vec<PullRequest>> {
let mut out: Vec<PullRequest> = Vec::new();
let mut page = 1u32;
loop {
let url = format!(
"{GITHUB_API_BASE}/repos/{owner}/{repo}/pulls?state=all&per_page={PAGE_SIZE}&page={page}"
);
debug!(url = %url, "GET");
let resp = self.retry_request(&url).await?;
if let Some(rem) = resp
.headers()
.get("x-ratelimit-remaining")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u32>().ok())
{
if rem < 5 {
warn!(remaining = rem, "GitHub rate limit nearly exhausted");
}
}
let resp = resp.error_for_status()?;
let pulls: Vec<ApiPull> = resp.json().await?;
if pulls.is_empty() {
break;
}
let n = pulls.len();
for p in pulls {
let state = if p.merged_at.is_some() {
PrState::Merged
} else if p.state == "closed" {
PrState::Closed
} else {
PrState::Open
};
let commit_shas = commit_shas_for_pull(&p)?;
out.push(PullRequest {
id: 0,
pr_number: p.number,
repository: format!("{owner}/{repo}"),
title: p.title,
author: p.user.map(|u| u.login).unwrap_or_default(),
state,
created_at: p.created_at,
merged_at: p.merged_at,
commit_shas,
});
}
if (n as u32) < PAGE_SIZE {
break;
}
page += 1;
}
Ok(out)
}
pub fn store_pull_requests(
&self,
db: &Database,
prs: &[PullRequest],
) -> crate::core::Result<usize> {
let conn = db.connection();
let mut count = 0usize;
for pr in prs {
conn.execute(
"INSERT INTO pull_requests \
(provider,repository,pr_number,title,author,state,created_at,merged_at,commit_shas) \
VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9) \
ON CONFLICT(provider,repository,pr_number) DO UPDATE SET \
title=excluded.title,author=excluded.author,state=excluded.state,\
merged_at=excluded.merged_at,commit_shas=excluded.commit_shas",
params![
"github",
pr.repository,
pr.pr_number as i64,
pr.title,
pr.author,
pr.state.as_str(),
pr.created_at.to_rfc3339(),
pr.merged_at.map(|t| t.to_rfc3339()),
pr.commit_shas,
],
)?;
count += 1;
}
Ok(count)
}
pub fn has_token(&self) -> bool {
self.token.is_some()
}
pub async fn fetch_issue(&self, number: u64) -> Result<Option<GitHubIssue>> {
let url = format!(
"{GITHUB_API_BASE}/repos/{}/{}/issues/{number}",
self.owner, self.repo
);
debug!(url = %url, "GET");
let resp = self.client.get(&url).send().await?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(None);
}
let resp = resp.error_for_status()?;
let issue: GitHubIssue = resp.json().await?;
Ok(Some(issue))
}
async fn retry_request(&self, url: &str) -> Result<reqwest::Response> {
retry_get(&self.client, url).await
}
pub async fn fetch_pr_reviews_for_repo(
&self,
owner: &str,
repo: &str,
pr_number: u64,
) -> Result<Vec<GitHubReview>> {
let mut out = Vec::new();
let mut page = 1u32;
loop {
let url = format!(
"{GITHUB_API_BASE}/repos/{owner}/{repo}/pulls/{pr_number}/reviews?per_page={PAGE_SIZE}&page={page}"
);
let resp = self.retry_request(&url).await?.error_for_status()?;
let batch: Vec<GitHubReview> = resp.json().await?;
let n = batch.len();
out.extend(batch);
if (n as u32) < PAGE_SIZE {
break;
}
page += 1;
}
Ok(out)
}
pub fn http_client(&self) -> &reqwest::Client {
&self.client
}
pub async fn fetch_pr_commits(&self, pr_number: u64) -> Result<Vec<GitHubPrCommit>> {
let mut out = Vec::new();
let mut page = 1u32;
loop {
let url = format!(
"{GITHUB_API_BASE}/repos/{}/{}/pulls/{pr_number}/commits?per_page={PAGE_SIZE}&page={page}",
self.owner, self.repo
);
let resp = self.retry_request(&url).await?.error_for_status()?;
let batch: Vec<GitHubPrCommit> = resp.json().await?;
let n = batch.len();
out.extend(batch);
if (n as u32) < PAGE_SIZE {
break;
}
page += 1;
}
Ok(out)
}
pub async fn list_issues(&self, state: &str, since: Option<&str>) -> Result<Vec<GitHubIssue>> {
let mut out = Vec::new();
let mut page = 1u32;
loop {
let mut url = format!(
"{GITHUB_API_BASE}/repos/{}/{}/issues?state={state}&per_page={PAGE_SIZE}&page={page}",
self.owner, self.repo
);
if let Some(s) = since {
url.push_str("&since=");
url.push_str(s);
}
let resp = self.retry_request(&url).await?.error_for_status()?;
let batch: Vec<GitHubIssue> = resp.json().await?;
let n = batch.len();
out.extend(batch);
if (n as u32) < PAGE_SIZE {
break;
}
page += 1;
}
Ok(out)
}
}
#[async_trait]
impl PrProvider for GitHubClient {
fn name(&self) -> &str {
"github"
}
async fn fetch_pull_requests(&self) -> Result<Vec<PullRequest>> {
GitHubClient::fetch_pull_requests(self).await
}
fn store_pull_requests(
&self,
db: &Database,
prs: &[PullRequest],
) -> crate::core::Result<usize> {
GitHubClient::store_pull_requests(self, db, prs)
}
}
#[cfg(test)]
#[path = "client_tests.rs"]
mod tests;