use chrono::{Duration, Utc};
use serde::{Deserialize, Serialize};
use tracing::{debug, instrument};
use crate::cache::FileCache;
use crate::config::load_config;
use crate::error::AptuError;
use crate::github::auth::create_client_with_token;
use secrecy::SecretString;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiscoveredRepo {
pub owner: String,
pub name: String,
pub language: Option<String>,
pub description: Option<String>,
pub stars: u32,
pub url: String,
pub score: u32,
}
impl DiscoveredRepo {
#[must_use]
pub fn full_name(&self) -> String {
format!("{}/{}", self.owner, self.name)
}
}
#[derive(Debug, Clone)]
pub struct DiscoveryFilter {
pub language: Option<String>,
pub min_stars: u32,
pub limit: u32,
}
impl Default for DiscoveryFilter {
fn default() -> Self {
Self {
language: None,
min_stars: 10,
limit: 20,
}
}
}
#[must_use]
pub fn score_repo(repo: &octocrab::models::Repository, filter: &DiscoveryFilter) -> u32 {
let mut score = 0u32;
let stars = f64::from(repo.stargazers_count.unwrap_or(0));
let star_score = if stars > 0.0 {
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let score_val = ((stars.ln() + 1.0) / 10.0 * 50.0).min(50.0) as u32;
score_val
} else {
0
};
score += star_score;
if let Some(ref filter_lang) = filter.language
&& let Some(ref repo_lang) = repo.language
&& let Some(lang_str) = repo_lang.as_str()
&& lang_str.to_lowercase() == filter_lang.to_lowercase()
{
score += 30;
}
if repo.description.is_some() && !repo.description.as_ref().unwrap().is_empty() {
score += 20;
}
score.min(100)
}
use std::fmt::Write as FmtWrite;
#[must_use]
pub fn build_search_query(filter: &DiscoveryFilter) -> String {
let mut query = String::from("good-first-issues:>0");
let thirty_days_ago = Utc::now() - Duration::days(30);
let date_str = thirty_days_ago.format("%Y-%m-%d").to_string();
let _ = write!(query, " pushed:>{date_str}");
let _ = write!(query, " stars:>={}", filter.min_stars);
if let Some(ref lang) = filter.language {
let _ = write!(query, " language:{lang}");
}
query
}
#[instrument(skip(token), fields(language = ?filter.language, min_stars = filter.min_stars, limit = filter.limit))]
pub async fn search_repositories(
token: &SecretString,
filter: &DiscoveryFilter,
) -> crate::Result<Vec<DiscoveredRepo>> {
let cache_key = format!(
"discovered_repos_{}_{}_{}",
filter.language.as_deref().unwrap_or("any"),
filter.min_stars,
filter.limit
);
let config = load_config()?;
let ttl = Duration::hours(config.cache.repo_ttl_hours);
let cache: crate::cache::FileCacheImpl<Vec<DiscoveredRepo>> =
crate::cache::FileCacheImpl::new("discovery", ttl);
if let Ok(Some(repos)) = cache.get(&cache_key) {
debug!("Using cached discovered repositories");
return Ok(repos);
}
let client = create_client_with_token(token).map_err(|e| AptuError::GitHub {
message: format!("Failed to create GitHub client: {e}"),
})?;
let query = build_search_query(filter);
debug!("Searching with query: {}", query);
let repos = client
.search()
.repositories(&query)
.per_page(100)
.send()
.await
.map_err(|e| AptuError::GitHub {
message: format!("Failed to search repositories: {e}"),
})?;
let mut discovered: Vec<DiscoveredRepo> = repos
.items
.into_iter()
.filter_map(|repo| {
let score = score_repo(&repo, filter);
let url = repo.html_url.as_ref().map(ToString::to_string)?;
let language = repo
.language
.as_ref()
.and_then(|v| v.as_str())
.map(ToString::to_string);
Some(DiscoveredRepo {
owner: repo
.owner
.as_ref()
.map(|o| o.login.clone())
.unwrap_or_default(),
name: repo.name.clone(),
language,
description: repo.description.clone(),
stars: repo.stargazers_count.unwrap_or(0),
url,
score,
})
})
.collect();
discovered.sort_by(|a, b| b.score.cmp(&a.score).then_with(|| b.stars.cmp(&a.stars)));
discovered.truncate(filter.limit as usize);
let _ = cache.set(&cache_key, &discovered);
debug!(
"Found and cached {} discovered repositories",
discovered.len()
);
Ok(discovered)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_search_query_basic() {
let filter = DiscoveryFilter {
language: None,
min_stars: 10,
limit: 20,
};
let query = build_search_query(&filter);
assert!(query.contains("good-first-issues:>0"));
assert!(query.contains("pushed:>"));
assert!(query.contains("stars:>=10"));
assert!(!query.contains("language:"));
}
#[test]
fn build_search_query_with_language() {
let filter = DiscoveryFilter {
language: Some("Rust".to_string()),
min_stars: 50,
limit: 10,
};
let query = build_search_query(&filter);
assert!(query.contains("good-first-issues:>0"));
assert!(query.contains("language:Rust"));
assert!(query.contains("stars:>=50"));
}
#[test]
fn discovered_repo_full_name() {
let repo = DiscoveredRepo {
owner: "owner".to_string(),
name: "repo".to_string(),
language: Some("Rust".to_string()),
description: Some("Test".to_string()),
stars: 100,
url: "https://github.com/owner/repo".to_string(),
score: 75,
};
assert_eq!(repo.full_name(), "owner/repo");
}
}