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