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::{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#[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 let client = create_client_from_provider(provider)?;
60
61 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 let config = load_config()?;
80 let ttl = Duration::minutes(config.cache.issue_ttl_minutes);
81
82 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 repos_to_fetch.is_empty() {
99 return Ok(cached_results);
100 }
101
102 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 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 cached_results.extend(api_results);
124 Ok(cached_results)
125 } else {
126 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
139pub async fn list_curated_repos() -> crate::Result<Vec<CuratedRepo>> {
152 repos::fetch().await
153}
154
155#[instrument]
174pub async fn add_custom_repo(owner: &str, name: &str) -> crate::Result<CuratedRepo> {
175 let repo = repos::custom::validate_and_fetch_metadata(owner, name).await?;
177
178 let mut custom_repos = repos::custom::read_custom_repos()?;
180
181 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 custom_repos.push(repo.clone());
196
197 repos::custom::write_custom_repos(&custom_repos)?;
199
200 Ok(repo)
201}
202
203#[instrument]
218pub fn remove_custom_repo(owner: &str, name: &str) -> crate::Result<bool> {
219 let full_name = format!("{owner}/{name}");
220
221 let mut custom_repos = repos::custom::read_custom_repos()?;
223
224 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); }
231
232 repos::custom::write_custom_repos(&custom_repos)?;
234
235 Ok(true)
236}
237
238#[instrument]
252pub async fn list_repos(filter: repos::RepoFilter) -> crate::Result<Vec<CuratedRepo>> {
253 repos::fetch_all(filter).await
254}
255
256#[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
285fn validate_provider_model(provider: &str, model: &str) -> crate::Result<()> {
302 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 tracing::debug!(provider = provider, model = model, "Validating model");
312 Ok(())
313}
314
315fn try_setup_primary_client(
318 provider: &dyn TokenProvider,
319 primary_provider: &str,
320 model_name: &str,
321 ai_config: &AiConfig,
322) -> crate::Result<AiClient> {
323 let api_key = provider.ai_api_key(primary_provider).ok_or_else(|| {
324 let env_var = get_provider(primary_provider).map_or("API_KEY", |p| p.api_key_env);
325 AptuError::AiProviderNotAuthenticated {
326 provider: primary_provider.to_string(),
327 env_var: env_var.to_string(),
328 }
329 })?;
330
331 if ai_config.validation_enabled {
332 validate_provider_model(primary_provider, model_name)?;
333 }
334
335 AiClient::with_api_key(primary_provider, api_key, model_name, ai_config).map_err(|e| {
336 AptuError::AI {
337 message: e.to_string(),
338 status: None,
339 provider: primary_provider.to_string(),
340 }
341 })
342}
343
344fn setup_fallback_client(
348 provider: &dyn TokenProvider,
349 entry: &crate::config::FallbackEntry,
350 model_name: &str,
351 ai_config: &AiConfig,
352) -> Option<AiClient> {
353 let Some(api_key) = provider.ai_api_key(&entry.provider) else {
354 warn!(
355 fallback_provider = entry.provider,
356 "No API key available for fallback provider"
357 );
358 return None;
359 };
360
361 let fallback_model = entry.model.as_deref().unwrap_or(model_name);
362
363 if ai_config.validation_enabled
364 && validate_provider_model(&entry.provider, fallback_model).is_err()
365 {
366 warn!(
367 fallback_provider = entry.provider,
368 fallback_model = fallback_model,
369 "Fallback provider model validation failed, continuing to next provider"
370 );
371 return None;
372 }
373
374 if let Ok(client) = AiClient::with_api_key(&entry.provider, api_key, fallback_model, ai_config)
375 {
376 Some(client)
377 } else {
378 warn!(
379 fallback_provider = entry.provider,
380 "Failed to create AI client for fallback provider"
381 );
382 None
383 }
384}
385
386async fn try_fallback_entry<T, F, Fut>(
388 provider: &dyn TokenProvider,
389 entry: &crate::config::FallbackEntry,
390 model_name: &str,
391 ai_config: &AiConfig,
392 operation: &F,
393) -> crate::Result<Option<T>>
394where
395 F: Fn(AiClient) -> Fut,
396 Fut: std::future::Future<Output = anyhow::Result<T>>,
397{
398 warn!(
399 fallback_provider = entry.provider,
400 "Attempting fallback provider"
401 );
402
403 let Some(ai_client) = setup_fallback_client(provider, entry, model_name, ai_config) else {
404 return Ok(None);
405 };
406
407 match operation(ai_client).await {
408 Ok(response) => {
409 info!(
410 fallback_provider = entry.provider,
411 "Successfully completed operation with fallback provider"
412 );
413 Ok(Some(response))
414 }
415 Err(e) => {
416 if is_retryable_anyhow(&e) {
417 return Err(AptuError::AI {
418 message: e.to_string(),
419 status: None,
420 provider: entry.provider.clone(),
421 });
422 }
423 warn!(
424 fallback_provider = entry.provider,
425 error = %e,
426 "Fallback provider failed with non-retryable error"
427 );
428 Ok(None)
429 }
430 }
431}
432
433async fn execute_fallback_chain<T, F, Fut>(
435 provider: &dyn TokenProvider,
436 primary_provider: &str,
437 model_name: &str,
438 ai_config: &AiConfig,
439 operation: F,
440) -> crate::Result<T>
441where
442 F: Fn(AiClient) -> Fut,
443 Fut: std::future::Future<Output = anyhow::Result<T>>,
444{
445 if let Some(fallback_config) = &ai_config.fallback {
446 for entry in &fallback_config.chain {
447 if let Some(response) =
448 try_fallback_entry(provider, entry, model_name, ai_config, &operation).await?
449 {
450 return Ok(response);
451 }
452 }
453 }
454
455 Err(AptuError::AI {
456 message: "All AI providers failed (primary and fallback chain)".to_string(),
457 status: None,
458 provider: primary_provider.to_string(),
459 })
460}
461
462async fn try_with_fallback<T, F, Fut>(
463 provider: &dyn TokenProvider,
464 primary_provider: &str,
465 model_name: &str,
466 ai_config: &AiConfig,
467 operation: F,
468) -> crate::Result<T>
469where
470 F: Fn(AiClient) -> Fut,
471 Fut: std::future::Future<Output = anyhow::Result<T>>,
472{
473 let ai_client = try_setup_primary_client(provider, primary_provider, model_name, ai_config)?;
474
475 match operation(ai_client).await {
476 Ok(response) => return Ok(response),
477 Err(e) => {
478 if is_retryable_anyhow(&e) {
479 return Err(AptuError::AI {
480 message: e.to_string(),
481 status: None,
482 provider: primary_provider.to_string(),
483 });
484 }
485 warn!(
486 primary_provider = primary_provider,
487 error = %e,
488 "Primary provider failed with non-retryable error, trying fallback chain"
489 );
490 }
491 }
492
493 execute_fallback_chain(provider, primary_provider, model_name, ai_config, operation).await
494}
495
496#[instrument(skip(provider, issue), fields(issue_number = issue.number, repo = %format!("{}/{}", issue.owner, issue.repo)))]
517pub async fn analyze_issue(
518 provider: &dyn TokenProvider,
519 issue: &IssueDetails,
520 ai_config: &AiConfig,
521) -> crate::Result<AiResponse> {
522 let mut issue_mut = issue.clone();
524
525 if issue_mut.available_labels.is_empty()
527 && !issue_mut.owner.is_empty()
528 && !issue_mut.repo.is_empty()
529 {
530 if let Some(github_token) = provider.github_token() {
532 let token = SecretString::from(github_token);
533 if let Ok(client) = create_client_with_token(&token) {
534 if let Ok((_, repo_data)) = fetch_issue_with_repo_context(
536 &client,
537 &issue_mut.owner,
538 &issue_mut.repo,
539 issue_mut.number,
540 )
541 .await
542 {
543 issue_mut.available_labels =
545 repo_data.labels.nodes.into_iter().map(Into::into).collect();
546 }
547 }
548 }
549 }
550
551 if !issue_mut.available_labels.is_empty() {
553 issue_mut.available_labels =
554 filter_labels_by_relevance(&issue_mut.available_labels, MAX_LABELS);
555 }
556
557 let (provider_name, model_name) = ai_config.resolve_for_task(TaskType::Triage);
559
560 try_with_fallback(provider, &provider_name, &model_name, ai_config, |client| {
562 let issue = issue_mut.clone();
563 async move { client.analyze_issue(&issue).await }
564 })
565 .await
566}
567
568#[instrument(skip(provider), fields(reference = %reference))]
607pub async fn fetch_pr_for_review(
608 provider: &dyn TokenProvider,
609 reference: &str,
610 repo_context: Option<&str>,
611) -> crate::Result<PrDetails> {
612 use crate::github::pulls::parse_pr_reference;
613
614 let (owner, repo, number) =
616 parse_pr_reference(reference, repo_context).map_err(|e| AptuError::GitHub {
617 message: e.to_string(),
618 })?;
619
620 let client = create_client_from_provider(provider)?;
622
623 fetch_pr_details(&client, &owner, &repo, number)
625 .await
626 .map_err(|e| AptuError::GitHub {
627 message: e.to_string(),
628 })
629}
630
631#[instrument(skip(provider, pr_details), fields(number = pr_details.number))]
652pub async fn analyze_pr(
653 provider: &dyn TokenProvider,
654 pr_details: &PrDetails,
655 ai_config: &AiConfig,
656) -> crate::Result<(crate::ai::types::PrReviewResponse, crate::history::AiStats)> {
657 let (provider_name, model_name) = ai_config.resolve_for_task(TaskType::Review);
659
660 try_with_fallback(provider, &provider_name, &model_name, ai_config, |client| {
662 let pr = pr_details.clone();
663 async move { client.review_pr(&pr).await }
664 })
665 .await
666}
667
668#[instrument(skip(provider), fields(reference = %reference, event = %event))]
693pub async fn post_pr_review(
694 provider: &dyn TokenProvider,
695 reference: &str,
696 repo_context: Option<&str>,
697 body: &str,
698 event: ReviewEvent,
699) -> crate::Result<u64> {
700 use crate::github::pulls::parse_pr_reference;
701
702 let (owner, repo, number) =
704 parse_pr_reference(reference, repo_context).map_err(|e| AptuError::GitHub {
705 message: e.to_string(),
706 })?;
707
708 let client = create_client_from_provider(provider)?;
710
711 gh_post_pr_review(&client, &owner, &repo, number, body, event)
713 .await
714 .map_err(|e| AptuError::GitHub {
715 message: e.to_string(),
716 })
717}
718
719#[instrument(skip(provider), fields(reference = %reference))]
742pub async fn label_pr(
743 provider: &dyn TokenProvider,
744 reference: &str,
745 repo_context: Option<&str>,
746 dry_run: bool,
747 ai_config: &AiConfig,
748) -> crate::Result<(u64, String, String, Vec<String>)> {
749 use crate::github::issues::apply_labels_to_number;
750 use crate::github::pulls::{fetch_pr_details, labels_from_pr_metadata, parse_pr_reference};
751
752 let (owner, repo, number) =
754 parse_pr_reference(reference, repo_context).map_err(|e| AptuError::GitHub {
755 message: e.to_string(),
756 })?;
757
758 let client = create_client_from_provider(provider)?;
760
761 let pr_details = fetch_pr_details(&client, &owner, &repo, number)
763 .await
764 .map_err(|e| AptuError::GitHub {
765 message: e.to_string(),
766 })?;
767
768 let file_paths: Vec<String> = pr_details
770 .files
771 .iter()
772 .map(|f| f.filename.clone())
773 .collect();
774 let mut labels = labels_from_pr_metadata(&pr_details.title, &file_paths);
775
776 if labels.is_empty() {
778 let (provider_name, model_name) = ai_config.resolve_for_task(TaskType::Create);
780
781 if let Some(api_key) = provider.ai_api_key(&provider_name) {
783 if let Ok(ai_client) =
785 crate::ai::AiClient::with_api_key(&provider_name, api_key, &model_name, ai_config)
786 {
787 match ai_client
788 .suggest_pr_labels(&pr_details.title, &pr_details.body, &file_paths)
789 .await
790 {
791 Ok((ai_labels, _stats)) => {
792 labels = ai_labels;
793 debug!("AI fallback provided {} labels", labels.len());
794 }
795 Err(e) => {
796 debug!("AI fallback failed: {}", e);
797 }
799 }
800 }
801 }
802 }
803
804 if !dry_run && !labels.is_empty() {
806 apply_labels_to_number(&client, &owner, &repo, number, &labels)
807 .await
808 .map_err(|e| AptuError::GitHub {
809 message: e.to_string(),
810 })?;
811 }
812
813 Ok((number, pr_details.title, pr_details.url, labels))
814}
815
816#[allow(clippy::too_many_lines)]
838#[instrument(skip(provider), fields(reference = %reference))]
839pub async fn fetch_issue_for_triage(
840 provider: &dyn TokenProvider,
841 reference: &str,
842 repo_context: Option<&str>,
843) -> crate::Result<IssueDetails> {
844 let (owner, repo, number) =
846 crate::github::issues::parse_issue_reference(reference, repo_context).map_err(|e| {
847 AptuError::GitHub {
848 message: e.to_string(),
849 }
850 })?;
851
852 let client = create_client_from_provider(provider)?;
854
855 let (issue_node, repo_data) = fetch_issue_with_repo_context(&client, &owner, &repo, number)
857 .await
858 .map_err(|e| AptuError::GitHub {
859 message: e.to_string(),
860 })?;
861
862 let labels: Vec<String> = issue_node
864 .labels
865 .nodes
866 .iter()
867 .map(|label| label.name.clone())
868 .collect();
869
870 let comments: Vec<crate::ai::types::IssueComment> = issue_node
871 .comments
872 .nodes
873 .iter()
874 .map(|comment| crate::ai::types::IssueComment {
875 author: comment.author.login.clone(),
876 body: comment.body.clone(),
877 })
878 .collect();
879
880 let available_labels: Vec<crate::ai::types::RepoLabel> = repo_data
881 .labels
882 .nodes
883 .iter()
884 .map(|label| crate::ai::types::RepoLabel {
885 name: label.name.clone(),
886 description: String::new(),
887 color: String::new(),
888 })
889 .collect();
890
891 let available_milestones: Vec<crate::ai::types::RepoMilestone> = repo_data
892 .milestones
893 .nodes
894 .iter()
895 .map(|milestone| crate::ai::types::RepoMilestone {
896 number: milestone.number,
897 title: milestone.title.clone(),
898 description: String::new(),
899 })
900 .collect();
901
902 let mut issue_details = IssueDetails::builder()
903 .owner(owner.clone())
904 .repo(repo.clone())
905 .number(number)
906 .title(issue_node.title.clone())
907 .body(issue_node.body.clone().unwrap_or_default())
908 .labels(labels)
909 .comments(comments)
910 .url(issue_node.url.clone())
911 .available_labels(available_labels)
912 .available_milestones(available_milestones)
913 .build();
914
915 issue_details.author = issue_node.author.as_ref().map(|a| a.login.clone());
917 issue_details.created_at = Some(issue_node.created_at.clone());
918 issue_details.updated_at = Some(issue_node.updated_at.clone());
919
920 let keywords = crate::github::issues::extract_keywords(&issue_details.title);
922 let language = repo_data
923 .primary_language
924 .as_ref()
925 .map_or("unknown", |l| l.name.as_str())
926 .to_string();
927
928 let (search_result, tree_result) = tokio::join!(
930 crate::github::issues::search_related_issues(
931 &client,
932 &owner,
933 &repo,
934 &issue_details.title,
935 number
936 ),
937 crate::github::issues::fetch_repo_tree(&client, &owner, &repo, &language, &keywords)
938 );
939
940 match search_result {
942 Ok(related) => {
943 issue_details.repo_context = related;
944 debug!(
945 related_count = issue_details.repo_context.len(),
946 "Found related issues"
947 );
948 }
949 Err(e) => {
950 debug!(error = %e, "Failed to search for related issues, continuing without context");
951 }
952 }
953
954 match tree_result {
956 Ok(tree) => {
957 issue_details.repo_tree = tree;
958 debug!(
959 tree_count = issue_details.repo_tree.len(),
960 "Fetched repository tree"
961 );
962 }
963 Err(e) => {
964 debug!(error = %e, "Failed to fetch repository tree, continuing without context");
965 }
966 }
967
968 debug!(issue_number = number, "Issue fetched successfully");
969 Ok(issue_details)
970}
971
972#[instrument(skip(provider, triage), fields(owner = %issue_details.owner, repo = %issue_details.repo, number = issue_details.number))]
992pub async fn post_triage_comment(
993 provider: &dyn TokenProvider,
994 issue_details: &IssueDetails,
995 triage: &TriageResponse,
996) -> crate::Result<String> {
997 let client = create_client_from_provider(provider)?;
999
1000 let comment_body = crate::triage::render_triage_markdown(triage);
1002 let comment_url = crate::github::issues::post_comment(
1003 &client,
1004 &issue_details.owner,
1005 &issue_details.repo,
1006 issue_details.number,
1007 &comment_body,
1008 )
1009 .await
1010 .map_err(|e| AptuError::GitHub {
1011 message: e.to_string(),
1012 })?;
1013
1014 debug!(comment_url = %comment_url, "Triage comment posted");
1015 Ok(comment_url)
1016}
1017
1018#[instrument(skip(provider, triage), fields(owner = %issue_details.owner, repo = %issue_details.repo, number = issue_details.number))]
1040pub async fn apply_triage_labels(
1041 provider: &dyn TokenProvider,
1042 issue_details: &IssueDetails,
1043 triage: &TriageResponse,
1044) -> crate::Result<crate::github::issues::ApplyResult> {
1045 debug!("Applying labels and milestone to issue");
1046
1047 let client = create_client_from_provider(provider)?;
1049
1050 let result = crate::github::issues::update_issue_labels_and_milestone(
1052 &client,
1053 &issue_details.owner,
1054 &issue_details.repo,
1055 issue_details.number,
1056 &issue_details.labels,
1057 &triage.suggested_labels,
1058 issue_details.milestone.as_deref(),
1059 triage.suggested_milestone.as_deref(),
1060 &issue_details.available_labels,
1061 &issue_details.available_milestones,
1062 )
1063 .await
1064 .map_err(|e| AptuError::GitHub {
1065 message: e.to_string(),
1066 })?;
1067
1068 info!(
1069 labels = ?result.applied_labels,
1070 milestone = ?result.applied_milestone,
1071 warnings = ?result.warnings,
1072 "Labels and milestone applied"
1073 );
1074
1075 Ok(result)
1076}
1077
1078async fn get_from_ref_or_root(
1120 gh_client: &octocrab::Octocrab,
1121 owner: &str,
1122 repo: &str,
1123 to_ref: &str,
1124) -> Result<String, AptuError> {
1125 let previous_tag_opt =
1127 crate::github::releases::get_previous_tag(gh_client, owner, repo, to_ref)
1128 .await
1129 .map_err(|e| AptuError::GitHub {
1130 message: e.to_string(),
1131 })?;
1132
1133 if let Some((tag, _)) = previous_tag_opt {
1134 Ok(tag)
1135 } else {
1136 tracing::info!(
1138 "No previous tag found before {}, using root commit for first release",
1139 to_ref
1140 );
1141 crate::github::releases::get_root_commit(gh_client, owner, repo)
1142 .await
1143 .map_err(|e| AptuError::GitHub {
1144 message: e.to_string(),
1145 })
1146 }
1147}
1148
1149#[instrument(skip(provider))]
1170pub async fn generate_release_notes(
1171 provider: &dyn TokenProvider,
1172 owner: &str,
1173 repo: &str,
1174 from_tag: Option<&str>,
1175 to_tag: Option<&str>,
1176) -> Result<crate::ai::types::ReleaseNotesResponse, AptuError> {
1177 let token = provider.github_token().ok_or_else(|| AptuError::GitHub {
1178 message: "GitHub token not available".to_string(),
1179 })?;
1180
1181 let gh_client = create_client_with_token(&token).map_err(|e| AptuError::GitHub {
1182 message: e.to_string(),
1183 })?;
1184
1185 let config = load_config().map_err(|e| AptuError::Config {
1187 message: e.to_string(),
1188 })?;
1189
1190 let ai_client = AiClient::new(&config.ai.provider, &config.ai).map_err(|e| AptuError::AI {
1192 message: e.to_string(),
1193 status: None,
1194 provider: config.ai.provider.clone(),
1195 })?;
1196
1197 let (from_ref, to_ref) = if let (Some(from), Some(to)) = (from_tag, to_tag) {
1199 (from.to_string(), to.to_string())
1200 } else if let Some(to) = to_tag {
1201 let from_ref = get_from_ref_or_root(&gh_client, owner, repo, to).await?;
1203 (from_ref, to.to_string())
1204 } else if let Some(from) = from_tag {
1205 (from.to_string(), "HEAD".to_string())
1207 } else {
1208 let latest_tag_opt = crate::github::releases::get_latest_tag(&gh_client, owner, repo)
1211 .await
1212 .map_err(|e| AptuError::GitHub {
1213 message: e.to_string(),
1214 })?;
1215
1216 let to_ref = if let Some((tag, _)) = latest_tag_opt {
1217 tag
1218 } else {
1219 "HEAD".to_string()
1220 };
1221
1222 let from_ref = get_from_ref_or_root(&gh_client, owner, repo, &to_ref).await?;
1223 (from_ref, to_ref)
1224 };
1225
1226 let prs = crate::github::releases::fetch_prs_between_refs(
1228 &gh_client, owner, repo, &from_ref, &to_ref,
1229 )
1230 .await
1231 .map_err(|e| AptuError::GitHub {
1232 message: e.to_string(),
1233 })?;
1234
1235 if prs.is_empty() {
1236 return Err(AptuError::GitHub {
1237 message: "No merged PRs found between the specified tags".to_string(),
1238 });
1239 }
1240
1241 let version = crate::github::releases::parse_tag_reference(&to_ref);
1243 let (response, _ai_stats) = ai_client
1244 .generate_release_notes(prs, &version)
1245 .await
1246 .map_err(|e: anyhow::Error| AptuError::AI {
1247 message: e.to_string(),
1248 status: None,
1249 provider: config.ai.provider.clone(),
1250 })?;
1251
1252 info!(
1253 theme = ?response.theme,
1254 highlights_count = response.highlights.len(),
1255 contributors_count = response.contributors.len(),
1256 "Release notes generated"
1257 );
1258
1259 Ok(response)
1260}
1261
1262#[instrument(skip(provider))]
1285pub async fn post_release_notes(
1286 provider: &dyn TokenProvider,
1287 owner: &str,
1288 repo: &str,
1289 tag: &str,
1290 body: &str,
1291) -> Result<String, AptuError> {
1292 let token = provider.github_token().ok_or_else(|| AptuError::GitHub {
1293 message: "GitHub token not available".to_string(),
1294 })?;
1295
1296 let gh_client = create_client_with_token(&token).map_err(|e| AptuError::GitHub {
1297 message: e.to_string(),
1298 })?;
1299
1300 crate::github::releases::post_release_notes(&gh_client, owner, repo, tag, body)
1301 .await
1302 .map_err(|e| AptuError::GitHub {
1303 message: e.to_string(),
1304 })
1305}
1306
1307#[cfg(test)]
1308mod tests {
1309 use crate::config::{FallbackConfig, FallbackEntry};
1310
1311 #[test]
1312 fn test_fallback_chain_config_structure() {
1313 let fallback_config = FallbackConfig {
1315 chain: vec![
1316 FallbackEntry {
1317 provider: "openrouter".to_string(),
1318 model: None,
1319 },
1320 FallbackEntry {
1321 provider: "anthropic".to_string(),
1322 model: Some("claude-haiku-4.5".to_string()),
1323 },
1324 ],
1325 };
1326
1327 assert_eq!(fallback_config.chain.len(), 2);
1328 assert_eq!(fallback_config.chain[0].provider, "openrouter");
1329 assert_eq!(fallback_config.chain[0].model, None);
1330 assert_eq!(fallback_config.chain[1].provider, "anthropic");
1331 assert_eq!(
1332 fallback_config.chain[1].model,
1333 Some("claude-haiku-4.5".to_string())
1334 );
1335 }
1336
1337 #[test]
1338 fn test_fallback_chain_empty() {
1339 let fallback_config = FallbackConfig { chain: vec![] };
1341
1342 assert_eq!(fallback_config.chain.len(), 0);
1343 }
1344
1345 #[test]
1346 fn test_fallback_chain_single_provider() {
1347 let fallback_config = FallbackConfig {
1349 chain: vec![FallbackEntry {
1350 provider: "openrouter".to_string(),
1351 model: None,
1352 }],
1353 };
1354
1355 assert_eq!(fallback_config.chain.len(), 1);
1356 assert_eq!(fallback_config.chain[0].provider, "openrouter");
1357 }
1358}
1359
1360#[allow(clippy::items_after_test_module)]
1361#[instrument(skip(provider, ai_config), fields(repo = %repo))]
1388pub async fn format_issue(
1389 provider: &dyn TokenProvider,
1390 title: &str,
1391 body: &str,
1392 repo: &str,
1393 ai_config: &AiConfig,
1394) -> crate::Result<CreateIssueResponse> {
1395 let (provider_name, model_name) = ai_config.resolve_for_task(TaskType::Create);
1397
1398 try_with_fallback(provider, &provider_name, &model_name, ai_config, |client| {
1400 let title = title.to_string();
1401 let body = body.to_string();
1402 let repo = repo.to_string();
1403 async move {
1404 let (response, _stats) = client.create_issue(&title, &body, &repo).await?;
1405 Ok(response)
1406 }
1407 })
1408 .await
1409}
1410
1411#[instrument(skip(provider), fields(owner = %owner, repo = %repo))]
1435pub async fn post_issue(
1436 provider: &dyn TokenProvider,
1437 owner: &str,
1438 repo: &str,
1439 title: &str,
1440 body: &str,
1441) -> crate::Result<(String, u64)> {
1442 let client = create_client_from_provider(provider)?;
1444
1445 gh_create_issue(&client, owner, repo, title, body)
1447 .await
1448 .map_err(|e| AptuError::GitHub {
1449 message: e.to_string(),
1450 })
1451}
1452#[instrument(skip(provider), fields(provider_name))]
1474pub async fn list_models(
1475 provider: &dyn TokenProvider,
1476 provider_name: &str,
1477) -> crate::Result<Vec<crate::ai::registry::CachedModel>> {
1478 use crate::ai::registry::{CachedModelRegistry, ModelRegistry};
1479 use crate::cache::cache_dir;
1480
1481 let cache_dir = cache_dir();
1482 let registry =
1483 CachedModelRegistry::new(cache_dir, crate::cache::DEFAULT_MODEL_TTL_SECS, provider);
1484
1485 registry
1486 .list_models(provider_name)
1487 .await
1488 .map_err(|e| AptuError::ModelRegistry {
1489 message: format!("Failed to list models: {e}"),
1490 })
1491}
1492
1493#[instrument(skip(provider), fields(provider_name, model_id))]
1515pub async fn validate_model(
1516 provider: &dyn TokenProvider,
1517 provider_name: &str,
1518 model_id: &str,
1519) -> crate::Result<bool> {
1520 use crate::ai::registry::{CachedModelRegistry, ModelRegistry};
1521 use crate::cache::cache_dir;
1522
1523 let cache_dir = cache_dir();
1524 let registry =
1525 CachedModelRegistry::new(cache_dir, crate::cache::DEFAULT_MODEL_TTL_SECS, provider);
1526
1527 registry
1528 .model_exists(provider_name, model_id)
1529 .await
1530 .map_err(|e| AptuError::ModelRegistry {
1531 message: format!("Failed to validate model: {e}"),
1532 })
1533}