use chrono::Duration;
use secrecy::SecretString;
use tracing::instrument;
use crate::auth::TokenProvider;
use crate::cache::{FileCache, FileCacheImpl};
use crate::config::load_config;
use crate::error::AptuError;
use crate::github::auth::create_client_from_provider;
use crate::github::graphql::{IssueNode, fetch_issues as gh_fetch_issues};
use crate::repos::{self, CuratedRepo};
#[instrument(skip(provider), fields(repo_filter = ?repo_filter, use_cache))]
pub async fn fetch_issues(
provider: &dyn TokenProvider,
repo_filter: Option<&str>,
use_cache: bool,
) -> crate::Result<Vec<(String, Vec<IssueNode>)>> {
let client = create_client_from_provider(provider)?;
let all_repos = repos::fetch().await?;
let repos_to_query: Vec<_> = match repo_filter {
Some(filter) => {
let filter_lower = filter.to_lowercase();
all_repos
.iter()
.filter(|r| {
r.full_name().to_lowercase().contains(&filter_lower)
|| r.name.to_lowercase().contains(&filter_lower)
})
.cloned()
.collect()
}
None => all_repos,
};
let config = load_config()?;
let ttl = Duration::minutes(config.cache.issue_ttl_minutes);
if use_cache {
let cache: FileCacheImpl<Vec<IssueNode>> = FileCacheImpl::new("issues", ttl);
let mut cached_results = Vec::new();
let mut repos_to_fetch = Vec::new();
for repo in &repos_to_query {
let cache_key = format!("{}_{}", repo.owner, repo.name);
if let Ok(Some(issues)) = cache.get(&cache_key).await {
cached_results.push((repo.full_name(), issues));
} else {
repos_to_fetch.push(repo.clone());
}
}
if repos_to_fetch.is_empty() {
return Ok(cached_results);
}
let repo_tuples: Vec<_> = repos_to_fetch
.iter()
.map(|r| (r.owner.as_str(), r.name.as_str()))
.collect();
let api_results =
gh_fetch_issues(&client, &repo_tuples)
.await
.map_err(|e| AptuError::GitHub {
message: format!("Failed to fetch issues: {e}"),
})?;
for (repo_name, issues) in &api_results {
if let Some(repo) = repos_to_fetch.iter().find(|r| r.full_name() == *repo_name) {
let cache_key = format!("{}_{}", repo.owner, repo.name);
let _ = cache.set(&cache_key, issues).await;
}
}
cached_results.extend(api_results);
Ok(cached_results)
} else {
let repo_tuples: Vec<_> = repos_to_query
.iter()
.map(|r| (r.owner.as_str(), r.name.as_str()))
.collect();
gh_fetch_issues(&client, &repo_tuples)
.await
.map_err(|e| AptuError::GitHub {
message: format!("Failed to fetch issues: {e}"),
})
}
}
pub async fn list_curated_repos() -> crate::Result<Vec<CuratedRepo>> {
repos::fetch().await
}
#[instrument]
pub async fn add_custom_repo(owner: &str, name: &str) -> crate::Result<CuratedRepo> {
let repo = repos::custom::validate_and_fetch_metadata(owner, name).await?;
let mut custom_repos = repos::custom::read_custom_repos()?;
if custom_repos
.iter()
.any(|r| r.full_name() == repo.full_name())
{
return Err(crate::error::AptuError::Config {
message: format!(
"Repository {} already exists in custom repos",
repo.full_name()
),
});
}
custom_repos.push(repo.clone());
repos::custom::write_custom_repos(&custom_repos)?;
Ok(repo)
}
#[instrument]
pub fn remove_custom_repo(owner: &str, name: &str) -> crate::Result<bool> {
let full_name = format!("{owner}/{name}");
let mut custom_repos = repos::custom::read_custom_repos()?;
let initial_len = custom_repos.len();
custom_repos.retain(|r| r.full_name() != full_name);
if custom_repos.len() == initial_len {
return Ok(false); }
repos::custom::write_custom_repos(&custom_repos)?;
Ok(true)
}
#[instrument]
pub async fn list_repos(filter: repos::RepoFilter) -> crate::Result<Vec<CuratedRepo>> {
repos::fetch_all(filter).await
}
#[instrument(skip(provider), fields(language = ?filter.language, min_stars = filter.min_stars, limit = filter.limit))]
pub async fn discover_repos(
provider: &dyn TokenProvider,
filter: repos::discovery::DiscoveryFilter,
) -> crate::Result<Vec<repos::discovery::DiscoveredRepo>> {
let token = provider.github_token().ok_or(AptuError::NotAuthenticated)?;
let token = SecretString::from(token);
repos::discovery::search_repositories(&token, &filter).await
}