use tracing::{debug, warn};
use crate::{
config::ReviewConfig,
integrations::{
apex_context::fetch_apex_context,
context::{
ConfluenceSource, ContextSource, GithubIssuesSource, JiraSource, ReviewSubject,
gather_external_context, render_sections,
},
github::RunMode,
},
pipeline::prompt::ReviewContext,
pipeline::runner::ReviewDeps,
};
pub(crate) async fn gather_context(
config: &ReviewConfig,
deps: &ReviewDeps,
identifiers: &[String],
changed_files: &[String],
pr_title: &str,
pr_description: &str,
) -> ReviewContext {
let query_parts: Vec<&str> = {
let mut parts: Vec<&str> = identifiers.iter().map(|s| s.as_str()).collect();
if !pr_title.is_empty() {
parts.push(pr_title);
}
parts.truncate(5);
parts
};
let query = query_parts.join(" ");
let search_fut = async {
if query.is_empty() {
return Vec::new();
}
match deps
.search
.search(&config.search_index, &query, Some(8))
.await
{
Ok(results) => {
debug!(count = results.len(), "search context retrieved");
results
}
Err(e) => {
warn!("trusty-search unavailable (proceeding with no context): {e}");
Vec::new()
}
}
};
let analyze_fut = async {
let Some(ref analyze) = deps.analyze else {
return (Vec::new(), Vec::new());
};
if !analyze.has_analysis(&config.search_index).await {
debug!("trusty-analyze not available or has no index — skipping");
return (Vec::new(), Vec::new());
}
let hotspots = match analyze
.complexity_hotspots(&config.search_index, Some(10))
.await
{
Ok(h) => h
.into_iter()
.filter(|h| changed_files.iter().any(|f| f == &h.file))
.collect(),
Err(e) => {
debug!("complexity_hotspots failed (optional): {e}");
Vec::new()
}
};
let smells = match analyze.smells(&config.search_index).await {
Ok(s) => s
.into_iter()
.filter(|s| changed_files.iter().any(|f| f == &s.file))
.collect(),
Err(e) => {
debug!("smells failed (optional): {e}");
Vec::new()
}
};
(hotspots, smells)
};
let apex_fut = async {
let cross_query = build_apex_cross_query(pr_title, pr_description, changed_files);
fetch_apex_context(
deps.search.as_ref(),
&config.apex_index,
&config.apex_path_prefixes,
&cross_query,
)
.await
};
let (search_results, (complexity_hotspots, smells), apex_results) =
tokio::join!(search_fut, analyze_fut, apex_fut);
ReviewContext {
search_results,
complexity_hotspots,
smells,
apex_results,
}
}
fn build_apex_cross_query(
pr_title: &str,
pr_description: &str,
changed_files: &[String],
) -> String {
let combined = format!("{}\n{}", pr_title.trim(), pr_description.trim());
let trimmed = combined.trim();
if !trimmed.is_empty() {
return trimmed.to_string();
}
changed_files
.iter()
.take(6)
.cloned()
.collect::<Vec<_>>()
.join(" ")
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn gather_external_context_md(
config: &ReviewConfig,
owner: &str,
repo: &str,
identifiers: &[String],
changed_files: &[String],
pr_title: &str,
pr_body: &str,
run_mode: RunMode,
) -> String {
let cs = &config.context_sources;
let sources: Vec<Box<dyn ContextSource>> = vec![
Box::new(JiraSource::from_config(&cs.jira)),
Box::new(ConfluenceSource::from_config(&cs.confluence)),
Box::new(GithubIssuesSource::from_config(
&cs.github_issues,
run_mode,
config.clone(),
)),
];
if !sources.iter().any(|s| s.is_enabled()) {
debug!("no external context sources enabled — skipping enrichment");
return String::new();
}
let subject = ReviewSubject {
owner: owner.to_string(),
repo: repo.to_string(),
title: pr_title.to_string(),
body: pr_body.to_string(),
changed_files: changed_files.to_vec(),
identifiers: identifiers.to_vec(),
};
let sections = gather_external_context(&sources, &subject).await;
render_sections(§ions)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
integrations::{
analyze_client::{AnalyzeClientError, AnalyzeHealthResponse, ComplexityHotspot, Smell},
search_client::{
HealthResponse, IndexInfo, SearchClient, SearchClientError, SearchResult,
},
},
pipeline::runner::ReviewDeps,
};
use async_trait::async_trait;
use std::sync::Arc;
struct FailSearch;
#[async_trait]
impl SearchClient for FailSearch {
async fn health(&self) -> Result<HealthResponse, SearchClientError> {
Err(SearchClientError::Unavailable("down".into()))
}
async fn list_indexes(&self) -> Result<Vec<IndexInfo>, SearchClientError> {
Err(SearchClientError::Unavailable("down".into()))
}
async fn search(
&self,
_: &str,
_: &str,
_: Option<u32>,
) -> Result<Vec<SearchResult>, SearchClientError> {
Err(SearchClientError::Transport("refused".into()))
}
}
struct NullAnalyze;
#[async_trait]
impl crate::integrations::analyze_client::AnalyzeClient for NullAnalyze {
async fn health(&self) -> Result<AnalyzeHealthResponse, AnalyzeClientError> {
Err(AnalyzeClientError::Unavailable("down".into()))
}
async fn has_analysis(&self, _: &str) -> bool {
false
}
async fn complexity_hotspots(
&self,
_: &str,
_: Option<u32>,
) -> Result<Vec<ComplexityHotspot>, AnalyzeClientError> {
Ok(vec![])
}
async fn smells(&self, _: &str) -> Result<Vec<Smell>, AnalyzeClientError> {
Ok(vec![])
}
}
use crate::llm::{LlmError, LlmProvider, LlmRequest, LlmResponse};
struct FakeLlmApprove;
#[async_trait]
impl LlmProvider for FakeLlmApprove {
fn name(&self) -> &str {
"fake"
}
async fn complete(&self, req: LlmRequest) -> Result<LlmResponse, LlmError> {
Ok(LlmResponse {
text: r#"{"verdict":"APPROVE","summary":"ok","findings":[]}"#.into(),
model: req.model,
input_tokens: 1,
output_tokens: 1,
latency_ms: 0,
cost_usd: 0.0,
})
}
}
fn make_deps() -> ReviewDeps {
ReviewDeps {
llm: Arc::new(FakeLlmApprove),
verifier: None,
search: Arc::new(FailSearch),
analyze: Some(Arc::new(NullAnalyze)),
dedup: None,
}
}
#[tokio::test]
async fn gather_context_apex_failure_is_fail_open() {
let mut config = ReviewConfig::load(None);
config.apex_index = "apex-index".to_string();
config.apex_path_prefixes = vec!["apex/".to_string()];
let deps = make_deps();
let ctx = gather_context(&config, &deps, &[], &[], "PR title", "PR body").await;
assert!(
ctx.apex_results.is_empty(),
"APEX search failure must produce empty results (fail-open)"
);
}
#[test]
fn build_apex_cross_query_uses_title_and_body() {
let q = build_apex_cross_query("Fix auth bug", "Closes PROJ-1", &[]);
assert_eq!(q, "Fix auth bug\nCloses PROJ-1");
}
#[test]
fn build_apex_cross_query_falls_back_to_changed_files() {
let files = vec!["src/a.rs".into(), "src/b.rs".into()];
let q = build_apex_cross_query("", "", &files);
assert_eq!(q, "src/a.rs src/b.rs");
}
#[test]
fn build_apex_cross_query_empty_when_all_blank() {
assert_eq!(build_apex_cross_query("", " ", &[]), "");
}
}