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 crate::security::SecurityScanner;
32use secrecy::SecretString;
33
34#[instrument(skip(provider), fields(repo_filter = ?repo_filter, use_cache))]
56pub async fn fetch_issues(
57 provider: &dyn TokenProvider,
58 repo_filter: Option<&str>,
59 use_cache: bool,
60) -> crate::Result<Vec<(String, Vec<IssueNode>)>> {
61 let client = create_client_from_provider(provider)?;
63
64 let all_repos = repos::fetch().await?;
66 let repos_to_query: Vec<_> = match repo_filter {
67 Some(filter) => {
68 let filter_lower = filter.to_lowercase();
69 all_repos
70 .iter()
71 .filter(|r| {
72 r.full_name().to_lowercase().contains(&filter_lower)
73 || r.name.to_lowercase().contains(&filter_lower)
74 })
75 .cloned()
76 .collect()
77 }
78 None => all_repos,
79 };
80
81 let config = load_config()?;
83 let ttl = Duration::minutes(config.cache.issue_ttl_minutes);
84
85 if use_cache {
87 let cache: FileCacheImpl<Vec<IssueNode>> = FileCacheImpl::new("issues", ttl);
88 let mut cached_results = Vec::new();
89 let mut repos_to_fetch = Vec::new();
90
91 for repo in &repos_to_query {
92 let cache_key = format!("{}_{}", repo.owner, repo.name);
93 if let Ok(Some(issues)) = cache.get(&cache_key) {
94 cached_results.push((repo.full_name(), issues));
95 } else {
96 repos_to_fetch.push(repo.clone());
97 }
98 }
99
100 if repos_to_fetch.is_empty() {
102 return Ok(cached_results);
103 }
104
105 let repo_tuples: Vec<_> = repos_to_fetch
107 .iter()
108 .map(|r| (r.owner.as_str(), r.name.as_str()))
109 .collect();
110 let api_results =
111 gh_fetch_issues(&client, &repo_tuples)
112 .await
113 .map_err(|e| AptuError::GitHub {
114 message: format!("Failed to fetch issues: {e}"),
115 })?;
116
117 for (repo_name, issues) in &api_results {
119 if let Some(repo) = repos_to_fetch.iter().find(|r| r.full_name() == *repo_name) {
120 let cache_key = format!("{}_{}", repo.owner, repo.name);
121 let _ = cache.set(&cache_key, issues);
122 }
123 }
124
125 cached_results.extend(api_results);
127 Ok(cached_results)
128 } else {
129 let repo_tuples: Vec<_> = repos_to_query
131 .iter()
132 .map(|r| (r.owner.as_str(), r.name.as_str()))
133 .collect();
134 gh_fetch_issues(&client, &repo_tuples)
135 .await
136 .map_err(|e| AptuError::GitHub {
137 message: format!("Failed to fetch issues: {e}"),
138 })
139 }
140}
141
142pub async fn list_curated_repos() -> crate::Result<Vec<CuratedRepo>> {
155 repos::fetch().await
156}
157
158#[instrument]
177pub async fn add_custom_repo(owner: &str, name: &str) -> crate::Result<CuratedRepo> {
178 let repo = repos::custom::validate_and_fetch_metadata(owner, name).await?;
180
181 let mut custom_repos = repos::custom::read_custom_repos()?;
183
184 if custom_repos
186 .iter()
187 .any(|r| r.full_name() == repo.full_name())
188 {
189 return Err(crate::error::AptuError::Config {
190 message: format!(
191 "Repository {} already exists in custom repos",
192 repo.full_name()
193 ),
194 });
195 }
196
197 custom_repos.push(repo.clone());
199
200 repos::custom::write_custom_repos(&custom_repos)?;
202
203 Ok(repo)
204}
205
206#[instrument]
221pub fn remove_custom_repo(owner: &str, name: &str) -> crate::Result<bool> {
222 let full_name = format!("{owner}/{name}");
223
224 let mut custom_repos = repos::custom::read_custom_repos()?;
226
227 let initial_len = custom_repos.len();
229 custom_repos.retain(|r| r.full_name() != full_name);
230
231 if custom_repos.len() == initial_len {
232 return Ok(false); }
234
235 repos::custom::write_custom_repos(&custom_repos)?;
237
238 Ok(true)
239}
240
241#[instrument]
255pub async fn list_repos(filter: repos::RepoFilter) -> crate::Result<Vec<CuratedRepo>> {
256 repos::fetch_all(filter).await
257}
258
259#[instrument(skip(provider), fields(language = ?filter.language, min_stars = filter.min_stars, limit = filter.limit))]
279pub async fn discover_repos(
280 provider: &dyn TokenProvider,
281 filter: repos::discovery::DiscoveryFilter,
282) -> crate::Result<Vec<repos::discovery::DiscoveredRepo>> {
283 let token = provider.github_token().ok_or(AptuError::NotAuthenticated)?;
284 let token = SecretString::from(token);
285 repos::discovery::search_repositories(&token, &filter).await
286}
287
288fn validate_provider_model(provider: &str, model: &str) -> crate::Result<()> {
305 if crate::ai::registry::get_provider(provider).is_none() {
307 return Err(AptuError::ModelRegistry {
308 message: format!("Provider not found: {provider}"),
309 });
310 }
311
312 tracing::debug!(provider = provider, model = model, "Validating model");
315 Ok(())
316}
317
318fn try_setup_primary_client(
321 provider: &dyn TokenProvider,
322 primary_provider: &str,
323 model_name: &str,
324 ai_config: &AiConfig,
325) -> crate::Result<AiClient> {
326 let api_key = provider.ai_api_key(primary_provider).ok_or_else(|| {
327 let env_var = get_provider(primary_provider).map_or("API_KEY", |p| p.api_key_env);
328 AptuError::AiProviderNotAuthenticated {
329 provider: primary_provider.to_string(),
330 env_var: env_var.to_string(),
331 }
332 })?;
333
334 if ai_config.validation_enabled {
335 validate_provider_model(primary_provider, model_name)?;
336 }
337
338 AiClient::with_api_key(primary_provider, api_key, model_name, ai_config).map_err(|e| {
339 AptuError::AI {
340 message: e.to_string(),
341 status: None,
342 provider: primary_provider.to_string(),
343 }
344 })
345}
346
347fn setup_fallback_client(
351 provider: &dyn TokenProvider,
352 entry: &crate::config::FallbackEntry,
353 model_name: &str,
354 ai_config: &AiConfig,
355) -> Option<AiClient> {
356 let Some(api_key) = provider.ai_api_key(&entry.provider) else {
357 warn!(
358 fallback_provider = entry.provider,
359 "No API key available for fallback provider"
360 );
361 return None;
362 };
363
364 let fallback_model = entry.model.as_deref().unwrap_or(model_name);
365
366 if ai_config.validation_enabled
367 && validate_provider_model(&entry.provider, fallback_model).is_err()
368 {
369 warn!(
370 fallback_provider = entry.provider,
371 fallback_model = fallback_model,
372 "Fallback provider model validation failed, continuing to next provider"
373 );
374 return None;
375 }
376
377 if let Ok(client) = AiClient::with_api_key(&entry.provider, api_key, fallback_model, ai_config)
378 {
379 Some(client)
380 } else {
381 warn!(
382 fallback_provider = entry.provider,
383 "Failed to create AI client for fallback provider"
384 );
385 None
386 }
387}
388
389async fn try_fallback_entry<T, F, Fut>(
391 provider: &dyn TokenProvider,
392 entry: &crate::config::FallbackEntry,
393 model_name: &str,
394 ai_config: &AiConfig,
395 operation: &F,
396) -> crate::Result<Option<T>>
397where
398 F: Fn(AiClient) -> Fut,
399 Fut: std::future::Future<Output = anyhow::Result<T>>,
400{
401 warn!(
402 fallback_provider = entry.provider,
403 "Attempting fallback provider"
404 );
405
406 let Some(ai_client) = setup_fallback_client(provider, entry, model_name, ai_config) else {
407 return Ok(None);
408 };
409
410 match operation(ai_client).await {
411 Ok(response) => {
412 info!(
413 fallback_provider = entry.provider,
414 "Successfully completed operation with fallback provider"
415 );
416 Ok(Some(response))
417 }
418 Err(e) => {
419 if is_retryable_anyhow(&e) {
420 return Err(AptuError::AI {
421 message: e.to_string(),
422 status: None,
423 provider: entry.provider.clone(),
424 });
425 }
426 warn!(
427 fallback_provider = entry.provider,
428 error = %e,
429 "Fallback provider failed with non-retryable error"
430 );
431 Ok(None)
432 }
433 }
434}
435
436async fn execute_fallback_chain<T, F, Fut>(
438 provider: &dyn TokenProvider,
439 primary_provider: &str,
440 model_name: &str,
441 ai_config: &AiConfig,
442 operation: F,
443) -> crate::Result<T>
444where
445 F: Fn(AiClient) -> Fut,
446 Fut: std::future::Future<Output = anyhow::Result<T>>,
447{
448 if let Some(fallback_config) = &ai_config.fallback {
449 for entry in &fallback_config.chain {
450 if let Some(response) =
451 try_fallback_entry(provider, entry, model_name, ai_config, &operation).await?
452 {
453 return Ok(response);
454 }
455 }
456 }
457
458 Err(AptuError::AI {
459 message: "All AI providers failed (primary and fallback chain)".to_string(),
460 status: None,
461 provider: primary_provider.to_string(),
462 })
463}
464
465async fn try_with_fallback<T, F, Fut>(
466 provider: &dyn TokenProvider,
467 primary_provider: &str,
468 model_name: &str,
469 ai_config: &AiConfig,
470 operation: F,
471) -> crate::Result<T>
472where
473 F: Fn(AiClient) -> Fut,
474 Fut: std::future::Future<Output = anyhow::Result<T>>,
475{
476 let ai_client = try_setup_primary_client(provider, primary_provider, model_name, ai_config)?;
477
478 match operation(ai_client).await {
479 Ok(response) => return Ok(response),
480 Err(e) => {
481 if is_retryable_anyhow(&e) {
482 return Err(AptuError::AI {
483 message: e.to_string(),
484 status: None,
485 provider: primary_provider.to_string(),
486 });
487 }
488 warn!(
489 primary_provider = primary_provider,
490 error = %e,
491 "Primary provider failed with non-retryable error, trying fallback chain"
492 );
493 }
494 }
495
496 execute_fallback_chain(provider, primary_provider, model_name, ai_config, operation).await
497}
498
499#[instrument(skip(provider, issue), fields(issue_number = issue.number, repo = %format!("{}/{}", issue.owner, issue.repo)))]
520pub async fn analyze_issue(
521 provider: &dyn TokenProvider,
522 issue: &IssueDetails,
523 ai_config: &AiConfig,
524) -> crate::Result<AiResponse> {
525 let mut issue_mut = issue.clone();
527
528 if issue_mut.available_labels.is_empty()
530 && !issue_mut.owner.is_empty()
531 && !issue_mut.repo.is_empty()
532 {
533 if let Some(github_token) = provider.github_token() {
535 let token = SecretString::from(github_token);
536 if let Ok(client) = create_client_with_token(&token) {
537 if let Ok((_, repo_data)) = fetch_issue_with_repo_context(
539 &client,
540 &issue_mut.owner,
541 &issue_mut.repo,
542 issue_mut.number,
543 )
544 .await
545 {
546 issue_mut.available_labels =
548 repo_data.labels.nodes.into_iter().map(Into::into).collect();
549 }
550 }
551 }
552 }
553
554 if !issue_mut.available_labels.is_empty() {
556 issue_mut.available_labels =
557 filter_labels_by_relevance(&issue_mut.available_labels, MAX_LABELS);
558 }
559
560 let (provider_name, model_name) = ai_config.resolve_for_task(TaskType::Triage);
562
563 try_with_fallback(provider, &provider_name, &model_name, ai_config, |client| {
565 let issue = issue_mut.clone();
566 async move { client.analyze_issue(&issue).await }
567 })
568 .await
569}
570
571#[instrument(skip(provider), fields(reference = %reference))]
610pub async fn fetch_pr_for_review(
611 provider: &dyn TokenProvider,
612 reference: &str,
613 repo_context: Option<&str>,
614) -> crate::Result<PrDetails> {
615 use crate::github::pulls::parse_pr_reference;
616
617 let (owner, repo, number) =
619 parse_pr_reference(reference, repo_context).map_err(|e| AptuError::GitHub {
620 message: e.to_string(),
621 })?;
622
623 let client = create_client_from_provider(provider)?;
625
626 let app_config = load_config().unwrap_or_default();
628
629 fetch_pr_details(&client, &owner, &repo, number, &app_config.review)
631 .await
632 .map_err(|e| AptuError::GitHub {
633 message: e.to_string(),
634 })
635}
636
637fn reconstruct_diff_from_pr(files: &[crate::ai::types::PrFile]) -> String {
648 use crate::ai::provider::MAX_TOTAL_DIFF_SIZE;
649 let mut diff = String::new();
650 for file in files {
651 if let Some(patch) = &file.patch {
652 if diff.len() >= MAX_TOTAL_DIFF_SIZE {
656 break;
657 }
658 diff.push_str("+++ b/");
659 diff.push_str(&file.filename);
660 diff.push('\n');
661 diff.push_str(patch);
662 diff.push('\n');
663 }
664 }
665 diff
666}
667
668#[allow(clippy::unused_async)] async fn build_ctx_ast(repo_path: Option<&str>, files: &[crate::ai::types::PrFile]) -> String {
672 let Some(path) = repo_path else {
673 return String::new();
674 };
675 #[cfg(feature = "ast-context")]
676 {
677 return crate::ast_context::build_ast_context(path, files).await;
678 }
679 #[cfg(not(feature = "ast-context"))]
680 {
681 let _ = (path, files);
682 String::new()
683 }
684}
685
686#[allow(clippy::unused_async)] async fn build_ctx_call_graph(
691 repo_path: Option<&str>,
692 files: &[crate::ai::types::PrFile],
693 deep: bool,
694) -> String {
695 if !deep {
696 return String::new();
697 }
698 let Some(path) = repo_path else {
699 return String::new();
700 };
701 #[cfg(feature = "ast-context")]
702 {
703 return crate::ast_context::build_call_graph_context(path, files).await;
704 }
705 #[cfg(not(feature = "ast-context"))]
706 {
707 let _ = (path, files);
708 String::new()
709 }
710}
711
712#[instrument(skip(provider, pr_details), fields(number = pr_details.number))]
733pub async fn analyze_pr(
734 provider: &dyn TokenProvider,
735 pr_details: &PrDetails,
736 ai_config: &AiConfig,
737 repo_path: Option<String>,
738 deep: bool,
739) -> crate::Result<(crate::ai::types::PrReviewResponse, crate::history::AiStats)> {
740 let app_config = load_config().unwrap_or_default();
742 let review_config = app_config.review;
743 let repo_path_ref = repo_path.as_deref();
744 let (ast_ctx, call_graph_ctx) = tokio::join!(
745 build_ctx_ast(repo_path_ref, &pr_details.files),
746 build_ctx_call_graph(repo_path_ref, &pr_details.files, deep)
747 );
748
749 let (provider_name, model_name) = ai_config.resolve_for_task(TaskType::Review);
751
752 let diff = reconstruct_diff_from_pr(&pr_details.files);
754 let injection_findings: Vec<_> = SecurityScanner::new()
755 .scan_diff(&diff)
756 .into_iter()
757 .filter(|f| f.pattern_id.starts_with("prompt-injection"))
758 .collect();
759 if !injection_findings.is_empty() {
760 let pattern_ids: Vec<&str> = injection_findings
761 .iter()
762 .map(|f| f.pattern_id.as_str())
763 .collect();
764 warn!(
765 injection_count = injection_findings.len(),
766 ?pattern_ids,
767 "Prompt injection patterns detected in PR diff; proceeding with AI review"
768 );
769 }
770
771 try_with_fallback(provider, &provider_name, &model_name, ai_config, |client| {
773 let pr = pr_details.clone();
774 let ast = ast_ctx.clone();
775 let call_graph = call_graph_ctx.clone();
776 let review_cfg = review_config.clone();
777 async move { client.review_pr(&pr, ast, call_graph, &review_cfg).await }
778 })
779 .await
780}
781
782#[instrument(skip(provider, comments), fields(reference = %reference, event = %event))]
809pub async fn post_pr_review(
810 provider: &dyn TokenProvider,
811 reference: &str,
812 repo_context: Option<&str>,
813 body: &str,
814 event: ReviewEvent,
815 comments: &[PrReviewComment],
816 commit_id: &str,
817) -> crate::Result<u64> {
818 use crate::github::pulls::parse_pr_reference;
819
820 let (owner, repo, number) =
822 parse_pr_reference(reference, repo_context).map_err(|e| AptuError::GitHub {
823 message: e.to_string(),
824 })?;
825
826 let client = create_client_from_provider(provider)?;
828
829 gh_post_pr_review(
831 &client, &owner, &repo, number, body, event, comments, commit_id,
832 )
833 .await
834 .map_err(|e| AptuError::GitHub {
835 message: e.to_string(),
836 })
837}
838
839#[instrument(skip(provider), fields(reference = %reference))]
862pub async fn label_pr(
863 provider: &dyn TokenProvider,
864 reference: &str,
865 repo_context: Option<&str>,
866 dry_run: bool,
867 ai_config: &AiConfig,
868) -> crate::Result<(u64, String, String, Vec<String>)> {
869 use crate::github::issues::apply_labels_to_number;
870 use crate::github::pulls::{fetch_pr_details, labels_from_pr_metadata, parse_pr_reference};
871
872 let (owner, repo, number) =
874 parse_pr_reference(reference, repo_context).map_err(|e| AptuError::GitHub {
875 message: e.to_string(),
876 })?;
877
878 let client = create_client_from_provider(provider)?;
880
881 let app_config = load_config().unwrap_or_default();
883
884 let pr_details = fetch_pr_details(&client, &owner, &repo, number, &app_config.review)
886 .await
887 .map_err(|e| AptuError::GitHub {
888 message: e.to_string(),
889 })?;
890
891 let file_paths: Vec<String> = pr_details
893 .files
894 .iter()
895 .map(|f| f.filename.clone())
896 .collect();
897 let mut labels = labels_from_pr_metadata(&pr_details.title, &file_paths);
898
899 if labels.is_empty() {
901 let (provider_name, model_name) = ai_config.resolve_for_task(TaskType::Create);
903
904 if let Some(api_key) = provider.ai_api_key(&provider_name) {
906 if let Ok(ai_client) =
908 crate::ai::AiClient::with_api_key(&provider_name, api_key, &model_name, ai_config)
909 {
910 match ai_client
911 .suggest_pr_labels(&pr_details.title, &pr_details.body, &file_paths)
912 .await
913 {
914 Ok((ai_labels, _stats)) => {
915 labels = ai_labels;
916 debug!("AI fallback provided {} labels", labels.len());
917 }
918 Err(e) => {
919 debug!("AI fallback failed: {}", e);
920 }
922 }
923 }
924 }
925 }
926
927 if !dry_run && !labels.is_empty() {
929 apply_labels_to_number(&client, &owner, &repo, number, &labels)
930 .await
931 .map_err(|e| AptuError::GitHub {
932 message: e.to_string(),
933 })?;
934 }
935
936 Ok((number, pr_details.title, pr_details.url, labels))
937}
938
939#[allow(clippy::too_many_lines)]
961#[instrument(skip(provider), fields(reference = %reference))]
962pub async fn fetch_issue_for_triage(
963 provider: &dyn TokenProvider,
964 reference: &str,
965 repo_context: Option<&str>,
966) -> crate::Result<IssueDetails> {
967 let (owner, repo, number) =
969 crate::github::issues::parse_issue_reference(reference, repo_context).map_err(|e| {
970 AptuError::GitHub {
971 message: e.to_string(),
972 }
973 })?;
974
975 let client = create_client_from_provider(provider)?;
977
978 let (issue_node, repo_data) = fetch_issue_with_repo_context(&client, &owner, &repo, number)
980 .await
981 .map_err(|e| AptuError::GitHub {
982 message: e.to_string(),
983 })?;
984
985 let labels: Vec<String> = issue_node
987 .labels
988 .nodes
989 .iter()
990 .map(|label| label.name.clone())
991 .collect();
992
993 let comments: Vec<crate::ai::types::IssueComment> = issue_node
994 .comments
995 .nodes
996 .iter()
997 .map(|comment| crate::ai::types::IssueComment {
998 author: comment.author.login.clone(),
999 body: comment.body.clone(),
1000 })
1001 .collect();
1002
1003 let available_labels: Vec<crate::ai::types::RepoLabel> = repo_data
1004 .labels
1005 .nodes
1006 .iter()
1007 .map(|label| crate::ai::types::RepoLabel {
1008 name: label.name.clone(),
1009 description: String::new(),
1010 color: String::new(),
1011 })
1012 .collect();
1013
1014 let available_milestones: Vec<crate::ai::types::RepoMilestone> = repo_data
1015 .milestones
1016 .nodes
1017 .iter()
1018 .map(|milestone| crate::ai::types::RepoMilestone {
1019 number: milestone.number,
1020 title: milestone.title.clone(),
1021 description: String::new(),
1022 })
1023 .collect();
1024
1025 let mut issue_details = IssueDetails::builder()
1026 .owner(owner.clone())
1027 .repo(repo.clone())
1028 .number(number)
1029 .title(issue_node.title.clone())
1030 .body(issue_node.body.clone().unwrap_or_default())
1031 .labels(labels)
1032 .comments(comments)
1033 .url(issue_node.url.clone())
1034 .available_labels(available_labels)
1035 .available_milestones(available_milestones)
1036 .build();
1037
1038 issue_details.author = issue_node.author.as_ref().map(|a| a.login.clone());
1040 issue_details.created_at = Some(issue_node.created_at.clone());
1041 issue_details.updated_at = Some(issue_node.updated_at.clone());
1042
1043 let keywords = crate::github::issues::extract_keywords(&issue_details.title);
1045 let language = repo_data
1046 .primary_language
1047 .as_ref()
1048 .map_or("unknown", |l| l.name.as_str())
1049 .to_string();
1050
1051 let (search_result, tree_result) = tokio::join!(
1053 crate::github::issues::search_related_issues(
1054 &client,
1055 &owner,
1056 &repo,
1057 &issue_details.title,
1058 number
1059 ),
1060 crate::github::issues::fetch_repo_tree(&client, &owner, &repo, &language, &keywords)
1061 );
1062
1063 match search_result {
1065 Ok(related) => {
1066 issue_details.repo_context = related;
1067 debug!(
1068 related_count = issue_details.repo_context.len(),
1069 "Found related issues"
1070 );
1071 }
1072 Err(e) => {
1073 debug!(error = %e, "Failed to search for related issues, continuing without context");
1074 }
1075 }
1076
1077 match tree_result {
1079 Ok(tree) => {
1080 issue_details.repo_tree = tree;
1081 debug!(
1082 tree_count = issue_details.repo_tree.len(),
1083 "Fetched repository tree"
1084 );
1085 }
1086 Err(e) => {
1087 debug!(error = %e, "Failed to fetch repository tree, continuing without context");
1088 }
1089 }
1090
1091 debug!(issue_number = number, "Issue fetched successfully");
1092 Ok(issue_details)
1093}
1094
1095#[instrument(skip(provider, triage), fields(owner = %issue_details.owner, repo = %issue_details.repo, number = issue_details.number))]
1115pub async fn post_triage_comment(
1116 provider: &dyn TokenProvider,
1117 issue_details: &IssueDetails,
1118 triage: &TriageResponse,
1119) -> crate::Result<String> {
1120 let client = create_client_from_provider(provider)?;
1122
1123 let comment_body = crate::triage::render_triage_markdown(triage);
1125 let comment_url = crate::github::issues::post_comment(
1126 &client,
1127 &issue_details.owner,
1128 &issue_details.repo,
1129 issue_details.number,
1130 &comment_body,
1131 )
1132 .await
1133 .map_err(|e| AptuError::GitHub {
1134 message: e.to_string(),
1135 })?;
1136
1137 debug!(comment_url = %comment_url, "Triage comment posted");
1138 Ok(comment_url)
1139}
1140
1141#[instrument(skip(provider, triage), fields(owner = %issue_details.owner, repo = %issue_details.repo, number = issue_details.number))]
1163pub async fn apply_triage_labels(
1164 provider: &dyn TokenProvider,
1165 issue_details: &IssueDetails,
1166 triage: &TriageResponse,
1167) -> crate::Result<crate::github::issues::ApplyResult> {
1168 debug!("Applying labels and milestone to issue");
1169
1170 let client = create_client_from_provider(provider)?;
1172
1173 let result = crate::github::issues::update_issue_labels_and_milestone(
1175 &client,
1176 &issue_details.owner,
1177 &issue_details.repo,
1178 issue_details.number,
1179 &issue_details.labels,
1180 &triage.suggested_labels,
1181 issue_details.milestone.as_deref(),
1182 triage.suggested_milestone.as_deref(),
1183 &issue_details.available_labels,
1184 &issue_details.available_milestones,
1185 )
1186 .await
1187 .map_err(|e| AptuError::GitHub {
1188 message: e.to_string(),
1189 })?;
1190
1191 info!(
1192 labels = ?result.applied_labels,
1193 milestone = ?result.applied_milestone,
1194 warnings = ?result.warnings,
1195 "Labels and milestone applied"
1196 );
1197
1198 Ok(result)
1199}
1200
1201async fn get_from_ref_or_root(
1243 gh_client: &octocrab::Octocrab,
1244 owner: &str,
1245 repo: &str,
1246 to_ref: &str,
1247) -> Result<String, AptuError> {
1248 let previous_tag_opt =
1250 crate::github::releases::get_previous_tag(gh_client, owner, repo, to_ref)
1251 .await
1252 .map_err(|e| AptuError::GitHub {
1253 message: e.to_string(),
1254 })?;
1255
1256 if let Some((tag, _)) = previous_tag_opt {
1257 Ok(tag)
1258 } else {
1259 tracing::info!(
1261 "No previous tag found before {}, using root commit for first release",
1262 to_ref
1263 );
1264 crate::github::releases::get_root_commit(gh_client, owner, repo)
1265 .await
1266 .map_err(|e| AptuError::GitHub {
1267 message: e.to_string(),
1268 })
1269 }
1270}
1271
1272#[instrument(skip(provider))]
1293pub async fn generate_release_notes(
1294 provider: &dyn TokenProvider,
1295 owner: &str,
1296 repo: &str,
1297 from_tag: Option<&str>,
1298 to_tag: Option<&str>,
1299) -> Result<crate::ai::types::ReleaseNotesResponse, 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 let config = load_config().map_err(|e| AptuError::Config {
1310 message: e.to_string(),
1311 })?;
1312
1313 let ai_client = AiClient::new(&config.ai.provider, &config.ai).map_err(|e| AptuError::AI {
1315 message: e.to_string(),
1316 status: None,
1317 provider: config.ai.provider.clone(),
1318 })?;
1319
1320 let (from_ref, to_ref) = if let (Some(from), Some(to)) = (from_tag, to_tag) {
1322 (from.to_string(), to.to_string())
1323 } else if let Some(to) = to_tag {
1324 let from_ref = get_from_ref_or_root(&gh_client, owner, repo, to).await?;
1326 (from_ref, to.to_string())
1327 } else if let Some(from) = from_tag {
1328 (from.to_string(), "HEAD".to_string())
1330 } else {
1331 let latest_tag_opt = crate::github::releases::get_latest_tag(&gh_client, owner, repo)
1334 .await
1335 .map_err(|e| AptuError::GitHub {
1336 message: e.to_string(),
1337 })?;
1338
1339 let to_ref = if let Some((tag, _)) = latest_tag_opt {
1340 tag
1341 } else {
1342 "HEAD".to_string()
1343 };
1344
1345 let from_ref = get_from_ref_or_root(&gh_client, owner, repo, &to_ref).await?;
1346 (from_ref, to_ref)
1347 };
1348
1349 let prs = crate::github::releases::fetch_prs_between_refs(
1351 &gh_client, owner, repo, &from_ref, &to_ref,
1352 )
1353 .await
1354 .map_err(|e| AptuError::GitHub {
1355 message: e.to_string(),
1356 })?;
1357
1358 if prs.is_empty() {
1359 return Err(AptuError::GitHub {
1360 message: "No merged PRs found between the specified tags".to_string(),
1361 });
1362 }
1363
1364 let version = crate::github::releases::parse_tag_reference(&to_ref);
1366 let (response, _ai_stats) = ai_client
1367 .generate_release_notes(prs, &version)
1368 .await
1369 .map_err(|e: anyhow::Error| AptuError::AI {
1370 message: e.to_string(),
1371 status: None,
1372 provider: config.ai.provider.clone(),
1373 })?;
1374
1375 info!(
1376 theme = ?response.theme,
1377 highlights_count = response.highlights.len(),
1378 contributors_count = response.contributors.len(),
1379 "Release notes generated"
1380 );
1381
1382 Ok(response)
1383}
1384
1385#[instrument(skip(provider))]
1408pub async fn post_release_notes(
1409 provider: &dyn TokenProvider,
1410 owner: &str,
1411 repo: &str,
1412 tag: &str,
1413 body: &str,
1414) -> Result<String, AptuError> {
1415 let token = provider.github_token().ok_or_else(|| AptuError::GitHub {
1416 message: "GitHub token not available".to_string(),
1417 })?;
1418
1419 let gh_client = create_client_with_token(&token).map_err(|e| AptuError::GitHub {
1420 message: e.to_string(),
1421 })?;
1422
1423 crate::github::releases::post_release_notes(&gh_client, owner, repo, tag, body)
1424 .await
1425 .map_err(|e| AptuError::GitHub {
1426 message: e.to_string(),
1427 })
1428}
1429
1430#[cfg(test)]
1431mod tests {
1432 use crate::config::{FallbackConfig, FallbackEntry};
1433
1434 #[test]
1435 fn test_fallback_chain_config_structure() {
1436 let fallback_config = FallbackConfig {
1438 chain: vec![
1439 FallbackEntry {
1440 provider: "openrouter".to_string(),
1441 model: None,
1442 },
1443 FallbackEntry {
1444 provider: "anthropic".to_string(),
1445 model: Some("claude-haiku-4.5".to_string()),
1446 },
1447 ],
1448 };
1449
1450 assert_eq!(fallback_config.chain.len(), 2);
1451 assert_eq!(fallback_config.chain[0].provider, "openrouter");
1452 assert_eq!(fallback_config.chain[0].model, None);
1453 assert_eq!(fallback_config.chain[1].provider, "anthropic");
1454 assert_eq!(
1455 fallback_config.chain[1].model,
1456 Some("claude-haiku-4.5".to_string())
1457 );
1458 }
1459
1460 #[test]
1461 fn test_fallback_chain_empty() {
1462 let fallback_config = FallbackConfig { chain: vec![] };
1464
1465 assert_eq!(fallback_config.chain.len(), 0);
1466 }
1467
1468 #[test]
1469 fn test_fallback_chain_single_provider() {
1470 let fallback_config = FallbackConfig {
1472 chain: vec![FallbackEntry {
1473 provider: "openrouter".to_string(),
1474 model: None,
1475 }],
1476 };
1477
1478 assert_eq!(fallback_config.chain.len(), 1);
1479 assert_eq!(fallback_config.chain[0].provider, "openrouter");
1480 }
1481}
1482
1483#[allow(clippy::items_after_test_module)]
1484#[instrument(skip(provider, ai_config), fields(repo = %repo))]
1511pub async fn format_issue(
1512 provider: &dyn TokenProvider,
1513 title: &str,
1514 body: &str,
1515 repo: &str,
1516 ai_config: &AiConfig,
1517) -> crate::Result<CreateIssueResponse> {
1518 let (provider_name, model_name) = ai_config.resolve_for_task(TaskType::Create);
1520
1521 try_with_fallback(provider, &provider_name, &model_name, ai_config, |client| {
1523 let title = title.to_string();
1524 let body = body.to_string();
1525 let repo = repo.to_string();
1526 async move {
1527 let (response, _stats) = client.create_issue(&title, &body, &repo).await?;
1528 Ok(response)
1529 }
1530 })
1531 .await
1532}
1533
1534#[instrument(skip(provider), fields(owner = %owner, repo = %repo))]
1558pub async fn post_issue(
1559 provider: &dyn TokenProvider,
1560 owner: &str,
1561 repo: &str,
1562 title: &str,
1563 body: &str,
1564) -> crate::Result<(String, u64)> {
1565 let client = create_client_from_provider(provider)?;
1567
1568 gh_create_issue(&client, owner, repo, title, body)
1570 .await
1571 .map_err(|e| AptuError::GitHub {
1572 message: e.to_string(),
1573 })
1574}
1575#[instrument(skip(provider), fields(owner = %owner, repo = %repo, head = %head_branch, base = %base_branch))]
1598pub async fn create_pr(
1599 provider: &dyn TokenProvider,
1600 owner: &str,
1601 repo: &str,
1602 title: &str,
1603 base_branch: &str,
1604 head_branch: &str,
1605 body: Option<&str>,
1606) -> crate::Result<crate::github::pulls::PrCreateResult> {
1607 let client = create_client_from_provider(provider)?;
1609
1610 crate::github::pulls::create_pull_request(
1612 &client,
1613 owner,
1614 repo,
1615 title,
1616 head_branch,
1617 base_branch,
1618 body,
1619 )
1620 .await
1621 .map_err(|e| AptuError::GitHub {
1622 message: e.to_string(),
1623 })
1624}
1625
1626#[instrument(skip(provider), fields(provider_name))]
1648pub async fn list_models(
1649 provider: &dyn TokenProvider,
1650 provider_name: &str,
1651) -> crate::Result<Vec<crate::ai::registry::CachedModel>> {
1652 use crate::ai::registry::{CachedModelRegistry, ModelRegistry};
1653 use crate::cache::cache_dir;
1654
1655 let cache_dir = cache_dir();
1656 let registry =
1657 CachedModelRegistry::new(cache_dir, crate::cache::DEFAULT_MODEL_TTL_SECS, provider);
1658
1659 registry
1660 .list_models(provider_name)
1661 .await
1662 .map_err(|e| AptuError::ModelRegistry {
1663 message: format!("Failed to list models: {e}"),
1664 })
1665}
1666
1667#[instrument(skip(provider), fields(provider_name, model_id))]
1689pub async fn validate_model(
1690 provider: &dyn TokenProvider,
1691 provider_name: &str,
1692 model_id: &str,
1693) -> crate::Result<bool> {
1694 use crate::ai::registry::{CachedModelRegistry, ModelRegistry};
1695 use crate::cache::cache_dir;
1696
1697 let cache_dir = cache_dir();
1698 let registry =
1699 CachedModelRegistry::new(cache_dir, crate::cache::DEFAULT_MODEL_TTL_SECS, provider);
1700
1701 registry
1702 .model_exists(provider_name, model_id)
1703 .await
1704 .map_err(|e| AptuError::ModelRegistry {
1705 message: format!("Failed to validate model: {e}"),
1706 })
1707}