use async_trait::async_trait;
use super::atlassian::{AtlassianCreds, AtlassianProduct};
use super::confluence_parse::parse_section;
use super::{
ContextSection, ContextSource, ContextSourceError, RetrievalMode, ReviewSubject, TransportErr,
};
const SOURCE_NAME: &str = "confluence";
const MAX_RESULTS: u32 = 5;
const MAX_QUERY_IDENTIFIERS: usize = 6;
#[async_trait]
pub trait ConfluenceTransport: Send + Sync {
async fn search_cql(
&self,
creds: &AtlassianCreds,
cql: &str,
limit: u32,
) -> Result<String, ContextSourceError>;
}
pub struct ReqwestConfluenceTransport {
http: reqwest::Client,
}
impl ReqwestConfluenceTransport {
pub fn new() -> Self {
let http = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.expect("reqwest::Client::build failed — TLS backend unavailable");
Self { http }
}
}
impl Default for ReqwestConfluenceTransport {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl ConfluenceTransport for ReqwestConfluenceTransport {
async fn search_cql(
&self,
creds: &AtlassianCreds,
cql: &str,
limit: u32,
) -> Result<String, ContextSourceError> {
let url = format!("{}/wiki/rest/api/content/search", creds.base_url);
let resp = self
.http
.get(&url)
.query(&[
("cql", cql),
("limit", &limit.to_string()),
("expand", "body.view"),
])
.header("Authorization", creds.basic_auth_header())
.header("Accept", "application/json")
.send()
.await
.map_err(|e| ContextSourceError::Transport {
src: SOURCE_NAME,
err: TransportErr(format!("GET {url}: {e}")),
})?;
let status = resp.status();
let text = resp
.text()
.await
.map_err(|e| ContextSourceError::Transport {
src: SOURCE_NAME,
err: TransportErr(format!("read body of {url}: {e}")),
})?;
if !status.is_success() {
return Err(ContextSourceError::Api {
src: SOURCE_NAME,
status: status.as_u16(),
body: text,
});
}
Ok(text)
}
}
pub struct ConfluenceSource {
enabled: bool,
mode: RetrievalMode,
creds: Option<AtlassianCreds>,
transport: Box<dyn ConfluenceTransport>,
}
impl ConfluenceSource {
pub fn from_config(cfg: &super::SourceConfig) -> Self {
let creds = AtlassianCreds::from_env_for(AtlassianProduct::Confluence);
let enabled = cfg.effective_enabled(creds.is_some());
Self {
enabled,
mode: cfg.mode,
creds,
transport: Box::new(ReqwestConfluenceTransport::new()),
}
}
pub fn new(
enabled: bool,
mode: RetrievalMode,
creds: Option<AtlassianCreds>,
transport: Box<dyn ConfluenceTransport>,
) -> Self {
Self {
enabled,
mode,
creds,
transport,
}
}
fn build_cql(subject: &ReviewSubject) -> Option<String> {
let keywords = subject.keyword_query(MAX_QUERY_IDENTIFIERS);
let keywords = keywords.replace('"', " ");
let keywords = keywords.trim();
if keywords.is_empty() {
return None;
}
Some(format!(
"type=page AND text ~ \"{keywords}\" ORDER BY lastmodified DESC"
))
}
}
#[async_trait]
impl ContextSource for ConfluenceSource {
fn name(&self) -> &'static str {
SOURCE_NAME
}
fn is_enabled(&self) -> bool {
self.enabled
}
fn mode(&self) -> RetrievalMode {
self.mode
}
async fn gather(&self, subject: &ReviewSubject) -> Result<ContextSection, ContextSourceError> {
if self.mode == RetrievalMode::Semantic {
return Err(ContextSourceError::SemanticNotImplemented { src: SOURCE_NAME });
}
let creds = self
.creds
.as_ref()
.ok_or(ContextSourceError::NotConfigured {
src: SOURCE_NAME,
reason: "ATLASSIAN_API_TOKEN / ATLASSIAN_EMAIL / ATLASSIAN_URL not set".to_string(),
})?;
let Some(cql) = Self::build_cql(subject) else {
return Ok(ContextSection {
heading: "Related Confluence docs".to_string(),
snippets: Vec::new(),
});
};
let body = self.transport.search_cql(creds, &cql, MAX_RESULTS).await?;
parse_section(&body, &creds.base_url)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn creds() -> AtlassianCreds {
AtlassianCreds {
email: "bob@acme.com".to_string(),
token: "tok".to_string(), base_url: "https://acme.atlassian.net".to_string(),
}
}
struct FakeConfluence {
body: Result<String, ()>,
}
#[async_trait]
impl ConfluenceTransport for FakeConfluence {
async fn search_cql(
&self,
_creds: &AtlassianCreds,
_cql: &str,
_limit: u32,
) -> Result<String, ContextSourceError> {
self.body.clone().map_err(|_| ContextSourceError::Api {
src: SOURCE_NAME,
status: 502,
body: "down".to_string(),
})
}
}
fn subject() -> ReviewSubject {
ReviewSubject {
owner: "acme".to_string(),
repo: "backend".to_string(),
title: "Auth design".to_string(),
identifiers: vec!["Session".to_string()],
..Default::default()
}
}
#[test]
fn query_builds_cql() {
let cql = ConfluenceSource::build_cql(&subject()).expect("has signal");
assert!(cql.contains("type=page"));
assert!(cql.contains("text ~ \"Auth design Session\""));
assert!(cql.contains("ORDER BY lastmodified DESC"));
}
#[test]
fn query_none_without_signal() {
assert!(ConfluenceSource::build_cql(&ReviewSubject::default()).is_none());
}
#[tokio::test]
async fn disabled_when_no_creds() {
let src = ConfluenceSource::new(
true,
RetrievalMode::Live,
None,
Box::new(FakeConfluence {
body: Ok("{}".into()),
}),
);
let r = src.gather(&subject()).await;
assert!(matches!(r, Err(ContextSourceError::NotConfigured { .. })));
}
#[tokio::test]
async fn semantic_mode_errors() {
let src = ConfluenceSource::new(
true,
RetrievalMode::Semantic,
Some(creds()),
Box::new(FakeConfluence {
body: Ok("{}".into()),
}),
);
let r = src.gather(&subject()).await;
assert!(matches!(
r,
Err(ContextSourceError::SemanticNotImplemented { src: "confluence" })
));
}
#[tokio::test]
async fn gather_with_fake_transport() {
let body = r#"{"results":[{"title":"Design Doc","space":{"name":"Eng"}}]}"#;
let src = ConfluenceSource::new(
true,
RetrievalMode::Live,
Some(creds()),
Box::new(FakeConfluence {
body: Ok(body.to_string()),
}),
);
let section = src.gather(&subject()).await.expect("ok");
assert_eq!(section.snippets.len(), 1);
assert_eq!(section.snippets[0].title, "Design Doc");
}
}