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
315async fn try_with_fallback<T, F, Fut>(
317 provider: &dyn TokenProvider,
318 primary_provider: &str,
319 model_name: &str,
320 ai_config: &AiConfig,
321 operation: F,
322) -> crate::Result<T>
323where
324 F: Fn(AiClient) -> Fut,
325 Fut: std::future::Future<Output = anyhow::Result<T>>,
326{
327 let api_key = provider.ai_api_key(primary_provider).ok_or_else(|| {
328 let env_var = get_provider(primary_provider).map_or("API_KEY", |p| p.api_key_env);
329 AptuError::AiProviderNotAuthenticated {
330 provider: primary_provider.to_string(),
331 env_var: env_var.to_string(),
332 }
333 })?;
334
335 if ai_config.validation_enabled {
336 validate_provider_model(primary_provider, model_name)?;
337 }
338
339 let ai_client = AiClient::with_api_key(primary_provider, api_key, model_name, ai_config)
340 .map_err(|e| AptuError::AI {
341 message: e.to_string(),
342 status: None,
343 provider: primary_provider.to_string(),
344 })?;
345
346 match operation(ai_client).await {
347 Ok(response) => return Ok(response),
348 Err(e) => {
349 if is_retryable_anyhow(&e) {
350 return Err(AptuError::AI {
351 message: e.to_string(),
352 status: None,
353 provider: primary_provider.to_string(),
354 });
355 }
356 warn!(
357 primary_provider = primary_provider,
358 error = %e,
359 "Primary provider failed with non-retryable error, trying fallback chain"
360 );
361 }
362 }
363
364 if let Some(fallback_config) = &ai_config.fallback {
365 for entry in &fallback_config.chain {
366 warn!(
367 fallback_provider = entry.provider,
368 "Attempting fallback provider"
369 );
370
371 let Some(api_key) = provider.ai_api_key(&entry.provider) else {
372 warn!(
373 fallback_provider = entry.provider,
374 "No API key available for fallback provider"
375 );
376 continue;
377 };
378
379 let fallback_model = entry.model.as_deref().unwrap_or(model_name);
380
381 if ai_config.validation_enabled
382 && validate_provider_model(&entry.provider, fallback_model).is_err()
383 {
384 warn!(
385 fallback_provider = entry.provider,
386 fallback_model = fallback_model,
387 "Fallback provider model validation failed, continuing to next provider"
388 );
389 continue;
390 }
391
392 let Ok(ai_client) =
393 AiClient::with_api_key(&entry.provider, api_key, fallback_model, ai_config)
394 else {
395 warn!(
396 fallback_provider = entry.provider,
397 "Failed to create AI client for fallback provider"
398 );
399 continue;
400 };
401
402 match operation(ai_client).await {
403 Ok(response) => {
404 info!(
405 fallback_provider = entry.provider,
406 "Successfully completed operation with fallback provider"
407 );
408 return Ok(response);
409 }
410 Err(e) => {
411 warn!(
412 fallback_provider = entry.provider,
413 error = %e,
414 "Fallback provider failed"
415 );
416 }
417 }
418 }
419 }
420
421 Err(AptuError::AI {
422 message: "All AI providers failed (primary and fallback chain)".to_string(),
423 status: None,
424 provider: primary_provider.to_string(),
425 })
426}
427
428#[instrument(skip(provider, issue), fields(issue_number = issue.number, repo = %format!("{}/{}", issue.owner, issue.repo)))]
449pub async fn analyze_issue(
450 provider: &dyn TokenProvider,
451 issue: &IssueDetails,
452 ai_config: &AiConfig,
453) -> crate::Result<AiResponse> {
454 let mut issue_mut = issue.clone();
456
457 if issue_mut.available_labels.is_empty()
459 && !issue_mut.owner.is_empty()
460 && !issue_mut.repo.is_empty()
461 {
462 if let Some(github_token) = provider.github_token() {
464 let token = SecretString::from(github_token);
465 if let Ok(client) = create_client_with_token(&token) {
466 if let Ok((_, repo_data)) = fetch_issue_with_repo_context(
468 &client,
469 &issue_mut.owner,
470 &issue_mut.repo,
471 issue_mut.number,
472 )
473 .await
474 {
475 issue_mut.available_labels =
477 repo_data.labels.nodes.into_iter().map(Into::into).collect();
478 }
479 }
480 }
481 }
482
483 if !issue_mut.available_labels.is_empty() {
485 issue_mut.available_labels =
486 filter_labels_by_relevance(&issue_mut.available_labels, MAX_LABELS);
487 }
488
489 let (provider_name, model_name) = ai_config.resolve_for_task(TaskType::Triage);
491
492 try_with_fallback(provider, &provider_name, &model_name, ai_config, |client| {
494 let issue = issue_mut.clone();
495 async move { client.analyze_issue(&issue).await }
496 })
497 .await
498}
499
500#[instrument(skip(provider), fields(reference = %reference))]
539pub async fn fetch_pr_for_review(
540 provider: &dyn TokenProvider,
541 reference: &str,
542 repo_context: Option<&str>,
543) -> crate::Result<PrDetails> {
544 use crate::github::pulls::parse_pr_reference;
545
546 let (owner, repo, number) =
548 parse_pr_reference(reference, repo_context).map_err(|e| AptuError::GitHub {
549 message: e.to_string(),
550 })?;
551
552 let client = create_client_from_provider(provider)?;
554
555 fetch_pr_details(&client, &owner, &repo, number)
557 .await
558 .map_err(|e| AptuError::GitHub {
559 message: e.to_string(),
560 })
561}
562
563#[instrument(skip(provider, pr_details), fields(number = pr_details.number))]
584pub async fn analyze_pr(
585 provider: &dyn TokenProvider,
586 pr_details: &PrDetails,
587 ai_config: &AiConfig,
588) -> crate::Result<(crate::ai::types::PrReviewResponse, crate::history::AiStats)> {
589 let (provider_name, model_name) = ai_config.resolve_for_task(TaskType::Review);
591
592 try_with_fallback(provider, &provider_name, &model_name, ai_config, |client| {
594 let pr = pr_details.clone();
595 async move { client.review_pr(&pr).await }
596 })
597 .await
598}
599
600#[instrument(skip(provider), fields(reference = %reference, event = %event))]
625pub async fn post_pr_review(
626 provider: &dyn TokenProvider,
627 reference: &str,
628 repo_context: Option<&str>,
629 body: &str,
630 event: ReviewEvent,
631) -> crate::Result<u64> {
632 use crate::github::pulls::parse_pr_reference;
633
634 let (owner, repo, number) =
636 parse_pr_reference(reference, repo_context).map_err(|e| AptuError::GitHub {
637 message: e.to_string(),
638 })?;
639
640 let client = create_client_from_provider(provider)?;
642
643 gh_post_pr_review(&client, &owner, &repo, number, body, event)
645 .await
646 .map_err(|e| AptuError::GitHub {
647 message: e.to_string(),
648 })
649}
650
651#[instrument(skip(provider), fields(reference = %reference))]
674pub async fn label_pr(
675 provider: &dyn TokenProvider,
676 reference: &str,
677 repo_context: Option<&str>,
678 dry_run: bool,
679 ai_config: &AiConfig,
680) -> crate::Result<(u64, String, String, Vec<String>)> {
681 use crate::github::issues::apply_labels_to_number;
682 use crate::github::pulls::{fetch_pr_details, labels_from_pr_metadata, parse_pr_reference};
683
684 let (owner, repo, number) =
686 parse_pr_reference(reference, repo_context).map_err(|e| AptuError::GitHub {
687 message: e.to_string(),
688 })?;
689
690 let client = create_client_from_provider(provider)?;
692
693 let pr_details = fetch_pr_details(&client, &owner, &repo, number)
695 .await
696 .map_err(|e| AptuError::GitHub {
697 message: e.to_string(),
698 })?;
699
700 let file_paths: Vec<String> = pr_details
702 .files
703 .iter()
704 .map(|f| f.filename.clone())
705 .collect();
706 let mut labels = labels_from_pr_metadata(&pr_details.title, &file_paths);
707
708 if labels.is_empty() {
710 let (provider_name, model_name) = ai_config.resolve_for_task(TaskType::Create);
712
713 if let Some(api_key) = provider.ai_api_key(&provider_name) {
715 if let Ok(ai_client) =
717 crate::ai::AiClient::with_api_key(&provider_name, api_key, &model_name, ai_config)
718 {
719 match ai_client
720 .suggest_pr_labels(&pr_details.title, &pr_details.body, &file_paths)
721 .await
722 {
723 Ok((ai_labels, _stats)) => {
724 labels = ai_labels;
725 debug!("AI fallback provided {} labels", labels.len());
726 }
727 Err(e) => {
728 debug!("AI fallback failed: {}", e);
729 }
731 }
732 }
733 }
734 }
735
736 if !dry_run && !labels.is_empty() {
738 apply_labels_to_number(&client, &owner, &repo, number, &labels)
739 .await
740 .map_err(|e| AptuError::GitHub {
741 message: e.to_string(),
742 })?;
743 }
744
745 Ok((number, pr_details.title, pr_details.url, labels))
746}
747
748#[allow(clippy::too_many_lines)]
770#[instrument(skip(provider), fields(reference = %reference))]
771pub async fn fetch_issue_for_triage(
772 provider: &dyn TokenProvider,
773 reference: &str,
774 repo_context: Option<&str>,
775) -> crate::Result<IssueDetails> {
776 let (owner, repo, number) =
778 crate::github::issues::parse_issue_reference(reference, repo_context).map_err(|e| {
779 AptuError::GitHub {
780 message: e.to_string(),
781 }
782 })?;
783
784 let client = create_client_from_provider(provider)?;
786
787 let (issue_node, repo_data) = fetch_issue_with_repo_context(&client, &owner, &repo, number)
789 .await
790 .map_err(|e| AptuError::GitHub {
791 message: e.to_string(),
792 })?;
793
794 let labels: Vec<String> = issue_node
796 .labels
797 .nodes
798 .iter()
799 .map(|label| label.name.clone())
800 .collect();
801
802 let comments: Vec<crate::ai::types::IssueComment> = issue_node
803 .comments
804 .nodes
805 .iter()
806 .map(|comment| crate::ai::types::IssueComment {
807 author: comment.author.login.clone(),
808 body: comment.body.clone(),
809 })
810 .collect();
811
812 let available_labels: Vec<crate::ai::types::RepoLabel> = repo_data
813 .labels
814 .nodes
815 .iter()
816 .map(|label| crate::ai::types::RepoLabel {
817 name: label.name.clone(),
818 description: String::new(),
819 color: String::new(),
820 })
821 .collect();
822
823 let available_milestones: Vec<crate::ai::types::RepoMilestone> = repo_data
824 .milestones
825 .nodes
826 .iter()
827 .map(|milestone| crate::ai::types::RepoMilestone {
828 number: milestone.number,
829 title: milestone.title.clone(),
830 description: String::new(),
831 })
832 .collect();
833
834 let mut issue_details = IssueDetails::builder()
835 .owner(owner.clone())
836 .repo(repo.clone())
837 .number(number)
838 .title(issue_node.title.clone())
839 .body(issue_node.body.clone().unwrap_or_default())
840 .labels(labels)
841 .comments(comments)
842 .url(issue_node.url.clone())
843 .available_labels(available_labels)
844 .available_milestones(available_milestones)
845 .build();
846
847 issue_details.author = issue_node.author.as_ref().map(|a| a.login.clone());
849 issue_details.created_at = Some(issue_node.created_at.clone());
850 issue_details.updated_at = Some(issue_node.updated_at.clone());
851
852 let keywords = crate::github::issues::extract_keywords(&issue_details.title);
854 let language = repo_data
855 .primary_language
856 .as_ref()
857 .map_or("unknown", |l| l.name.as_str())
858 .to_string();
859
860 let (search_result, tree_result) = tokio::join!(
862 crate::github::issues::search_related_issues(
863 &client,
864 &owner,
865 &repo,
866 &issue_details.title,
867 number
868 ),
869 crate::github::issues::fetch_repo_tree(&client, &owner, &repo, &language, &keywords)
870 );
871
872 match search_result {
874 Ok(related) => {
875 issue_details.repo_context = related;
876 debug!(
877 related_count = issue_details.repo_context.len(),
878 "Found related issues"
879 );
880 }
881 Err(e) => {
882 debug!(error = %e, "Failed to search for related issues, continuing without context");
883 }
884 }
885
886 match tree_result {
888 Ok(tree) => {
889 issue_details.repo_tree = tree;
890 debug!(
891 tree_count = issue_details.repo_tree.len(),
892 "Fetched repository tree"
893 );
894 }
895 Err(e) => {
896 debug!(error = %e, "Failed to fetch repository tree, continuing without context");
897 }
898 }
899
900 debug!(issue_number = number, "Issue fetched successfully");
901 Ok(issue_details)
902}
903
904#[instrument(skip(provider, triage), fields(owner = %issue_details.owner, repo = %issue_details.repo, number = issue_details.number))]
924pub async fn post_triage_comment(
925 provider: &dyn TokenProvider,
926 issue_details: &IssueDetails,
927 triage: &TriageResponse,
928) -> crate::Result<String> {
929 let client = create_client_from_provider(provider)?;
931
932 let comment_body = crate::triage::render_triage_markdown(triage);
934 let comment_url = crate::github::issues::post_comment(
935 &client,
936 &issue_details.owner,
937 &issue_details.repo,
938 issue_details.number,
939 &comment_body,
940 )
941 .await
942 .map_err(|e| AptuError::GitHub {
943 message: e.to_string(),
944 })?;
945
946 debug!(comment_url = %comment_url, "Triage comment posted");
947 Ok(comment_url)
948}
949
950#[instrument(skip(provider, triage), fields(owner = %issue_details.owner, repo = %issue_details.repo, number = issue_details.number))]
972pub async fn apply_triage_labels(
973 provider: &dyn TokenProvider,
974 issue_details: &IssueDetails,
975 triage: &TriageResponse,
976) -> crate::Result<crate::github::issues::ApplyResult> {
977 debug!("Applying labels and milestone to issue");
978
979 let client = create_client_from_provider(provider)?;
981
982 let result = crate::github::issues::update_issue_labels_and_milestone(
984 &client,
985 &issue_details.owner,
986 &issue_details.repo,
987 issue_details.number,
988 &issue_details.labels,
989 &triage.suggested_labels,
990 issue_details.milestone.as_deref(),
991 triage.suggested_milestone.as_deref(),
992 &issue_details.available_labels,
993 &issue_details.available_milestones,
994 )
995 .await
996 .map_err(|e| AptuError::GitHub {
997 message: e.to_string(),
998 })?;
999
1000 info!(
1001 labels = ?result.applied_labels,
1002 milestone = ?result.applied_milestone,
1003 warnings = ?result.warnings,
1004 "Labels and milestone applied"
1005 );
1006
1007 Ok(result)
1008}
1009
1010async fn get_from_ref_or_root(
1052 gh_client: &octocrab::Octocrab,
1053 owner: &str,
1054 repo: &str,
1055 to_ref: &str,
1056) -> Result<String, AptuError> {
1057 let previous_tag_opt =
1059 crate::github::releases::get_previous_tag(gh_client, owner, repo, to_ref)
1060 .await
1061 .map_err(|e| AptuError::GitHub {
1062 message: e.to_string(),
1063 })?;
1064
1065 if let Some((tag, _)) = previous_tag_opt {
1066 Ok(tag)
1067 } else {
1068 tracing::info!(
1070 "No previous tag found before {}, using root commit for first release",
1071 to_ref
1072 );
1073 crate::github::releases::get_root_commit(gh_client, owner, repo)
1074 .await
1075 .map_err(|e| AptuError::GitHub {
1076 message: e.to_string(),
1077 })
1078 }
1079}
1080
1081#[instrument(skip(provider))]
1102pub async fn generate_release_notes(
1103 provider: &dyn TokenProvider,
1104 owner: &str,
1105 repo: &str,
1106 from_tag: Option<&str>,
1107 to_tag: Option<&str>,
1108) -> Result<crate::ai::types::ReleaseNotesResponse, AptuError> {
1109 let token = provider.github_token().ok_or_else(|| AptuError::GitHub {
1110 message: "GitHub token not available".to_string(),
1111 })?;
1112
1113 let gh_client = create_client_with_token(&token).map_err(|e| AptuError::GitHub {
1114 message: e.to_string(),
1115 })?;
1116
1117 let config = load_config().map_err(|e| AptuError::Config {
1119 message: e.to_string(),
1120 })?;
1121
1122 let ai_client = AiClient::new(&config.ai.provider, &config.ai).map_err(|e| AptuError::AI {
1124 message: e.to_string(),
1125 status: None,
1126 provider: config.ai.provider.clone(),
1127 })?;
1128
1129 let (from_ref, to_ref) = if let (Some(from), Some(to)) = (from_tag, to_tag) {
1131 (from.to_string(), to.to_string())
1132 } else if let Some(to) = to_tag {
1133 let from_ref = get_from_ref_or_root(&gh_client, owner, repo, to).await?;
1135 (from_ref, to.to_string())
1136 } else if let Some(from) = from_tag {
1137 (from.to_string(), "HEAD".to_string())
1139 } else {
1140 let latest_tag_opt = crate::github::releases::get_latest_tag(&gh_client, owner, repo)
1143 .await
1144 .map_err(|e| AptuError::GitHub {
1145 message: e.to_string(),
1146 })?;
1147
1148 let to_ref = if let Some((tag, _)) = latest_tag_opt {
1149 tag
1150 } else {
1151 "HEAD".to_string()
1152 };
1153
1154 let from_ref = get_from_ref_or_root(&gh_client, owner, repo, &to_ref).await?;
1155 (from_ref, to_ref)
1156 };
1157
1158 let prs = crate::github::releases::fetch_prs_between_refs(
1160 &gh_client, owner, repo, &from_ref, &to_ref,
1161 )
1162 .await
1163 .map_err(|e| AptuError::GitHub {
1164 message: e.to_string(),
1165 })?;
1166
1167 if prs.is_empty() {
1168 return Err(AptuError::GitHub {
1169 message: "No merged PRs found between the specified tags".to_string(),
1170 });
1171 }
1172
1173 let version = crate::github::releases::parse_tag_reference(&to_ref);
1175 let (response, _ai_stats) = ai_client
1176 .generate_release_notes(prs, &version)
1177 .await
1178 .map_err(|e: anyhow::Error| AptuError::AI {
1179 message: e.to_string(),
1180 status: None,
1181 provider: config.ai.provider.clone(),
1182 })?;
1183
1184 info!(
1185 theme = ?response.theme,
1186 highlights_count = response.highlights.len(),
1187 contributors_count = response.contributors.len(),
1188 "Release notes generated"
1189 );
1190
1191 Ok(response)
1192}
1193
1194#[instrument(skip(provider))]
1217pub async fn post_release_notes(
1218 provider: &dyn TokenProvider,
1219 owner: &str,
1220 repo: &str,
1221 tag: &str,
1222 body: &str,
1223) -> Result<String, AptuError> {
1224 let token = provider.github_token().ok_or_else(|| AptuError::GitHub {
1225 message: "GitHub token not available".to_string(),
1226 })?;
1227
1228 let gh_client = create_client_with_token(&token).map_err(|e| AptuError::GitHub {
1229 message: e.to_string(),
1230 })?;
1231
1232 crate::github::releases::post_release_notes(&gh_client, owner, repo, tag, body)
1233 .await
1234 .map_err(|e| AptuError::GitHub {
1235 message: e.to_string(),
1236 })
1237}
1238
1239#[cfg(test)]
1240mod tests {
1241 use crate::config::{FallbackConfig, FallbackEntry};
1242
1243 #[test]
1244 fn test_fallback_chain_config_structure() {
1245 let fallback_config = FallbackConfig {
1247 chain: vec![
1248 FallbackEntry {
1249 provider: "openrouter".to_string(),
1250 model: None,
1251 },
1252 FallbackEntry {
1253 provider: "anthropic".to_string(),
1254 model: Some("claude-haiku-4.5".to_string()),
1255 },
1256 ],
1257 };
1258
1259 assert_eq!(fallback_config.chain.len(), 2);
1260 assert_eq!(fallback_config.chain[0].provider, "openrouter");
1261 assert_eq!(fallback_config.chain[0].model, None);
1262 assert_eq!(fallback_config.chain[1].provider, "anthropic");
1263 assert_eq!(
1264 fallback_config.chain[1].model,
1265 Some("claude-haiku-4.5".to_string())
1266 );
1267 }
1268
1269 #[test]
1270 fn test_fallback_chain_empty() {
1271 let fallback_config = FallbackConfig { chain: vec![] };
1273
1274 assert_eq!(fallback_config.chain.len(), 0);
1275 }
1276
1277 #[test]
1278 fn test_fallback_chain_single_provider() {
1279 let fallback_config = FallbackConfig {
1281 chain: vec![FallbackEntry {
1282 provider: "openrouter".to_string(),
1283 model: None,
1284 }],
1285 };
1286
1287 assert_eq!(fallback_config.chain.len(), 1);
1288 assert_eq!(fallback_config.chain[0].provider, "openrouter");
1289 }
1290}
1291
1292#[allow(clippy::items_after_test_module)]
1293#[instrument(skip(provider, ai_config), fields(repo = %repo))]
1320pub async fn format_issue(
1321 provider: &dyn TokenProvider,
1322 title: &str,
1323 body: &str,
1324 repo: &str,
1325 ai_config: &AiConfig,
1326) -> crate::Result<CreateIssueResponse> {
1327 let (provider_name, model_name) = ai_config.resolve_for_task(TaskType::Create);
1329
1330 try_with_fallback(provider, &provider_name, &model_name, ai_config, |client| {
1332 let title = title.to_string();
1333 let body = body.to_string();
1334 let repo = repo.to_string();
1335 async move {
1336 let (response, _stats) = client.create_issue(&title, &body, &repo).await?;
1337 Ok(response)
1338 }
1339 })
1340 .await
1341}
1342
1343#[instrument(skip(provider), fields(owner = %owner, repo = %repo))]
1367pub async fn post_issue(
1368 provider: &dyn TokenProvider,
1369 owner: &str,
1370 repo: &str,
1371 title: &str,
1372 body: &str,
1373) -> crate::Result<(String, u64)> {
1374 let client = create_client_from_provider(provider)?;
1376
1377 gh_create_issue(&client, owner, repo, title, body)
1379 .await
1380 .map_err(|e| AptuError::GitHub {
1381 message: e.to_string(),
1382 })
1383}
1384#[instrument(skip(provider), fields(provider_name))]
1406pub async fn list_models(
1407 provider: &dyn TokenProvider,
1408 provider_name: &str,
1409) -> crate::Result<Vec<crate::ai::registry::CachedModel>> {
1410 use crate::ai::registry::{CachedModelRegistry, ModelRegistry};
1411 use crate::cache::cache_dir;
1412
1413 let cache_dir = cache_dir();
1414 let registry =
1415 CachedModelRegistry::new(cache_dir, crate::cache::DEFAULT_MODEL_TTL_SECS, provider);
1416
1417 registry
1418 .list_models(provider_name)
1419 .await
1420 .map_err(|e| AptuError::ModelRegistry {
1421 message: format!("Failed to list models: {e}"),
1422 })
1423}
1424
1425#[instrument(skip(provider), fields(provider_name, model_id))]
1447pub async fn validate_model(
1448 provider: &dyn TokenProvider,
1449 provider_name: &str,
1450 model_id: &str,
1451) -> crate::Result<bool> {
1452 use crate::ai::registry::{CachedModelRegistry, ModelRegistry};
1453 use crate::cache::cache_dir;
1454
1455 let cache_dir = cache_dir();
1456 let registry =
1457 CachedModelRegistry::new(cache_dir, crate::cache::DEFAULT_MODEL_TTL_SECS, provider);
1458
1459 registry
1460 .model_exists(provider_name, model_id)
1461 .await
1462 .map_err(|e| AptuError::ModelRegistry {
1463 message: format!("Failed to validate model: {e}"),
1464 })
1465}