1use 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#[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 let client = create_client_from_provider(provider)?;
59
60 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 let config = load_config()?;
79 let ttl = Duration::minutes(config.cache.issue_ttl_minutes.try_into().unwrap_or(60));
80
81 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 repos_to_fetch.is_empty() {
100 return Ok(cached_results);
101 }
102
103 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 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 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
317async 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#[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 let mut issue_mut = issue.clone();
454
455 if issue_mut.available_labels.is_empty()
457 && !issue_mut.owner.is_empty()
458 && !issue_mut.repo.is_empty()
459 {
460 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 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 issue_mut.available_labels =
475 repo_data.labels.nodes.into_iter().map(Into::into).collect();
476 }
477 }
478 }
479 }
480
481 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 let (provider_name, model_name) = ai_config.resolve_for_task(TaskType::Triage);
489
490 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#[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 let (owner, repo, number) =
546 parse_pr_reference(reference, repo_context).map_err(|e| AptuError::GitHub {
547 message: e.to_string(),
548 })?;
549
550 let client = create_client_from_provider(provider)?;
552
553 fetch_pr_details(&client, &owner, &repo, number)
555 .await
556 .map_err(|e| AptuError::GitHub {
557 message: e.to_string(),
558 })
559}
560
561#[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 let (provider_name, model_name) = ai_config.resolve_for_task(TaskType::Review);
589
590 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#[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 let (owner, repo, number) =
634 parse_pr_reference(reference, repo_context).map_err(|e| AptuError::GitHub {
635 message: e.to_string(),
636 })?;
637
638 let client = create_client_from_provider(provider)?;
640
641 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#[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 let (owner, repo, number) =
684 parse_pr_reference(reference, repo_context).map_err(|e| AptuError::GitHub {
685 message: e.to_string(),
686 })?;
687
688 let client = create_client_from_provider(provider)?;
690
691 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 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 labels.is_empty() {
708 let (provider_name, model_name) = ai_config.resolve_for_task(TaskType::Create);
710
711 if let Some(api_key) = provider.ai_api_key(&provider_name) {
713 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 }
729 }
730 }
731 }
732 }
733
734 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#[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 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 let client = create_client_from_provider(provider)?;
784
785 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 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 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 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 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 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 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#[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 let client = create_client_from_provider(provider)?;
929
930 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#[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 let client = create_client_from_provider(provider)?;
979
980 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
1008async 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 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 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#[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 let config = load_config().map_err(|e| AptuError::Config {
1117 message: e.to_string(),
1118 })?;
1119
1120 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 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 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 (from.to_string(), "HEAD".to_string())
1137 } else {
1138 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 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 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#[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 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 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 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#[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 let (provider_name, model_name) = ai_config.resolve_for_task(TaskType::Create);
1327
1328 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#[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 let client = create_client_from_provider(provider)?;
1374
1375 gh_create_issue(&client, owner, repo, title, body)
1377 .await
1378 .map_err(|e| AptuError::GitHub {
1379 message: e.to_string(),
1380 })
1381}
1382#[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); 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#[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); 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}