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