use std::time::Duration;
use futures_util::future::join_all;
use tracing::{debug, warn};
use super::{ContextSection, ContextSource, ReviewSubject};
const MAX_CONCURRENCY: usize = 4;
const PER_SOURCE_TIMEOUT: Duration = Duration::from_secs(20);
pub async fn gather_external_context(
sources: &[Box<dyn ContextSource>],
subject: &ReviewSubject,
) -> Vec<ContextSection> {
let mut collected: Vec<(usize, ContextSection)> = Vec::new();
let enabled: Vec<(usize, &Box<dyn ContextSource>)> = sources
.iter()
.enumerate()
.filter(|(_, s)| s.is_enabled())
.collect();
if enabled.is_empty() {
debug!("no enabled external context sources");
return Vec::new();
}
for chunk in enabled.chunks(MAX_CONCURRENCY) {
let futs = chunk.iter().map(|(idx, source)| async move {
let name = source.name();
let result = tokio::time::timeout(PER_SOURCE_TIMEOUT, source.gather(subject)).await;
(*idx, name, result)
});
let outcomes = join_all(futs).await;
for (idx, name, result) in outcomes {
match result {
Ok(Ok(section)) => {
if section.snippets.is_empty() {
debug!(source = name, "context source returned no results");
} else {
debug!(
source = name,
count = section.snippets.len(),
"context source contributed"
);
collected.push((idx, section));
}
}
Ok(Err(e)) => {
warn!(
source = name,
"context source failed (continuing without it): {e}"
);
}
Err(_) => {
warn!(
source = name,
timeout_secs = PER_SOURCE_TIMEOUT.as_secs(),
"context source timed out (continuing without it)"
);
}
}
}
}
collected.sort_by_key(|(idx, _)| *idx);
collected.into_iter().map(|(_, section)| section).collect()
}
pub fn render_sections(sections: &[ContextSection]) -> String {
if sections.is_empty() {
return String::new();
}
let mut out = String::new();
for section in sections {
if section.snippets.is_empty() {
continue;
}
out.push_str(&format!("## {}\n\n", section.heading));
for snip in §ion.snippets {
out.push_str("- **");
out.push_str(&snip.title);
out.push_str("**");
if let Some(sub) = &snip.subtitle {
out.push_str(&format!(" — {sub}"));
}
if let Some(link) = &snip.link {
out.push_str(&format!(" ([link]({link}))"));
}
out.push('\n');
if let Some(body) = &snip.body {
for line in body.lines() {
out.push_str(" ");
out.push_str(line);
out.push('\n');
}
}
}
out.push('\n');
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::integrations::context::{ContextSnippet, ContextSourceError, RetrievalMode};
use async_trait::async_trait;
use std::time::Duration;
struct ScriptedSource {
name: &'static str,
enabled: bool,
outcome: Outcome,
}
#[derive(Clone)]
enum Outcome {
Section(ContextSection),
Error,
Hang,
}
#[async_trait]
impl ContextSource for ScriptedSource {
fn name(&self) -> &'static str {
self.name
}
fn is_enabled(&self) -> bool {
self.enabled
}
fn mode(&self) -> RetrievalMode {
RetrievalMode::Live
}
async fn gather(
&self,
_subject: &ReviewSubject,
) -> Result<ContextSection, ContextSourceError> {
match &self.outcome {
Outcome::Section(s) => Ok(s.clone()),
Outcome::Error => Err(ContextSourceError::Api {
src: self.name,
status: 500,
body: "boom".to_string(),
}),
Outcome::Hang => {
tokio::time::sleep(Duration::from_secs(3600)).await;
unreachable!("should be cancelled by timeout")
}
}
}
}
fn section(heading: &str, n: usize) -> ContextSection {
ContextSection {
heading: heading.to_string(),
snippets: (0..n)
.map(|i| ContextSnippet {
title: format!("item{i}"),
subtitle: None,
body: None,
link: None,
})
.collect(),
}
}
fn boxed(s: ScriptedSource) -> Box<dyn ContextSource> {
Box::new(s)
}
#[tokio::test]
async fn gathers_enabled_only() {
let sources = vec![
boxed(ScriptedSource {
name: "a",
enabled: true,
outcome: Outcome::Section(section("A", 2)),
}),
boxed(ScriptedSource {
name: "b",
enabled: false, outcome: Outcome::Section(section("B", 5)),
}),
];
let out = gather_external_context(&sources, &ReviewSubject::default()).await;
assert_eq!(out.len(), 1);
assert_eq!(out[0].heading, "A");
}
#[tokio::test]
async fn fail_open_on_source_error() {
let sources = vec![
boxed(ScriptedSource {
name: "bad",
enabled: true,
outcome: Outcome::Error,
}),
boxed(ScriptedSource {
name: "good",
enabled: true,
outcome: Outcome::Section(section("Good", 1)),
}),
];
let out = gather_external_context(&sources, &ReviewSubject::default()).await;
assert_eq!(out.len(), 1);
assert_eq!(out[0].heading, "Good");
}
#[tokio::test]
async fn fail_open_on_timeout() {
tokio::time::pause();
let sources = vec![
boxed(ScriptedSource {
name: "slow",
enabled: true,
outcome: Outcome::Hang,
}),
boxed(ScriptedSource {
name: "fast",
enabled: true,
outcome: Outcome::Section(section("Fast", 1)),
}),
];
let out = gather_external_context(&sources, &ReviewSubject::default()).await;
assert_eq!(out.len(), 1, "hanging source dropped via timeout");
assert_eq!(out[0].heading, "Fast");
}
#[tokio::test]
async fn empty_sections_dropped() {
let sources = vec![boxed(ScriptedSource {
name: "empty",
enabled: true,
outcome: Outcome::Section(section("Empty", 0)),
})];
let out = gather_external_context(&sources, &ReviewSubject::default()).await;
assert!(out.is_empty(), "empty section contributes nothing");
}
#[tokio::test]
async fn sections_stable_order() {
let sources = vec![
boxed(ScriptedSource {
name: "first",
enabled: true,
outcome: Outcome::Section(section("First", 1)),
}),
boxed(ScriptedSource {
name: "second",
enabled: true,
outcome: Outcome::Section(section("Second", 1)),
}),
boxed(ScriptedSource {
name: "third",
enabled: true,
outcome: Outcome::Section(section("Third", 1)),
}),
];
let out = gather_external_context(&sources, &ReviewSubject::default()).await;
let headings: Vec<&str> = out.iter().map(|s| s.heading.as_str()).collect();
assert_eq!(headings, vec!["First", "Second", "Third"]);
}
#[test]
fn render_sections_empty() {
assert_eq!(render_sections(&[]), "");
assert_eq!(render_sections(&[section("Empty", 0)]), "");
}
#[test]
fn render_sections_emits_headings_and_bullets() {
let sec = ContextSection {
heading: "Related JIRA tickets".to_string(),
snippets: vec![ContextSnippet {
title: "PROJ-1 — Add auth".to_string(),
subtitle: Some("In Progress".to_string()),
body: None,
link: Some("https://acme.atlassian.net/browse/PROJ-1".to_string()),
}],
};
let md = render_sections(&[sec]);
assert!(md.contains("## Related JIRA tickets"));
assert!(md.contains("- **PROJ-1 — Add auth** — In Progress"));
assert!(md.contains("([link](https://acme.atlassian.net/browse/PROJ-1))"));
}
#[test]
fn render_sections_includes_body_indented() {
let sec = ContextSection {
heading: "Related GitHub issues".to_string(),
snippets: vec![ContextSnippet {
title: "#42 — Bug".to_string(),
subtitle: Some("open".to_string()),
body: Some("first line\nsecond line".to_string()),
link: None,
}],
};
let md = render_sections(&[sec]);
assert!(md.contains(" first line"));
assert!(md.contains(" second line"));
}
}