use crate::buffered_eprintln;
use anyhow::{anyhow, Context, Result};
use futures::stream::{FuturesUnordered, StreamExt};
use octocrab::Octocrab;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use crate::github::types::PullRequest;
pub async fn search_prs(client: &Octocrab, query: &str) -> Result<Vec<PullRequest>> {
let query = if query.contains("is:pr") {
query.to_string()
} else {
format!("{} is:pr", query)
};
let max_retries = 3;
let mut attempt = 0;
loop {
attempt += 1;
match client
.search()
.issues_and_pull_requests(&query)
.send()
.await
{
Ok(results) => {
let prs: Vec<PullRequest> = results
.items
.into_iter()
.filter(|issue| issue.pull_request.is_some()) .map(|issue| {
let path = issue.html_url.path();
let parts: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
let repo = if parts.len() >= 2 {
format!("{}/{}", parts[0], parts[1])
} else {
"unknown/unknown".to_string()
};
PullRequest {
title: issue.title,
number: issue.number,
author: issue.user.login.clone(),
repo,
url: issue.html_url.to_string(),
created_at: issue.created_at,
updated_at: issue.updated_at,
additions: 0, deletions: 0, approvals: 0, draft: false, labels: issue.labels.iter().map(|l| l.name.clone()).collect(),
user_has_reviewed: false, filtered_size: None, }
})
.collect();
return Ok(prs);
}
Err(e) => {
let error_str = format!("{:?}", e);
if error_str.contains("401") || error_str.contains("Bad credentials") {
return Err(crate::fetch::AuthError {
message:
"Authentication failed. Your GitHub token may be invalid or expired."
.to_string(),
}
.into());
}
if error_str.contains("rate limit") || error_str.contains("403") {
return Err(anyhow!(
"GitHub API rate limit exceeded. Wait a few minutes and try again."
));
}
if error_str.contains("do not have permission")
|| error_str.contains("resources do not exist")
{
return Err(anyhow!("Repository not found or no access. Check repo name and token permissions (needs 'repo' scope for private repos)."));
}
if attempt >= max_retries {
return Err(anyhow!(
"GitHub API error after {} attempts: {}",
max_retries,
e
));
}
let delay = std::time::Duration::from_millis(100 * (1 << (attempt - 1))); tokio::time::sleep(delay).await;
}
}
}
}
async fn fetch_pr_details(
client: &Octocrab,
owner: &str,
repo: &str,
number: u64,
) -> Result<(u64, u64, bool)> {
let pr = client
.pulls(owner, repo)
.get(number)
.await
.context("Failed to fetch PR details")?;
let additions = pr.additions.unwrap_or(0);
let deletions = pr.deletions.unwrap_or(0);
let draft = pr.draft.unwrap_or(false);
Ok((additions, deletions, draft))
}
async fn fetch_pr_reviews(
client: &Octocrab,
owner: &str,
repo: &str,
number: u64,
auth_username: Option<&str>,
) -> Result<(u32, bool)> {
let reviews = client
.pulls(owner, repo)
.list_reviews(number)
.send()
.await
.context("Failed to fetch PR reviews")?;
let approved_count = reviews
.items
.iter()
.filter(|review| {
matches!(
review.state,
Some(octocrab::models::pulls::ReviewState::Approved)
)
})
.count() as u32;
let user_has_reviewed = auth_username.is_some_and(|username| {
reviews.items.iter().any(|r| {
r.user
.as_ref()
.is_some_and(|u| u.login.eq_ignore_ascii_case(username))
})
});
Ok((approved_count, user_has_reviewed))
}
async fn fetch_pr_file_list(
client: &Octocrab,
owner: &str,
repo: &str,
number: u64,
) -> Result<Vec<(String, u64, u64)>> {
let page = client
.pulls(owner, repo)
.list_files(number)
.await
.context("Failed to fetch PR file list")?;
let all_files = client
.all_pages(page)
.await
.context("Failed to paginate PR file list")?;
Ok(all_files
.into_iter()
.map(|f| (f.filename, f.additions, f.deletions))
.collect())
}
fn apply_size_exclusions(files: &[(String, u64, u64)], exclude_patterns: &[String]) -> Result<u64> {
let compiled: Vec<glob::Pattern> = exclude_patterns
.iter()
.map(|p| glob::Pattern::new(p).context(format!("Invalid glob pattern: {}", p)))
.collect::<Result<Vec<_>>>()?;
let total = files
.iter()
.filter(|(filename, _, _)| {
let basename = std::path::Path::new(filename)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(filename);
!compiled.iter().any(|pat| pat.matches(basename))
})
.map(|(_, additions, deletions)| additions + deletions)
.sum();
Ok(total)
}
async fn enrich_pr(
client: &Octocrab,
pr: &mut PullRequest,
auth_username: Option<&str>,
exclude_patterns: &Option<Vec<String>>,
) -> Result<()> {
let parts: Vec<&str> = pr.repo.split('/').collect();
if parts.len() != 2 {
return Err(anyhow!("Invalid repo format: {}", pr.repo));
}
let owner = parts[0];
let repo_name = parts[1];
let details_fut = fetch_pr_details(client, owner, repo_name, pr.number);
let reviews_fut = fetch_pr_reviews(client, owner, repo_name, pr.number, auth_username);
match tokio::try_join!(details_fut, reviews_fut) {
Ok(((additions, deletions, draft), (approvals, user_has_reviewed))) => {
pr.additions = additions;
pr.deletions = deletions;
pr.draft = draft;
pr.approvals = approvals;
pr.user_has_reviewed = user_has_reviewed;
if let Some(ref patterns) = exclude_patterns {
if !patterns.is_empty() {
match fetch_pr_file_list(client, owner, repo_name, pr.number).await {
Ok(files) => {
match apply_size_exclusions(&files, patterns) {
Ok(filtered) => pr.filtered_size = Some(filtered),
Err(e) => {
buffered_eprintln!(
"Warning: Failed to apply size exclusions for PR {}: {}",
pr.number,
e
);
}
}
}
Err(e) => {
buffered_eprintln!(
"Warning: Failed to fetch file list for PR {}: {}",
pr.number,
e
);
}
}
}
}
Ok(())
}
Err(e) => {
buffered_eprintln!("Warning: Failed to enrich PR {}: {}", pr.number, e);
Ok(())
}
}
}
async fn enrich_pr_with_rate_limit_check(
client: Octocrab,
mut pr: PullRequest,
rate_limited: Arc<AtomicBool>,
auth_username: Option<String>,
exclude_patterns: Option<Vec<String>>,
) -> PullRequest {
if rate_limited.load(Ordering::Relaxed) {
return pr; }
match enrich_pr(
&client,
&mut pr,
auth_username.as_deref(),
&exclude_patterns,
)
.await
{
Ok(_) => {}
Err(e) => {
let err_str = e.to_string();
if err_str.contains("rate limit") || err_str.contains("403") {
buffered_eprintln!(
"Warning: Rate limit hit during enrichment. Returning partial results."
);
rate_limited.store(true, Ordering::Relaxed);
} else {
buffered_eprintln!("Warning: Failed to enrich PR {}: {}", pr.number, e);
}
}
}
pr
}
pub async fn search_and_enrich_prs(
client: &Octocrab,
query: &str,
auth_username: Option<&str>,
exclude_patterns: Option<Vec<String>>,
) -> Result<Vec<PullRequest>> {
let prs = search_prs(client, query).await?;
const MAX_CONCURRENT_ENRICHMENTS: usize = 10;
let rate_limited = Arc::new(AtomicBool::new(false));
let mut futures = FuturesUnordered::new();
let mut prs_iter = prs.into_iter();
let mut enriched_prs = Vec::new();
for _ in 0..MAX_CONCURRENT_ENRICHMENTS {
if let Some(pr) = prs_iter.next() {
futures.push(enrich_pr_with_rate_limit_check(
client.clone(),
pr,
rate_limited.clone(),
auth_username.map(|s| s.to_string()),
exclude_patterns.clone(),
));
}
}
while let Some(pr) = futures.next().await {
enriched_prs.push(pr);
if !rate_limited.load(Ordering::Relaxed) {
if let Some(next_pr) = prs_iter.next() {
futures.push(enrich_pr_with_rate_limit_check(
client.clone(),
next_pr,
rate_limited.clone(),
auth_username.map(|s| s.to_string()),
exclude_patterns.clone(),
));
}
}
}
enriched_prs.extend(prs_iter);
Ok(enriched_prs)
}