Skip to main content

aptu_core/
facade.rs

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