Skip to main content

aptu_core/
facade.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Platform-agnostic facade functions for FFI and CLI integration.
4//!
5//! This module provides high-level functions that abstract away the complexity
6//! of credential resolution, API client creation, and data transformation.
7//! Each platform (CLI, iOS, MCP) implements `TokenProvider` and calls these
8//! functions with their own credential source.
9
10use chrono::Duration;
11use tracing::{debug, info, instrument, warn};
12
13use crate::ai::provider::MAX_LABELS;
14use crate::ai::registry::get_provider;
15use crate::ai::types::{CreateIssueResponse, PrDetails, ReviewEvent, TriageResponse};
16use crate::ai::{AiClient, AiProvider, AiResponse, types::IssueDetails};
17use crate::auth::TokenProvider;
18use crate::cache::{FileCache, FileCacheImpl};
19use crate::config::{AiConfig, TaskType, load_config};
20use crate::error::AptuError;
21use crate::github::auth::{create_client_from_provider, create_client_with_token};
22use crate::github::graphql::{
23    IssueNode, fetch_issue_with_repo_context, fetch_issues as gh_fetch_issues,
24};
25use crate::github::issues::{create_issue as gh_create_issue, filter_labels_by_relevance};
26use crate::github::pulls::{fetch_pr_details, post_pr_review as gh_post_pr_review};
27use crate::repos::{self, CuratedRepo};
28use crate::retry::is_retryable_anyhow;
29use secrecy::SecretString;
30
31/// Fetches "good first issue" issues from curated repositories.
32///
33/// This function abstracts the credential resolution and API client creation,
34/// allowing platforms to provide credentials via `TokenProvider` implementations.
35///
36/// # Arguments
37///
38/// * `provider` - Token provider for GitHub credentials
39/// * `repo_filter` - Optional repository filter (case-insensitive substring match on full name or short name)
40/// * `use_cache` - Whether to use cached results (if available and valid)
41///
42/// # Returns
43///
44/// A vector of `(repo_name, issues)` tuples.
45///
46/// # Errors
47///
48/// Returns an error if:
49/// - GitHub token is not available from the provider
50/// - GitHub API call fails
51/// - Response parsing fails
52#[instrument(skip(provider), fields(repo_filter = ?repo_filter, use_cache))]
53pub async fn fetch_issues(
54    provider: &dyn TokenProvider,
55    repo_filter: Option<&str>,
56    use_cache: bool,
57) -> crate::Result<Vec<(String, Vec<IssueNode>)>> {
58    // Create GitHub client from provider
59    let client = create_client_from_provider(provider)?;
60
61    // Get curated repos, optionally filtered
62    let all_repos = repos::fetch().await?;
63    let repos_to_query: Vec<_> = match repo_filter {
64        Some(filter) => {
65            let filter_lower = filter.to_lowercase();
66            all_repos
67                .iter()
68                .filter(|r| {
69                    r.full_name().to_lowercase().contains(&filter_lower)
70                        || r.name.to_lowercase().contains(&filter_lower)
71                })
72                .cloned()
73                .collect()
74        }
75        None => all_repos,
76    };
77
78    // Load config for cache TTL
79    let config = load_config()?;
80    let ttl = Duration::minutes(config.cache.issue_ttl_minutes);
81
82    // Try to read from cache if enabled
83    if use_cache {
84        let cache: FileCacheImpl<Vec<IssueNode>> = FileCacheImpl::new("issues", ttl);
85        let mut cached_results = Vec::new();
86        let mut repos_to_fetch = Vec::new();
87
88        for repo in &repos_to_query {
89            let cache_key = format!("{}_{}", repo.owner, repo.name);
90            if let Ok(Some(issues)) = cache.get(&cache_key) {
91                cached_results.push((repo.full_name(), issues));
92            } else {
93                repos_to_fetch.push(repo.clone());
94            }
95        }
96
97        // If all repos are cached, return early
98        if repos_to_fetch.is_empty() {
99            return Ok(cached_results);
100        }
101
102        // Fetch missing repos from API - convert to tuples for GraphQL
103        let repo_tuples: Vec<_> = repos_to_fetch
104            .iter()
105            .map(|r| (r.owner.as_str(), r.name.as_str()))
106            .collect();
107        let api_results =
108            gh_fetch_issues(&client, &repo_tuples)
109                .await
110                .map_err(|e| AptuError::GitHub {
111                    message: format!("Failed to fetch issues: {e}"),
112                })?;
113
114        // Write fetched results to cache
115        for (repo_name, issues) in &api_results {
116            if let Some(repo) = repos_to_fetch.iter().find(|r| r.full_name() == *repo_name) {
117                let cache_key = format!("{}_{}", repo.owner, repo.name);
118                let _ = cache.set(&cache_key, issues);
119            }
120        }
121
122        // Combine cached and fetched results
123        cached_results.extend(api_results);
124        Ok(cached_results)
125    } else {
126        // Cache disabled, fetch directly from API - convert to tuples
127        let repo_tuples: Vec<_> = repos_to_query
128            .iter()
129            .map(|r| (r.owner.as_str(), r.name.as_str()))
130            .collect();
131        gh_fetch_issues(&client, &repo_tuples)
132            .await
133            .map_err(|e| AptuError::GitHub {
134                message: format!("Failed to fetch issues: {e}"),
135            })
136    }
137}
138
139/// Fetches curated repositories with platform-agnostic API.
140///
141/// This function provides a facade for fetching curated repositories,
142/// allowing platforms (CLI, iOS, MCP) to use a consistent interface.
143///
144/// # Returns
145///
146/// A vector of `CuratedRepo` structs.
147///
148/// # Errors
149///
150/// Returns an error if configuration cannot be loaded.
151pub async fn list_curated_repos() -> crate::Result<Vec<CuratedRepo>> {
152    repos::fetch().await
153}
154
155/// Adds a custom repository.
156///
157/// Validates the repository via GitHub API and adds it to the custom repos file.
158///
159/// # Arguments
160///
161/// * `owner` - Repository owner
162/// * `name` - Repository name
163///
164/// # Returns
165///
166/// The added `CuratedRepo`.
167///
168/// # Errors
169///
170/// Returns an error if:
171/// - Repository cannot be found on GitHub
172/// - Custom repos file cannot be read or written
173#[instrument]
174pub async fn add_custom_repo(owner: &str, name: &str) -> crate::Result<CuratedRepo> {
175    // Validate and fetch metadata from GitHub
176    let repo = repos::custom::validate_and_fetch_metadata(owner, name).await?;
177
178    // Read existing custom repos
179    let mut custom_repos = repos::custom::read_custom_repos()?;
180
181    // Check if repo already exists
182    if custom_repos
183        .iter()
184        .any(|r| r.full_name() == repo.full_name())
185    {
186        return Err(crate::error::AptuError::Config {
187            message: format!(
188                "Repository {} already exists in custom repos",
189                repo.full_name()
190            ),
191        });
192    }
193
194    // Add new repo
195    custom_repos.push(repo.clone());
196
197    // Write back to file
198    repos::custom::write_custom_repos(&custom_repos)?;
199
200    Ok(repo)
201}
202
203/// Removes a custom repository.
204///
205/// # Arguments
206///
207/// * `owner` - Repository owner
208/// * `name` - Repository name
209///
210/// # Returns
211///
212/// True if the repository was removed, false if it was not found.
213///
214/// # Errors
215///
216/// Returns an error if the custom repos file cannot be read or written.
217#[instrument]
218pub fn remove_custom_repo(owner: &str, name: &str) -> crate::Result<bool> {
219    let full_name = format!("{owner}/{name}");
220
221    // Read existing custom repos
222    let mut custom_repos = repos::custom::read_custom_repos()?;
223
224    // Find and remove the repo
225    let initial_len = custom_repos.len();
226    custom_repos.retain(|r| r.full_name() != full_name);
227
228    if custom_repos.len() == initial_len {
229        return Ok(false); // Not found
230    }
231
232    // Write back to file
233    repos::custom::write_custom_repos(&custom_repos)?;
234
235    Ok(true)
236}
237
238/// Lists repositories with optional filtering.
239///
240/// # Arguments
241///
242/// * `filter` - Repository filter (All, Curated, or Custom)
243///
244/// # Returns
245///
246/// A vector of `CuratedRepo` structs.
247///
248/// # Errors
249///
250/// Returns an error if repositories cannot be fetched.
251#[instrument]
252pub async fn list_repos(filter: repos::RepoFilter) -> crate::Result<Vec<CuratedRepo>> {
253    repos::fetch_all(filter).await
254}
255
256/// Discovers repositories matching a filter via GitHub Search API.
257///
258/// Searches GitHub for welcoming repositories with good first issue or help wanted labels.
259/// Results are scored client-side and cached with 24-hour TTL.
260///
261/// # Arguments
262///
263/// * `provider` - Token provider for GitHub credentials
264/// * `filter` - Discovery filter (language, `min_stars`, `limit`)
265///
266/// # Returns
267///
268/// A vector of discovered repositories, sorted by relevance score.
269///
270/// # Errors
271///
272/// Returns an error if:
273/// - GitHub token is not available from the provider
274/// - GitHub API call fails
275#[instrument(skip(provider), fields(language = ?filter.language, min_stars = filter.min_stars, limit = filter.limit))]
276pub async fn discover_repos(
277    provider: &dyn TokenProvider,
278    filter: repos::discovery::DiscoveryFilter,
279) -> crate::Result<Vec<repos::discovery::DiscoveredRepo>> {
280    let token = provider.github_token().ok_or(AptuError::NotAuthenticated)?;
281    let token = SecretString::from(token);
282    repos::discovery::search_repositories(&token, &filter).await
283}
284
285/// Generic helper function to try AI operations with fallback chain.
286///
287/// Attempts an AI operation with the primary provider first. If the primary
288/// provider fails with a non-retryable error, iterates through the fallback chain.
289///
290/// # Arguments
291///
292/// * `provider` - Token provider for AI credentials
293/// * `primary_provider` - Primary AI provider name
294/// * `model_name` - Model name to use
295/// * `ai_config` - AI configuration including fallback chain
296/// * `operation` - Async closure that performs the AI operation
297///
298/// # Returns
299///
300/// Validates a model for a given provider, converting registry errors to `AptuError`.
301fn validate_provider_model(provider: &str, model: &str) -> crate::Result<()> {
302    // Simple static validation: check if provider exists
303    if crate::ai::registry::get_provider(provider).is_none() {
304        return Err(AptuError::ModelRegistry {
305            message: format!("Provider not found: {provider}"),
306        });
307    }
308
309    // For now, we allow any model ID (permissive fallback)
310    // Unknown models will log a warning but won't fail validation
311    tracing::debug!(provider = provider, model = model, "Validating model");
312    Ok(())
313}
314
315/// Setup and validate primary AI provider synchronously.
316/// Returns the created AI client or an error.
317fn try_setup_primary_client(
318    provider: &dyn TokenProvider,
319    primary_provider: &str,
320    model_name: &str,
321    ai_config: &AiConfig,
322) -> crate::Result<AiClient> {
323    let api_key = provider.ai_api_key(primary_provider).ok_or_else(|| {
324        let env_var = get_provider(primary_provider).map_or("API_KEY", |p| p.api_key_env);
325        AptuError::AiProviderNotAuthenticated {
326            provider: primary_provider.to_string(),
327            env_var: env_var.to_string(),
328        }
329    })?;
330
331    if ai_config.validation_enabled {
332        validate_provider_model(primary_provider, model_name)?;
333    }
334
335    AiClient::with_api_key(primary_provider, api_key, model_name, ai_config).map_err(|e| {
336        AptuError::AI {
337            message: e.to_string(),
338            status: None,
339            provider: primary_provider.to_string(),
340        }
341    })
342}
343
344/// Set up an AI client for a single fallback provider entry.
345///
346/// Returns `Some(client)` on success, `None` if the entry should be skipped.
347fn setup_fallback_client(
348    provider: &dyn TokenProvider,
349    entry: &crate::config::FallbackEntry,
350    model_name: &str,
351    ai_config: &AiConfig,
352) -> Option<AiClient> {
353    let Some(api_key) = provider.ai_api_key(&entry.provider) else {
354        warn!(
355            fallback_provider = entry.provider,
356            "No API key available for fallback provider"
357        );
358        return None;
359    };
360
361    let fallback_model = entry.model.as_deref().unwrap_or(model_name);
362
363    if ai_config.validation_enabled
364        && validate_provider_model(&entry.provider, fallback_model).is_err()
365    {
366        warn!(
367            fallback_provider = entry.provider,
368            fallback_model = fallback_model,
369            "Fallback provider model validation failed, continuing to next provider"
370        );
371        return None;
372    }
373
374    if let Ok(client) = AiClient::with_api_key(&entry.provider, api_key, fallback_model, ai_config)
375    {
376        Some(client)
377    } else {
378        warn!(
379            fallback_provider = entry.provider,
380            "Failed to create AI client for fallback provider"
381        );
382        None
383    }
384}
385
386/// Try a single fallback provider entry.
387async fn try_fallback_entry<T, F, Fut>(
388    provider: &dyn TokenProvider,
389    entry: &crate::config::FallbackEntry,
390    model_name: &str,
391    ai_config: &AiConfig,
392    operation: &F,
393) -> crate::Result<Option<T>>
394where
395    F: Fn(AiClient) -> Fut,
396    Fut: std::future::Future<Output = anyhow::Result<T>>,
397{
398    warn!(
399        fallback_provider = entry.provider,
400        "Attempting fallback provider"
401    );
402
403    let Some(ai_client) = setup_fallback_client(provider, entry, model_name, ai_config) else {
404        return Ok(None);
405    };
406
407    match operation(ai_client).await {
408        Ok(response) => {
409            info!(
410                fallback_provider = entry.provider,
411                "Successfully completed operation with fallback provider"
412            );
413            Ok(Some(response))
414        }
415        Err(e) => {
416            if is_retryable_anyhow(&e) {
417                return Err(AptuError::AI {
418                    message: e.to_string(),
419                    status: None,
420                    provider: entry.provider.clone(),
421                });
422            }
423            warn!(
424                fallback_provider = entry.provider,
425                error = %e,
426                "Fallback provider failed with non-retryable error"
427            );
428            Ok(None)
429        }
430    }
431}
432
433/// Execute fallback chain when primary provider fails with non-retryable error.
434async fn execute_fallback_chain<T, F, Fut>(
435    provider: &dyn TokenProvider,
436    primary_provider: &str,
437    model_name: &str,
438    ai_config: &AiConfig,
439    operation: F,
440) -> crate::Result<T>
441where
442    F: Fn(AiClient) -> Fut,
443    Fut: std::future::Future<Output = anyhow::Result<T>>,
444{
445    if let Some(fallback_config) = &ai_config.fallback {
446        for entry in &fallback_config.chain {
447            if let Some(response) =
448                try_fallback_entry(provider, entry, model_name, ai_config, &operation).await?
449            {
450                return Ok(response);
451            }
452        }
453    }
454
455    Err(AptuError::AI {
456        message: "All AI providers failed (primary and fallback chain)".to_string(),
457        status: None,
458        provider: primary_provider.to_string(),
459    })
460}
461
462async fn try_with_fallback<T, F, Fut>(
463    provider: &dyn TokenProvider,
464    primary_provider: &str,
465    model_name: &str,
466    ai_config: &AiConfig,
467    operation: F,
468) -> crate::Result<T>
469where
470    F: Fn(AiClient) -> Fut,
471    Fut: std::future::Future<Output = anyhow::Result<T>>,
472{
473    let ai_client = try_setup_primary_client(provider, primary_provider, model_name, ai_config)?;
474
475    match operation(ai_client).await {
476        Ok(response) => return Ok(response),
477        Err(e) => {
478            if is_retryable_anyhow(&e) {
479                return Err(AptuError::AI {
480                    message: e.to_string(),
481                    status: None,
482                    provider: primary_provider.to_string(),
483                });
484            }
485            warn!(
486                primary_provider = primary_provider,
487                error = %e,
488                "Primary provider failed with non-retryable error, trying fallback chain"
489            );
490        }
491    }
492
493    execute_fallback_chain(provider, primary_provider, model_name, ai_config, operation).await
494}
495
496/// Analyzes a GitHub issue and generates triage suggestions.
497///
498/// This function abstracts the credential resolution and API client creation,
499/// allowing platforms to provide credentials via `TokenProvider` implementations.
500///
501/// # Arguments
502///
503/// * `provider` - Token provider for GitHub and AI provider credentials
504/// * `issue` - Issue details to analyze
505///
506/// # Returns
507///
508/// AI response with triage data and usage statistics.
509///
510/// # Errors
511///
512/// Returns an error if:
513/// - GitHub or AI provider token is not available from the provider
514/// - AI API call fails
515/// - Response parsing fails
516#[instrument(skip(provider, issue), fields(issue_number = issue.number, repo = %format!("{}/{}", issue.owner, issue.repo)))]
517pub async fn analyze_issue(
518    provider: &dyn TokenProvider,
519    issue: &IssueDetails,
520    ai_config: &AiConfig,
521) -> crate::Result<AiResponse> {
522    // Clone issue into mutable local variable for potential label enrichment
523    let mut issue_mut = issue.clone();
524
525    // Fetch repository labels via GraphQL if available_labels is empty and owner/repo are non-empty
526    if issue_mut.available_labels.is_empty()
527        && !issue_mut.owner.is_empty()
528        && !issue_mut.repo.is_empty()
529    {
530        // Get GitHub token from provider
531        if let Some(github_token) = provider.github_token() {
532            let token = SecretString::from(github_token);
533            if let Ok(client) = create_client_with_token(&token) {
534                // Attempt to fetch issue with repo context to get repository labels
535                if let Ok((_, repo_data)) = fetch_issue_with_repo_context(
536                    &client,
537                    &issue_mut.owner,
538                    &issue_mut.repo,
539                    issue_mut.number,
540                )
541                .await
542                {
543                    // Extract available labels from repository data (not issue labels)
544                    issue_mut.available_labels =
545                        repo_data.labels.nodes.into_iter().map(Into::into).collect();
546                }
547            }
548        }
549    }
550
551    // Apply label filtering before AI analysis
552    if !issue_mut.available_labels.is_empty() {
553        issue_mut.available_labels =
554            filter_labels_by_relevance(&issue_mut.available_labels, MAX_LABELS);
555    }
556
557    // Resolve task-specific provider and model
558    let (provider_name, model_name) = ai_config.resolve_for_task(TaskType::Triage);
559
560    // Use fallback chain if configured
561    try_with_fallback(provider, &provider_name, &model_name, ai_config, |client| {
562        let issue = issue_mut.clone();
563        async move { client.analyze_issue(&issue).await }
564    })
565    .await
566}
567
568/// Reviews a pull request and generates AI feedback.
569///
570/// This function abstracts the credential resolution and API client creation,
571/// allowing platforms to provide credentials via `TokenProvider` implementations.
572///
573/// # Arguments
574///
575/// * `provider` - Token provider for GitHub and AI provider credentials
576/// * `reference` - PR reference (URL, owner/repo#number, or number)
577/// * `repo_context` - Optional repository context for bare numbers
578/// * `ai_config` - AI configuration (provider, model, etc.)
579///
580/// # Returns
581///
582/// Tuple of (`PrDetails`, `PrReviewResponse`) with PR info and AI review.
583///
584/// # Errors
585///
586/// Fetches PR details for review without AI analysis.
587///
588/// This function handles credential resolution and GitHub API calls,
589/// allowing platforms to display PR metadata before starting AI analysis.
590///
591/// # Arguments
592///
593/// * `provider` - Token provider for GitHub credentials
594/// * `reference` - PR reference (URL, owner/repo#number, or number)
595/// * `repo_context` - Optional repository context for bare numbers
596///
597/// # Returns
598///
599/// PR details including title, body, files, and labels.
600///
601/// # Errors
602///
603/// Returns an error if:
604/// - GitHub token is not available from the provider
605/// - PR cannot be fetched
606#[instrument(skip(provider), fields(reference = %reference))]
607pub async fn fetch_pr_for_review(
608    provider: &dyn TokenProvider,
609    reference: &str,
610    repo_context: Option<&str>,
611) -> crate::Result<PrDetails> {
612    use crate::github::pulls::parse_pr_reference;
613
614    // Parse PR reference
615    let (owner, repo, number) =
616        parse_pr_reference(reference, repo_context).map_err(|e| AptuError::GitHub {
617            message: e.to_string(),
618        })?;
619
620    // Create GitHub client from provider
621    let client = create_client_from_provider(provider)?;
622
623    // Fetch PR details
624    fetch_pr_details(&client, &owner, &repo, number)
625        .await
626        .map_err(|e| AptuError::GitHub {
627            message: e.to_string(),
628        })
629}
630
631/// Analyzes PR details with AI to generate a review.
632///
633/// This function takes pre-fetched PR details and performs AI analysis.
634/// It should be called after `fetch_pr_for_review()` to allow intermediate display.
635///
636/// # Arguments
637///
638/// * `provider` - Token provider for AI credentials
639/// * `pr_details` - PR details from `fetch_pr_for_review()`
640/// * `ai_config` - AI configuration
641///
642/// # Returns
643///
644/// Tuple of (review response, AI stats).
645///
646/// # Errors
647///
648/// Returns an error if:
649/// - AI provider token is not available from the provider
650/// - AI API call fails
651#[instrument(skip(provider, pr_details), fields(number = pr_details.number))]
652pub async fn analyze_pr(
653    provider: &dyn TokenProvider,
654    pr_details: &PrDetails,
655    ai_config: &AiConfig,
656) -> crate::Result<(crate::ai::types::PrReviewResponse, crate::history::AiStats)> {
657    // Resolve task-specific provider and model
658    let (provider_name, model_name) = ai_config.resolve_for_task(TaskType::Review);
659
660    // Use fallback chain if configured
661    try_with_fallback(provider, &provider_name, &model_name, ai_config, |client| {
662        let pr = pr_details.clone();
663        async move { client.review_pr(&pr).await }
664    })
665    .await
666}
667
668/// Posts a PR review to GitHub.
669///
670/// This function abstracts the credential resolution and API client creation,
671/// allowing platforms to provide credentials via `TokenProvider` implementations.
672///
673/// # Arguments
674///
675/// * `provider` - Token provider for GitHub credentials
676/// * `reference` - PR reference (URL, owner/repo#number, or number)
677/// * `repo_context` - Optional repository context for bare numbers
678/// * `body` - Review comment text
679/// * `event` - Review event type (Comment, Approve, or `RequestChanges`)
680///
681/// # Returns
682///
683/// Review ID on success.
684///
685/// # Errors
686///
687/// Returns an error if:
688/// - GitHub token is not available from the provider
689/// - PR cannot be parsed or found
690/// - User lacks write access to the repository
691/// - API call fails
692#[instrument(skip(provider), fields(reference = %reference, event = %event))]
693pub async fn post_pr_review(
694    provider: &dyn TokenProvider,
695    reference: &str,
696    repo_context: Option<&str>,
697    body: &str,
698    event: ReviewEvent,
699) -> crate::Result<u64> {
700    use crate::github::pulls::parse_pr_reference;
701
702    // Parse PR reference
703    let (owner, repo, number) =
704        parse_pr_reference(reference, repo_context).map_err(|e| AptuError::GitHub {
705            message: e.to_string(),
706        })?;
707
708    // Create GitHub client from provider
709    let client = create_client_from_provider(provider)?;
710
711    // Post the review
712    gh_post_pr_review(&client, &owner, &repo, number, body, event)
713        .await
714        .map_err(|e| AptuError::GitHub {
715            message: e.to_string(),
716        })
717}
718
719/// Auto-label a pull request based on conventional commit prefix and file paths.
720///
721/// Fetches PR details, extracts labels from title and changed files,
722/// and applies them to the PR. Optionally previews without applying.
723///
724/// # Arguments
725///
726/// * `provider` - Token provider for GitHub credentials
727/// * `reference` - PR reference (URL, owner/repo#number, or bare number)
728/// * `repo_context` - Optional repository context for bare numbers
729/// * `dry_run` - If true, preview labels without applying
730///
731/// # Returns
732///
733/// Tuple of (`pr_number`, `pr_title`, `pr_url`, `labels`).
734///
735/// # Errors
736///
737/// Returns an error if:
738/// - GitHub token is not available from the provider
739/// - PR cannot be parsed or found
740/// - API call fails
741#[instrument(skip(provider), fields(reference = %reference))]
742pub async fn label_pr(
743    provider: &dyn TokenProvider,
744    reference: &str,
745    repo_context: Option<&str>,
746    dry_run: bool,
747    ai_config: &AiConfig,
748) -> crate::Result<(u64, String, String, Vec<String>)> {
749    use crate::github::issues::apply_labels_to_number;
750    use crate::github::pulls::{fetch_pr_details, labels_from_pr_metadata, parse_pr_reference};
751
752    // Parse PR reference
753    let (owner, repo, number) =
754        parse_pr_reference(reference, repo_context).map_err(|e| AptuError::GitHub {
755            message: e.to_string(),
756        })?;
757
758    // Create GitHub client from provider
759    let client = create_client_from_provider(provider)?;
760
761    // Fetch PR details
762    let pr_details = fetch_pr_details(&client, &owner, &repo, number)
763        .await
764        .map_err(|e| AptuError::GitHub {
765            message: e.to_string(),
766        })?;
767
768    // Extract labels from PR metadata (deterministic approach)
769    let file_paths: Vec<String> = pr_details
770        .files
771        .iter()
772        .map(|f| f.filename.clone())
773        .collect();
774    let mut labels = labels_from_pr_metadata(&pr_details.title, &file_paths);
775
776    // If no labels found, try AI fallback
777    if labels.is_empty() {
778        // Resolve task-specific provider and model for Create task
779        let (provider_name, model_name) = ai_config.resolve_for_task(TaskType::Create);
780
781        // Get API key from provider using the resolved provider name
782        if let Some(api_key) = provider.ai_api_key(&provider_name) {
783            // Create AI client with resolved provider and model
784            if let Ok(ai_client) =
785                crate::ai::AiClient::with_api_key(&provider_name, api_key, &model_name, ai_config)
786            {
787                match ai_client
788                    .suggest_pr_labels(&pr_details.title, &pr_details.body, &file_paths)
789                    .await
790                {
791                    Ok((ai_labels, _stats)) => {
792                        labels = ai_labels;
793                        debug!("AI fallback provided {} labels", labels.len());
794                    }
795                    Err(e) => {
796                        debug!("AI fallback failed: {}", e);
797                        // Continue without labels rather than failing
798                    }
799                }
800            }
801        }
802    }
803
804    // Apply labels if not dry-run
805    if !dry_run && !labels.is_empty() {
806        apply_labels_to_number(&client, &owner, &repo, number, &labels)
807            .await
808            .map_err(|e| AptuError::GitHub {
809                message: e.to_string(),
810            })?;
811    }
812
813    Ok((number, pr_details.title, pr_details.url, labels))
814}
815
816/// Fetches an issue for triage analysis.
817///
818/// Parses the issue reference, checks authentication, and fetches issue details
819/// including labels, milestones, and repository context.
820///
821/// # Arguments
822///
823/// * `provider` - Token provider for GitHub credentials
824/// * `reference` - Issue reference (URL, owner/repo#number, or bare number)
825/// * `repo_context` - Optional repository context for bare numbers
826///
827/// # Returns
828///
829/// Issue details including title, body, labels, comments, and available labels/milestones.
830///
831/// # Errors
832///
833/// Returns an error if:
834/// - GitHub token is not available from the provider
835/// - Issue reference cannot be parsed
836/// - GitHub API call fails
837#[allow(clippy::too_many_lines)]
838#[instrument(skip(provider), fields(reference = %reference))]
839pub async fn fetch_issue_for_triage(
840    provider: &dyn TokenProvider,
841    reference: &str,
842    repo_context: Option<&str>,
843) -> crate::Result<IssueDetails> {
844    // Parse the issue reference
845    let (owner, repo, number) =
846        crate::github::issues::parse_issue_reference(reference, repo_context).map_err(|e| {
847            AptuError::GitHub {
848                message: e.to_string(),
849            }
850        })?;
851
852    // Create GitHub client from provider
853    let client = create_client_from_provider(provider)?;
854
855    // Fetch issue with repository context (labels, milestones) in a single GraphQL call
856    let (issue_node, repo_data) = fetch_issue_with_repo_context(&client, &owner, &repo, number)
857        .await
858        .map_err(|e| AptuError::GitHub {
859            message: e.to_string(),
860        })?;
861
862    // Convert GraphQL response to IssueDetails
863    let labels: Vec<String> = issue_node
864        .labels
865        .nodes
866        .iter()
867        .map(|label| label.name.clone())
868        .collect();
869
870    let comments: Vec<crate::ai::types::IssueComment> = issue_node
871        .comments
872        .nodes
873        .iter()
874        .map(|comment| crate::ai::types::IssueComment {
875            author: comment.author.login.clone(),
876            body: comment.body.clone(),
877        })
878        .collect();
879
880    let available_labels: Vec<crate::ai::types::RepoLabel> = repo_data
881        .labels
882        .nodes
883        .iter()
884        .map(|label| crate::ai::types::RepoLabel {
885            name: label.name.clone(),
886            description: String::new(),
887            color: String::new(),
888        })
889        .collect();
890
891    let available_milestones: Vec<crate::ai::types::RepoMilestone> = repo_data
892        .milestones
893        .nodes
894        .iter()
895        .map(|milestone| crate::ai::types::RepoMilestone {
896            number: milestone.number,
897            title: milestone.title.clone(),
898            description: String::new(),
899        })
900        .collect();
901
902    let mut issue_details = IssueDetails::builder()
903        .owner(owner.clone())
904        .repo(repo.clone())
905        .number(number)
906        .title(issue_node.title.clone())
907        .body(issue_node.body.clone().unwrap_or_default())
908        .labels(labels)
909        .comments(comments)
910        .url(issue_node.url.clone())
911        .available_labels(available_labels)
912        .available_milestones(available_milestones)
913        .build();
914
915    // Populate optional fields from issue_node
916    issue_details.author = issue_node.author.as_ref().map(|a| a.login.clone());
917    issue_details.created_at = Some(issue_node.created_at.clone());
918    issue_details.updated_at = Some(issue_node.updated_at.clone());
919
920    // Extract keywords and language for parallel calls
921    let keywords = crate::github::issues::extract_keywords(&issue_details.title);
922    let language = repo_data
923        .primary_language
924        .as_ref()
925        .map_or("unknown", |l| l.name.as_str())
926        .to_string();
927
928    // Run search and tree fetch in parallel
929    let (search_result, tree_result) = tokio::join!(
930        crate::github::issues::search_related_issues(
931            &client,
932            &owner,
933            &repo,
934            &issue_details.title,
935            number
936        ),
937        crate::github::issues::fetch_repo_tree(&client, &owner, &repo, &language, &keywords)
938    );
939
940    // Handle search results
941    match search_result {
942        Ok(related) => {
943            issue_details.repo_context = related;
944            debug!(
945                related_count = issue_details.repo_context.len(),
946                "Found related issues"
947            );
948        }
949        Err(e) => {
950            debug!(error = %e, "Failed to search for related issues, continuing without context");
951        }
952    }
953
954    // Handle tree results
955    match tree_result {
956        Ok(tree) => {
957            issue_details.repo_tree = tree;
958            debug!(
959                tree_count = issue_details.repo_tree.len(),
960                "Fetched repository tree"
961            );
962        }
963        Err(e) => {
964            debug!(error = %e, "Failed to fetch repository tree, continuing without context");
965        }
966    }
967
968    debug!(issue_number = number, "Issue fetched successfully");
969    Ok(issue_details)
970}
971
972/// Posts a triage comment to GitHub.
973///
974/// Renders the triage response as markdown and posts it as a comment on the issue.
975///
976/// # Arguments
977///
978/// * `provider` - Token provider for GitHub credentials
979/// * `issue_details` - Issue details (owner, repo, number)
980/// * `triage` - Triage response to post
981///
982/// # Returns
983///
984/// The URL of the posted comment.
985///
986/// # Errors
987///
988/// Returns an error if:
989/// - GitHub token is not available from the provider
990/// - GitHub API call fails
991#[instrument(skip(provider, triage), fields(owner = %issue_details.owner, repo = %issue_details.repo, number = issue_details.number))]
992pub async fn post_triage_comment(
993    provider: &dyn TokenProvider,
994    issue_details: &IssueDetails,
995    triage: &TriageResponse,
996) -> crate::Result<String> {
997    // Create GitHub client from provider
998    let client = create_client_from_provider(provider)?;
999
1000    // Render markdown and post comment
1001    let comment_body = crate::triage::render_triage_markdown(triage);
1002    let comment_url = crate::github::issues::post_comment(
1003        &client,
1004        &issue_details.owner,
1005        &issue_details.repo,
1006        issue_details.number,
1007        &comment_body,
1008    )
1009    .await
1010    .map_err(|e| AptuError::GitHub {
1011        message: e.to_string(),
1012    })?;
1013
1014    debug!(comment_url = %comment_url, "Triage comment posted");
1015    Ok(comment_url)
1016}
1017
1018/// Applies AI-suggested labels and milestone to an issue.
1019///
1020/// Labels are applied additively: existing labels are preserved and AI-suggested labels
1021/// are merged in. Priority labels (p1/p2/p3) defer to existing human judgment.
1022/// Milestones are only set if the issue doesn't already have one.
1023///
1024/// # Arguments
1025///
1026/// * `provider` - Token provider for GitHub credentials
1027/// * `issue_details` - Issue details including available labels and milestones
1028/// * `triage` - AI triage response with suggestions
1029///
1030/// # Returns
1031///
1032/// Result of applying labels and milestone.
1033///
1034/// # Errors
1035///
1036/// Returns an error if:
1037/// - GitHub token is not available from the provider
1038/// - GitHub API call fails
1039#[instrument(skip(provider, triage), fields(owner = %issue_details.owner, repo = %issue_details.repo, number = issue_details.number))]
1040pub async fn apply_triage_labels(
1041    provider: &dyn TokenProvider,
1042    issue_details: &IssueDetails,
1043    triage: &TriageResponse,
1044) -> crate::Result<crate::github::issues::ApplyResult> {
1045    debug!("Applying labels and milestone to issue");
1046
1047    // Create GitHub client from provider
1048    let client = create_client_from_provider(provider)?;
1049
1050    // Call the update function with validation
1051    let result = crate::github::issues::update_issue_labels_and_milestone(
1052        &client,
1053        &issue_details.owner,
1054        &issue_details.repo,
1055        issue_details.number,
1056        &issue_details.labels,
1057        &triage.suggested_labels,
1058        issue_details.milestone.as_deref(),
1059        triage.suggested_milestone.as_deref(),
1060        &issue_details.available_labels,
1061        &issue_details.available_milestones,
1062    )
1063    .await
1064    .map_err(|e| AptuError::GitHub {
1065        message: e.to_string(),
1066    })?;
1067
1068    info!(
1069        labels = ?result.applied_labels,
1070        milestone = ?result.applied_milestone,
1071        warnings = ?result.warnings,
1072        "Labels and milestone applied"
1073    );
1074
1075    Ok(result)
1076}
1077
1078/// Generate AI-curated release notes from PRs between git tags.
1079///
1080/// # Arguments
1081///
1082/// * `provider` - Token provider for GitHub credentials
1083/// * `owner` - Repository owner
1084/// * `repo` - Repository name
1085/// * `from_tag` - Starting tag (or None for latest)
1086/// * `to_tag` - Ending tag (or None for HEAD)
1087///
1088/// # Returns
1089///
1090/// Structured release notes with theme, highlights, and categorized changes.
1091///
1092/// # Errors
1093///
1094/// Returns an error if:
1095/// - GitHub token is not available
1096/// - GitHub API calls fail
1097/// - AI response parsing fails
1098///
1099/// Helper to get a reference from the previous tag or fall back to root commit.
1100///
1101/// This helper encapsulates the common pattern of trying to get the previous tag
1102/// (before the target tag), and if no previous tag exists, falling back to the root
1103/// commit for first release scenarios.
1104///
1105/// # Arguments
1106///
1107/// * `gh_client` - Octocrab GitHub client
1108/// * `owner` - Repository owner
1109/// * `repo` - Repository name
1110/// * `to_ref` - The target tag to find the predecessor for
1111///
1112/// # Returns
1113///
1114/// A commit SHA or tag name to use as a reference.
1115///
1116/// # Errors
1117///
1118/// Returns an error if both tag and root commit fetches fail.
1119async fn get_from_ref_or_root(
1120    gh_client: &octocrab::Octocrab,
1121    owner: &str,
1122    repo: &str,
1123    to_ref: &str,
1124) -> Result<String, AptuError> {
1125    // Try to find the previous tag before the target tag
1126    let previous_tag_opt =
1127        crate::github::releases::get_previous_tag(gh_client, owner, repo, to_ref)
1128            .await
1129            .map_err(|e| AptuError::GitHub {
1130                message: e.to_string(),
1131            })?;
1132
1133    if let Some((tag, _)) = previous_tag_opt {
1134        Ok(tag)
1135    } else {
1136        // No previous tag exists, use root commit for first release
1137        tracing::info!(
1138            "No previous tag found before {}, using root commit for first release",
1139            to_ref
1140        );
1141        crate::github::releases::get_root_commit(gh_client, owner, repo)
1142            .await
1143            .map_err(|e| AptuError::GitHub {
1144                message: e.to_string(),
1145            })
1146    }
1147}
1148
1149/// Generate AI-curated release notes from PRs between git tags.
1150///
1151/// # Arguments
1152///
1153/// * `provider` - Token provider for GitHub credentials
1154/// * `owner` - Repository owner
1155/// * `repo` - Repository name
1156/// * `from_tag` - Starting tag (or None for latest)
1157/// * `to_tag` - Ending tag (or None for HEAD)
1158///
1159/// # Returns
1160///
1161/// Structured release notes with theme, highlights, and categorized changes.
1162///
1163/// # Errors
1164///
1165/// Returns an error if:
1166/// - GitHub token is not available
1167/// - GitHub API calls fail
1168/// - AI response parsing fails
1169#[instrument(skip(provider))]
1170pub async fn generate_release_notes(
1171    provider: &dyn TokenProvider,
1172    owner: &str,
1173    repo: &str,
1174    from_tag: Option<&str>,
1175    to_tag: Option<&str>,
1176) -> Result<crate::ai::types::ReleaseNotesResponse, AptuError> {
1177    let token = provider.github_token().ok_or_else(|| AptuError::GitHub {
1178        message: "GitHub token not available".to_string(),
1179    })?;
1180
1181    let gh_client = create_client_with_token(&token).map_err(|e| AptuError::GitHub {
1182        message: e.to_string(),
1183    })?;
1184
1185    // Load AI config
1186    let config = load_config().map_err(|e| AptuError::Config {
1187        message: e.to_string(),
1188    })?;
1189
1190    // Create AI client
1191    let ai_client = AiClient::new(&config.ai.provider, &config.ai).map_err(|e| AptuError::AI {
1192        message: e.to_string(),
1193        status: None,
1194        provider: config.ai.provider.clone(),
1195    })?;
1196
1197    // Determine tags to use
1198    let (from_ref, to_ref) = if let (Some(from), Some(to)) = (from_tag, to_tag) {
1199        (from.to_string(), to.to_string())
1200    } else if let Some(to) = to_tag {
1201        // Get previous tag before to_ref, or root commit if no previous tag exists
1202        let from_ref = get_from_ref_or_root(&gh_client, owner, repo, to).await?;
1203        (from_ref, to.to_string())
1204    } else if let Some(from) = from_tag {
1205        // Use HEAD as to_ref
1206        (from.to_string(), "HEAD".to_string())
1207    } else {
1208        // Get latest tag and use HEAD, or root commit if no tags exist
1209        // For this case, we need to get the latest tag first, then find its predecessor
1210        let latest_tag_opt = crate::github::releases::get_latest_tag(&gh_client, owner, repo)
1211            .await
1212            .map_err(|e| AptuError::GitHub {
1213                message: e.to_string(),
1214            })?;
1215
1216        let to_ref = if let Some((tag, _)) = latest_tag_opt {
1217            tag
1218        } else {
1219            "HEAD".to_string()
1220        };
1221
1222        let from_ref = get_from_ref_or_root(&gh_client, owner, repo, &to_ref).await?;
1223        (from_ref, to_ref)
1224    };
1225
1226    // Fetch PRs between tags
1227    let prs = crate::github::releases::fetch_prs_between_refs(
1228        &gh_client, owner, repo, &from_ref, &to_ref,
1229    )
1230    .await
1231    .map_err(|e| AptuError::GitHub {
1232        message: e.to_string(),
1233    })?;
1234
1235    if prs.is_empty() {
1236        return Err(AptuError::GitHub {
1237            message: "No merged PRs found between the specified tags".to_string(),
1238        });
1239    }
1240
1241    // Generate release notes via AI
1242    let version = crate::github::releases::parse_tag_reference(&to_ref);
1243    let (response, _ai_stats) = ai_client
1244        .generate_release_notes(prs, &version)
1245        .await
1246        .map_err(|e: anyhow::Error| AptuError::AI {
1247            message: e.to_string(),
1248            status: None,
1249            provider: config.ai.provider.clone(),
1250        })?;
1251
1252    info!(
1253        theme = ?response.theme,
1254        highlights_count = response.highlights.len(),
1255        contributors_count = response.contributors.len(),
1256        "Release notes generated"
1257    );
1258
1259    Ok(response)
1260}
1261
1262/// Post release notes to GitHub.
1263///
1264/// Creates or updates a release on GitHub with the provided release notes body.
1265/// If the release already exists, it will be updated. Otherwise, a new release is created.
1266///
1267/// # Arguments
1268///
1269/// * `provider` - Token provider for GitHub credentials
1270/// * `owner` - Repository owner
1271/// * `repo` - Repository name
1272/// * `tag` - The tag name for the release
1273/// * `body` - The release notes body
1274///
1275/// # Returns
1276///
1277/// The URL of the created or updated release.
1278///
1279/// # Errors
1280///
1281/// Returns an error if:
1282/// - GitHub token is not available
1283/// - GitHub API call fails
1284#[instrument(skip(provider))]
1285pub async fn post_release_notes(
1286    provider: &dyn TokenProvider,
1287    owner: &str,
1288    repo: &str,
1289    tag: &str,
1290    body: &str,
1291) -> Result<String, AptuError> {
1292    let token = provider.github_token().ok_or_else(|| AptuError::GitHub {
1293        message: "GitHub token not available".to_string(),
1294    })?;
1295
1296    let gh_client = create_client_with_token(&token).map_err(|e| AptuError::GitHub {
1297        message: e.to_string(),
1298    })?;
1299
1300    crate::github::releases::post_release_notes(&gh_client, owner, repo, tag, body)
1301        .await
1302        .map_err(|e| AptuError::GitHub {
1303            message: e.to_string(),
1304        })
1305}
1306
1307#[cfg(test)]
1308mod tests {
1309    use crate::config::{FallbackConfig, FallbackEntry};
1310
1311    #[test]
1312    fn test_fallback_chain_config_structure() {
1313        // Test that fallback chain config structure is correct
1314        let fallback_config = FallbackConfig {
1315            chain: vec![
1316                FallbackEntry {
1317                    provider: "openrouter".to_string(),
1318                    model: None,
1319                },
1320                FallbackEntry {
1321                    provider: "anthropic".to_string(),
1322                    model: Some("claude-haiku-4.5".to_string()),
1323                },
1324            ],
1325        };
1326
1327        assert_eq!(fallback_config.chain.len(), 2);
1328        assert_eq!(fallback_config.chain[0].provider, "openrouter");
1329        assert_eq!(fallback_config.chain[0].model, None);
1330        assert_eq!(fallback_config.chain[1].provider, "anthropic");
1331        assert_eq!(
1332            fallback_config.chain[1].model,
1333            Some("claude-haiku-4.5".to_string())
1334        );
1335    }
1336
1337    #[test]
1338    fn test_fallback_chain_empty() {
1339        // Test that empty fallback chain is valid
1340        let fallback_config = FallbackConfig { chain: vec![] };
1341
1342        assert_eq!(fallback_config.chain.len(), 0);
1343    }
1344
1345    #[test]
1346    fn test_fallback_chain_single_provider() {
1347        // Test that single provider fallback chain is valid
1348        let fallback_config = FallbackConfig {
1349            chain: vec![FallbackEntry {
1350                provider: "openrouter".to_string(),
1351                model: None,
1352            }],
1353        };
1354
1355        assert_eq!(fallback_config.chain.len(), 1);
1356        assert_eq!(fallback_config.chain[0].provider, "openrouter");
1357    }
1358}
1359
1360#[allow(clippy::items_after_test_module)]
1361/// Formats a GitHub issue with AI assistance.
1362///
1363/// This function takes raw issue title and body, and uses AI to format them
1364/// according to project conventions. Returns formatted title, body, and suggested labels.
1365///
1366/// This is the first step of the two-step issue creation process. Use `post_issue()`
1367/// to post the formatted issue to GitHub.
1368///
1369/// # Arguments
1370///
1371/// * `provider` - Token provider for AI provider credentials
1372/// * `title` - Raw issue title
1373/// * `body` - Raw issue body
1374/// * `repo` - Repository name (owner/repo format) for context
1375/// * `ai_config` - AI configuration (provider, model, etc.)
1376///
1377/// # Returns
1378///
1379/// `CreateIssueResponse` with formatted title, body, and suggested labels.
1380///
1381/// # Errors
1382///
1383/// Returns an error if:
1384/// - AI provider token is not available from the provider
1385/// - AI API call fails
1386/// - Response parsing fails
1387#[instrument(skip(provider, ai_config), fields(repo = %repo))]
1388pub async fn format_issue(
1389    provider: &dyn TokenProvider,
1390    title: &str,
1391    body: &str,
1392    repo: &str,
1393    ai_config: &AiConfig,
1394) -> crate::Result<CreateIssueResponse> {
1395    // Resolve task-specific provider and model
1396    let (provider_name, model_name) = ai_config.resolve_for_task(TaskType::Create);
1397
1398    // Use fallback chain if configured
1399    try_with_fallback(provider, &provider_name, &model_name, ai_config, |client| {
1400        let title = title.to_string();
1401        let body = body.to_string();
1402        let repo = repo.to_string();
1403        async move {
1404            let (response, _stats) = client.create_issue(&title, &body, &repo).await?;
1405            Ok(response)
1406        }
1407    })
1408    .await
1409}
1410
1411/// Posts a formatted issue to GitHub.
1412///
1413/// This function takes formatted issue content and posts it to GitHub.
1414/// It is the second step of the two-step issue creation process.
1415/// Use `format_issue()` first to format the issue content.
1416///
1417/// # Arguments
1418///
1419/// * `provider` - Token provider for GitHub credentials
1420/// * `owner` - Repository owner
1421/// * `repo` - Repository name
1422/// * `title` - Formatted issue title
1423/// * `body` - Formatted issue body
1424///
1425/// # Returns
1426///
1427/// Tuple of (`issue_url`, `issue_number`).
1428///
1429/// # Errors
1430///
1431/// Returns an error if:
1432/// - GitHub token is not available from the provider
1433/// - GitHub API call fails
1434#[instrument(skip(provider), fields(owner = %owner, repo = %repo))]
1435pub async fn post_issue(
1436    provider: &dyn TokenProvider,
1437    owner: &str,
1438    repo: &str,
1439    title: &str,
1440    body: &str,
1441) -> crate::Result<(String, u64)> {
1442    // Create GitHub client from provider
1443    let client = create_client_from_provider(provider)?;
1444
1445    // Post issue to GitHub
1446    gh_create_issue(&client, owner, repo, title, body)
1447        .await
1448        .map_err(|e| AptuError::GitHub {
1449            message: e.to_string(),
1450        })
1451}
1452/// Lists available models from a provider API with caching.
1453///
1454/// This function fetches the list of available models from a provider's API,
1455/// with automatic caching and TTL validation. If the cache is valid, it returns
1456/// cached data. Otherwise, it fetches from the API and updates the cache.
1457///
1458/// # Arguments
1459///
1460/// * `provider` - Token provider for API credentials
1461/// * `provider_name` - Name of the provider (e.g., "openrouter", "gemini")
1462///
1463/// # Returns
1464///
1465/// A vector of `ModelInfo` structs with available models.
1466///
1467/// # Errors
1468///
1469/// Returns an error if:
1470/// - Provider is not found
1471/// - API request fails
1472/// - Response parsing fails
1473#[instrument(skip(provider), fields(provider_name))]
1474pub async fn list_models(
1475    provider: &dyn TokenProvider,
1476    provider_name: &str,
1477) -> crate::Result<Vec<crate::ai::registry::CachedModel>> {
1478    use crate::ai::registry::{CachedModelRegistry, ModelRegistry};
1479    use crate::cache::cache_dir;
1480
1481    let cache_dir = cache_dir();
1482    let registry =
1483        CachedModelRegistry::new(cache_dir, crate::cache::DEFAULT_MODEL_TTL_SECS, provider);
1484
1485    registry
1486        .list_models(provider_name)
1487        .await
1488        .map_err(|e| AptuError::ModelRegistry {
1489            message: format!("Failed to list models: {e}"),
1490        })
1491}
1492
1493/// Validates if a model exists for a provider.
1494///
1495/// This function checks if a specific model identifier is available for a provider,
1496/// using the cached model registry with automatic caching.
1497///
1498/// # Arguments
1499///
1500/// * `provider` - Token provider for API credentials
1501/// * `provider_name` - Name of the provider (e.g., "openrouter", "gemini")
1502/// * `model_id` - Model identifier to validate
1503///
1504/// # Returns
1505///
1506/// `true` if the model exists, `false` otherwise.
1507///
1508/// # Errors
1509///
1510/// Returns an error if:
1511/// - Provider is not found
1512/// - API request fails
1513/// - Response parsing fails
1514#[instrument(skip(provider), fields(provider_name, model_id))]
1515pub async fn validate_model(
1516    provider: &dyn TokenProvider,
1517    provider_name: &str,
1518    model_id: &str,
1519) -> crate::Result<bool> {
1520    use crate::ai::registry::{CachedModelRegistry, ModelRegistry};
1521    use crate::cache::cache_dir;
1522
1523    let cache_dir = cache_dir();
1524    let registry =
1525        CachedModelRegistry::new(cache_dir, crate::cache::DEFAULT_MODEL_TTL_SECS, provider);
1526
1527    registry
1528        .model_exists(provider_name, model_id)
1529        .await
1530        .map_err(|e| AptuError::ModelRegistry {
1531            message: format!("Failed to validate model: {e}"),
1532        })
1533}