use chrono::Duration;
use tracing::{debug, info, instrument, warn};
use crate::ai::provider::MAX_LABELS;
use crate::ai::registry::get_provider;
use crate::ai::types::{
CreateIssueResponse, PrDetails, PrReviewComment, ReviewEvent, TriageResponse,
};
use crate::ai::{AiClient, AiProvider, AiResponse, types::IssueDetails};
use crate::auth::TokenProvider;
use crate::cache::{FileCache, FileCacheImpl};
use crate::config::{AiConfig, TaskType, load_config};
use crate::error::AptuError;
use crate::github::auth::{create_client_from_provider, create_client_with_token};
use crate::github::graphql::{
IssueNode, fetch_issue_with_repo_context, fetch_issues as gh_fetch_issues,
};
use crate::github::issues::{create_issue as gh_create_issue, filter_labels_by_relevance};
use crate::github::pulls::{fetch_pr_details, post_pr_review as gh_post_pr_review};
use crate::repos::{self, CuratedRepo};
use crate::retry::is_retryable_anyhow;
use crate::security::SecurityScanner;
use secrecy::SecretString;
#[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) {
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);
}
}
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
}
fn validate_provider_model(provider: &str, model: &str) -> crate::Result<()> {
if crate::ai::registry::get_provider(provider).is_none() {
return Err(AptuError::ModelRegistry {
message: format!("Provider not found: {provider}"),
});
}
tracing::debug!(provider = provider, model = model, "Validating model");
Ok(())
}
fn try_setup_primary_client(
provider: &dyn TokenProvider,
primary_provider: &str,
model_name: &str,
ai_config: &AiConfig,
) -> crate::Result<AiClient> {
let api_key = provider.ai_api_key(primary_provider).ok_or_else(|| {
let env_var = get_provider(primary_provider).map_or("API_KEY", |p| p.api_key_env);
AptuError::AiProviderNotAuthenticated {
provider: primary_provider.to_string(),
env_var: env_var.to_string(),
}
})?;
if ai_config.validation_enabled {
validate_provider_model(primary_provider, model_name)?;
}
AiClient::with_api_key(primary_provider, api_key, model_name, ai_config).map_err(|e| {
AptuError::AI {
message: e.to_string(),
status: None,
provider: primary_provider.to_string(),
}
})
}
fn setup_fallback_client(
provider: &dyn TokenProvider,
entry: &crate::config::FallbackEntry,
model_name: &str,
ai_config: &AiConfig,
) -> Option<AiClient> {
let Some(api_key) = provider.ai_api_key(&entry.provider) else {
warn!(
fallback_provider = entry.provider,
"No API key available for fallback provider"
);
return None;
};
let fallback_model = entry.model.as_deref().unwrap_or(model_name);
if ai_config.validation_enabled
&& validate_provider_model(&entry.provider, fallback_model).is_err()
{
warn!(
fallback_provider = entry.provider,
fallback_model = fallback_model,
"Fallback provider model validation failed, continuing to next provider"
);
return None;
}
if let Ok(client) = AiClient::with_api_key(&entry.provider, api_key, fallback_model, ai_config)
{
Some(client)
} else {
warn!(
fallback_provider = entry.provider,
"Failed to create AI client for fallback provider"
);
None
}
}
async fn try_fallback_entry<T, F, Fut>(
provider: &dyn TokenProvider,
entry: &crate::config::FallbackEntry,
model_name: &str,
ai_config: &AiConfig,
operation: &F,
) -> crate::Result<Option<T>>
where
F: Fn(AiClient) -> Fut,
Fut: std::future::Future<Output = anyhow::Result<T>>,
{
warn!(
fallback_provider = entry.provider,
"Attempting fallback provider"
);
let Some(ai_client) = setup_fallback_client(provider, entry, model_name, ai_config) else {
return Ok(None);
};
match operation(ai_client).await {
Ok(response) => {
info!(
fallback_provider = entry.provider,
"Successfully completed operation with fallback provider"
);
Ok(Some(response))
}
Err(e) => {
if is_retryable_anyhow(&e) {
return Err(AptuError::AI {
message: e.to_string(),
status: None,
provider: entry.provider.clone(),
});
}
warn!(
fallback_provider = entry.provider,
error = %e,
"Fallback provider failed with non-retryable error"
);
Ok(None)
}
}
}
async fn execute_fallback_chain<T, F, Fut>(
provider: &dyn TokenProvider,
primary_provider: &str,
model_name: &str,
ai_config: &AiConfig,
operation: F,
) -> crate::Result<T>
where
F: Fn(AiClient) -> Fut,
Fut: std::future::Future<Output = anyhow::Result<T>>,
{
if let Some(fallback_config) = &ai_config.fallback {
for entry in &fallback_config.chain {
if let Some(response) =
try_fallback_entry(provider, entry, model_name, ai_config, &operation).await?
{
return Ok(response);
}
}
}
Err(AptuError::AI {
message: "All AI providers failed (primary and fallback chain)".to_string(),
status: None,
provider: primary_provider.to_string(),
})
}
async fn try_with_fallback<T, F, Fut>(
provider: &dyn TokenProvider,
primary_provider: &str,
model_name: &str,
ai_config: &AiConfig,
operation: F,
) -> crate::Result<T>
where
F: Fn(AiClient) -> Fut,
Fut: std::future::Future<Output = anyhow::Result<T>>,
{
let ai_client = try_setup_primary_client(provider, primary_provider, model_name, ai_config)?;
match operation(ai_client).await {
Ok(response) => return Ok(response),
Err(e) => {
if is_retryable_anyhow(&e) {
return Err(AptuError::AI {
message: e.to_string(),
status: None,
provider: primary_provider.to_string(),
});
}
warn!(
primary_provider = primary_provider,
error = %e,
"Primary provider failed with non-retryable error, trying fallback chain"
);
}
}
execute_fallback_chain(provider, primary_provider, model_name, ai_config, operation).await
}
#[instrument(skip(provider, issue), fields(issue_number = issue.number, repo = %format!("{}/{}", issue.owner, issue.repo)))]
pub async fn analyze_issue(
provider: &dyn TokenProvider,
issue: &IssueDetails,
ai_config: &AiConfig,
) -> crate::Result<AiResponse> {
let mut issue_mut = issue.clone();
if issue_mut.available_labels.is_empty()
&& !issue_mut.owner.is_empty()
&& !issue_mut.repo.is_empty()
{
if let Some(github_token) = provider.github_token() {
let token = SecretString::from(github_token);
if let Ok(client) = create_client_with_token(&token) {
if let Ok((_, repo_data)) = fetch_issue_with_repo_context(
&client,
&issue_mut.owner,
&issue_mut.repo,
issue_mut.number,
)
.await
{
issue_mut.available_labels =
repo_data.labels.nodes.into_iter().map(Into::into).collect();
}
}
}
}
if !issue_mut.available_labels.is_empty() {
issue_mut.available_labels =
filter_labels_by_relevance(&issue_mut.available_labels, MAX_LABELS);
}
let (provider_name, model_name) = ai_config.resolve_for_task(TaskType::Triage);
try_with_fallback(provider, &provider_name, &model_name, ai_config, |client| {
let issue = issue_mut.clone();
async move { client.analyze_issue(&issue).await }
})
.await
}
#[instrument(skip(provider), fields(reference = %reference))]
pub async fn fetch_pr_for_review(
provider: &dyn TokenProvider,
reference: &str,
repo_context: Option<&str>,
) -> crate::Result<PrDetails> {
use crate::github::pulls::parse_pr_reference;
let (owner, repo, number) =
parse_pr_reference(reference, repo_context).map_err(|e| AptuError::GitHub {
message: e.to_string(),
})?;
let client = create_client_from_provider(provider)?;
let app_config = load_config().unwrap_or_default();
fetch_pr_details(&client, &owner, &repo, number, &app_config.review)
.await
.map_err(|e| AptuError::GitHub {
message: e.to_string(),
})
}
fn reconstruct_diff_from_pr(files: &[crate::ai::types::PrFile]) -> String {
use crate::ai::provider::MAX_TOTAL_DIFF_SIZE;
let mut diff = String::new();
for file in files {
if let Some(patch) = &file.patch {
if diff.len() >= MAX_TOTAL_DIFF_SIZE {
break;
}
diff.push_str("+++ b/");
diff.push_str(&file.filename);
diff.push('\n');
diff.push_str(patch);
diff.push('\n');
}
}
diff
}
#[allow(clippy::unused_async)] async fn build_ctx_ast(repo_path: Option<&str>, files: &[crate::ai::types::PrFile]) -> String {
let Some(path) = repo_path else {
return String::new();
};
#[cfg(feature = "ast-context")]
{
return crate::ast_context::build_ast_context(path, files).await;
}
#[cfg(not(feature = "ast-context"))]
{
let _ = (path, files);
String::new()
}
}
#[allow(clippy::unused_async)] async fn build_ctx_call_graph(
repo_path: Option<&str>,
files: &[crate::ai::types::PrFile],
deep: bool,
) -> String {
if !deep {
return String::new();
}
let Some(path) = repo_path else {
return String::new();
};
#[cfg(feature = "ast-context")]
{
return crate::ast_context::build_call_graph_context(path, files).await;
}
#[cfg(not(feature = "ast-context"))]
{
let _ = (path, files);
String::new()
}
}
#[instrument(skip(provider, pr_details), fields(number = pr_details.number))]
pub async fn analyze_pr(
provider: &dyn TokenProvider,
pr_details: &PrDetails,
ai_config: &AiConfig,
repo_path: Option<String>,
deep: bool,
) -> crate::Result<(crate::ai::types::PrReviewResponse, crate::history::AiStats)> {
let app_config = load_config().unwrap_or_default();
let review_config = app_config.review;
let repo_path_ref = repo_path.as_deref();
let (ast_ctx, call_graph_ctx) = tokio::join!(
build_ctx_ast(repo_path_ref, &pr_details.files),
build_ctx_call_graph(repo_path_ref, &pr_details.files, deep)
);
let (provider_name, model_name) = ai_config.resolve_for_task(TaskType::Review);
let diff = reconstruct_diff_from_pr(&pr_details.files);
let injection_findings: Vec<_> = SecurityScanner::new()
.scan_diff(&diff)
.into_iter()
.filter(|f| f.pattern_id.starts_with("prompt-injection"))
.collect();
if !injection_findings.is_empty() {
let pattern_ids: Vec<&str> = injection_findings
.iter()
.map(|f| f.pattern_id.as_str())
.collect();
warn!(
injection_count = injection_findings.len(),
?pattern_ids,
"Prompt injection patterns detected in PR diff; proceeding with AI review"
);
}
try_with_fallback(provider, &provider_name, &model_name, ai_config, |client| {
let pr = pr_details.clone();
let ast = ast_ctx.clone();
let call_graph = call_graph_ctx.clone();
let review_cfg = review_config.clone();
async move { client.review_pr(&pr, ast, call_graph, &review_cfg).await }
})
.await
}
#[instrument(skip(provider, comments), fields(reference = %reference, event = %event))]
pub async fn post_pr_review(
provider: &dyn TokenProvider,
reference: &str,
repo_context: Option<&str>,
body: &str,
event: ReviewEvent,
comments: &[PrReviewComment],
commit_id: &str,
) -> crate::Result<u64> {
use crate::github::pulls::parse_pr_reference;
let (owner, repo, number) =
parse_pr_reference(reference, repo_context).map_err(|e| AptuError::GitHub {
message: e.to_string(),
})?;
let client = create_client_from_provider(provider)?;
gh_post_pr_review(
&client, &owner, &repo, number, body, event, comments, commit_id,
)
.await
.map_err(|e| AptuError::GitHub {
message: e.to_string(),
})
}
#[instrument(skip(provider), fields(reference = %reference))]
pub async fn label_pr(
provider: &dyn TokenProvider,
reference: &str,
repo_context: Option<&str>,
dry_run: bool,
ai_config: &AiConfig,
) -> crate::Result<(u64, String, String, Vec<String>)> {
use crate::github::issues::apply_labels_to_number;
use crate::github::pulls::{fetch_pr_details, labels_from_pr_metadata, parse_pr_reference};
let (owner, repo, number) =
parse_pr_reference(reference, repo_context).map_err(|e| AptuError::GitHub {
message: e.to_string(),
})?;
let client = create_client_from_provider(provider)?;
let app_config = load_config().unwrap_or_default();
let pr_details = fetch_pr_details(&client, &owner, &repo, number, &app_config.review)
.await
.map_err(|e| AptuError::GitHub {
message: e.to_string(),
})?;
let file_paths: Vec<String> = pr_details
.files
.iter()
.map(|f| f.filename.clone())
.collect();
let mut labels = labels_from_pr_metadata(&pr_details.title, &file_paths);
if labels.is_empty() {
let (provider_name, model_name) = ai_config.resolve_for_task(TaskType::Create);
if let Some(api_key) = provider.ai_api_key(&provider_name) {
if let Ok(ai_client) =
crate::ai::AiClient::with_api_key(&provider_name, api_key, &model_name, ai_config)
{
match ai_client
.suggest_pr_labels(&pr_details.title, &pr_details.body, &file_paths)
.await
{
Ok((ai_labels, _stats)) => {
labels = ai_labels;
debug!("AI fallback provided {} labels", labels.len());
}
Err(e) => {
debug!("AI fallback failed: {}", e);
}
}
}
}
}
if !dry_run && !labels.is_empty() {
apply_labels_to_number(&client, &owner, &repo, number, &labels)
.await
.map_err(|e| AptuError::GitHub {
message: e.to_string(),
})?;
}
Ok((number, pr_details.title, pr_details.url, labels))
}
#[allow(clippy::too_many_lines)]
#[instrument(skip(provider), fields(reference = %reference))]
pub async fn fetch_issue_for_triage(
provider: &dyn TokenProvider,
reference: &str,
repo_context: Option<&str>,
) -> crate::Result<IssueDetails> {
let (owner, repo, number) =
crate::github::issues::parse_issue_reference(reference, repo_context).map_err(|e| {
AptuError::GitHub {
message: e.to_string(),
}
})?;
let client = create_client_from_provider(provider)?;
let (issue_node, repo_data) = fetch_issue_with_repo_context(&client, &owner, &repo, number)
.await
.map_err(|e| AptuError::GitHub {
message: e.to_string(),
})?;
let labels: Vec<String> = issue_node
.labels
.nodes
.iter()
.map(|label| label.name.clone())
.collect();
let comments: Vec<crate::ai::types::IssueComment> = issue_node
.comments
.nodes
.iter()
.map(|comment| crate::ai::types::IssueComment {
author: comment.author.login.clone(),
body: comment.body.clone(),
})
.collect();
let available_labels: Vec<crate::ai::types::RepoLabel> = repo_data
.labels
.nodes
.iter()
.map(|label| crate::ai::types::RepoLabel {
name: label.name.clone(),
description: String::new(),
color: String::new(),
})
.collect();
let available_milestones: Vec<crate::ai::types::RepoMilestone> = repo_data
.milestones
.nodes
.iter()
.map(|milestone| crate::ai::types::RepoMilestone {
number: milestone.number,
title: milestone.title.clone(),
description: String::new(),
})
.collect();
let mut issue_details = IssueDetails::builder()
.owner(owner.clone())
.repo(repo.clone())
.number(number)
.title(issue_node.title.clone())
.body(issue_node.body.clone().unwrap_or_default())
.labels(labels)
.comments(comments)
.url(issue_node.url.clone())
.available_labels(available_labels)
.available_milestones(available_milestones)
.build();
issue_details.author = issue_node.author.as_ref().map(|a| a.login.clone());
issue_details.created_at = Some(issue_node.created_at.clone());
issue_details.updated_at = Some(issue_node.updated_at.clone());
let keywords = crate::github::issues::extract_keywords(&issue_details.title);
let language = repo_data
.primary_language
.as_ref()
.map_or("unknown", |l| l.name.as_str())
.to_string();
let (search_result, tree_result) = tokio::join!(
crate::github::issues::search_related_issues(
&client,
&owner,
&repo,
&issue_details.title,
number
),
crate::github::issues::fetch_repo_tree(&client, &owner, &repo, &language, &keywords)
);
match search_result {
Ok(related) => {
issue_details.repo_context = related;
debug!(
related_count = issue_details.repo_context.len(),
"Found related issues"
);
}
Err(e) => {
debug!(error = %e, "Failed to search for related issues, continuing without context");
}
}
match tree_result {
Ok(tree) => {
issue_details.repo_tree = tree;
debug!(
tree_count = issue_details.repo_tree.len(),
"Fetched repository tree"
);
}
Err(e) => {
debug!(error = %e, "Failed to fetch repository tree, continuing without context");
}
}
debug!(issue_number = number, "Issue fetched successfully");
Ok(issue_details)
}
#[instrument(skip(provider, triage), fields(owner = %issue_details.owner, repo = %issue_details.repo, number = issue_details.number))]
pub async fn post_triage_comment(
provider: &dyn TokenProvider,
issue_details: &IssueDetails,
triage: &TriageResponse,
) -> crate::Result<String> {
let client = create_client_from_provider(provider)?;
let comment_body = crate::triage::render_triage_markdown(triage);
let comment_url = crate::github::issues::post_comment(
&client,
&issue_details.owner,
&issue_details.repo,
issue_details.number,
&comment_body,
)
.await
.map_err(|e| AptuError::GitHub {
message: e.to_string(),
})?;
debug!(comment_url = %comment_url, "Triage comment posted");
Ok(comment_url)
}
#[instrument(skip(provider, triage), fields(owner = %issue_details.owner, repo = %issue_details.repo, number = issue_details.number))]
pub async fn apply_triage_labels(
provider: &dyn TokenProvider,
issue_details: &IssueDetails,
triage: &TriageResponse,
) -> crate::Result<crate::github::issues::ApplyResult> {
debug!("Applying labels and milestone to issue");
let client = create_client_from_provider(provider)?;
let result = crate::github::issues::update_issue_labels_and_milestone(
&client,
&issue_details.owner,
&issue_details.repo,
issue_details.number,
&issue_details.labels,
&triage.suggested_labels,
issue_details.milestone.as_deref(),
triage.suggested_milestone.as_deref(),
&issue_details.available_labels,
&issue_details.available_milestones,
)
.await
.map_err(|e| AptuError::GitHub {
message: e.to_string(),
})?;
info!(
labels = ?result.applied_labels,
milestone = ?result.applied_milestone,
warnings = ?result.warnings,
"Labels and milestone applied"
);
Ok(result)
}
async fn get_from_ref_or_root(
gh_client: &octocrab::Octocrab,
owner: &str,
repo: &str,
to_ref: &str,
) -> Result<String, AptuError> {
let previous_tag_opt =
crate::github::releases::get_previous_tag(gh_client, owner, repo, to_ref)
.await
.map_err(|e| AptuError::GitHub {
message: e.to_string(),
})?;
if let Some((tag, _)) = previous_tag_opt {
Ok(tag)
} else {
tracing::info!(
"No previous tag found before {}, using root commit for first release",
to_ref
);
crate::github::releases::get_root_commit(gh_client, owner, repo)
.await
.map_err(|e| AptuError::GitHub {
message: e.to_string(),
})
}
}
#[instrument(skip(provider))]
pub async fn generate_release_notes(
provider: &dyn TokenProvider,
owner: &str,
repo: &str,
from_tag: Option<&str>,
to_tag: Option<&str>,
) -> Result<crate::ai::types::ReleaseNotesResponse, AptuError> {
let token = provider.github_token().ok_or_else(|| AptuError::GitHub {
message: "GitHub token not available".to_string(),
})?;
let gh_client = create_client_with_token(&token).map_err(|e| AptuError::GitHub {
message: e.to_string(),
})?;
let config = load_config().map_err(|e| AptuError::Config {
message: e.to_string(),
})?;
let ai_client = AiClient::new(&config.ai.provider, &config.ai).map_err(|e| AptuError::AI {
message: e.to_string(),
status: None,
provider: config.ai.provider.clone(),
})?;
let (from_ref, to_ref) = if let (Some(from), Some(to)) = (from_tag, to_tag) {
(from.to_string(), to.to_string())
} else if let Some(to) = to_tag {
let from_ref = get_from_ref_or_root(&gh_client, owner, repo, to).await?;
(from_ref, to.to_string())
} else if let Some(from) = from_tag {
(from.to_string(), "HEAD".to_string())
} else {
let latest_tag_opt = crate::github::releases::get_latest_tag(&gh_client, owner, repo)
.await
.map_err(|e| AptuError::GitHub {
message: e.to_string(),
})?;
let to_ref = if let Some((tag, _)) = latest_tag_opt {
tag
} else {
"HEAD".to_string()
};
let from_ref = get_from_ref_or_root(&gh_client, owner, repo, &to_ref).await?;
(from_ref, to_ref)
};
let prs = crate::github::releases::fetch_prs_between_refs(
&gh_client, owner, repo, &from_ref, &to_ref,
)
.await
.map_err(|e| AptuError::GitHub {
message: e.to_string(),
})?;
if prs.is_empty() {
return Err(AptuError::GitHub {
message: "No merged PRs found between the specified tags".to_string(),
});
}
let version = crate::github::releases::parse_tag_reference(&to_ref);
let (response, _ai_stats) = ai_client
.generate_release_notes(prs, &version)
.await
.map_err(|e: anyhow::Error| AptuError::AI {
message: e.to_string(),
status: None,
provider: config.ai.provider.clone(),
})?;
info!(
theme = ?response.theme,
highlights_count = response.highlights.len(),
contributors_count = response.contributors.len(),
"Release notes generated"
);
Ok(response)
}
#[instrument(skip(provider))]
pub async fn post_release_notes(
provider: &dyn TokenProvider,
owner: &str,
repo: &str,
tag: &str,
body: &str,
) -> Result<String, AptuError> {
let token = provider.github_token().ok_or_else(|| AptuError::GitHub {
message: "GitHub token not available".to_string(),
})?;
let gh_client = create_client_with_token(&token).map_err(|e| AptuError::GitHub {
message: e.to_string(),
})?;
crate::github::releases::post_release_notes(&gh_client, owner, repo, tag, body)
.await
.map_err(|e| AptuError::GitHub {
message: e.to_string(),
})
}
#[cfg(test)]
mod tests {
use crate::config::{FallbackConfig, FallbackEntry};
#[test]
fn test_fallback_chain_config_structure() {
let fallback_config = FallbackConfig {
chain: vec![
FallbackEntry {
provider: "openrouter".to_string(),
model: None,
},
FallbackEntry {
provider: "anthropic".to_string(),
model: Some("claude-haiku-4.5".to_string()),
},
],
};
assert_eq!(fallback_config.chain.len(), 2);
assert_eq!(fallback_config.chain[0].provider, "openrouter");
assert_eq!(fallback_config.chain[0].model, None);
assert_eq!(fallback_config.chain[1].provider, "anthropic");
assert_eq!(
fallback_config.chain[1].model,
Some("claude-haiku-4.5".to_string())
);
}
#[test]
fn test_fallback_chain_empty() {
let fallback_config = FallbackConfig { chain: vec![] };
assert_eq!(fallback_config.chain.len(), 0);
}
#[test]
fn test_fallback_chain_single_provider() {
let fallback_config = FallbackConfig {
chain: vec![FallbackEntry {
provider: "openrouter".to_string(),
model: None,
}],
};
assert_eq!(fallback_config.chain.len(), 1);
assert_eq!(fallback_config.chain[0].provider, "openrouter");
}
}
#[allow(clippy::items_after_test_module)]
#[instrument(skip(provider, ai_config), fields(repo = %repo))]
pub async fn format_issue(
provider: &dyn TokenProvider,
title: &str,
body: &str,
repo: &str,
ai_config: &AiConfig,
) -> crate::Result<CreateIssueResponse> {
let (provider_name, model_name) = ai_config.resolve_for_task(TaskType::Create);
try_with_fallback(provider, &provider_name, &model_name, ai_config, |client| {
let title = title.to_string();
let body = body.to_string();
let repo = repo.to_string();
async move {
let (response, _stats) = client.create_issue(&title, &body, &repo).await?;
Ok(response)
}
})
.await
}
#[instrument(skip(provider), fields(owner = %owner, repo = %repo))]
pub async fn post_issue(
provider: &dyn TokenProvider,
owner: &str,
repo: &str,
title: &str,
body: &str,
) -> crate::Result<(String, u64)> {
let client = create_client_from_provider(provider)?;
gh_create_issue(&client, owner, repo, title, body)
.await
.map_err(|e| AptuError::GitHub {
message: e.to_string(),
})
}
#[instrument(skip(provider), fields(owner = %owner, repo = %repo, head = %head_branch, base = %base_branch))]
pub async fn create_pr(
provider: &dyn TokenProvider,
owner: &str,
repo: &str,
title: &str,
base_branch: &str,
head_branch: &str,
body: Option<&str>,
) -> crate::Result<crate::github::pulls::PrCreateResult> {
let client = create_client_from_provider(provider)?;
crate::github::pulls::create_pull_request(
&client,
owner,
repo,
title,
head_branch,
base_branch,
body,
)
.await
.map_err(|e| AptuError::GitHub {
message: e.to_string(),
})
}
#[instrument(skip(provider), fields(provider_name))]
pub async fn list_models(
provider: &dyn TokenProvider,
provider_name: &str,
) -> crate::Result<Vec<crate::ai::registry::CachedModel>> {
use crate::ai::registry::{CachedModelRegistry, ModelRegistry};
use crate::cache::cache_dir;
let cache_dir = cache_dir();
let registry =
CachedModelRegistry::new(cache_dir, crate::cache::DEFAULT_MODEL_TTL_SECS, provider);
registry
.list_models(provider_name)
.await
.map_err(|e| AptuError::ModelRegistry {
message: format!("Failed to list models: {e}"),
})
}
#[instrument(skip(provider), fields(provider_name, model_id))]
pub async fn validate_model(
provider: &dyn TokenProvider,
provider_name: &str,
model_id: &str,
) -> crate::Result<bool> {
use crate::ai::registry::{CachedModelRegistry, ModelRegistry};
use crate::cache::cache_dir;
let cache_dir = cache_dir();
let registry =
CachedModelRegistry::new(cache_dir, crate::cache::DEFAULT_MODEL_TTL_SECS, provider);
registry
.model_exists(provider_name, model_id)
.await
.map_err(|e| AptuError::ModelRegistry {
message: format!("Failed to validate model: {e}"),
})
}