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