1use chrono::Duration;
11use tracing::{debug, info, instrument, warn};
12
13use crate::ai::provider::MAX_LABELS;
14use crate::ai::registry::get_provider;
15use crate::ai::types::{
16 CreateIssueResponse, PrDetails, PrReviewComment, ReviewEvent, TriageResponse,
17};
18use crate::ai::{AiClient, AiProvider, AiResponse, types::IssueDetails};
19use crate::auth::TokenProvider;
20use crate::cache::{FileCache, FileCacheImpl};
21use crate::config::{AiConfig, TaskType, load_config};
22use crate::error::AptuError;
23use crate::github::auth::{create_client_from_provider, create_client_with_token};
24use crate::github::graphql::{
25 IssueNode, fetch_issue_with_repo_context, fetch_issues as gh_fetch_issues,
26};
27use crate::github::issues::{create_issue as gh_create_issue, filter_labels_by_relevance};
28use crate::github::pulls::{fetch_pr_details, post_pr_review as gh_post_pr_review};
29use crate::repos::{self, CuratedRepo};
30use crate::retry::is_retryable_anyhow;
31use secrecy::SecretString;
32
33#[instrument(skip(provider), fields(repo_filter = ?repo_filter, use_cache))]
55pub async fn fetch_issues(
56 provider: &dyn TokenProvider,
57 repo_filter: Option<&str>,
58 use_cache: bool,
59) -> crate::Result<Vec<(String, Vec<IssueNode>)>> {
60 let client = create_client_from_provider(provider)?;
62
63 let all_repos = repos::fetch().await?;
65 let repos_to_query: Vec<_> = match repo_filter {
66 Some(filter) => {
67 let filter_lower = filter.to_lowercase();
68 all_repos
69 .iter()
70 .filter(|r| {
71 r.full_name().to_lowercase().contains(&filter_lower)
72 || r.name.to_lowercase().contains(&filter_lower)
73 })
74 .cloned()
75 .collect()
76 }
77 None => all_repos,
78 };
79
80 let config = load_config()?;
82 let ttl = Duration::minutes(config.cache.issue_ttl_minutes);
83
84 if use_cache {
86 let cache: FileCacheImpl<Vec<IssueNode>> = FileCacheImpl::new("issues", ttl);
87 let mut cached_results = Vec::new();
88 let mut repos_to_fetch = Vec::new();
89
90 for repo in &repos_to_query {
91 let cache_key = format!("{}_{}", repo.owner, repo.name);
92 if let Ok(Some(issues)) = cache.get(&cache_key) {
93 cached_results.push((repo.full_name(), issues));
94 } else {
95 repos_to_fetch.push(repo.clone());
96 }
97 }
98
99 if repos_to_fetch.is_empty() {
101 return Ok(cached_results);
102 }
103
104 let repo_tuples: Vec<_> = repos_to_fetch
106 .iter()
107 .map(|r| (r.owner.as_str(), r.name.as_str()))
108 .collect();
109 let api_results =
110 gh_fetch_issues(&client, &repo_tuples)
111 .await
112 .map_err(|e| AptuError::GitHub {
113 message: format!("Failed to fetch issues: {e}"),
114 })?;
115
116 for (repo_name, issues) in &api_results {
118 if let Some(repo) = repos_to_fetch.iter().find(|r| r.full_name() == *repo_name) {
119 let cache_key = format!("{}_{}", repo.owner, repo.name);
120 let _ = cache.set(&cache_key, issues);
121 }
122 }
123
124 cached_results.extend(api_results);
126 Ok(cached_results)
127 } else {
128 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
141pub async fn list_curated_repos() -> crate::Result<Vec<CuratedRepo>> {
154 repos::fetch().await
155}
156
157#[instrument]
176pub async fn add_custom_repo(owner: &str, name: &str) -> crate::Result<CuratedRepo> {
177 let repo = repos::custom::validate_and_fetch_metadata(owner, name).await?;
179
180 let mut custom_repos = repos::custom::read_custom_repos()?;
182
183 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 custom_repos.push(repo.clone());
198
199 repos::custom::write_custom_repos(&custom_repos)?;
201
202 Ok(repo)
203}
204
205#[instrument]
220pub fn remove_custom_repo(owner: &str, name: &str) -> crate::Result<bool> {
221 let full_name = format!("{owner}/{name}");
222
223 let mut custom_repos = repos::custom::read_custom_repos()?;
225
226 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); }
233
234 repos::custom::write_custom_repos(&custom_repos)?;
236
237 Ok(true)
238}
239
240#[instrument]
254pub async fn list_repos(filter: repos::RepoFilter) -> crate::Result<Vec<CuratedRepo>> {
255 repos::fetch_all(filter).await
256}
257
258#[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
287fn validate_provider_model(provider: &str, model: &str) -> crate::Result<()> {
304 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 tracing::debug!(provider = provider, model = model, "Validating model");
314 Ok(())
315}
316
317fn try_setup_primary_client(
320 provider: &dyn TokenProvider,
321 primary_provider: &str,
322 model_name: &str,
323 ai_config: &AiConfig,
324) -> crate::Result<AiClient> {
325 let api_key = provider.ai_api_key(primary_provider).ok_or_else(|| {
326 let env_var = get_provider(primary_provider).map_or("API_KEY", |p| p.api_key_env);
327 AptuError::AiProviderNotAuthenticated {
328 provider: primary_provider.to_string(),
329 env_var: env_var.to_string(),
330 }
331 })?;
332
333 if ai_config.validation_enabled {
334 validate_provider_model(primary_provider, model_name)?;
335 }
336
337 AiClient::with_api_key(primary_provider, api_key, model_name, ai_config).map_err(|e| {
338 AptuError::AI {
339 message: e.to_string(),
340 status: None,
341 provider: primary_provider.to_string(),
342 }
343 })
344}
345
346fn setup_fallback_client(
350 provider: &dyn TokenProvider,
351 entry: &crate::config::FallbackEntry,
352 model_name: &str,
353 ai_config: &AiConfig,
354) -> Option<AiClient> {
355 let Some(api_key) = provider.ai_api_key(&entry.provider) else {
356 warn!(
357 fallback_provider = entry.provider,
358 "No API key available for fallback provider"
359 );
360 return None;
361 };
362
363 let fallback_model = entry.model.as_deref().unwrap_or(model_name);
364
365 if ai_config.validation_enabled
366 && validate_provider_model(&entry.provider, fallback_model).is_err()
367 {
368 warn!(
369 fallback_provider = entry.provider,
370 fallback_model = fallback_model,
371 "Fallback provider model validation failed, continuing to next provider"
372 );
373 return None;
374 }
375
376 if let Ok(client) = AiClient::with_api_key(&entry.provider, api_key, fallback_model, ai_config)
377 {
378 Some(client)
379 } else {
380 warn!(
381 fallback_provider = entry.provider,
382 "Failed to create AI client for fallback provider"
383 );
384 None
385 }
386}
387
388async fn try_fallback_entry<T, F, Fut>(
390 provider: &dyn TokenProvider,
391 entry: &crate::config::FallbackEntry,
392 model_name: &str,
393 ai_config: &AiConfig,
394 operation: &F,
395) -> crate::Result<Option<T>>
396where
397 F: Fn(AiClient) -> Fut,
398 Fut: std::future::Future<Output = anyhow::Result<T>>,
399{
400 warn!(
401 fallback_provider = entry.provider,
402 "Attempting fallback provider"
403 );
404
405 let Some(ai_client) = setup_fallback_client(provider, entry, model_name, ai_config) else {
406 return Ok(None);
407 };
408
409 match operation(ai_client).await {
410 Ok(response) => {
411 info!(
412 fallback_provider = entry.provider,
413 "Successfully completed operation with fallback provider"
414 );
415 Ok(Some(response))
416 }
417 Err(e) => {
418 if is_retryable_anyhow(&e) {
419 return Err(AptuError::AI {
420 message: e.to_string(),
421 status: None,
422 provider: entry.provider.clone(),
423 });
424 }
425 warn!(
426 fallback_provider = entry.provider,
427 error = %e,
428 "Fallback provider failed with non-retryable error"
429 );
430 Ok(None)
431 }
432 }
433}
434
435async fn execute_fallback_chain<T, F, Fut>(
437 provider: &dyn TokenProvider,
438 primary_provider: &str,
439 model_name: &str,
440 ai_config: &AiConfig,
441 operation: F,
442) -> crate::Result<T>
443where
444 F: Fn(AiClient) -> Fut,
445 Fut: std::future::Future<Output = anyhow::Result<T>>,
446{
447 if let Some(fallback_config) = &ai_config.fallback {
448 for entry in &fallback_config.chain {
449 if let Some(response) =
450 try_fallback_entry(provider, entry, model_name, ai_config, &operation).await?
451 {
452 return Ok(response);
453 }
454 }
455 }
456
457 Err(AptuError::AI {
458 message: "All AI providers failed (primary and fallback chain)".to_string(),
459 status: None,
460 provider: primary_provider.to_string(),
461 })
462}
463
464async fn try_with_fallback<T, F, Fut>(
465 provider: &dyn TokenProvider,
466 primary_provider: &str,
467 model_name: &str,
468 ai_config: &AiConfig,
469 operation: F,
470) -> crate::Result<T>
471where
472 F: Fn(AiClient) -> Fut,
473 Fut: std::future::Future<Output = anyhow::Result<T>>,
474{
475 let ai_client = try_setup_primary_client(provider, primary_provider, model_name, ai_config)?;
476
477 match operation(ai_client).await {
478 Ok(response) => return Ok(response),
479 Err(e) => {
480 if is_retryable_anyhow(&e) {
481 return Err(AptuError::AI {
482 message: e.to_string(),
483 status: None,
484 provider: primary_provider.to_string(),
485 });
486 }
487 warn!(
488 primary_provider = primary_provider,
489 error = %e,
490 "Primary provider failed with non-retryable error, trying fallback chain"
491 );
492 }
493 }
494
495 execute_fallback_chain(provider, primary_provider, model_name, ai_config, operation).await
496}
497
498#[instrument(skip(provider, issue), fields(issue_number = issue.number, repo = %format!("{}/{}", issue.owner, issue.repo)))]
519pub async fn analyze_issue(
520 provider: &dyn TokenProvider,
521 issue: &IssueDetails,
522 ai_config: &AiConfig,
523) -> crate::Result<AiResponse> {
524 let mut issue_mut = issue.clone();
526
527 if issue_mut.available_labels.is_empty()
529 && !issue_mut.owner.is_empty()
530 && !issue_mut.repo.is_empty()
531 {
532 if let Some(github_token) = provider.github_token() {
534 let token = SecretString::from(github_token);
535 if let Ok(client) = create_client_with_token(&token) {
536 if let Ok((_, repo_data)) = fetch_issue_with_repo_context(
538 &client,
539 &issue_mut.owner,
540 &issue_mut.repo,
541 issue_mut.number,
542 )
543 .await
544 {
545 issue_mut.available_labels =
547 repo_data.labels.nodes.into_iter().map(Into::into).collect();
548 }
549 }
550 }
551 }
552
553 if !issue_mut.available_labels.is_empty() {
555 issue_mut.available_labels =
556 filter_labels_by_relevance(&issue_mut.available_labels, MAX_LABELS);
557 }
558
559 let (provider_name, model_name) = ai_config.resolve_for_task(TaskType::Triage);
561
562 try_with_fallback(provider, &provider_name, &model_name, ai_config, |client| {
564 let issue = issue_mut.clone();
565 async move { client.analyze_issue(&issue).await }
566 })
567 .await
568}
569
570#[instrument(skip(provider), fields(reference = %reference))]
609pub async fn fetch_pr_for_review(
610 provider: &dyn TokenProvider,
611 reference: &str,
612 repo_context: Option<&str>,
613) -> crate::Result<PrDetails> {
614 use crate::github::pulls::parse_pr_reference;
615
616 let (owner, repo, number) =
618 parse_pr_reference(reference, repo_context).map_err(|e| AptuError::GitHub {
619 message: e.to_string(),
620 })?;
621
622 let client = create_client_from_provider(provider)?;
624
625 fetch_pr_details(&client, &owner, &repo, number)
627 .await
628 .map_err(|e| AptuError::GitHub {
629 message: e.to_string(),
630 })
631}
632
633#[instrument(skip(provider, pr_details), fields(number = pr_details.number))]
654pub async fn analyze_pr(
655 provider: &dyn TokenProvider,
656 pr_details: &PrDetails,
657 ai_config: &AiConfig,
658) -> crate::Result<(crate::ai::types::PrReviewResponse, crate::history::AiStats)> {
659 let (provider_name, model_name) = ai_config.resolve_for_task(TaskType::Review);
661
662 try_with_fallback(provider, &provider_name, &model_name, ai_config, |client| {
664 let pr = pr_details.clone();
665 async move { client.review_pr(&pr).await }
666 })
667 .await
668}
669
670#[instrument(skip(provider, comments), fields(reference = %reference, event = %event))]
697pub async fn post_pr_review(
698 provider: &dyn TokenProvider,
699 reference: &str,
700 repo_context: Option<&str>,
701 body: &str,
702 event: ReviewEvent,
703 comments: &[PrReviewComment],
704 commit_id: &str,
705) -> crate::Result<u64> {
706 use crate::github::pulls::parse_pr_reference;
707
708 let (owner, repo, number) =
710 parse_pr_reference(reference, repo_context).map_err(|e| AptuError::GitHub {
711 message: e.to_string(),
712 })?;
713
714 let client = create_client_from_provider(provider)?;
716
717 gh_post_pr_review(
719 &client, &owner, &repo, number, body, event, comments, commit_id,
720 )
721 .await
722 .map_err(|e| AptuError::GitHub {
723 message: e.to_string(),
724 })
725}
726
727#[instrument(skip(provider), fields(reference = %reference))]
750pub async fn label_pr(
751 provider: &dyn TokenProvider,
752 reference: &str,
753 repo_context: Option<&str>,
754 dry_run: bool,
755 ai_config: &AiConfig,
756) -> crate::Result<(u64, String, String, Vec<String>)> {
757 use crate::github::issues::apply_labels_to_number;
758 use crate::github::pulls::{fetch_pr_details, labels_from_pr_metadata, parse_pr_reference};
759
760 let (owner, repo, number) =
762 parse_pr_reference(reference, repo_context).map_err(|e| AptuError::GitHub {
763 message: e.to_string(),
764 })?;
765
766 let client = create_client_from_provider(provider)?;
768
769 let pr_details = fetch_pr_details(&client, &owner, &repo, number)
771 .await
772 .map_err(|e| AptuError::GitHub {
773 message: e.to_string(),
774 })?;
775
776 let file_paths: Vec<String> = pr_details
778 .files
779 .iter()
780 .map(|f| f.filename.clone())
781 .collect();
782 let mut labels = labels_from_pr_metadata(&pr_details.title, &file_paths);
783
784 if labels.is_empty() {
786 let (provider_name, model_name) = ai_config.resolve_for_task(TaskType::Create);
788
789 if let Some(api_key) = provider.ai_api_key(&provider_name) {
791 if let Ok(ai_client) =
793 crate::ai::AiClient::with_api_key(&provider_name, api_key, &model_name, ai_config)
794 {
795 match ai_client
796 .suggest_pr_labels(&pr_details.title, &pr_details.body, &file_paths)
797 .await
798 {
799 Ok((ai_labels, _stats)) => {
800 labels = ai_labels;
801 debug!("AI fallback provided {} labels", labels.len());
802 }
803 Err(e) => {
804 debug!("AI fallback failed: {}", e);
805 }
807 }
808 }
809 }
810 }
811
812 if !dry_run && !labels.is_empty() {
814 apply_labels_to_number(&client, &owner, &repo, number, &labels)
815 .await
816 .map_err(|e| AptuError::GitHub {
817 message: e.to_string(),
818 })?;
819 }
820
821 Ok((number, pr_details.title, pr_details.url, labels))
822}
823
824#[allow(clippy::too_many_lines)]
846#[instrument(skip(provider), fields(reference = %reference))]
847pub async fn fetch_issue_for_triage(
848 provider: &dyn TokenProvider,
849 reference: &str,
850 repo_context: Option<&str>,
851) -> crate::Result<IssueDetails> {
852 let (owner, repo, number) =
854 crate::github::issues::parse_issue_reference(reference, repo_context).map_err(|e| {
855 AptuError::GitHub {
856 message: e.to_string(),
857 }
858 })?;
859
860 let client = create_client_from_provider(provider)?;
862
863 let (issue_node, repo_data) = fetch_issue_with_repo_context(&client, &owner, &repo, number)
865 .await
866 .map_err(|e| AptuError::GitHub {
867 message: e.to_string(),
868 })?;
869
870 let labels: Vec<String> = issue_node
872 .labels
873 .nodes
874 .iter()
875 .map(|label| label.name.clone())
876 .collect();
877
878 let comments: Vec<crate::ai::types::IssueComment> = issue_node
879 .comments
880 .nodes
881 .iter()
882 .map(|comment| crate::ai::types::IssueComment {
883 author: comment.author.login.clone(),
884 body: comment.body.clone(),
885 })
886 .collect();
887
888 let available_labels: Vec<crate::ai::types::RepoLabel> = repo_data
889 .labels
890 .nodes
891 .iter()
892 .map(|label| crate::ai::types::RepoLabel {
893 name: label.name.clone(),
894 description: String::new(),
895 color: String::new(),
896 })
897 .collect();
898
899 let available_milestones: Vec<crate::ai::types::RepoMilestone> = repo_data
900 .milestones
901 .nodes
902 .iter()
903 .map(|milestone| crate::ai::types::RepoMilestone {
904 number: milestone.number,
905 title: milestone.title.clone(),
906 description: String::new(),
907 })
908 .collect();
909
910 let mut issue_details = IssueDetails::builder()
911 .owner(owner.clone())
912 .repo(repo.clone())
913 .number(number)
914 .title(issue_node.title.clone())
915 .body(issue_node.body.clone().unwrap_or_default())
916 .labels(labels)
917 .comments(comments)
918 .url(issue_node.url.clone())
919 .available_labels(available_labels)
920 .available_milestones(available_milestones)
921 .build();
922
923 issue_details.author = issue_node.author.as_ref().map(|a| a.login.clone());
925 issue_details.created_at = Some(issue_node.created_at.clone());
926 issue_details.updated_at = Some(issue_node.updated_at.clone());
927
928 let keywords = crate::github::issues::extract_keywords(&issue_details.title);
930 let language = repo_data
931 .primary_language
932 .as_ref()
933 .map_or("unknown", |l| l.name.as_str())
934 .to_string();
935
936 let (search_result, tree_result) = tokio::join!(
938 crate::github::issues::search_related_issues(
939 &client,
940 &owner,
941 &repo,
942 &issue_details.title,
943 number
944 ),
945 crate::github::issues::fetch_repo_tree(&client, &owner, &repo, &language, &keywords)
946 );
947
948 match search_result {
950 Ok(related) => {
951 issue_details.repo_context = related;
952 debug!(
953 related_count = issue_details.repo_context.len(),
954 "Found related issues"
955 );
956 }
957 Err(e) => {
958 debug!(error = %e, "Failed to search for related issues, continuing without context");
959 }
960 }
961
962 match tree_result {
964 Ok(tree) => {
965 issue_details.repo_tree = tree;
966 debug!(
967 tree_count = issue_details.repo_tree.len(),
968 "Fetched repository tree"
969 );
970 }
971 Err(e) => {
972 debug!(error = %e, "Failed to fetch repository tree, continuing without context");
973 }
974 }
975
976 debug!(issue_number = number, "Issue fetched successfully");
977 Ok(issue_details)
978}
979
980#[instrument(skip(provider, triage), fields(owner = %issue_details.owner, repo = %issue_details.repo, number = issue_details.number))]
1000pub async fn post_triage_comment(
1001 provider: &dyn TokenProvider,
1002 issue_details: &IssueDetails,
1003 triage: &TriageResponse,
1004) -> crate::Result<String> {
1005 let client = create_client_from_provider(provider)?;
1007
1008 let comment_body = crate::triage::render_triage_markdown(triage);
1010 let comment_url = crate::github::issues::post_comment(
1011 &client,
1012 &issue_details.owner,
1013 &issue_details.repo,
1014 issue_details.number,
1015 &comment_body,
1016 )
1017 .await
1018 .map_err(|e| AptuError::GitHub {
1019 message: e.to_string(),
1020 })?;
1021
1022 debug!(comment_url = %comment_url, "Triage comment posted");
1023 Ok(comment_url)
1024}
1025
1026#[instrument(skip(provider, triage), fields(owner = %issue_details.owner, repo = %issue_details.repo, number = issue_details.number))]
1048pub async fn apply_triage_labels(
1049 provider: &dyn TokenProvider,
1050 issue_details: &IssueDetails,
1051 triage: &TriageResponse,
1052) -> crate::Result<crate::github::issues::ApplyResult> {
1053 debug!("Applying labels and milestone to issue");
1054
1055 let client = create_client_from_provider(provider)?;
1057
1058 let result = crate::github::issues::update_issue_labels_and_milestone(
1060 &client,
1061 &issue_details.owner,
1062 &issue_details.repo,
1063 issue_details.number,
1064 &issue_details.labels,
1065 &triage.suggested_labels,
1066 issue_details.milestone.as_deref(),
1067 triage.suggested_milestone.as_deref(),
1068 &issue_details.available_labels,
1069 &issue_details.available_milestones,
1070 )
1071 .await
1072 .map_err(|e| AptuError::GitHub {
1073 message: e.to_string(),
1074 })?;
1075
1076 info!(
1077 labels = ?result.applied_labels,
1078 milestone = ?result.applied_milestone,
1079 warnings = ?result.warnings,
1080 "Labels and milestone applied"
1081 );
1082
1083 Ok(result)
1084}
1085
1086async fn get_from_ref_or_root(
1128 gh_client: &octocrab::Octocrab,
1129 owner: &str,
1130 repo: &str,
1131 to_ref: &str,
1132) -> Result<String, AptuError> {
1133 let previous_tag_opt =
1135 crate::github::releases::get_previous_tag(gh_client, owner, repo, to_ref)
1136 .await
1137 .map_err(|e| AptuError::GitHub {
1138 message: e.to_string(),
1139 })?;
1140
1141 if let Some((tag, _)) = previous_tag_opt {
1142 Ok(tag)
1143 } else {
1144 tracing::info!(
1146 "No previous tag found before {}, using root commit for first release",
1147 to_ref
1148 );
1149 crate::github::releases::get_root_commit(gh_client, owner, repo)
1150 .await
1151 .map_err(|e| AptuError::GitHub {
1152 message: e.to_string(),
1153 })
1154 }
1155}
1156
1157#[instrument(skip(provider))]
1178pub async fn generate_release_notes(
1179 provider: &dyn TokenProvider,
1180 owner: &str,
1181 repo: &str,
1182 from_tag: Option<&str>,
1183 to_tag: Option<&str>,
1184) -> Result<crate::ai::types::ReleaseNotesResponse, AptuError> {
1185 let token = provider.github_token().ok_or_else(|| AptuError::GitHub {
1186 message: "GitHub token not available".to_string(),
1187 })?;
1188
1189 let gh_client = create_client_with_token(&token).map_err(|e| AptuError::GitHub {
1190 message: e.to_string(),
1191 })?;
1192
1193 let config = load_config().map_err(|e| AptuError::Config {
1195 message: e.to_string(),
1196 })?;
1197
1198 let ai_client = AiClient::new(&config.ai.provider, &config.ai).map_err(|e| AptuError::AI {
1200 message: e.to_string(),
1201 status: None,
1202 provider: config.ai.provider.clone(),
1203 })?;
1204
1205 let (from_ref, to_ref) = if let (Some(from), Some(to)) = (from_tag, to_tag) {
1207 (from.to_string(), to.to_string())
1208 } else if let Some(to) = to_tag {
1209 let from_ref = get_from_ref_or_root(&gh_client, owner, repo, to).await?;
1211 (from_ref, to.to_string())
1212 } else if let Some(from) = from_tag {
1213 (from.to_string(), "HEAD".to_string())
1215 } else {
1216 let latest_tag_opt = crate::github::releases::get_latest_tag(&gh_client, owner, repo)
1219 .await
1220 .map_err(|e| AptuError::GitHub {
1221 message: e.to_string(),
1222 })?;
1223
1224 let to_ref = if let Some((tag, _)) = latest_tag_opt {
1225 tag
1226 } else {
1227 "HEAD".to_string()
1228 };
1229
1230 let from_ref = get_from_ref_or_root(&gh_client, owner, repo, &to_ref).await?;
1231 (from_ref, to_ref)
1232 };
1233
1234 let prs = crate::github::releases::fetch_prs_between_refs(
1236 &gh_client, owner, repo, &from_ref, &to_ref,
1237 )
1238 .await
1239 .map_err(|e| AptuError::GitHub {
1240 message: e.to_string(),
1241 })?;
1242
1243 if prs.is_empty() {
1244 return Err(AptuError::GitHub {
1245 message: "No merged PRs found between the specified tags".to_string(),
1246 });
1247 }
1248
1249 let version = crate::github::releases::parse_tag_reference(&to_ref);
1251 let (response, _ai_stats) = ai_client
1252 .generate_release_notes(prs, &version)
1253 .await
1254 .map_err(|e: anyhow::Error| AptuError::AI {
1255 message: e.to_string(),
1256 status: None,
1257 provider: config.ai.provider.clone(),
1258 })?;
1259
1260 info!(
1261 theme = ?response.theme,
1262 highlights_count = response.highlights.len(),
1263 contributors_count = response.contributors.len(),
1264 "Release notes generated"
1265 );
1266
1267 Ok(response)
1268}
1269
1270#[instrument(skip(provider))]
1293pub async fn post_release_notes(
1294 provider: &dyn TokenProvider,
1295 owner: &str,
1296 repo: &str,
1297 tag: &str,
1298 body: &str,
1299) -> Result<String, AptuError> {
1300 let token = provider.github_token().ok_or_else(|| AptuError::GitHub {
1301 message: "GitHub token not available".to_string(),
1302 })?;
1303
1304 let gh_client = create_client_with_token(&token).map_err(|e| AptuError::GitHub {
1305 message: e.to_string(),
1306 })?;
1307
1308 crate::github::releases::post_release_notes(&gh_client, owner, repo, tag, body)
1309 .await
1310 .map_err(|e| AptuError::GitHub {
1311 message: e.to_string(),
1312 })
1313}
1314
1315#[cfg(test)]
1316mod tests {
1317 use crate::config::{FallbackConfig, FallbackEntry};
1318
1319 #[test]
1320 fn test_fallback_chain_config_structure() {
1321 let fallback_config = FallbackConfig {
1323 chain: vec![
1324 FallbackEntry {
1325 provider: "openrouter".to_string(),
1326 model: None,
1327 },
1328 FallbackEntry {
1329 provider: "anthropic".to_string(),
1330 model: Some("claude-haiku-4.5".to_string()),
1331 },
1332 ],
1333 };
1334
1335 assert_eq!(fallback_config.chain.len(), 2);
1336 assert_eq!(fallback_config.chain[0].provider, "openrouter");
1337 assert_eq!(fallback_config.chain[0].model, None);
1338 assert_eq!(fallback_config.chain[1].provider, "anthropic");
1339 assert_eq!(
1340 fallback_config.chain[1].model,
1341 Some("claude-haiku-4.5".to_string())
1342 );
1343 }
1344
1345 #[test]
1346 fn test_fallback_chain_empty() {
1347 let fallback_config = FallbackConfig { chain: vec![] };
1349
1350 assert_eq!(fallback_config.chain.len(), 0);
1351 }
1352
1353 #[test]
1354 fn test_fallback_chain_single_provider() {
1355 let fallback_config = FallbackConfig {
1357 chain: vec![FallbackEntry {
1358 provider: "openrouter".to_string(),
1359 model: None,
1360 }],
1361 };
1362
1363 assert_eq!(fallback_config.chain.len(), 1);
1364 assert_eq!(fallback_config.chain[0].provider, "openrouter");
1365 }
1366}
1367
1368#[allow(clippy::items_after_test_module)]
1369#[instrument(skip(provider, ai_config), fields(repo = %repo))]
1396pub async fn format_issue(
1397 provider: &dyn TokenProvider,
1398 title: &str,
1399 body: &str,
1400 repo: &str,
1401 ai_config: &AiConfig,
1402) -> crate::Result<CreateIssueResponse> {
1403 let (provider_name, model_name) = ai_config.resolve_for_task(TaskType::Create);
1405
1406 try_with_fallback(provider, &provider_name, &model_name, ai_config, |client| {
1408 let title = title.to_string();
1409 let body = body.to_string();
1410 let repo = repo.to_string();
1411 async move {
1412 let (response, _stats) = client.create_issue(&title, &body, &repo).await?;
1413 Ok(response)
1414 }
1415 })
1416 .await
1417}
1418
1419#[instrument(skip(provider), fields(owner = %owner, repo = %repo))]
1443pub async fn post_issue(
1444 provider: &dyn TokenProvider,
1445 owner: &str,
1446 repo: &str,
1447 title: &str,
1448 body: &str,
1449) -> crate::Result<(String, u64)> {
1450 let client = create_client_from_provider(provider)?;
1452
1453 gh_create_issue(&client, owner, repo, title, body)
1455 .await
1456 .map_err(|e| AptuError::GitHub {
1457 message: e.to_string(),
1458 })
1459}
1460#[instrument(skip(provider), fields(owner = %owner, repo = %repo, head = %head_branch, base = %base_branch))]
1483pub async fn create_pr(
1484 provider: &dyn TokenProvider,
1485 owner: &str,
1486 repo: &str,
1487 title: &str,
1488 base_branch: &str,
1489 head_branch: &str,
1490 body: Option<&str>,
1491) -> crate::Result<crate::github::pulls::PrCreateResult> {
1492 let client = create_client_from_provider(provider)?;
1494
1495 crate::github::pulls::create_pull_request(
1497 &client,
1498 owner,
1499 repo,
1500 title,
1501 head_branch,
1502 base_branch,
1503 body,
1504 )
1505 .await
1506 .map_err(|e| AptuError::GitHub {
1507 message: e.to_string(),
1508 })
1509}
1510
1511#[instrument(skip(provider), fields(provider_name))]
1533pub async fn list_models(
1534 provider: &dyn TokenProvider,
1535 provider_name: &str,
1536) -> crate::Result<Vec<crate::ai::registry::CachedModel>> {
1537 use crate::ai::registry::{CachedModelRegistry, ModelRegistry};
1538 use crate::cache::cache_dir;
1539
1540 let cache_dir = cache_dir();
1541 let registry =
1542 CachedModelRegistry::new(cache_dir, crate::cache::DEFAULT_MODEL_TTL_SECS, provider);
1543
1544 registry
1545 .list_models(provider_name)
1546 .await
1547 .map_err(|e| AptuError::ModelRegistry {
1548 message: format!("Failed to list models: {e}"),
1549 })
1550}
1551
1552#[instrument(skip(provider), fields(provider_name, model_id))]
1574pub async fn validate_model(
1575 provider: &dyn TokenProvider,
1576 provider_name: &str,
1577 model_id: &str,
1578) -> crate::Result<bool> {
1579 use crate::ai::registry::{CachedModelRegistry, ModelRegistry};
1580 use crate::cache::cache_dir;
1581
1582 let cache_dir = cache_dir();
1583 let registry =
1584 CachedModelRegistry::new(cache_dir, crate::cache::DEFAULT_MODEL_TTL_SECS, provider);
1585
1586 registry
1587 .model_exists(provider_name, model_id)
1588 .await
1589 .map_err(|e| AptuError::ModelRegistry {
1590 message: format!("Failed to validate model: {e}"),
1591 })
1592}